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** (`.r2.cloudflarestorage.com`), and Cloudflare’s UI does **not** require adding `OPTIONS` to `AllowedMethods`. +> **Note:** Replace the `AllowedOrigins` values with the exact origins you use in development and production (the scheme and port must match exactly). Uploads go through the **S3 API endpoint** (`.r2.cloudflarestorage.com`), and browser-based uploads require `OPTIONS` for preflight requests. 5. Click **"Save"**. -### 3. Create an API Token +### 3. Create an API Token (S3 Credentials) 1. Go back to the **R2 Overview** page. 2. Click **"Manage R2 API Tokens"** (right sidebar). @@ -54,17 +54,19 @@ To allow your browser to upload files directly to Cloudflare, you must allow Cro - **Specific Bucket(s)**: Select the bucket you created in Step 1. - **TTL**: "Forever". 5. Click **"Create API Token"**. +6. **Copy the credentials**: You will need the **Access Key ID** and **Secret Access Key**. ### 4. Configure bitvid 1. Copy the **Access Key ID**, **Secret Access Key**, and your **Account ID** (found on the R2 Overview page). -2. Return to the **Upload Video** modal in bitvid. -3. Click **"Configure R2 Storage"**. -4. Enter your: +2. In bitvid, open your **Profile** (click your avatar). +3. Navigate to the **Storage** tab (or click **"Configure R2 Storage"** in the Upload Modal to be redirected there). +4. Click **"Add Connection"** and select **Cloudflare R2**. +5. Enter your: - **Account ID** - **Access Key ID** - **Secret Access Key** - **Public Bucket URL** (from Step 1) -5. Click **"Verify & Save"**. +6. Click **"Save Connection"**. -bitvid will verify your credentials by uploading a small test file. Once verified, you are ready to upload! +bitvid will verify your credentials by uploading a small test file. Once verified, return to the Upload Modal to start sharing! From a50e5c861a56fc27ca0d664f37ac9960cf5b2d87 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:05:13 +0000 Subject: [PATCH 004/117] Add subscription history and backup/restore functionality - Added `SUBSCRIPTION_BACKUP` note type schema - Implemented `createBackup`, `fetchHistory`, `restoreBackup`, `rebroadcastBackup` in `SubscriptionsManager` - Added Subscription History UI modal with list/details view - Integrated History/Backup buttons into Profile Modal Subscriptions pane - Fixed XSS vulnerability in history details view - Added profile fetching for history details Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- components/profile-modal.html | 36 ++- components/subscription-history-modal.html | 111 +++++++ js/nostrEventSchemas.js | 15 + js/subscriptions.js | 97 +++++- js/ui/profileModalController.js | 32 ++ js/ui/subscriptionHistoryController.js | 352 +++++++++++++++++++++ 6 files changed, 633 insertions(+), 10 deletions(-) create mode 100644 components/subscription-history-modal.html create mode 100644 js/ui/subscriptionHistoryController.js diff --git a/components/profile-modal.html b/components/profile-modal.html index 301b7d0c..b610f523 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1087,15 +1087,33 @@ >

Subscriptions

