From 24a8d1da80e8c52f96da92d26b1f38e9f4b153e9 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 2 Feb 2026 02:47:05 +0000
Subject: [PATCH 001/117] optimize user block list loading performance
Prioritizes fetching standard NIP-51 (Kind 10000) mute lists with a fail-fast timeout (6s) and applies them immediately upon receipt. Legacy block lists (Kind 30002, Kind 10000 d=user-blocks) are now fetched in the background without blocking the UI initialization.
This resolves an issue where slow relays handling legacy queries could cause the entire block list loading process to hang for minutes.
Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
---
js/userBlocks.js | 63 ++++++++++++++++++++++++++++++++++--------------
1 file changed, 45 insertions(+), 18 deletions(-)
diff --git a/js/userBlocks.js b/js/userBlocks.js
index 41834dca..48daef32 100644
--- a/js/userBlocks.js
+++ b/js/userBlocks.js
@@ -1468,14 +1468,18 @@ class UserBlockListManager {
: 0;
// Concurrent incremental fetch for all variants
- const [muteEvents, blockEvents, legacyEvents] = await Promise.all([
- nostrClient.fetchListIncrementally({
- kind: 10000,
- pubkey: normalized,
- relayUrls: relays,
- since: fetchSince,
- timeoutMs: 12000,
- }),
+ // We prioritize standard NIP-51 lists (Kind 10000) to ensure the UI loads quickly.
+ // Legacy variants are fetched in parallel but processed separately to avoid blocking.
+
+ const standardFetchPromise = nostrClient.fetchListIncrementally({
+ kind: 10000,
+ pubkey: normalized,
+ relayUrls: relays,
+ since: fetchSince,
+ timeoutMs: 6000,
+ });
+
+ const legacyFetchPromise = Promise.all([
nostrClient.fetchListIncrementally({
kind: 10000,
pubkey: normalized,
@@ -1494,18 +1498,41 @@ class UserBlockListManager {
}),
]);
- const combined = [...muteEvents, ...blockEvents, ...legacyEvents];
-
- // If combined is empty, check if we have loaded state
- if (!combined.length) {
- if (!this.loaded) {
- // Assuming empty if we have no prior state
- await applyEvents([], { source: "incremental" });
- }
- } else {
- await applyEvents(combined, { source: "incremental" });
+ // FAST PATH: Process Standard List immediately
+ let standardEvents = [];
+ try {
+ standardEvents = await standardFetchPromise;
+ if (Array.isArray(standardEvents) && standardEvents.length > 0) {
+ await applyEvents(standardEvents, { source: "fast-standard" });
+ this.loaded = true;
+ }
+ } catch (standardError) {
+ devLogger.warn("[UserBlockList] Standard fetch failed:", standardError);
}
+ // SLOW PATH: Process Legacy Lists (and Standard if it was empty/failed)
+ // We do NOT await this for the main return, allowing the UI to unblock immediately
+ // if the standard list was found.
+ legacyFetchPromise
+ .then(async ([blockEvents, legacyEvents]) => {
+ const combined = [...standardEvents, ...blockEvents, ...legacyEvents];
+
+ if (!combined.length) {
+ // Only apply empty if we haven't already marked loaded (meaning standard failed or was empty)
+ // or if we want to ensure we settle state.
+ // If standard was found, we don't need to do anything for empty legacy.
+ if (!this.loaded) {
+ await applyEvents([], { source: "empty-result" });
+ }
+ } else {
+ // Re-apply merged results to catch any legacy blocks
+ await applyEvents(combined, { source: "incremental-merge" });
+ }
+ })
+ .catch((legacyError) => {
+ devLogger.warn("[UserBlockList] Legacy fetch failed:", legacyError);
+ });
+
} catch (error) {
userLogger.error("[UserBlockList] loadBlocks failed (keeping local state):", error);
From 511cfa25cf029cbaa24951e6cc5a870f7843335d Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 2 Feb 2026 02:55:55 +0000
Subject: [PATCH 002/117] fix(for-you): preload watch history and filter
watched videos
Preload watch history in `refreshForYouFeed` to ensure `watchHistoryService` has the latest data.
Update `buildForYouFeedRuntime` to include a `watchHistory` suppression hook that checks against preloaded watch history items.
Register `createWatchHistorySuppressionStage` in the "for-you" feed pipeline to filter out watched videos.
Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
---
js/app.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 66 insertions(+), 3 deletions(-)
diff --git a/js/app.js b/js/app.js
index 3bf32daf..43cd3698 100644
--- a/js/app.js
+++ b/js/app.js
@@ -47,6 +47,7 @@ import {
createModerationStage,
createResolvePostedAtStage,
createTagPreferenceFilterStage,
+ createWatchHistorySuppressionStage,
createChronologicalSorter,
createSubscriptionAuthorsSource,
registerWatchHistoryFeed,
@@ -120,6 +121,7 @@ import RevertModalController from "./ui/revertModalController.js";
import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js";
import ReactionController from "./ui/reactionController.js";
import { pointerArrayToKey } from "./utils/pointer.js";
+import { pointerKey } from "./nostr/watchHistory.js";
import resolveVideoPointer, {
buildVideoAddressPointer,
} from "./utils/videoPointer.js";
@@ -5753,6 +5755,7 @@ class Application {
shouldIncludeVideo: (video, options) =>
this.nostrService.shouldIncludeVideo(video, options),
}),
+ createWatchHistorySuppressionStage(),
createDedupeByRootStage({
dedupe: (videos) => this.dedupeVideosByRoot(videos),
}),
@@ -6093,7 +6096,7 @@ class Application {
}
}
- buildForYouFeedRuntime() {
+ buildForYouFeedRuntime({ watchHistoryItems = [] } = {}) {
const blacklist =
this.blacklistedEventIds instanceof Set
? new Set(this.blacklistedEventIds)
@@ -6111,6 +6114,19 @@ class Application {
const { interests = [], disinterests = [] } = preferenceSource || {};
const moderationThresholds = this.getActiveModerationThresholds();
+ const watchedKeys = new Set();
+ if (Array.isArray(watchHistoryItems)) {
+ for (const item of watchHistoryItems) {
+ const key =
+ typeof item.pointerKey === "string" && item.pointerKey
+ ? item.pointerKey
+ : pointerKey(item.pointer || item);
+ if (key) {
+ watchedKeys.add(key);
+ }
+ }
+ }
+
return {
blacklistedEventIds: blacklist,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
@@ -6121,6 +6137,41 @@ class Application {
moderationThresholds: moderationThresholds
? { ...moderationThresholds }
: undefined,
+ watchHistory: {
+ shouldSuppress: (item) => {
+ const video = item?.video;
+ if (!video || typeof video !== "object") {
+ return false;
+ }
+
+ if (video.id) {
+ const eKey = pointerKey({ type: "e", value: video.id });
+ if (watchedKeys.has(eKey)) {
+ return true;
+ }
+ }
+
+ if (
+ Number.isFinite(video.kind) &&
+ video.kind >= 30000 &&
+ video.kind < 40000 &&
+ typeof video.pubkey === "string"
+ ) {
+ const dTag = Array.isArray(video.tags)
+ ? video.tags.find((t) => Array.isArray(t) && t[0] === "d")
+ : null;
+ const dValue = dTag && typeof dTag[1] === "string" ? dTag[1] : "";
+ if (dValue) {
+ const aValue = `${video.kind}:${video.pubkey}:${dValue}`;
+ const aKey = pointerKey({ type: "a", value: aValue });
+ if (watchedKeys.has(aKey)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+ },
};
}
@@ -6324,8 +6375,20 @@ class Application {
};
}
- refreshForYouFeed({ reason, fallbackVideos } = {}) {
- const runtime = this.buildForYouFeedRuntime();
+ async refreshForYouFeed({ reason, fallbackVideos } = {}) {
+ let watchHistoryItems = [];
+ try {
+ watchHistoryItems = await watchHistoryService.loadLatest(undefined, {
+ allowStale: true,
+ });
+ } catch (error) {
+ devLogger.warn(
+ "[Application] Failed to preload watch history for For You feed:",
+ error,
+ );
+ }
+
+ const runtime = this.buildForYouFeedRuntime({ watchHistoryItems });
const normalizedReason = typeof reason === "string" ? reason : undefined;
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
From 55b588ded92e6d6d37a1e55800671cda0bb90b03 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 2 Feb 2026 03:02:16 +0000
Subject: [PATCH 003/117] docs: update /content user guides to match upload
workflow and moderation config
Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
---
content/docs/community/community-guidelines.md | 2 +-
content/docs/getting-started.md | 6 ++++--
content/docs/guides/upload-content.md | 18 ++++++++++--------
3 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/content/docs/community/community-guidelines.md b/content/docs/community/community-guidelines.md
index d401d7d7..e8becbdc 100644
--- a/content/docs/community/community-guidelines.md
+++ b/content/docs/community/community-guidelines.md
@@ -56,7 +56,7 @@ To maintain a functional and ethical platform, the following types of content ar
bitvid enforces these guidelines through client-side visibility rules that match the current `moreMenuController` and `userBlocks` behavior:
-- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway� override. The upstream configuration currently uses three trusted reporters, but adjust the export to match your policy.
+- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway� override. The upstream configuration currently uses one trusted report, but adjust the export to match your policy.
- **Downranked authors:** Muting a creator adds them to your NIP-51 mute list (kind `10000`). Videos from muted creators are deprioritized but still visible with clear badges so you can reverse the action.
- **Personal blocks:** Blocking a creator adds them to your private block list (`userBlocks`). Videos from blocked creators are hidden from feeds, carousels, and modals unless you explicitly unblock them.
- **Admin hard hides:** Moderator-maintained blacklists (NIP-51 kind `30000`) remove creators from default feeds for everyone using the hosted client. Moderators can still review the content through “show anyway� controls when auditing decisions.
diff --git a/content/docs/getting-started.md b/content/docs/getting-started.md
index d3f46855..40a8402c 100644
--- a/content/docs/getting-started.md
+++ b/content/docs/getting-started.md
@@ -26,9 +26,11 @@ Ready to jump in? Here's everything you need to know to start watching and shari
### Step 3: Share on bitvid
1. Click "Share a Video" on bitvid to open the upload modal.
-2. In **Custom** mode, enter your title, paste the hosted HTTPS URL, and (optionally) add the magnet plus any `ws`/`xs` hints. Switch to the experimental **Cloudflare** quick upload mode if you want bitvid to push the file straight to your Cloudflare R2 bucket—have your Account ID, S3 Access Key ID, and Secret Access Key ready (the advanced toggle also accepts an API token, Zone ID, and base domain if you use them).
+2. Choose your upload mode:
+ - **Upload Video**: Select a file from your device to upload directly to your Cloudflare R2 bucket. If you haven't configured storage yet, click **"Configure Storage"** to enter your R2 credentials (Account ID, Access Key ID, Secret Access Key) in your Profile settings.
+ - **External URL**: If you already host your video elsewhere, paste the HTTPS URL and (optionally) a WebTorrent magnet link.
3. Review the rest of the form: add a description and thumbnail, decide whether to allow comments, and set the NSFW or "For Kids" toggles to match your content. Explore the metadata section to fill in captions, duration, publish time, hashtags, participants, and other fields so first-time viewers have all the context they need.
-4. Click "Post" to publish once everything looks right.
+4. Click "Publish Video" to post your content.
## Tips for Success
diff --git a/content/docs/guides/upload-content.md b/content/docs/guides/upload-content.md
index ff8665e8..9dcc805f 100644
--- a/content/docs/guides/upload-content.md
+++ b/content/docs/guides/upload-content.md
@@ -32,18 +32,18 @@ To allow your browser to upload files directly to Cloudflare, you must allow Cro
[
{
"AllowedOrigins": ["http://127.0.0.1:5500", "https://bitvid.network"],
- "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
+ "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
"MaxAgeSeconds": 3600
}
]
```
-> **Note:** Replace the `AllowedOrigins` values with the exact origins you use in development and production (the scheme and port must match exactly). Apply this policy on the **bucket’s S3 API endpoint**, not just the public URL or custom domain. Uploads go through the **S3 API endpoint** (`
${ + isBackup ? "Backup" : "Standard List" + }
+${dateStr}
+Decryption failed or content is invalid.
`; + this.selectedMeta.textContent = "Unknown content"; + } finally { + this.decryptingIndicator.classList.add("hidden"); + } + } + + async renderChannels(pubkeys) { + this.selectedChannelsList.innerHTML = ""; + + const app = getApplication(); + if (app && typeof app.batchFetchProfiles === "function") { + try { + await app.batchFetchProfiles(pubkeys); + } catch (e) { + userLogger.warn("Failed to batch fetch profiles", e); + } + } + + pubkeys.forEach((pubkey) => { + const profile = profileCache.getProfile(pubkey); + const name = profile?.name || profile?.display_name || pubkey.slice(0, 8); + const avatar = profile?.picture || "assets/svg/default-profile.svg"; + + const item = document.createElement("li"); + item.className = + "flex items-center gap-3 p-2 rounded-md hover:bg-surface-2 border border-transparent hover:border-border/50 transition-colors"; + + const img = document.createElement("img"); + img.src = avatar; + img.className = "w-8 h-8 rounded-full object-cover bg-surface-2"; + img.onerror = function () { + this.src = "assets/svg/default-profile.svg"; + }; + + const div = document.createElement("div"); + div.className = "overflow-hidden"; + + const pName = document.createElement("p"); + pName.className = "text-sm font-medium truncate text-text"; + pName.textContent = name; + + const pKey = document.createElement("p"); + pKey.className = "text-xs text-muted truncate font-mono"; + pKey.textContent = `${pubkey.slice(0, 10)}...`; + + div.appendChild(pName); + div.appendChild(pKey); + + item.appendChild(img); + item.appendChild(div); + + this.selectedChannelsList.appendChild(item); + }); + } + + async handleRestore() { + if (!this.selectedEvent) return; + if ( + !confirm( + "Are you sure you want to restore this list? It will overwrite your current subscriptions." + ) + ) + return; + + this.restoreBtn.disabled = true; + this.restoreBtn.textContent = "Restoring..."; + + try { + await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey); + // alert("Subscriptions restored successfully!"); // alert blocks, toast is better if available, but alert is fine for now + this.hide(); + } catch (error) { + userLogger.error("Restore failed:", error); + alert("Failed to restore: " + error.message); + } finally { + this.restoreBtn.disabled = false; + this.restoreBtn.textContent = "Restore as Active"; + } + } + + async handleRebroadcast() { + if (!this.selectedEvent) return; + this.rebroadcastBtn.disabled = true; + this.rebroadcastBtn.textContent = "Broadcasting..."; + + try { + await subscriptions.rebroadcastBackup( + this.selectedEvent, + this.activePubkey + ); + // Refresh to show potentially new event + this.fetchHistory(); + } catch (error) { + userLogger.error("Rebroadcast failed:", error); + alert("Failed to rebroadcast: " + error.message); + } finally { + this.rebroadcastBtn.disabled = false; + this.rebroadcastBtn.textContent = "Rebroadcast"; + } + } + + async handleCreateBackup(pubkey) { + if (!pubkey) return; + + try { + await subscriptions.createBackup(pubkey); + // If modal is open, refresh + if ( + this.modal && + !this.modal.classList.contains("hidden") && + this.activePubkey === pubkey + ) { + this.fetchHistory(); + } else { + // Show success feedback + // Using a simple alert or console log if UI controller doesn't pass a toast function + // userLogger.info("Backup created"); + } + } catch (error) { + userLogger.error("Backup failed:", error); + alert("Backup failed: " + error.message); + } + } +} From 00ca2298e12d9f56208045e64c7e2fd5954421f8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 03:11:19 +0000 Subject: [PATCH 005/117] Fix CI lint error: Replace arbitrary tailwind value - Replaced `h-[80vh]` with standard `h-4/5` in `components/subscription-history-modal.html` to satisfy `lint:tailwind-brackets` token rules. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- components/subscription-history-modal.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/subscription-history-modal.html b/components/subscription-history-modal.html index 8718ec00..a47d7cea 100644 --- a/components/subscription-history-modal.html +++ b/components/subscription-history-modal.html @@ -10,7 +10,7 @@- Monitor connectivity, latency, and errors for your configured relays. -
-- Relay status -
- -