Merge pull request #191 from PR0M3TH3AN/unstable

Added watch list.
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-30 22:12:37 -04:00
commit cdff3efe23
17 files changed
+6071 -38

No files matched your search

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

After

Width:  |  Height:  |  Size: 1.0 KiB

+47
View File
@@ -88,6 +88,15 @@
>
Blocked
</button>
<button
id="profileNavHistory"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneHistory"
aria-selected="false"
>
Watch history
</button>
<button
id="profileNavAdmin"
type="button"
@@ -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"
+11
View File
@@ -34,6 +34,17 @@
/>
<span class="sidebar-text">Explore</span>
</a>
<a
href="#view=history"
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
>
<img
src="assets/svg/history-icon.svg"
alt="History"
class="w-6 h-6 mr-3 flex-shrink-0"
/>
<span class="sidebar-text">History</span>
</a>
<a
id="subscriptionsLink"
href="#view=subscriptions"
+69
View File
@@ -44,3 +44,72 @@ export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode";
* default state when no preference has been stored in localStorage yet.
*/
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
/**
* Nostr kind used when persisting watch history events.
*
* BitVid’s roadmap standardizes on kind 30078 so that watch events, view logs,
* and media metadata stay in the same family of documents. Operators that want
* to experiment with a separate list kind (for example, a NIP-51 collection)
* can flip this number so long as their relays accept the chosen kind.
*/
export const WATCH_HISTORY_KIND = 30078;
/**
* Identifier applied to the watch-history list when storing it on relays.
*
* This becomes the value of the `d` tag so clients can find the correct list
* without guessing. Customize the slug if you need isolation between multiple
* BitVid deployments that share relays, but keep it stable once clients begin
* syncing history.
*/
export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history";
/**
* Maximum number of watch-history entries to retain per user.
*
* The roadmap targets a rolling window of 1,500 items so the UI can highlight
* recently played videos without ballooning relay storage. Adjust this cap up
* or down depending on your retention and privacy policies; smaller values
* reduce storage pressure while larger ones surface a deeper backlog.
*/
export const WATCH_HISTORY_MAX_ITEMS = 1500;
/**
* Whether clients should resolve watch-history entries in batches.
*
* When enabled, BitVid fetches video metadata in grouped queries instead of
* issuing one request per entry. Operators with relays that struggle under
* bursty loads can disable batching at the cost of additional round trips.
*/
export const WATCH_HISTORY_BATCH_RESOLVE = true;
/**
* Maximum size of the JSON payload for each watch-history chunk, measured
* before encryption.
*
* BitVid splits large histories across multiple events to stay within relay
* limits. This cap keeps individual chunks under roughly 60 KB so that
* encrypted payloads remain relay-friendly even after base64 expansion.
* Lower the number if your relays enforce tighter limits.
*/
export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000;
/**
* Number of watch-history events to request when syncing from relays.
*
* Chunked histories can span several events. This fetch limit should exceed
* the maximum number of chunks you expect a client to publish in one snapshot
* so the UI can stitch the full list back together.
*/
export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12;
/**
* How long clients should cache watch-history snapshots in localStorage.
*
* A 24-hour window keeps recently played videos available across reloads
* without requiring a fresh relay sync on every visit. Increase the value for
* longer-lived caches or decrease it if your deployment needs tighter
* retention guarantees.
*/
export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+284
View File
@@ -104,6 +104,31 @@ header img {
padding: 1.5rem 0;
}
/* Dedicated watch-history layout stacks cards vertically */
.watch-history-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
padding: 1.5rem 0;
}
/* Profile modal uses the same vertical stack but without outer padding */
.watch-history-grid.watch-history-grid--modal {
padding: 0;
gap: 1.25rem;
}
@media (max-width: 640px) {
.watch-history-grid {
gap: 1.25rem;
}
.watch-history-grid.watch-history-grid--modal {
gap: 1.125rem;
}
}
/* Video Cards */
.video-card {
background-color: var(--color-card);
@@ -131,6 +156,249 @@ header img {
animation: video-card-fade-in 220ms ease-out forwards;
}
/* Watch history cards */
.watch-history-card {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: stretch;
background-color: var(--color-card);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 0.0625rem solid transparent;
box-shadow: var(--shadow-md);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.watch-history-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.watch-history-card--private {
border-color: rgba(234, 179, 8, 0.75);
}
.watch-history-card__primary {
display: flex;
gap: 1rem;
flex: 1 1 18rem;
min-width: 0;
}
.watch-history-card__thumbnail {
display: block;
flex: 0 0 14rem;
max-width: 14rem;
text-decoration: none;
}
.watch-history-card__thumbnailInner {
width: 100%;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
background-color: #0f172a;
}
.watch-history-card__thumbnailInner img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.watch-history-card__details {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
min-width: 0;
}
.watch-history-card__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #ffffff;
line-height: 1.4;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.watch-history-card__title:hover,
.watch-history-card__title:focus {
color: #60a5fa;
}
.watch-history-card__created {
margin: 0;
font-size: 0.875rem;
color: #9ca3af;
}
.watch-history-card__meta {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1rem;
flex: 0 0 16rem;
min-width: 14rem;
}
.watch-history-card__watched {
margin: 0;
font-size: 0.875rem;
color: #d1d5db;
}
.watch-history-card__creator {
display: flex;
align-items: center;
gap: 0.75rem;
}
.watch-history-card__creatorAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #1f2937;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__creatorAvatar:hover,
.watch-history-card__creatorAvatar:focus {
box-shadow: 0 0 0 0.125rem rgba(59, 130, 246, 0.5);
outline: none;
}
.watch-history-card__creatorAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 9999px;
}
.watch-history-card__creatorName {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #e5e7eb;
text-align: left;
cursor: pointer;
}
.watch-history-card__creatorName:hover,
.watch-history-card__creatorName:focus {
color: #60a5fa;
outline: none;
}
.watch-history-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.watch-history-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 0.0625rem solid transparent;
background-color: #1f2937;
color: #e5e7eb;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease,
border-color 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__action:hover,
.watch-history-card__action:focus {
background-color: #111827;
color: #ffffff;
border-color: #374151;
outline: none;
}
.watch-history-card__action--primary {
background-color: #2563eb;
color: #ffffff;
}
.watch-history-card__action--primary:hover,
.watch-history-card__action--primary:focus {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
.watch-history-card__action--danger {
background-color: #7f1d1d;
color: #ffffff;
}
.watch-history-card__action--danger:hover,
.watch-history-card__action--danger:focus {
background-color: #991b1b;
border-color: #991b1b;
}
@media (max-width: 1024px) {
.watch-history-card__meta {
flex: 1 1 18rem;
}
}
@media (max-width: 768px) {
.watch-history-card {
gap: 1.25rem;
padding: 1rem;
}
.watch-history-card__thumbnail {
flex-basis: 11rem;
max-width: 11rem;
}
}
@media (max-width: 640px) {
.watch-history-card {
flex-direction: column;
}
.watch-history-card__primary,
.watch-history-card__meta {
flex: 1 1 auto;
min-width: 0;
}
.watch-history-card__thumbnail {
width: 100%;
max-width: 100%;
}
.watch-history-card__meta {
gap: 0.75rem;
}
}
@keyframes video-card-fade-in {
from {
opacity: 0;
@@ -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
View File
@@ -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>
+565 -8
View File
@@ -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();
-1
View File
@@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) {
app.handleUrlHealthBadge({ video, url, badgeEl });
});
window.app.videoList = container;
window.app.attachVideoListHandler();
// Lazy-load images
+19
View File
@@ -4,6 +4,13 @@ import {
ADMIN_SUPER_NPUB,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
DEFAULT_WHITELIST_MODE_ENABLED,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
} from "../config/instance-config.js";
export const isDevMode = true; // Set to false for production
@@ -42,3 +49,15 @@ export function setWhitelistMode(enabled) {
}
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
// -----------------------------------------------------------------------------
// Watch history — roadmap defaults balance retention with relay load
// -----------------------------------------------------------------------------
export { WATCH_HISTORY_KIND };
export { WATCH_HISTORY_LIST_IDENTIFIER };
export { WATCH_HISTORY_MAX_ITEMS };
export { WATCH_HISTORY_BATCH_RESOLVE };
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
export { WATCH_HISTORY_FETCH_EVENT_LIMIT };
export { WATCH_HISTORY_CACHE_TTL_MS };
+1346
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -378,7 +378,6 @@ function handleHashChange() {
if (typeof initFn === "function") {
initFn();
}
recordView("most-recent-videos");
});
});
return;
+2892 -22
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -614,10 +614,7 @@ class SubscriptionsManager {
window.app.handleUrlHealthBadge({ video, url, badgeEl });
});
if (window.app) {
window.app.videoList = container;
window.app.attachVideoListHandler?.();
}
window.app?.attachVideoListHandler?.();
// Lazy-load
const lazyEls = container.querySelectorAll("[data-lazy]");
+10
View File
@@ -67,6 +67,16 @@ export const viewInitRegistry = {
explore: () => {
console.log("Explore view loaded.");
},
history: async () => {
try {
const module = await import("./historyView.js");
if (typeof module.initHistoryView === "function") {
await module.initHistoryView();
}
} catch (error) {
console.error("Failed to initialize history view:", error);
}
},
/**
* Subscriptions view:
+746
View File
@@ -0,0 +1,746 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const {
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_KIND,
} = await import("../js/config.js");
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (!globalThis.window.NostrTools) {
globalThis.window.NostrTools = {};
}
globalThis.window.NostrTools.getEventHash = (event) => {
const payload = `${event.kind}:${event.pubkey}:${event.created_at}`;
return `hash-${Buffer.from(payload).toString("hex")}`;
};
globalThis.window.NostrTools.signEvent = (event, privateKey) =>
`sig-${privateKey.slice(0, 8)}-${event.created_at}`;
delete globalThis.window.NostrTools.nip04;
delete globalThis.window.nostr;
const { nostrClient } = await import("../js/nostr.js");
function createPublishingClient(actorPubkey) {
const client = new nostrClient.constructor();
const publishedEvents = [];
const payloads = [];
client.pool = {
publish(urls, event) {
publishedEvents.push(event);
return {
on(eventName, handler) {
if (eventName === "ok" || eventName === "seen") {
setTimeout(handler, 0);
}
},
};
},
list: async () => [],
};
client.relays = ["wss://unit.test"];
client.pubkey = "";
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
client.ensureSessionActor = async () => actorPubkey;
client.encryptWatchHistoryPayload = async (_actor, payload) => {
payloads.push(payload);
return { ok: true, ciphertext: JSON.stringify(payload) };
};
client.persistWatchHistoryEntry = () => {};
client.cancelWatchHistoryRepublish = () => {};
client.scheduleWatchHistoryRepublish = () => {};
return { client, publishedEvents, payloads };
}
function createDecryptClient(actorPubkey) {
const client = new nostrClient.constructor();
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
client.ensureSessionActor = async () => actorPubkey;
return client;
}
const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { client: publishingClient, publishedEvents, payloads } =
createPublishingClient(ACTOR);
publishingClient.watchHistoryPayloadMaxBytes = 400;
publishingClient.watchHistoryFetchEventLimit = 10;
const baseWatchedAt = 1_700_000_000_000;
const longPointers = Array.from({ length: 8 }, (_, index) => {
const relay = index % 3 === 0 ? "wss://relay.unit" : null;
const watchedAt = baseWatchedAt + index * 60_000;
if (index % 2 === 0) {
return {
type: "e",
value: `event-${index}-${"x".repeat(90)}`,
relay,
watchedAt,
};
}
return {
type: "a",
value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`,
relay,
watchedAt,
};
});
const publishResult = await publishingClient.publishWatchHistorySnapshot(
ACTOR,
longPointers
);
assert.equal(publishResult.ok, true, "publish should report success");
assert(publishResult.events.length > 1, "snapshot should chunk into multiple events");
assert.equal(
publishResult.events.length,
payloads.length,
"each chunk should produce an encryption payload"
);
payloads.forEach((payload) => {
const size = JSON.stringify(payload).length;
assert(
size <= publishingClient.watchHistoryPayloadMaxBytes,
`payload size ${size} should respect cap`
);
assert.equal(payload.version, 2, "chunk payload should use version 2");
});
const snapshotIds = new Set(
publishResult.events.map((event) =>
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1]
)
);
assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id");
const headTag = publishResult.event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "head"
);
assert(headTag, "head chunk should include head tag");
const chunkIdentifiers = publishResult.events.map((event) =>
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]
);
assert.equal(
chunkIdentifiers.length,
publishResult.events.length,
"each chunk should expose a d tag"
);
assert.equal(
chunkIdentifiers[0],
WATCH_HISTORY_LIST_IDENTIFIER,
"head chunk should retain canonical watch history identifier"
);
const uniqueChunkIdentifiers = new Set(chunkIdentifiers);
assert.equal(
uniqueChunkIdentifiers.size,
publishResult.events.length,
"each chunk should have a unique d tag"
);
chunkIdentifiers.slice(1).forEach((identifier, index) => {
assert(
identifier && identifier.startsWith("watch-history:"),
`chunk ${index + 1} should use a watch-history namespace identifier`
);
assert.notEqual(
identifier,
WATCH_HISTORY_LIST_IDENTIFIER,
"only the head chunk should use the canonical identifier"
);
});
const headATags = publishResult.event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
.map((tag) => tag[1]);
const expectedChunkAddresses = publishResult.events.map((event) => {
const identifier = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d"
)?.[1];
assert(identifier, "chunk should include identifier tag");
return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`;
});
expectedChunkAddresses.forEach((address) => {
assert(
headATags.includes(address),
`head chunk should reference ${address}`
);
});
delete globalThis.window.nostr;
if (!globalThis.window.NostrTools.nip04) {
globalThis.window.NostrTools.nip04 = {};
}
globalThis.window.NostrTools.nip04.decrypt = async (
_priv,
_pub,
ciphertext
) => ciphertext;
const decryptClient = createDecryptClient(ACTOR);
decryptClient.relays = ["wss://unit.test"];
decryptClient.watchHistoryFetchEventLimit = 10;
decryptClient.pool = {
list: async () => publishedEvents,
};
const fetched = await decryptClient.fetchWatchHistory(ACTOR);
assert.equal(
fetched.items.length,
longPointers.length,
"fetch should reassemble all chunked items"
);
assert.deepEqual(
fetched.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
longPointers.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
"chunked fetch should preserve pointer order and timestamps"
);
if (typeof localStorage !== "undefined") {
localStorage.clear();
}
const headEvent = publishResult.event;
const chunkEvents = publishResult.events.filter(
(event) => event.id !== headEvent.id
);
const snapshotId = headEvent.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
)?.[1];
const freshClient = createDecryptClient(ACTOR);
freshClient.relays = ["wss://unit.test"];
freshClient.watchHistoryFetchEventLimit = 10;
const observedFilters = [];
freshClient.pool = {
list: async (_relays, filters) => {
observedFilters.push(filters);
const firstFilter = filters[0] || {};
const dFilter = Array.isArray(firstFilter["#d"])
? firstFilter["#d"]
: [];
if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
return [headEvent];
}
const requestedIdentifiers = new Set();
const requestedSnapshots = new Set();
for (const filter of filters) {
if (Array.isArray(filter?.["#d"])) {
for (const identifier of filter["#d"]) {
requestedIdentifiers.add(identifier);
}
}
if (Array.isArray(filter?.["#snapshot"])) {
for (const value of filter["#snapshot"]) {
requestedSnapshots.add(value);
}
}
}
const responses = [];
for (const event of publishResult.events) {
const identifier = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d"
)?.[1];
const eventSnapshot = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
)?.[1];
if (identifier && requestedIdentifiers.has(identifier)) {
responses.push(event);
continue;
}
if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) {
responses.push(event);
}
}
return responses;
},
};
const rebuilt = await freshClient.fetchWatchHistory(ACTOR);
assert.equal(
rebuilt.items.length,
longPointers.length,
"fresh client should rebuild full watch history list"
);
assert.deepEqual(
rebuilt.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
longPointers.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
"rebuilt snapshot should preserve timestamps"
);
assert(
observedFilters.length >= 2,
"fetch should include a follow-up query for chunk events"
);
const followUpFilters = observedFilters[observedFilters.length - 1];
const identifiersRequested = new Set();
let snapshotRequested = false;
for (const filter of followUpFilters) {
if (Array.isArray(filter?.["#d"])) {
for (const identifier of filter["#d"]) {
identifiersRequested.add(identifier);
}
}
if (Array.isArray(filter?.["#snapshot"])) {
snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId);
}
}
assert(
chunkEvents.every((event) => {
const identifier = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d"
)?.[1];
return (
identifiersRequested.has(identifier) ||
(snapshotId && snapshotRequested)
);
}),
"follow-up query should target chunk identifiers or snapshot"
);
const MONOTONIC_ACTOR = `${ACTOR}-monotonic`;
const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } =
createPublishingClient(MONOTONIC_ACTOR);
const monotonicOriginalDateNow = Date.now;
try {
const monotonicBaseSeconds = 1_700_123_456;
Date.now = () => monotonicBaseSeconds * 1000;
const firstMonotonicPointers = [
{ type: "e", value: "event-monotonic-1", relay: null },
{ type: "e", value: "event-monotonic-2", relay: null },
];
const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
MONOTONIC_ACTOR,
firstMonotonicPointers
);
const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry(
firstSnapshot.event,
firstMonotonicPointers,
Date.now()
);
monotonicPublishingClient.watchHistoryCache.set(
MONOTONIC_ACTOR,
baselineEntry
);
const secondMonotonicPointers = [
{ type: "e", value: "event-monotonic-3", relay: null },
{ type: "e", value: "event-monotonic-1", relay: null },
];
const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
MONOTONIC_ACTOR,
secondMonotonicPointers,
baselineEntry
);
assert(
secondSnapshot.event.created_at > firstSnapshot.event.created_at,
"new snapshot should advance created_at when reusing the same second"
);
const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR);
monotonicDecryptClient.pool = { list: async () => monotonicEvents };
const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory(
MONOTONIC_ACTOR
);
assert.deepEqual(
monotonicFetched.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
})),
[
{ type: "e", value: "event-monotonic-3", relay: null },
{ type: "e", value: "event-monotonic-1", relay: null },
],
"newer snapshot should win pointer ordering"
);
} finally {
Date.now = monotonicOriginalDateNow;
}
localStorage.clear();
const UPDATE_ACTOR = `${ACTOR}-update`;
const { client: updateClient } = createPublishingClient(UPDATE_ACTOR);
const updateOriginalDateNow = Date.now;
try {
const updateTimestamp = 1_701_111_111_111;
Date.now = () => updateTimestamp;
const updateResult = await updateClient.updateWatchHistoryList({
type: "e",
value: "update-pointer",
relay: null,
});
assert.equal(
updateResult.ok,
true,
"updateWatchHistoryList should publish when adding a new pointer"
);
const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR);
assert(updateEntry, "updateWatchHistoryList should cache the new entry");
assert.equal(
updateEntry.items[0].watchedAt,
updateTimestamp,
"cached entry should record watchedAt from Date.now()"
);
assert.equal(
updateResult.items[0].watchedAt,
updateTimestamp,
"returned publish result should include the watchedAt timestamp"
);
} finally {
Date.now = updateOriginalDateNow;
}
const REMOVE_ACTOR = `${ACTOR}-remove`;
const {
client: removalClient,
payloads: removalPayloads,
} = createPublishingClient(REMOVE_ACTOR);
const removalPointers = [
{
type: "e",
value: "remove-first",
relay: null,
watchedAt: 1_701_222_000_000,
},
{
type: "e",
value: "remove-second",
relay: null,
watchedAt: 1_701_222_100_000,
},
];
await removalClient.publishWatchHistorySnapshot(
REMOVE_ACTOR,
removalPointers
);
const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`;
const removalResult = await removalClient.removeWatchHistoryItem(removalKey);
assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed");
assert.equal(
removalResult.removedKey,
removalKey,
"removeWatchHistoryItem should echo the removed pointer key"
);
const latestRemovalPayload = removalPayloads[removalPayloads.length - 1];
assert.equal(
latestRemovalPayload.items.length,
1,
"removal republish should trim the pointer list"
);
assert.equal(
latestRemovalPayload.items[0].value,
removalPointers[0].value,
"republished payload should only include remaining pointer"
);
assert.equal(
latestRemovalPayload.items[0].watchedAt,
removalPointers[0].watchedAt,
"republished payload should preserve watchedAt"
);
const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR);
assert.equal(
removalEntry.items.length,
1,
"watch history cache should drop the removed pointer"
);
assert.equal(
removalEntry.items[0].value,
removalPointers[0].value,
"remaining cached pointer should match the survivor"
);
assert.equal(
removalEntry.items[0].watchedAt,
removalPointers[0].watchedAt,
"remaining cached pointer should retain watchedAt"
);
assert.equal(
removalResult.items.map((item) => item.value).join(","),
removalPointers.slice(0, 1).map((item) => item.value).join(","),
"removeWatchHistoryItem result should reflect updated items"
);
assert.equal(
removalResult.items[0].watchedAt,
removalPointers[0].watchedAt,
"removeWatchHistoryItem result should preserve watchedAt"
);
const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
const videoPointer = {
type: "a",
value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`,
relay: null,
};
const pointerClient = createDecryptClient(ACTOR);
const pointerEntry = pointerClient.createWatchHistoryEntry(
null,
[videoPointer],
Date.now()
);
pointerClient.watchHistoryCache.set(ACTOR, pointerEntry);
pointerClient.fetchWatchHistory = async () => {};
const observedResolveFilters = [];
const videoEvent = {
id: "video-pointer-event",
kind: WATCH_HISTORY_KIND,
pubkey: VIDEO_POINTER_AUTHOR,
created_at: 1_700_555_000,
tags: [["d", VIDEO_POINTER_IDENTIFIER]],
content: JSON.stringify({
version: 3,
title: "Pointer Video",
url: "https://cdn.example.com/video.mp4",
}),
};
pointerClient.pool = {
list: async (_relays, filters) => {
observedResolveFilters.push(filters);
return [videoEvent];
},
};
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
assert.equal(
resolvedFromPointer.length,
1,
"resolveWatchHistory should return results for 'a' style video pointers"
);
assert.equal(
resolvedFromPointer[0].id,
videoEvent.id,
"resolved video should match the pointer event id"
);
assert.equal(
resolvedFromPointer[0].title,
"Pointer Video",
"resolved video should parse title from the event payload"
);
assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata");
assert.equal(
resolvedFromPointer[0].watchHistory.key,
pointerKey,
"watchHistory metadata should expose the pointer key"
);
assert.equal(
resolvedFromPointer[0].watchHistory.pointer.value,
videoPointer.value,
"watchHistory metadata should include the pointer payload"
);
assert.equal(
resolvedFromPointer[0].watchHistory.pointer.watchedAt,
pointerEntry.items[0].watchedAt,
"watchHistory metadata should preserve watchedAt"
);
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
assert(
cachedEntry.resolvedVideos.has(pointerKey),
"resolved cache should retain the video"
);
assert.equal(
cachedEntry.resolvedVideos.get(pointerKey)?.id,
videoEvent.id,
"resolved cache should store the converted video result"
);
assert(
cachedEntry.delivered.has(pointerKey),
"delivery set should mark the pointer as delivered"
);
assert(
observedResolveFilters.some((filters) =>
filters.some((filter) =>
Array.isArray(filter?.["#d"]) &&
filter["#d"].includes(VIDEO_POINTER_IDENTIFIER)
)
),
"resolveWatchHistory should request the video identifier instead of treating it as a chunk"
);
const secondResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
secondResolve.length,
0,
"subsequent resolve calls should avoid refetching already delivered videos"
);
pointerClient.resetWatchHistoryProgress(ACTOR);
assert.equal(
cachedEntry.delivered.size,
0,
"resetWatchHistoryProgress should clear delivered pointers"
);
pointerClient.pool = {
list: async () => {
throw new Error("cached resolve should not trigger network fetch");
},
};
const cachedResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
cachedResolve.length,
1,
"reset should allow cached videos to be delivered again"
);
assert.equal(
cachedResolve[0].id,
videoEvent.id,
"cached resolve should reuse the stored video payload"
);
assert(
cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt,
"cached resolve should retain watchHistory metadata"
);
const originalDateNow = Date.now;
try {
const baseTime = 1_700_000_000_000;
const ttlActor = `${ACTOR}-ttl`;
const pointer = { type: "e", value: "event-ttl", relay: null };
const cachingClient = createDecryptClient(ttlActor);
cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000;
Date.now = () => baseTime;
const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime);
cachingClient.watchHistoryCache.set(ttlActor, entry);
cachingClient.persistWatchHistoryEntry(ttlActor, entry);
const reloadClient = createDecryptClient(ttlActor);
reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs;
Date.now = () => baseTime + 60 * 60 * 1000;
const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor);
assert.equal(
cachedSnapshot.items.length,
1,
"cached snapshot should survive within extended TTL"
);
assert.equal(
cachedSnapshot.items[0].value,
pointer.value,
"cached snapshot should preserve pointer"
);
localStorage.clear();
const expiryClient = createDecryptClient(ttlActor);
expiryClient.watchHistoryCacheTtlMs = 1_000;
Date.now = () => baseTime;
const expiringEntry = expiryClient.createWatchHistoryEntry(
null,
[pointer],
baseTime
);
expiryClient.watchHistoryCache.set(ttlActor, expiringEntry);
expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry);
Date.now = () => baseTime + 1_500;
const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor);
assert.equal(
expiredSnapshot.items.length,
0,
"expired snapshot should be dropped"
);
const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor);
assert.equal(
cachedFallback?.items?.length ?? 0,
0,
"expired snapshot should not preserve stale items in memory"
);
const storage = expiryClient.getWatchHistoryStorage();
assert.equal(
Object.prototype.hasOwnProperty.call(storage.actors, ttlActor),
false,
"expired snapshot should be removed from localStorage"
);
} finally {
Date.now = originalDateNow;
}
console.log("watch history tests passed");
+26
View File
@@ -0,0 +1,26 @@
<!-- views/history.html -->
<section id="watchHistoryView" class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold text-white">Watch History</h2>
</div>
<div
id="watchHistoryLoading"
class="flex flex-col items-center justify-center py-12 space-y-4"
>
<div
class="h-10 w-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
aria-hidden="true"
></div>
<p
id="watchHistoryStatus"
class="text-gray-300 text-center hidden"
aria-live="polite"
></p>
</div>
<div id="watchHistoryGrid" class="watch-history-grid hidden"></div>
<p id="watchHistoryEmpty" class="hidden text-gray-400 text-center py-12">
Your watch history is empty. Watch some videos to populate this list.
</p>
<div id="watchHistorySentinel" class="h-1 w-full"></div>
<script type="module" src="js/historyView.js"></script>
</section>