mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +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**:
|
||||
- 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 |
@@ -88,6 +88,15 @@
|
||||
>
|
||||
Blocked
|
||||
</button>
|
||||
<button
|
||||
id="profileNavHistory"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneHistory"
|
||||
aria-selected="false"
|
||||
>
|
||||
Watch history
|
||||
</button>
|
||||
<button
|
||||
id="profileNavAdmin"
|
||||
type="button"
|
||||
@@ -272,6 +281,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;
|
||||
+284
@@ -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;
|
||||
@@ -855,6 +1123,22 @@ 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);
|
||||
|
||||
+26
-2
@@ -200,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>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// js/app.js
|
||||
|
||||
import { loadView } from "./viewManager.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import {
|
||||
nostrClient,
|
||||
recordVideoView,
|
||||
updateWatchHistoryList,
|
||||
} from "./nostr.js";
|
||||
import { torrentClient } from "./webtorrent.js";
|
||||
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
|
||||
import { accessControl, normalizeNpub } from "./accessControl.js";
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
ensureBucketCors,
|
||||
} from "./storage/r2-s3.js";
|
||||
import { initQuickR2Upload } from "./r2-quick.js";
|
||||
import { createWatchHistoryRenderer } from "./historyView.js";
|
||||
|
||||
function truncateMiddle(text, maxLength = 72) {
|
||||
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)}`;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -68,6 +97,8 @@ const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||
const ADMIN_DM_IMAGE_URL =
|
||||
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
|
||||
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 EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||
/**
|
||||
@@ -496,6 +527,8 @@ class bitvidApp {
|
||||
// Lazy-loading helper for images
|
||||
this.mediaLoader = new MediaLoader();
|
||||
this.loadedThumbnails = new Map();
|
||||
this.videoDiscussionCountCache = new Map();
|
||||
this.inFlightDiscussionCounts = new Map();
|
||||
this.activeIntervals = [];
|
||||
this.urlPlaybackWatchdogCleanup = null;
|
||||
|
||||
@@ -515,12 +548,14 @@ class bitvidApp {
|
||||
account: null,
|
||||
relays: null,
|
||||
blocked: null,
|
||||
history: null,
|
||||
admin: null,
|
||||
};
|
||||
this.profilePaneElements = {
|
||||
account: null,
|
||||
relays: null,
|
||||
blocked: null,
|
||||
history: null,
|
||||
admin: null,
|
||||
};
|
||||
this.profileRelayList = null;
|
||||
@@ -531,6 +566,8 @@ class bitvidApp {
|
||||
this.profileRelayInput = null;
|
||||
this.profileAddRelayBtn = null;
|
||||
this.profileRestoreRelaysBtn = null;
|
||||
this.profileHistoryRenderer = null;
|
||||
this.activeProfilePane = null;
|
||||
this.adminModeratorInput = null;
|
||||
this.adminAddModeratorBtn = null;
|
||||
this.adminModeratorList = null;
|
||||
@@ -671,6 +708,10 @@ class bitvidApp {
|
||||
this.pubkey = null;
|
||||
this.currentMagnetUri = null;
|
||||
this.currentVideo = null;
|
||||
this.currentVideoPointer = null;
|
||||
this.currentVideoPointerKey = null;
|
||||
this.playbackTelemetryState = null;
|
||||
this.loggedViewPointerKeys = new Set();
|
||||
this.videoSubscription = null;
|
||||
this.videoList = null;
|
||||
this._videoListElement = null;
|
||||
@@ -3883,6 +3924,8 @@ class bitvidApp {
|
||||
document.getElementById("profileNavRelays") || null;
|
||||
this.profileNavButtons.blocked =
|
||||
document.getElementById("profileNavBlocked") || null;
|
||||
this.profileNavButtons.history =
|
||||
document.getElementById("profileNavHistory") || null;
|
||||
this.profileNavButtons.admin =
|
||||
document.getElementById("profileNavAdmin") || null;
|
||||
this.profilePaneElements.account =
|
||||
@@ -3891,6 +3934,8 @@ class bitvidApp {
|
||||
document.getElementById("profilePaneRelays") || null;
|
||||
this.profilePaneElements.blocked =
|
||||
document.getElementById("profilePaneBlocked") || null;
|
||||
this.profilePaneElements.history =
|
||||
document.getElementById("profilePaneHistory") || null;
|
||||
this.profilePaneElements.admin =
|
||||
document.getElementById("profilePaneAdmin") || null;
|
||||
this.profileRelayList = document.getElementById("relayList") || null;
|
||||
@@ -3905,6 +3950,19 @@ class bitvidApp {
|
||||
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
|
||||
this.profileRestoreRelaysBtn =
|
||||
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 =
|
||||
document.getElementById("adminModeratorsSection") || null;
|
||||
this.adminModeratorsEmpty =
|
||||
@@ -4106,6 +4164,7 @@ class bitvidApp {
|
||||
|
||||
selectProfilePane(name = "account") {
|
||||
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
|
||||
const previous = this.activeProfilePane;
|
||||
const availableKeys = Object.keys(this.profilePaneElements).filter((key) => {
|
||||
const pane = this.profilePaneElements[key];
|
||||
if (!(pane instanceof HTMLElement)) {
|
||||
@@ -4125,6 +4184,14 @@ class bitvidApp {
|
||||
? normalized
|
||||
: 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]) => {
|
||||
if (!(pane instanceof HTMLElement)) {
|
||||
return;
|
||||
@@ -4146,6 +4213,11 @@ class bitvidApp {
|
||||
});
|
||||
|
||||
this.updateProfileModalFocusables();
|
||||
this.activeProfilePane = target;
|
||||
|
||||
if (target === "history") {
|
||||
void this.populateProfileWatchHistory();
|
||||
}
|
||||
}
|
||||
|
||||
updateProfileModalFocusables() {
|
||||
@@ -4188,6 +4260,7 @@ class bitvidApp {
|
||||
console.error("Failed to refresh admin pane while opening profile modal:", error);
|
||||
}
|
||||
|
||||
this.activeProfilePane = null;
|
||||
this.selectProfilePane("account");
|
||||
this.populateProfileRelays();
|
||||
try {
|
||||
@@ -4310,6 +4383,17 @@ class bitvidApp {
|
||||
|
||||
this.closeAllMoreMenus();
|
||||
|
||||
try {
|
||||
this.profileHistoryRenderer?.destroy();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[profileModal] Failed to reset watch history renderer on close:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
this.activeProfilePane = null;
|
||||
|
||||
if (
|
||||
this.lastFocusedBeforeProfileModal &&
|
||||
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() {
|
||||
if (!this.profileAddAccountBtn) {
|
||||
return;
|
||||
@@ -5474,6 +5574,12 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
attachVideoListHandler() {
|
||||
const nextTarget = document;
|
||||
|
||||
if (this._videoListElement === nextTarget && this._videoListClickHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._videoListElement && this._videoListClickHandler) {
|
||||
this._videoListElement.removeEventListener(
|
||||
"click",
|
||||
@@ -5483,10 +5589,6 @@ class bitvidApp {
|
||||
this._videoListClickHandler = null;
|
||||
}
|
||||
|
||||
if (!this.videoList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = async (event) => {
|
||||
const trigger = event.target.closest(
|
||||
"[data-play-magnet],[data-play-url]"
|
||||
@@ -5495,7 +5597,9 @@ class bitvidApp {
|
||||
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();
|
||||
|
||||
const rawUrlValue =
|
||||
@@ -5528,9 +5632,9 @@ class bitvidApp {
|
||||
}
|
||||
};
|
||||
|
||||
this._videoListElement = this.videoList;
|
||||
nextTarget.addEventListener("click", handler);
|
||||
this._videoListElement = nextTarget;
|
||||
this._videoListClickHandler = handler;
|
||||
this.videoList.addEventListener("click", handler);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6261,6 +6365,7 @@ class bitvidApp {
|
||||
`[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})`
|
||||
);
|
||||
try {
|
||||
this.cancelPendingViewLogging();
|
||||
this.clearActiveIntervals();
|
||||
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 } = {}) {
|
||||
if (!videoElement) {
|
||||
this.log(
|
||||
@@ -6774,6 +7082,7 @@ class bitvidApp {
|
||||
*/
|
||||
async hideModal() {
|
||||
// 1) Clear intervals, cleanup, etc. (unchanged)
|
||||
this.cancelPendingViewLogging();
|
||||
this.clearActiveIntervals();
|
||||
|
||||
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 =
|
||||
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
|
||||
const escapedThumbnail = rawThumbnail
|
||||
@@ -7528,6 +7847,7 @@ class bitvidApp {
|
||||
${cardControls}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
${discussionCountHtml}
|
||||
${torrentWarningHtml}
|
||||
</div>
|
||||
</div>
|
||||
@@ -7830,6 +8150,195 @@ class bitvidApp {
|
||||
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) {
|
||||
@@ -8963,6 +9472,7 @@ class bitvidApp {
|
||||
// blank frame. Guard every playback entry point with waitForCleanup() so the
|
||||
// race never reappears.
|
||||
await this.waitForCleanup();
|
||||
this.cancelPendingViewLogging();
|
||||
|
||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||
@@ -9089,6 +9599,7 @@ class bitvidApp {
|
||||
this.resetTorrentStats();
|
||||
this.playSource = null;
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
this.preparePlaybackViewLogging(activeVideoEl);
|
||||
|
||||
if (magnetForPlayback) {
|
||||
const label = playbackConfig.didMutate
|
||||
@@ -9378,6 +9889,9 @@ class bitvidApp {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentVideoPointer = null;
|
||||
this.currentVideoPointerKey = null;
|
||||
|
||||
if (this.blacklistedEventIds.has(eventId)) {
|
||||
this.showError("This content has been removed or is not allowed.");
|
||||
return;
|
||||
@@ -9447,6 +9961,44 @@ class bitvidApp {
|
||||
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.currentMagnetUri = sanitizedMagnet || null;
|
||||
@@ -9525,6 +10077,9 @@ class bitvidApp {
|
||||
title = "Untitled",
|
||||
description = "",
|
||||
} = {}) {
|
||||
this.currentVideoPointer = null;
|
||||
this.currentVideoPointerKey = null;
|
||||
|
||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
||||
@@ -9562,6 +10117,8 @@ class bitvidApp {
|
||||
originalMagnet: trimmedMagnet,
|
||||
torrentSupported: magnetSupported,
|
||||
lightningAddress: null,
|
||||
pointer: null,
|
||||
pointerKey: null,
|
||||
};
|
||||
|
||||
this.syncModalMoreMenuData();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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 });
|
||||
});
|
||||
|
||||
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