- +
+ + + +
diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index d96aa848..1cd60a93 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -33,6 +33,7 @@ export const NOTE_TYPES = Object.freeze({ ZAP_RECEIPT: "zapReceipt", WATCH_HISTORY: "watchHistory", SUBSCRIPTION_LIST: "subscriptionList", + SUBSCRIPTION_BACKUP: "subscriptionBackup", USER_BLOCK_LIST: "userBlockList", HASHTAG_PREFERENCES: "hashtagPreferences", DM_RELAY_LIST: "dmRelayList", @@ -520,6 +521,20 @@ const BASE_SCHEMAS = { "NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', ], …]).", }, }, + [NOTE_TYPES.SUBSCRIPTION_BACKUP]: { + type: NOTE_TYPES.SUBSCRIPTION_BACKUP, + label: "Subscription list backup", + kind: 30000, + identifierTag: { + name: "d", + }, + appendTags: [["encrypted", "nip44_v2"]], + content: { + format: "encrypted-tag-list", + description: + "NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', ], …]).", + }, + }, [NOTE_TYPES.USER_BLOCK_LIST]: { type: NOTE_TYPES.USER_BLOCK_LIST, label: "User block list (legacy)", diff --git a/js/subscriptions.js b/js/subscriptions.js index d5cd75e5..a20788e3 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1133,7 +1133,7 @@ class SubscriptionsManager { * as kind=30000 with ["d", "subscriptions"] to be replaceable. * The published event includes an ["encrypted", ""] tag. */ - async publishSubscriptionList(userPubkey) { + async publishSubscriptionList(userPubkey, { isBackup = false } = {}) { if (!userPubkey) { throw new Error("No pubkey => cannot publish subscription list."); } @@ -1258,6 +1258,17 @@ class SubscriptionsManager { encryption: encryptionTagValue, }); + if (isBackup) { + const backupId = `subscriptions-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`; + // Override d tag + const dTagIndex = evt.tags.findIndex(t => t[0] === 'd'); + if (dTagIndex >= 0) { + evt.tags[dTagIndex][1] = backupId; + } else { + evt.tags.push(['d', backupId]); + } + } + let signedEvent; try { signedEvent = await signer.signEvent(evt); @@ -2065,6 +2076,90 @@ class SubscriptionsManager { }); } + async createBackup(userPubkey) { + const backupEvent = await this.publishSubscriptionList(userPubkey, { + isBackup: true, + }); + return backupEvent; + } + + async fetchHistory(userPubkey) { + if (!userPubkey) return []; + const normalizedUserPubkey = normalizeHexPubkey(userPubkey); + + // Fetch all kind 30000 events for this user to find backups and old versions + const events = await nostrClient.fetchEvents({ + kinds: [SUBSCRIPTION_SET_KIND], + authors: [normalizedUserPubkey], + limit: 50, // Reasonable limit for history + }); + + return events + .filter((e) => { + const dTag = e.tags.find((t) => t[0] === "d")?.[1]; + return ( + dTag === SUBSCRIPTION_LIST_IDENTIFIER || + (typeof dTag === "string" && dTag.startsWith("subscriptions-backup-")) + ); + }) + .sort((a, b) => b.created_at - a.created_at); + } + + async restoreBackup(event, userPubkey) { + if (!event || !userPubkey) return; + + const decryptResult = await this.decryptSubscriptionEvent(event, userPubkey); + if (!decryptResult.ok) { + throw decryptResult.error || new Error("Failed to decrypt backup"); + } + + const decryptedStr = decryptResult.plaintext; + const normalized = parseSubscriptionPlaintext(decryptedStr); + + // Update local state + this.subscribedPubkeys = new Set(normalized); + this.saveToCache(userPubkey); + + // Publish as current list + await this.publishSubscriptionList(userPubkey); + + this.emitter.emit("change", { + action: "restore", + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); + + await this.refreshActiveFeed({ reason: "restore-backup" }); + } + + async rebroadcastBackup(event, userPubkey) { + if (!event || !userPubkey) return; + + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (!dTag) throw new Error("Invalid backup event: missing d tag"); + + let signer = getActiveSigner(); + if (!signer) { + signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey); + } + + const newEvent = { + kind: event.kind, + created_at: Math.floor(Date.now() / 1000), + tags: event.tags, + content: event.content, + pubkey: userPubkey + }; + + const signedEvent = await signer.signEvent(newEvent); + await publishEventToRelays( + nostrClient.pool, + sanitizeRelayList(nostrClient.writeRelays), + signedEvent + ); + + return signedEvent; + } + convertEventToVideo(evt) { return sharedConvertEventToVideo(evt); } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 3801e144..8ced99b4 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -43,6 +43,7 @@ import { getLinkPreviewSettings, setLinkPreviewAutoFetch, } from "../utils/linkPreviewSettings.js"; +import { SubscriptionHistoryController } from "./subscriptionHistoryController.js"; const noop = () => {}; @@ -1436,6 +1437,7 @@ export class ProfileModalController { this.addAccountButtonState = null; this.adminEmptyMessages = new Map(); this.hashtagPreferencesUnsubscribe = null; + this.subscriptionHistoryController = new SubscriptionHistoryController(); if ( this.subscriptionsService && @@ -1622,6 +1624,10 @@ export class ProfileModalController { document.getElementById("subscriptionsEmpty") || null; this.profileSubscriptionsRefreshBtn = document.getElementById("subscriptionsRefreshBtn") || null; + this.profileSubscriptionsHistoryBtn = + document.getElementById("subscriptionsHistoryBtn") || null; + this.profileSubscriptionsBackupBtn = + document.getElementById("subscriptionsBackupBtn") || null; this.friendList = document.getElementById("friendsList") || null; this.friendListEmpty = document.getElementById("friendsEmpty") || null; this.friendInput = document.getElementById("friendsInput") || null; @@ -5667,6 +5673,32 @@ export class ProfileModalController { }); } + if (this.profileSubscriptionsHistoryBtn) { + this.profileSubscriptionsHistoryBtn.addEventListener("click", () => { + const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); + if (pubkey) { + this.subscriptionHistoryController.show(pubkey); + } else { + this.showError("Please log in to view subscription history."); + } + }); + } + + if (this.profileSubscriptionsBackupBtn) { + this.profileSubscriptionsBackupBtn.addEventListener("click", () => { + const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); + if (pubkey) { + if (confirm("Create a backup of your current subscription list?")) { + this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => { + this.showSuccess("Backup created."); + }); + } + } else { + this.showError("Please log in to backup subscriptions."); + } + }); + } + if (this.walletUriInput instanceof HTMLElement) { this.walletUriInput.addEventListener("focus", () => { this.revealSecretInputValue(this.walletUriInput); diff --git a/js/ui/subscriptionHistoryController.js b/js/ui/subscriptionHistoryController.js new file mode 100644 index 00000000..9eb08a72 --- /dev/null +++ b/js/ui/subscriptionHistoryController.js @@ -0,0 +1,352 @@ +import { subscriptions } from "../subscriptions.js"; +import { profileCache } from "../state/profileCache.js"; +import { formatTimeAgo } from "../utils/formatters.js"; +import { userLogger } from "../utils/logger.js"; +import { getApplication } from "../applicationContext.js"; + +export class SubscriptionHistoryController { + constructor(options = {}) { + this.modal = null; + this.activePubkey = null; + this.selectedEvent = null; + this.historyEvents = []; + this.initialized = false; + this.callbacks = { + onClose: options.onClose || (() => {}), + }; + } + + async load() { + if (this.initialized) return; + + try { + const response = await fetch("components/subscription-history-modal.html"); + if (!response.ok) throw new Error("HTML not found"); + const html = await response.text(); + const template = document.createElement("template"); + template.innerHTML = html; + + // Append to body if not already present (avoid duplicates) + if (!document.getElementById("subscriptionHistoryModal")) { + document.body.appendChild(template.content); + } + + this.modal = document.getElementById("subscriptionHistoryModal"); + this.bindElements(); + this.initialized = true; + } catch (error) { + userLogger.error("Failed to load subscription history modal:", error); + } + } + + bindElements() { + if (!this.modal) return; + + this.listContainer = this.modal.querySelector("#subscriptionHistoryList"); + this.detailsContainer = this.modal.querySelector( + "#subscriptionHistoryDetailsContent" + ); + this.detailsEmpty = this.modal.querySelector( + "#subscriptionHistoryDetailsEmpty" + ); + this.loadingIndicator = this.modal.querySelector( + "#subscriptionHistoryLoading" + ); + this.emptyIndicator = this.modal.querySelector("#subscriptionHistoryEmpty"); + this.decryptingIndicator = this.modal.querySelector( + "#subscriptionHistoryDecrypting" + ); + this.selectedChannelsList = this.modal.querySelector( + "#subscriptionHistorySelectedChannels" + ); + this.selectedDate = this.modal.querySelector( + "#subscriptionHistorySelectedDate" + ); + this.selectedMeta = this.modal.querySelector( + "#subscriptionHistorySelectedMeta" + ); + this.selectedBadge = this.modal.querySelector( + "#subscriptionHistorySelectedBadge" + ); + + this.closeBtn = this.modal.querySelector("#closeSubscriptionHistoryModal"); + this.restoreBtn = this.modal.querySelector("#subscriptionHistoryRestoreBtn"); + this.rebroadcastBtn = this.modal.querySelector( + "#subscriptionHistoryRebroadcastBtn" + ); + + this.closeBtn?.addEventListener("click", () => this.hide()); + this.restoreBtn?.addEventListener("click", () => this.handleRestore()); + this.rebroadcastBtn?.addEventListener("click", () => + this.handleRebroadcast() + ); + + this.modal + .querySelector(".bv-modal-backdrop") + ?.addEventListener("click", () => this.hide()); + } + + async show(pubkey) { + if (!this.initialized) await this.load(); + if (!this.modal) return; + + this.activePubkey = pubkey; + this.modal.classList.remove("hidden"); + this.modal.setAttribute("aria-hidden", "false"); + // Ensure modal-open class is added to body for scroll locking if design system requires it + document.body.classList.add("modal-open"); + + this.fetchHistory(); + } + + hide() { + if (!this.modal) return; + this.modal.classList.add("hidden"); + this.modal.setAttribute("aria-hidden", "true"); + // Only remove if no other modals are open, ideally. + // But for simplicity: + document.body.classList.remove("modal-open"); + + this.selectedEvent = null; + // Reset view + this.detailsEmpty.classList.remove("hidden"); + this.detailsContainer.classList.add("hidden"); + this.callbacks.onClose(); + } + + async fetchHistory() { + if (!this.loadingIndicator) return; + this.loadingIndicator.classList.remove("hidden"); + this.listContainer.innerHTML = ""; + this.emptyIndicator.classList.add("hidden"); + + try { + this.historyEvents = await subscriptions.fetchHistory(this.activePubkey); + this.renderList(); + } catch (error) { + userLogger.error("Failed to fetch subscription history:", error); + this.emptyIndicator.textContent = "Failed to load history."; + this.emptyIndicator.classList.remove("hidden"); + } finally { + this.loadingIndicator.classList.add("hidden"); + } + } + + renderList() { + this.listContainer.innerHTML = ""; + if (!this.historyEvents.length) { + this.emptyIndicator.classList.remove("hidden"); + return; + } + + this.historyEvents.forEach((event) => { + const item = document.createElement("li"); + item.className = + "card p-3 cursor-pointer hover:bg-surface-2 transition-colors border-l-4 border-transparent rounded-r-md"; + + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + const isBackup = dTag && dTag.startsWith("subscriptions-backup-"); + + const date = new Date(event.created_at * 1000); + const dateStr = date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + const timeAgo = formatTimeAgo(event.created_at); + + item.innerHTML = ` +
+
+

${ + isBackup ? "Backup" : "Standard List" + }

+

${dateStr}

+
+ ${timeAgo} +
+ `; + + item.addEventListener("click", () => { + this.listContainer.querySelectorAll("li").forEach((li) => { + li.classList.remove("bg-surface-2", "border-accent"); + li.classList.add("border-transparent"); + }); + item.classList.add("bg-surface-2", "border-accent"); + item.classList.remove("border-transparent"); + this.selectEvent(event); + }); + + this.listContainer.appendChild(item); + }); + } + + async selectEvent(event) { + this.selectedEvent = event; + this.detailsEmpty.classList.add("hidden"); + this.detailsContainer.classList.remove("hidden"); + this.selectedChannelsList.innerHTML = ""; + this.decryptingIndicator.classList.remove("hidden"); + + const date = new Date(event.created_at * 1000); + this.selectedDate.textContent = date.toLocaleString(undefined, { + dateStyle: "full", + timeStyle: "short", + }); + + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + const isBackup = dTag?.startsWith("subscriptions-backup-"); + + this.selectedBadge.textContent = isBackup ? "Backup" : "Active / Standard"; + this.selectedBadge.dataset.variant = isBackup ? "accent" : "surface"; + + try { + const decryptResult = await subscriptions.decryptSubscriptionEvent( + event, + this.activePubkey + ); + if (!decryptResult.ok) { + throw new Error("Decryption failed"); + } + + let pubkeys = []; + try { + const parsed = JSON.parse(decryptResult.plaintext); + if (Array.isArray(parsed)) { + // [['p', hex], ...] + pubkeys = parsed + .filter((t) => Array.isArray(t) && t[0] === "p") + .map((t) => t[1]); + } + } catch (e) { + userLogger.warn("Failed to parse plaintext JSON", e); + } + + this.selectedMeta.textContent = `${pubkeys.length} channels`; + this.renderChannels(pubkeys); + } catch (error) { + userLogger.error("Failed to decrypt event:", error); + this.selectedChannelsList.innerHTML = `

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 @@
From ffa6b7076d5a7cc172781129c8f2eec8bba004a5 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 14:02:35 +0000 Subject: [PATCH 006/117] fix(dm): prevent repeated peek animation on re-renders Use module-level variables to track if the peek animation has played for ConversationList and MessageThread, ensuring it only runs once per page session to avoid annoying scrolling behavior. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/ui/dm/ConversationList.js | 6 +++++- js/ui/dm/MessageThread.js | 21 ++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/js/ui/dm/ConversationList.js b/js/ui/dm/ConversationList.js index 5aec1cd4..fc6f7545 100644 --- a/js/ui/dm/ConversationList.js +++ b/js/ui/dm/ConversationList.js @@ -1,5 +1,7 @@ import { ContactRow } from "./ContactRow.js"; +let hasPeekAnimationPlayed = false; + function createElement(doc, tag, className, text) { const element = doc.createElement(tag); if (className) { @@ -93,12 +95,13 @@ export function ConversationList({ }); // Peek animation - if (conversations.length > 0) { + if (conversations.length > 0 && !hasPeekAnimationPlayed) { if (typeof IntersectionObserver !== "undefined") { const observer = new IntersectionObserver((entries) => { const entry = entries[0]; if (entry.isIntersecting) { observer.disconnect(); + hasPeekAnimationPlayed = true; setTimeout(() => { if (list.scrollHeight > list.clientHeight) { list.scrollTo({ top: 40, behavior: "smooth" }); @@ -112,6 +115,7 @@ export function ConversationList({ observer.observe(list); } else { // Fallback for no IntersectionObserver (e.g. some tests or very old browsers) + hasPeekAnimationPlayed = true; setTimeout(() => { if (list.scrollHeight > list.clientHeight) { list.scrollTo({ top: 40, behavior: "smooth" }); diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index de405db2..e03f734d 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -1,6 +1,8 @@ import { DayDivider } from "./DayDivider.js"; import { MessageBubble } from "./MessageBubble.js"; +let hasPeekAnimationPlayed = false; + function createElement(doc, tag, className, text) { const element = doc.createElement(tag); if (className) { @@ -142,10 +144,13 @@ export function MessageThread({ // Scroll to bottom and peek if (messages.length > 0) { - const animate = () => { - timeline.scrollTop = timeline.scrollHeight; + const performPeek = () => { + if (hasPeekAnimationPlayed) { + return; + } if (timeline.scrollHeight > timeline.clientHeight) { + hasPeekAnimationPlayed = true; setTimeout(() => { const targetScroll = Math.max(0, timeline.scrollTop - 40); timeline.scrollTo({ top: targetScroll, behavior: "smooth" }); @@ -160,6 +165,11 @@ export function MessageThread({ } }; + const initializeScroll = () => { + timeline.scrollTop = timeline.scrollHeight; + performPeek(); + }; + if (typeof IntersectionObserver !== "undefined") { const observer = new IntersectionObserver((entries) => { const entry = entries[0]; @@ -167,15 +177,12 @@ export function MessageThread({ observer.disconnect(); // First ensure we are at bottom timeline.scrollTop = timeline.scrollHeight; - setTimeout(animate, 300); + setTimeout(initializeScroll, 300); } }, { threshold: 0.1 }); observer.observe(timeline); } else { - setTimeout(() => { - timeline.scrollTop = timeline.scrollHeight; - animate(); - }, 0); + setTimeout(initializeScroll, 0); } } From 5df489e504ddc98489c1debbd43b3a5a24f3043a 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 14:03:09 +0000 Subject: [PATCH 007/117] Combine relay list with relay health check - Remove separate relay health panel from profile modal. - Merge health indicators (latency, errors) directly into the relay list items. - Consolidate refresh logic to update both list configuration and health status. - Move telemetry toggle to the bottom of the relay section. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- components/profile-modal.html | 30 +---- js/ui/profileModalController.js | 196 ++++++++++---------------------- verification/relay_ui.png | Bin 0 -> 64622 bytes verification/verify_relay_ui.py | 75 ++++++++++++ 4 files changed, 133 insertions(+), 168 deletions(-) create mode 100644 verification/relay_ui.png create mode 100644 verification/verify_relay_ui.py diff --git a/components/profile-modal.html b/components/profile-modal.html index b610f523..4ecaef64 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -718,34 +718,7 @@
    -
    -
    -
    -

    Relay health

    -

    - Monitor connectivity, latency, and errors for your configured relays. -

    -
    - -
    -
    -
    -

    - Relay status -

    -

    -
    -

    -
    +