mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 20:32:27 +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 |
No files matched your search
@@ -107,6 +107,13 @@ latest styles.
|
|||||||
- **Magnet helpers**:
|
- **Magnet helpers**:
|
||||||
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
|
- 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
|
### Adding Features
|
||||||
|
|
||||||
1. **Fork the repository** and create a new branch for your feature.
|
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 |
@@ -88,6 +88,15 @@
|
|||||||
>
|
>
|
||||||
Blocked
|
Blocked
|
||||||
</button>
|
</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
|
<button
|
||||||
id="profileNavAdmin"
|
id="profileNavAdmin"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -272,6 +281,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul id="blockedList" class="space-y-3"></ul>
|
<ul id="blockedList" class="space-y-3"></ul>
|
||||||
</section>
|
</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
|
<section
|
||||||
id="profilePaneAdmin"
|
id="profilePaneAdmin"
|
||||||
class="profile-pane hidden space-y-6"
|
class="profile-pane hidden space-y-6"
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
/>
|
/>
|
||||||
<span class="sidebar-text">Explore</span>
|
<span class="sidebar-text">Explore</span>
|
||||||
</a>
|
</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
|
<a
|
||||||
id="subscriptionsLink"
|
id="subscriptionsLink"
|
||||||
href="#view=subscriptions"
|
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.
|
* default state when no preference has been stored in localStorage yet.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
|
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;
|
||||||
+284
@@ -104,6 +104,31 @@ header img {
|
|||||||
padding: 1.5rem 0;
|
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 Cards */
|
||||||
.video-card {
|
.video-card {
|
||||||
background-color: var(--color-card);
|
background-color: var(--color-card);
|
||||||
@@ -131,6 +156,249 @@ header img {
|
|||||||
animation: video-card-fade-in 220ms ease-out forwards;
|
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 {
|
@keyframes video-card-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -855,6 +1123,22 @@ button:not(.icon-button):focus {
|
|||||||
display: flex;
|
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 {
|
.status-banner {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: rgba(31, 41, 55, 0.9);
|
background-color: rgba(31, 41, 55, 0.9);
|
||||||
|
|||||||
+26
-2
@@ -200,8 +200,32 @@
|
|||||||
<!-- Additional Scripts -->
|
<!-- Additional Scripts -->
|
||||||
<script src="js/libs/nostr.bundle.js"></script>
|
<script src="js/libs/nostr.bundle.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { nip19, SimplePool } from "https://esm.sh/[email protected]";
|
import * as nostrTools from "https://esm.sh/[email protected]";
|
||||||
window.NostrTools = { nip19, SimplePool };
|
|
||||||
|
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>
|
||||||
<script type="module" src="js/config.js"></script>
|
<script type="module" src="js/config.js"></script>
|
||||||
<script type="module" src="js/lists.js"></script>
|
<script type="module" src="js/lists.js"></script>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// js/app.js
|
// js/app.js
|
||||||
|
|
||||||
import { loadView } from "./viewManager.js";
|
import { loadView } from "./viewManager.js";
|
||||||
import { nostrClient } from "./nostr.js";
|
import {
|
||||||
|
nostrClient,
|
||||||
|
recordVideoView,
|
||||||
|
updateWatchHistoryList,
|
||||||
|
} from "./nostr.js";
|
||||||
import { torrentClient } from "./webtorrent.js";
|
import { torrentClient } from "./webtorrent.js";
|
||||||
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
|
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
|
||||||
import { accessControl, normalizeNpub } from "./accessControl.js";
|
import { accessControl, normalizeNpub } from "./accessControl.js";
|
||||||
@@ -37,6 +41,7 @@ import {
|
|||||||
ensureBucketCors,
|
ensureBucketCors,
|
||||||
} from "./storage/r2-s3.js";
|
} from "./storage/r2-s3.js";
|
||||||
import { initQuickR2Upload } from "./r2-quick.js";
|
import { initQuickR2Upload } from "./r2-quick.js";
|
||||||
|
import { createWatchHistoryRenderer } from "./historyView.js";
|
||||||
|
|
||||||
function truncateMiddle(text, maxLength = 72) {
|
function truncateMiddle(text, maxLength = 72) {
|
||||||
if (!text || typeof text !== "string") {
|
if (!text || typeof text !== "string") {
|
||||||
@@ -54,6 +59,30 @@ function truncateMiddle(text, maxLength = 72) {
|
|||||||
return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`;
|
return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pointerArrayToKey(pointer) {
|
||||||
|
if (!Array.isArray(pointer) || pointer.length < 2) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = pointer[0] === "a" ? "a" : pointer[0] === "e" ? "e" : "";
|
||||||
|
if (!type) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const value =
|
||||||
|
typeof pointer[1] === "string" ? pointer[1].trim().toLowerCase() : "";
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const relay =
|
||||||
|
pointer.length > 2 && typeof pointer[2] === "string"
|
||||||
|
? pointer[2].trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return relay ? `${type}:${value}:${relay}` : `${type}:${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple "decryption" placeholder for private videos.
|
* Simple "decryption" placeholder for private videos.
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +97,8 @@ const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
|
|||||||
const ADMIN_DM_IMAGE_URL =
|
const ADMIN_DM_IMAGE_URL =
|
||||||
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
|
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
|
||||||
const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||||||
|
const MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||||||
|
const VIDEO_EVENT_KIND = 30078;
|
||||||
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||||||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||||
/**
|
/**
|
||||||
@@ -496,6 +527,8 @@ class bitvidApp {
|
|||||||
// Lazy-loading helper for images
|
// Lazy-loading helper for images
|
||||||
this.mediaLoader = new MediaLoader();
|
this.mediaLoader = new MediaLoader();
|
||||||
this.loadedThumbnails = new Map();
|
this.loadedThumbnails = new Map();
|
||||||
|
this.videoDiscussionCountCache = new Map();
|
||||||
|
this.inFlightDiscussionCounts = new Map();
|
||||||
this.activeIntervals = [];
|
this.activeIntervals = [];
|
||||||
this.urlPlaybackWatchdogCleanup = null;
|
this.urlPlaybackWatchdogCleanup = null;
|
||||||
|
|
||||||
@@ -515,12 +548,14 @@ class bitvidApp {
|
|||||||
account: null,
|
account: null,
|
||||||
relays: null,
|
relays: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
|
history: null,
|
||||||
admin: null,
|
admin: null,
|
||||||
};
|
};
|
||||||
this.profilePaneElements = {
|
this.profilePaneElements = {
|
||||||
account: null,
|
account: null,
|
||||||
relays: null,
|
relays: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
|
history: null,
|
||||||
admin: null,
|
admin: null,
|
||||||
};
|
};
|
||||||
this.profileRelayList = null;
|
this.profileRelayList = null;
|
||||||
@@ -531,6 +566,8 @@ class bitvidApp {
|
|||||||
this.profileRelayInput = null;
|
this.profileRelayInput = null;
|
||||||
this.profileAddRelayBtn = null;
|
this.profileAddRelayBtn = null;
|
||||||
this.profileRestoreRelaysBtn = null;
|
this.profileRestoreRelaysBtn = null;
|
||||||
|
this.profileHistoryRenderer = null;
|
||||||
|
this.activeProfilePane = null;
|
||||||
this.adminModeratorInput = null;
|
this.adminModeratorInput = null;
|
||||||
this.adminAddModeratorBtn = null;
|
this.adminAddModeratorBtn = null;
|
||||||
this.adminModeratorList = null;
|
this.adminModeratorList = null;
|
||||||
@@ -671,6 +708,10 @@ class bitvidApp {
|
|||||||
this.pubkey = null;
|
this.pubkey = null;
|
||||||
this.currentMagnetUri = null;
|
this.currentMagnetUri = null;
|
||||||
this.currentVideo = null;
|
this.currentVideo = null;
|
||||||
|
this.currentVideoPointer = null;
|
||||||
|
this.currentVideoPointerKey = null;
|
||||||
|
this.playbackTelemetryState = null;
|
||||||
|
this.loggedViewPointerKeys = new Set();
|
||||||
this.videoSubscription = null;
|
this.videoSubscription = null;
|
||||||
this.videoList = null;
|
this.videoList = null;
|
||||||
this._videoListElement = null;
|
this._videoListElement = null;
|
||||||
@@ -3883,6 +3924,8 @@ class bitvidApp {
|
|||||||
document.getElementById("profileNavRelays") || null;
|
document.getElementById("profileNavRelays") || null;
|
||||||
this.profileNavButtons.blocked =
|
this.profileNavButtons.blocked =
|
||||||
document.getElementById("profileNavBlocked") || null;
|
document.getElementById("profileNavBlocked") || null;
|
||||||
|
this.profileNavButtons.history =
|
||||||
|
document.getElementById("profileNavHistory") || null;
|
||||||
this.profileNavButtons.admin =
|
this.profileNavButtons.admin =
|
||||||
document.getElementById("profileNavAdmin") || null;
|
document.getElementById("profileNavAdmin") || null;
|
||||||
this.profilePaneElements.account =
|
this.profilePaneElements.account =
|
||||||
@@ -3891,6 +3934,8 @@ class bitvidApp {
|
|||||||
document.getElementById("profilePaneRelays") || null;
|
document.getElementById("profilePaneRelays") || null;
|
||||||
this.profilePaneElements.blocked =
|
this.profilePaneElements.blocked =
|
||||||
document.getElementById("profilePaneBlocked") || null;
|
document.getElementById("profilePaneBlocked") || null;
|
||||||
|
this.profilePaneElements.history =
|
||||||
|
document.getElementById("profilePaneHistory") || null;
|
||||||
this.profilePaneElements.admin =
|
this.profilePaneElements.admin =
|
||||||
document.getElementById("profilePaneAdmin") || null;
|
document.getElementById("profilePaneAdmin") || null;
|
||||||
this.profileRelayList = document.getElementById("relayList") || null;
|
this.profileRelayList = document.getElementById("relayList") || null;
|
||||||
@@ -3905,6 +3950,19 @@ class bitvidApp {
|
|||||||
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
|
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
|
||||||
this.profileRestoreRelaysBtn =
|
this.profileRestoreRelaysBtn =
|
||||||
document.getElementById("restoreRelaysBtn") || null;
|
document.getElementById("restoreRelaysBtn") || null;
|
||||||
|
if (!this.profileHistoryRenderer) {
|
||||||
|
this.profileHistoryRenderer = createWatchHistoryRenderer({
|
||||||
|
viewSelector: "#profilePaneHistory",
|
||||||
|
gridSelector: "#profileHistoryGrid",
|
||||||
|
loadingSelector: "#profileHistoryLoading",
|
||||||
|
statusSelector: "#profileHistoryStatus",
|
||||||
|
emptySelector: "#profileHistoryEmpty",
|
||||||
|
sentinelSelector: "#profileHistorySentinel",
|
||||||
|
scrollContainerSelector: "#profileHistoryScroll",
|
||||||
|
emptyCopy: "You haven’t watched any videos yet.",
|
||||||
|
getActor: async () => this.pubkey || window.app?.pubkey || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.adminModeratorsSection =
|
this.adminModeratorsSection =
|
||||||
document.getElementById("adminModeratorsSection") || null;
|
document.getElementById("adminModeratorsSection") || null;
|
||||||
this.adminModeratorsEmpty =
|
this.adminModeratorsEmpty =
|
||||||
@@ -4106,6 +4164,7 @@ class bitvidApp {
|
|||||||
|
|
||||||
selectProfilePane(name = "account") {
|
selectProfilePane(name = "account") {
|
||||||
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
|
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
|
||||||
|
const previous = this.activeProfilePane;
|
||||||
const availableKeys = Object.keys(this.profilePaneElements).filter((key) => {
|
const availableKeys = Object.keys(this.profilePaneElements).filter((key) => {
|
||||||
const pane = this.profilePaneElements[key];
|
const pane = this.profilePaneElements[key];
|
||||||
if (!(pane instanceof HTMLElement)) {
|
if (!(pane instanceof HTMLElement)) {
|
||||||
@@ -4125,6 +4184,14 @@ class bitvidApp {
|
|||||||
? normalized
|
? normalized
|
||||||
: fallbackTarget;
|
: fallbackTarget;
|
||||||
|
|
||||||
|
if (previous === "history" && target !== "history") {
|
||||||
|
try {
|
||||||
|
this.profileHistoryRenderer?.pause();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[profileModal] Failed to pause history renderer:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
|
Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
|
||||||
if (!(pane instanceof HTMLElement)) {
|
if (!(pane instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
@@ -4146,6 +4213,11 @@ class bitvidApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.updateProfileModalFocusables();
|
this.updateProfileModalFocusables();
|
||||||
|
this.activeProfilePane = target;
|
||||||
|
|
||||||
|
if (target === "history") {
|
||||||
|
void this.populateProfileWatchHistory();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProfileModalFocusables() {
|
updateProfileModalFocusables() {
|
||||||
@@ -4188,6 +4260,7 @@ class bitvidApp {
|
|||||||
console.error("Failed to refresh admin pane while opening profile modal:", error);
|
console.error("Failed to refresh admin pane while opening profile modal:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.activeProfilePane = null;
|
||||||
this.selectProfilePane("account");
|
this.selectProfilePane("account");
|
||||||
this.populateProfileRelays();
|
this.populateProfileRelays();
|
||||||
try {
|
try {
|
||||||
@@ -4310,6 +4383,17 @@ class bitvidApp {
|
|||||||
|
|
||||||
this.closeAllMoreMenus();
|
this.closeAllMoreMenus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.profileHistoryRenderer?.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[profileModal] Failed to reset watch history renderer on close:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeProfilePane = null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.lastFocusedBeforeProfileModal &&
|
this.lastFocusedBeforeProfileModal &&
|
||||||
typeof this.lastFocusedBeforeProfileModal.focus === "function"
|
typeof this.lastFocusedBeforeProfileModal.focus === "function"
|
||||||
@@ -4517,6 +4601,22 @@ class bitvidApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async populateProfileWatchHistory() {
|
||||||
|
if (!this.profileHistoryRenderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.profileHistoryRenderer.ensureInitialLoad();
|
||||||
|
this.profileHistoryRenderer.resume();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[profileModal] Failed to populate watch history pane:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleAddProfile() {
|
async handleAddProfile() {
|
||||||
if (!this.profileAddAccountBtn) {
|
if (!this.profileAddAccountBtn) {
|
||||||
return;
|
return;
|
||||||
@@ -5474,6 +5574,12 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
attachVideoListHandler() {
|
attachVideoListHandler() {
|
||||||
|
const nextTarget = document;
|
||||||
|
|
||||||
|
if (this._videoListElement === nextTarget && this._videoListClickHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._videoListElement && this._videoListClickHandler) {
|
if (this._videoListElement && this._videoListClickHandler) {
|
||||||
this._videoListElement.removeEventListener(
|
this._videoListElement.removeEventListener(
|
||||||
"click",
|
"click",
|
||||||
@@ -5483,10 +5589,6 @@ class bitvidApp {
|
|||||||
this._videoListClickHandler = null;
|
this._videoListClickHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.videoList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = async (event) => {
|
const handler = async (event) => {
|
||||||
const trigger = event.target.closest(
|
const trigger = event.target.closest(
|
||||||
"[data-play-magnet],[data-play-url]"
|
"[data-play-magnet],[data-play-url]"
|
||||||
@@ -5495,7 +5597,9 @@ class bitvidApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.button === 0 && !event.ctrlKey && !event.metaKey) {
|
const isPrimaryClick =
|
||||||
|
typeof event.button !== "number" || event.button === 0;
|
||||||
|
if (isPrimaryClick && !event.ctrlKey && !event.metaKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const rawUrlValue =
|
const rawUrlValue =
|
||||||
@@ -5528,9 +5632,9 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this._videoListElement = this.videoList;
|
nextTarget.addEventListener("click", handler);
|
||||||
|
this._videoListElement = nextTarget;
|
||||||
this._videoListClickHandler = handler;
|
this._videoListClickHandler = handler;
|
||||||
this.videoList.addEventListener("click", handler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6261,6 +6365,7 @@ class bitvidApp {
|
|||||||
`[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})`
|
`[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})`
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
this.cancelPendingViewLogging();
|
||||||
this.clearActiveIntervals();
|
this.clearActiveIntervals();
|
||||||
this.cleanupUrlPlaybackWatchdog();
|
this.cleanupUrlPlaybackWatchdog();
|
||||||
|
|
||||||
@@ -6358,6 +6463,209 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelPendingViewLogging() {
|
||||||
|
const state = this.playbackTelemetryState;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.timerId) {
|
||||||
|
clearTimeout(state.timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(state.handlers) && state.videoEl) {
|
||||||
|
for (const { eventName, handler } of state.handlers) {
|
||||||
|
try {
|
||||||
|
state.videoEl.removeEventListener(eventName, handler);
|
||||||
|
} catch (err) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
`[cancelPendingViewLogging] Failed to detach ${eventName} listener:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playbackTelemetryState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
preparePlaybackViewLogging(videoEl) {
|
||||||
|
this.cancelPendingViewLogging();
|
||||||
|
|
||||||
|
if (!videoEl || typeof videoEl.addEventListener !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = this.currentVideoPointer;
|
||||||
|
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
|
||||||
|
if (!pointer || !pointerKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loggedViewPointerKeys.has(pointerKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THRESHOLD_SECONDS = 12;
|
||||||
|
const state = {
|
||||||
|
videoEl,
|
||||||
|
pointer,
|
||||||
|
pointerKey,
|
||||||
|
handlers: [],
|
||||||
|
timerId: null,
|
||||||
|
fired: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
if (state.fired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.fired = true;
|
||||||
|
cancelTimer();
|
||||||
|
this.cancelPendingViewLogging();
|
||||||
|
|
||||||
|
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
|
||||||
|
state;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await recordVideoView(thresholdPointer);
|
||||||
|
const viewOk = !!result?.view?.ok;
|
||||||
|
if (viewOk) {
|
||||||
|
this.loggedViewPointerKeys.add(thresholdPointerKey);
|
||||||
|
} else if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] View event rejected by relays:",
|
||||||
|
result?.view || result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let historyOk = !!result?.history?.ok;
|
||||||
|
if (!historyOk) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Watch history update via recordVideoView failed; retrying.",
|
||||||
|
result?.history || result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fallbackHistory = await updateWatchHistoryList(
|
||||||
|
thresholdPointer
|
||||||
|
);
|
||||||
|
historyOk = !!fallbackHistory?.ok;
|
||||||
|
if (!historyOk && isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Watch history retry failed:",
|
||||||
|
fallbackHistory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viewOk && isDevMode && historyOk) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Watch history updated but view event was rejected."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Exception while recording video view:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fallbackHistory = await updateWatchHistoryList(
|
||||||
|
thresholdPointer
|
||||||
|
);
|
||||||
|
if (!fallbackHistory?.ok && isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Watch history retry after view exception failed:",
|
||||||
|
fallbackHistory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (historyError) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Exception while retrying watch history after view failure:",
|
||||||
|
historyError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
"[playVideoWithFallback] Unexpected error while recording video view:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelTimer = () => {
|
||||||
|
if (state.timerId) {
|
||||||
|
clearTimeout(state.timerId);
|
||||||
|
state.timerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleTimer = () => {
|
||||||
|
if (state.fired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentSeconds = Number.isFinite(videoEl.currentTime)
|
||||||
|
? videoEl.currentTime
|
||||||
|
: 0;
|
||||||
|
const remainingMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((THRESHOLD_SECONDS - currentSeconds) * 1000)
|
||||||
|
);
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
finalize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.timerId = window.setTimeout(finalize, remainingMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerHandler = (eventName, handler) => {
|
||||||
|
videoEl.addEventListener(eventName, handler);
|
||||||
|
state.handlers.push({ eventName, handler });
|
||||||
|
};
|
||||||
|
|
||||||
|
registerHandler("timeupdate", () => {
|
||||||
|
if (videoEl.currentTime >= THRESHOLD_SECONDS) {
|
||||||
|
finalize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelOnPause = () => {
|
||||||
|
if (!state.fired) {
|
||||||
|
cancelTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
|
||||||
|
registerHandler(event, cancelOnPause)
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeIfNeeded = () => {
|
||||||
|
if (!state.fired && !state.timerId) {
|
||||||
|
scheduleTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
["play", "playing"].forEach((event) =>
|
||||||
|
registerHandler(event, resumeIfNeeded)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.playbackTelemetryState = state;
|
||||||
|
|
||||||
|
if (!videoEl.paused && videoEl.currentTime > 0) {
|
||||||
|
resumeIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
|
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
|
||||||
if (!videoElement) {
|
if (!videoElement) {
|
||||||
this.log(
|
this.log(
|
||||||
@@ -6774,6 +7082,7 @@ class bitvidApp {
|
|||||||
*/
|
*/
|
||||||
async hideModal() {
|
async hideModal() {
|
||||||
// 1) Clear intervals, cleanup, etc. (unchanged)
|
// 1) Clear intervals, cleanup, etc. (unchanged)
|
||||||
|
this.cancelPendingViewLogging();
|
||||||
this.clearActiveIntervals();
|
this.clearActiveIntervals();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -7442,6 +7751,16 @@ class bitvidApp {
|
|||||||
`
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const showDiscussionCount = video.enableComments !== false;
|
||||||
|
const discussionCountHtml = showDiscussionCount
|
||||||
|
? `
|
||||||
|
<div class="flex items-center text-xs text-gray-500 mt-3" data-discussion-count="${video.id}" data-count-state="idle">
|
||||||
|
<span data-discussion-count-value>—</span>
|
||||||
|
<span class="ml-1">notes</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
const rawThumbnail =
|
const rawThumbnail =
|
||||||
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
|
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
|
||||||
const escapedThumbnail = rawThumbnail
|
const escapedThumbnail = rawThumbnail
|
||||||
@@ -7528,6 +7847,7 @@ class bitvidApp {
|
|||||||
${cardControls}
|
${cardControls}
|
||||||
</div>
|
</div>
|
||||||
${connectionBadgesHtml}
|
${connectionBadgesHtml}
|
||||||
|
${discussionCountHtml}
|
||||||
${torrentWarningHtml}
|
${torrentWarningHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7830,6 +8150,195 @@ class bitvidApp {
|
|||||||
this.goToProfile(pubkey);
|
this.goToProfile(pubkey);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.refreshVideoDiscussionCounts(dedupedVideos);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshVideoDiscussionCounts(videos = []) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(videos) ||
|
||||||
|
!videos.length ||
|
||||||
|
!this.videoList ||
|
||||||
|
!nostrClient?.pool
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligible = videos
|
||||||
|
.filter(
|
||||||
|
(video) =>
|
||||||
|
video &&
|
||||||
|
typeof video.id === "string" &&
|
||||||
|
video.id &&
|
||||||
|
video.enableComments !== false
|
||||||
|
)
|
||||||
|
.slice(0, MAX_DISCUSSION_COUNT_VIDEOS);
|
||||||
|
|
||||||
|
eligible.forEach((video) => {
|
||||||
|
const container = this.videoList.querySelector(
|
||||||
|
`[data-discussion-count="${video.id}"]`
|
||||||
|
);
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.videoDiscussionCountCache.get(video.id);
|
||||||
|
if (typeof cached === "number") {
|
||||||
|
this.updateDiscussionCountElement(container, cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = this.buildDiscussionCountFilters(video);
|
||||||
|
if (!filters.length) {
|
||||||
|
this.markDiscussionCountError(container, { unsupported: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPromise = this.inFlightDiscussionCounts.get(video.id);
|
||||||
|
if (existingPromise) {
|
||||||
|
this.setDiscussionCountPending(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setDiscussionCountPending(container);
|
||||||
|
|
||||||
|
const request = nostrClient
|
||||||
|
.countEventsAcrossRelays(filters)
|
||||||
|
.then((result) => {
|
||||||
|
const perRelay = Array.isArray(result?.perRelay)
|
||||||
|
? result.perRelay.filter((entry) => entry && entry.ok)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!perRelay.length) {
|
||||||
|
this.markDiscussionCountError(container, { unsupported: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Number(result?.total);
|
||||||
|
const normalized =
|
||||||
|
Number.isFinite(total) && total >= 0 ? total : 0;
|
||||||
|
this.videoDiscussionCountCache.set(video.id, normalized);
|
||||||
|
this.updateDiscussionCountElement(container, normalized);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn(
|
||||||
|
`[counts] Failed to fetch discussion count for ${video.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.markDiscussionCountError(container);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.inFlightDiscussionCounts.delete(video.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inFlightDiscussionCounts.set(video.id, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDiscussionCountFilters(video) {
|
||||||
|
if (!video || typeof video !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = [];
|
||||||
|
const eventId =
|
||||||
|
typeof video.id === "string" ? video.id.trim() : "";
|
||||||
|
if (eventId) {
|
||||||
|
filters.push({ kinds: [1], "#e": [eventId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.getVideoAddressPointer(video);
|
||||||
|
if (address) {
|
||||||
|
filters.push({ kinds: [1], "#a": [address] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoAddressPointer(video) {
|
||||||
|
if (!video || typeof video !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = Array.isArray(video.tags) ? video.tags : [];
|
||||||
|
const dTag = tags.find(
|
||||||
|
(tag) =>
|
||||||
|
Array.isArray(tag) &&
|
||||||
|
tag.length >= 2 &&
|
||||||
|
tag[0] === "d" &&
|
||||||
|
typeof tag[1] === "string" &&
|
||||||
|
tag[1].trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dTag) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey =
|
||||||
|
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||||
|
if (!pubkey) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = dTag[1].trim();
|
||||||
|
if (!identifier) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind =
|
||||||
|
Number.isFinite(video.kind) && video.kind > 0
|
||||||
|
? Math.floor(video.kind)
|
||||||
|
: VIDEO_EVENT_KIND;
|
||||||
|
|
||||||
|
return `${kind}:${pubkey}:${identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiscussionCountPending(element) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
element.dataset.countState = "pending";
|
||||||
|
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||||
|
if (valueEl) {
|
||||||
|
valueEl.textContent = "…";
|
||||||
|
}
|
||||||
|
element.removeAttribute("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDiscussionCountElement(element, count) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||||
|
if (!valueEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(count);
|
||||||
|
const safeValue =
|
||||||
|
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
|
||||||
|
element.dataset.countState = "ready";
|
||||||
|
valueEl.textContent = safeValue.toLocaleString();
|
||||||
|
element.removeAttribute("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
markDiscussionCountError(element, { unsupported = false } = {}) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||||
|
if (valueEl) {
|
||||||
|
valueEl.textContent = "—";
|
||||||
|
}
|
||||||
|
element.dataset.countState = unsupported ? "unsupported" : "error";
|
||||||
|
if (unsupported) {
|
||||||
|
element.title = "Relay does not support NIP-45 COUNT queries.";
|
||||||
|
} else {
|
||||||
|
element.removeAttribute("title");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bindThumbnailFallbacks(container) {
|
bindThumbnailFallbacks(container) {
|
||||||
@@ -8963,6 +9472,7 @@ class bitvidApp {
|
|||||||
// blank frame. Guard every playback entry point with waitForCleanup() so the
|
// blank frame. Guard every playback entry point with waitForCleanup() so the
|
||||||
// race never reappears.
|
// race never reappears.
|
||||||
await this.waitForCleanup();
|
await this.waitForCleanup();
|
||||||
|
this.cancelPendingViewLogging();
|
||||||
|
|
||||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||||
@@ -9089,6 +9599,7 @@ class bitvidApp {
|
|||||||
this.resetTorrentStats();
|
this.resetTorrentStats();
|
||||||
this.playSource = null;
|
this.playSource = null;
|
||||||
this.cleanupUrlPlaybackWatchdog();
|
this.cleanupUrlPlaybackWatchdog();
|
||||||
|
this.preparePlaybackViewLogging(activeVideoEl);
|
||||||
|
|
||||||
if (magnetForPlayback) {
|
if (magnetForPlayback) {
|
||||||
const label = playbackConfig.didMutate
|
const label = playbackConfig.didMutate
|
||||||
@@ -9378,6 +9889,9 @@ class bitvidApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.currentVideoPointer = null;
|
||||||
|
this.currentVideoPointerKey = null;
|
||||||
|
|
||||||
if (this.blacklistedEventIds.has(eventId)) {
|
if (this.blacklistedEventIds.has(eventId)) {
|
||||||
this.showError("This content has been removed or is not allowed.");
|
this.showError("This content has been removed or is not allowed.");
|
||||||
return;
|
return;
|
||||||
@@ -9447,6 +9961,44 @@ class bitvidApp {
|
|||||||
lightningAddress: null,
|
lightningAddress: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||||||
|
const normalizedPubkey =
|
||||||
|
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||||
|
const primaryPointer =
|
||||||
|
dTagValue && normalizedPubkey
|
||||||
|
? [
|
||||||
|
"a",
|
||||||
|
`${
|
||||||
|
typeof video.kind === "number" && Number.isFinite(video.kind)
|
||||||
|
? video.kind
|
||||||
|
: 30078
|
||||||
|
}:${normalizedPubkey}:${dTagValue}`,
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
const fallbackId =
|
||||||
|
typeof (video.id || eventId) === "string"
|
||||||
|
? (video.id || eventId).trim()
|
||||||
|
: "";
|
||||||
|
const fallbackPointer =
|
||||||
|
!primaryPointer && fallbackId
|
||||||
|
? ["e", fallbackId]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resolvedPointer = primaryPointer || fallbackPointer;
|
||||||
|
const resolvedPointerKey = pointerArrayToKey(resolvedPointer);
|
||||||
|
|
||||||
|
this.currentVideoPointer = resolvedPointer && resolvedPointerKey
|
||||||
|
? resolvedPointer
|
||||||
|
: null;
|
||||||
|
this.currentVideoPointerKey = this.currentVideoPointer
|
||||||
|
? resolvedPointerKey
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (this.currentVideo) {
|
||||||
|
this.currentVideo.pointer = this.currentVideoPointer;
|
||||||
|
this.currentVideo.pointerKey = this.currentVideoPointerKey;
|
||||||
|
}
|
||||||
|
|
||||||
this.syncModalMoreMenuData();
|
this.syncModalMoreMenuData();
|
||||||
|
|
||||||
this.currentMagnetUri = sanitizedMagnet || null;
|
this.currentMagnetUri = sanitizedMagnet || null;
|
||||||
@@ -9525,6 +10077,9 @@ class bitvidApp {
|
|||||||
title = "Untitled",
|
title = "Untitled",
|
||||||
description = "",
|
description = "",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
this.currentVideoPointer = null;
|
||||||
|
this.currentVideoPointerKey = null;
|
||||||
|
|
||||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||||
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
||||||
@@ -9562,6 +10117,8 @@ class bitvidApp {
|
|||||||
originalMagnet: trimmedMagnet,
|
originalMagnet: trimmedMagnet,
|
||||||
torrentSupported: magnetSupported,
|
torrentSupported: magnetSupported,
|
||||||
lightningAddress: null,
|
lightningAddress: null,
|
||||||
|
pointer: null,
|
||||||
|
pointerKey: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.syncModalMoreMenuData();
|
this.syncModalMoreMenuData();
|
||||||
|
|||||||
@@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) {
|
|||||||
app.handleUrlHealthBadge({ video, url, badgeEl });
|
app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||||
});
|
});
|
||||||
|
|
||||||
window.app.videoList = container;
|
|
||||||
window.app.attachVideoListHandler();
|
window.app.attachVideoListHandler();
|
||||||
|
|
||||||
// Lazy-load images
|
// Lazy-load images
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import {
|
|||||||
ADMIN_SUPER_NPUB,
|
ADMIN_SUPER_NPUB,
|
||||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
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";
|
} from "../config/instance-config.js";
|
||||||
|
|
||||||
export const isDevMode = true; // Set to false for production
|
export const isDevMode = true; // Set to false for production
|
||||||
@@ -42,3 +49,15 @@ export function setWhitelistMode(enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
|
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") {
|
if (typeof initFn === "function") {
|
||||||
initFn();
|
initFn();
|
||||||
}
|
}
|
||||||
recordView("most-recent-videos");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
+2892
-22
File diff suppressed because it is too large.
Load diff
+1
-4
@@ -614,10 +614,7 @@ class SubscriptionsManager {
|
|||||||
window.app.handleUrlHealthBadge({ video, url, badgeEl });
|
window.app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.app) {
|
window.app?.attachVideoListHandler?.();
|
||||||
window.app.videoList = container;
|
|
||||||
window.app.attachVideoListHandler?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy-load
|
// Lazy-load
|
||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export const viewInitRegistry = {
|
|||||||
explore: () => {
|
explore: () => {
|
||||||
console.log("Explore view loaded.");
|
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:
|
* 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