diff --git a/js/app.js b/js/app.js
index 4dc4cae4..f1a459ac 100644
--- a/js/app.js
+++ b/js/app.js
@@ -20,12 +20,19 @@ import {
loadR2Settings,
saveR2Settings,
clearR2Settings,
- bucketForNpub,
buildR2Key,
buildPublicUrl,
- createR2Client,
- uploadToR2,
+ mergeBucketEntry,
+ sanitizeBaseDomain,
} from "./r2.js";
+import {
+ sanitizeBucketName,
+ ensureBucket,
+ putCors,
+ attachCustomDomain,
+ enableManagedDomain,
+} from "./storage/r2-mgmt.js";
+import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
/**
* Simple "decryption" placeholder for private videos.
@@ -474,9 +481,6 @@ class bitvidApp {
this.cloudflareClearSettingsButton = null;
this.cloudflareSettingsStatus = null;
this.cloudflareBucketPreview = null;
- this.cloudflareBucketModeInputs = [];
- this.cloudflareManualBucketGroup = null;
- this.cloudflareManualBucketInput = null;
this.cloudflareUploadForm = null;
this.cloudflareFileInput = null;
this.cloudflareUploadButton = null;
@@ -492,7 +496,9 @@ class bitvidApp {
this.r2AccountIdInput = null;
this.r2AccessKeyIdInput = null;
this.r2SecretAccessKeyInput = null;
- this.r2PublicBaseUrlInput = null;
+ this.r2ApiTokenInput = null;
+ this.r2ZoneIdInput = null;
+ this.r2BaseDomainInput = null;
// Optional small inline player stats
this.status = document.getElementById("status") || null;
@@ -1094,13 +1100,6 @@ class bitvidApp {
document.getElementById("cloudflareSettingsStatus") || null;
this.cloudflareBucketPreview =
document.getElementById("cloudflareBucketPreview") || null;
- this.cloudflareBucketModeInputs = Array.from(
- document.querySelectorAll("input[name='r2BucketMode']")
- );
- this.cloudflareManualBucketGroup =
- document.getElementById("r2ManualBucketGroup") || null;
- this.cloudflareManualBucketInput =
- document.getElementById("r2ManualBucket") || null;
this.cloudflareUploadForm =
document.getElementById("cloudflareUploadForm") || null;
this.cloudflareFileInput =
@@ -1131,8 +1130,11 @@ class bitvidApp {
document.getElementById("r2AccessKeyId") || null;
this.r2SecretAccessKeyInput =
document.getElementById("r2SecretAccessKey") || null;
- this.r2PublicBaseUrlInput =
- document.getElementById("r2PublicBaseUrlTemplate") || null;
+ this.r2ApiTokenInput =
+ document.getElementById("r2ApiToken") || null;
+ this.r2ZoneIdInput = document.getElementById("r2ZoneId") || null;
+ this.r2BaseDomainInput =
+ document.getElementById("r2BaseDomain") || null;
// Optional: if close button found, wire up
if (this.closeUploadModalBtn) {
@@ -1163,20 +1165,6 @@ class bitvidApp {
});
}
- if (this.cloudflareBucketModeInputs.length > 0) {
- this.cloudflareBucketModeInputs.forEach((input) => {
- input.addEventListener("change", () => {
- this.handleCloudflareBucketModeChange();
- });
- });
- }
-
- if (this.cloudflareManualBucketInput) {
- this.cloudflareManualBucketInput.addEventListener("input", () => {
- this.handleManualBucketInputChange();
- });
- }
-
if (this.cloudflareUploadForm) {
this.cloudflareUploadForm.addEventListener("submit", (e) => {
e.preventDefault();
@@ -1247,25 +1235,6 @@ class bitvidApp {
}
}
- getCloudflareBucketMode() {
- if (this.cloudflareBucketModeInputs.length === 0) {
- return "auto";
- }
-
- const active = this.cloudflareBucketModeInputs.find(
- (input) => input && input.checked
- );
- return active && active.value === "manual" ? "manual" : "auto";
- }
-
- sanitizeBucketName(name) {
- const raw = String(name || "")
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, "");
- const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, "");
- return trimmed;
- }
-
setCloudflareSettingsStatus(message = "", variant = "info") {
if (!this.cloudflareSettingsStatus) {
return;
@@ -1381,24 +1350,25 @@ class bitvidApp {
accountId: "",
accessKeyId: "",
secretAccessKey: "",
- publicBaseUrlTemplate: "",
- bucketMode: "auto",
- manualBucket: "",
- autoBuckets: {},
+ apiToken: "",
+ zoneId: "",
+ baseDomain: "",
+ buckets: {},
};
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
}
}
populateCloudflareSettingsInputs(settings) {
- const data = settings || {
- accountId: "",
- accessKeyId: "",
- secretAccessKey: "",
- publicBaseUrlTemplate: "",
- bucketMode: "auto",
- manualBucket: "",
- };
+ const data =
+ settings || {
+ accountId: "",
+ accessKeyId: "",
+ secretAccessKey: "",
+ apiToken: "",
+ zoneId: "",
+ baseDomain: "",
+ };
if (this.r2AccountIdInput) {
this.r2AccountIdInput.value = data.accountId || "";
@@ -1409,29 +1379,14 @@ class bitvidApp {
if (this.r2SecretAccessKeyInput) {
this.r2SecretAccessKeyInput.value = data.secretAccessKey || "";
}
- if (this.r2PublicBaseUrlInput) {
- this.r2PublicBaseUrlInput.value = data.publicBaseUrlTemplate || "";
+ if (this.r2ApiTokenInput) {
+ this.r2ApiTokenInput.value = data.apiToken || "";
}
-
- const mode = data.bucketMode === "manual" ? "manual" : "auto";
- if (this.cloudflareBucketModeInputs.length > 0) {
- this.cloudflareBucketModeInputs.forEach((input) => {
- if (input) {
- input.checked = input.value === mode;
- }
- });
+ if (this.r2ZoneIdInput) {
+ this.r2ZoneIdInput.value = data.zoneId || "";
}
-
- if (this.cloudflareManualBucketGroup) {
- if (mode === "manual") {
- this.cloudflareManualBucketGroup.classList.remove("hidden");
- } else {
- this.cloudflareManualBucketGroup.classList.add("hidden");
- }
- }
-
- if (this.cloudflareManualBucketInput) {
- this.cloudflareManualBucketInput.value = data.manualBucket || "";
+ if (this.r2BaseDomainInput) {
+ this.r2BaseDomainInput.value = data.baseDomain || "";
}
this.setCloudflareSettingsStatus("");
@@ -1441,13 +1396,11 @@ class bitvidApp {
const accountId = (this.r2AccountIdInput?.value || "").trim();
const accessKeyId = (this.r2AccessKeyIdInput?.value || "").trim();
const secretAccessKey = (this.r2SecretAccessKeyInput?.value || "").trim();
- const publicBaseUrlTemplate =
- (this.r2PublicBaseUrlInput?.value || "").trim();
- const bucketMode = this.getCloudflareBucketMode();
- let manualBucket = this.cloudflareManualBucketInput
- ? this.cloudflareManualBucketInput.value
- : "";
- manualBucket = this.sanitizeBucketName(manualBucket);
+ const apiToken = (this.r2ApiTokenInput?.value || "").trim();
+ const zoneId = (this.r2ZoneIdInput?.value || "").trim();
+ const baseDomain = sanitizeBaseDomain(
+ this.r2BaseDomainInput?.value || ""
+ );
if (!accountId || !accessKeyId || !secretAccessKey) {
if (!quiet) {
@@ -1459,32 +1412,26 @@ class bitvidApp {
return false;
}
- if (bucketMode === "manual" && manualBucket.length < 3) {
- if (!quiet) {
- this.setCloudflareSettingsStatus(
- "Manual bucket names must be at least 3 characters.",
- "error"
- );
- }
- return false;
+ let buckets = { ...(this.cloudflareSettings?.buckets || {}) };
+ const previousAccount = this.cloudflareSettings?.accountId || "";
+ const previousBaseDomain = this.cloudflareSettings?.baseDomain || "";
+ const previousZoneId = this.cloudflareSettings?.zoneId || "";
+ if (
+ previousAccount !== accountId ||
+ previousBaseDomain !== baseDomain ||
+ previousZoneId !== zoneId
+ ) {
+ buckets = {};
}
- if (this.cloudflareManualBucketInput) {
- this.cloudflareManualBucketInput.value = manualBucket;
- }
-
- const autoBuckets = {
- ...(this.cloudflareSettings?.autoBuckets || {}),
- };
-
const updatedSettings = {
accountId,
accessKeyId,
secretAccessKey,
- publicBaseUrlTemplate,
- bucketMode,
- manualBucket,
- autoBuckets,
+ apiToken,
+ zoneId,
+ baseDomain,
+ buckets,
};
try {
@@ -1524,62 +1471,133 @@ class bitvidApp {
}
}
- handleCloudflareBucketModeChange() {
- const mode = this.getCloudflareBucketMode();
- if (this.cloudflareManualBucketGroup) {
- if (mode === "manual") {
- this.cloudflareManualBucketGroup.classList.remove("hidden");
- } else {
- this.cloudflareManualBucketGroup.classList.add("hidden");
+ getCorsOrigins() {
+ const origins = new Set();
+ if (typeof window !== "undefined" && window.location) {
+ const origin = window.location.origin;
+ if (origin && origin !== "null") {
+ origins.add(origin);
+ }
+ if (origin && origin.startsWith("http://localhost")) {
+ origins.add(origin.replace("http://", "https://"));
}
}
- if (this.cloudflareSettings) {
- this.cloudflareSettings.bucketMode = mode;
- }
- this.updateCloudflareBucketPreview();
+ return Array.from(origins);
}
- handleManualBucketInputChange() {
- this.updateCloudflareBucketPreview();
+ deriveSubdomainForNpub(npub) {
+ const base = String(npub || "user")
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/^-+|[-]+$/g, "");
+ return base.slice(0, 48) || "user";
}
- async ensureAutoBucketForCurrentUser() {
- if (!this.pubkey || !this.cloudflareSettings) {
+ async ensureBucketConfigForNpub(npub) {
+ if (!npub || !this.cloudflareSettings) {
return null;
}
- const mode = this.cloudflareSettings.bucketMode === "manual" ? "manual" : "auto";
- if (mode !== "auto") {
- return null;
+ const accountId = (this.cloudflareSettings.accountId || "").trim();
+ const apiToken = (this.cloudflareSettings.apiToken || "").trim();
+ const zoneId = (this.cloudflareSettings.zoneId || "").trim();
+ const baseDomain = this.cloudflareSettings.baseDomain || "";
+
+ if (!accountId) {
+ throw new Error("Cloudflare account ID is missing.");
}
- const npub = this.safeEncodeNpub(this.pubkey);
- if (!npub) {
- return null;
+ let entry = this.cloudflareSettings.buckets?.[npub] || null;
+
+ if (entry && entry.publicBaseUrl) {
+ if (apiToken) {
+ try {
+ await ensureBucket({
+ accountId,
+ bucket: entry.bucket,
+ token: apiToken,
+ });
+ await putCors({
+ accountId,
+ bucket: entry.bucket,
+ token: apiToken,
+ origins: this.getCorsOrigins(),
+ });
+ } catch (err) {
+ console.warn("Failed to refresh bucket configuration:", err);
+ }
+ }
+ return { entry, usedManagedFallback: entry.domainType !== "custom" };
}
- const existing = this.cloudflareSettings.autoBuckets?.[npub];
- if (existing) {
- return existing;
+ if (!apiToken) {
+ throw new Error(
+ "Provide a Cloudflare API token to auto-create the bucket and domain."
+ );
}
- const bucket = bucketForNpub(npub);
- const updatedSettings = {
- ...this.cloudflareSettings,
- autoBuckets: {
- ...(this.cloudflareSettings.autoBuckets || {}),
- [npub]: bucket,
- },
- };
+ const bucketName = entry?.bucket || sanitizeBucketName(npub);
+
+ await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
try {
- this.cloudflareSettings = await saveR2Settings(updatedSettings);
+ await putCors({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ origins: this.getCorsOrigins(),
+ });
} catch (err) {
- console.warn("Failed to persist auto bucket name:", err);
- this.cloudflareSettings = updatedSettings;
+ console.warn("Failed to apply R2 CORS rules:", err);
}
- return bucket;
+ let publicBaseUrl = entry?.publicBaseUrl || "";
+ let domainType = entry?.domainType || "managed";
+ let usedManagedFallback = false;
+
+ if (baseDomain && zoneId) {
+ const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
+ try {
+ publicBaseUrl = await attachCustomDomain({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ zoneId,
+ domain,
+ });
+ domainType = "custom";
+ } catch (err) {
+ if (/already exists/i.test(err.message || "")) {
+ publicBaseUrl = `https://${domain}`;
+ domainType = "custom";
+ } else {
+ console.warn("Failed to attach custom domain, falling back:", err);
+ usedManagedFallback = true;
+ }
+ }
+ }
+
+ if (!publicBaseUrl) {
+ publicBaseUrl = await enableManagedDomain({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ });
+ domainType = "managed";
+ usedManagedFallback = true;
+ }
+
+ const mergedEntry = {
+ bucket: bucketName,
+ publicBaseUrl,
+ domainType,
+ lastUpdated: Date.now(),
+ };
+ const updatedSettings = await saveR2Settings(
+ mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry)
+ );
+ this.cloudflareSettings = updatedSettings;
+ return { entry: mergedEntry, usedManagedFallback };
}
async updateCloudflareBucketPreview() {
@@ -1588,36 +1606,13 @@ class bitvidApp {
}
const el = this.cloudflareBucketPreview;
- const mode = this.getCloudflareBucketMode();
- const template = (this.r2PublicBaseUrlInput?.value || "").trim();
-
- if (mode === "manual") {
- const manualBucket = this.sanitizeBucketName(
- this.cloudflareManualBucketInput?.value ||
- this.cloudflareSettings?.manualBucket ||
- ""
- );
- if (!manualBucket) {
- el.textContent = "Enter a manual bucket name.";
- return;
- }
-
- const sampleNpub = this.safeEncodeNpub(this.pubkey) || "anon";
- const sampleKey = buildR2Key(sampleNpub, {
- name: "sample.mp4",
- });
- const publicUrl = buildPublicUrl(manualBucket, sampleKey, template);
- el.textContent = `${manualBucket} • ${publicUrl}`;
+ if (!this.cloudflareSettings) {
+ el.textContent = "Save your credentials to configure R2.";
return;
}
if (!this.pubkey) {
- el.textContent = "Login to preview your auto bucket.";
- return;
- }
-
- if (!this.cloudflareSettings) {
- el.textContent = "Save your credentials to generate a bucket.";
+ el.textContent = "Login to preview your R2 bucket.";
return;
}
@@ -1627,19 +1622,15 @@ class bitvidApp {
return;
}
- let bucket = this.cloudflareSettings.autoBuckets?.[npub];
- if (!bucket) {
- bucket = await this.ensureAutoBucketForCurrentUser();
- }
-
- if (!bucket) {
- el.textContent = "Save your credentials to generate a bucket.";
+ const entry = this.cloudflareSettings.buckets?.[npub];
+ if (!entry || !entry.publicBaseUrl) {
+ el.textContent = "Bucket will be auto-created on your next upload.";
return;
}
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
- const publicUrl = buildPublicUrl(bucket, sampleKey, template);
- el.textContent = `${bucket} • ${publicUrl}`;
+ const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
+ el.textContent = `${entry.bucket} • ${publicUrl}`;
}
async handleCloudflareUploadSubmit() {
@@ -1678,11 +1669,11 @@ class bitvidApp {
const ws = (this.cloudflareWsInput?.value || "").trim();
const xs = (this.cloudflareXsInput?.value || "").trim();
- const accountId = this.cloudflareSettings?.accountId || "";
- const accessKeyId = this.cloudflareSettings?.accessKeyId || "";
- const secretAccessKey = this.cloudflareSettings?.secretAccessKey || "";
- const publicBaseUrlTemplate =
- this.cloudflareSettings?.publicBaseUrlTemplate || "";
+ const accountId = (this.cloudflareSettings?.accountId || "").trim();
+ const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
+ const secretAccessKey = (
+ this.cloudflareSettings?.secretAccessKey || ""
+ ).trim();
if (!accountId || !accessKeyId || !secretAccessKey) {
this.setCloudflareUploadStatus(
@@ -1698,54 +1689,56 @@ class bitvidApp {
return;
}
- let bucketName = "";
- const mode = this.cloudflareSettings?.bucketMode === "manual" ? "manual" : "auto";
- if (mode === "manual") {
- bucketName = this.sanitizeBucketName(
- this.cloudflareSettings?.manualBucket ||
- this.cloudflareManualBucketInput?.value ||
- ""
- );
- if (!bucketName) {
- this.setCloudflareUploadStatus(
- "Enter a valid manual bucket name.",
- "error"
- );
- return;
- }
- } else {
- bucketName = this.cloudflareSettings?.autoBuckets?.[npub] ||
- (await this.ensureAutoBucketForCurrentUser());
- if (!bucketName) {
- this.setCloudflareUploadStatus(
- "Failed to compute an auto bucket name.",
- "error"
- );
- return;
- }
- }
-
- const key = buildR2Key(npub, file);
- const publicUrl = buildPublicUrl(
- bucketName,
- key,
- publicBaseUrlTemplate
- );
-
- this.setCloudflareUploadStatus("Uploading…", "info");
+ this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
this.updateCloudflareProgress(0);
this.setCloudflareUploading(true);
+ let bucketResult = null;
try {
- const s3 = await createR2Client({
- accountId,
- accessKeyId,
- secretAccessKey,
- });
+ bucketResult = await this.ensureBucketConfigForNpub(npub);
+ } catch (err) {
+ console.error("Failed to prepare R2 bucket:", err);
+ this.setCloudflareUploadStatus(
+ err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.",
+ "error"
+ );
+ this.setCloudflareUploading(false);
+ this.updateCloudflareProgress(Number.NaN);
+ return;
+ }
- await uploadToR2({
+ const bucketEntry =
+ bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
+
+ if (!bucketEntry || !bucketEntry.publicBaseUrl) {
+ this.setCloudflareUploadStatus(
+ "Bucket is missing a public domain. Check your Cloudflare settings.",
+ "error"
+ );
+ this.setCloudflareUploading(false);
+ this.updateCloudflareProgress(Number.NaN);
+ return;
+ }
+
+ const statusMessage = bucketResult?.usedManagedFallback &&
+ (this.cloudflareSettings?.baseDomain || "")
+ ? `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone. Uploading…`
+ : `Uploading to ${bucketEntry.bucket}…`;
+
+ this.setCloudflareUploadStatus(
+ statusMessage,
+ bucketResult?.usedManagedFallback ? "warning" : "info"
+ );
+
+ const key = buildR2Key(npub, file);
+ const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
+
+ try {
+ const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
+
+ await multipartUpload({
s3,
- bucket: bucketName,
+ bucket: bucketEntry.bucket,
key,
file,
contentType: file.type,
diff --git a/js/r2.js b/js/r2.js
index dc356111..d3b73429 100644
--- a/js/r2.js
+++ b/js/r2.js
@@ -1,19 +1,56 @@
-const S3_MODULE_URL =
- "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle";
-
const DB_NAME = "bitvidSettings";
const DB_VERSION = 1;
const STORE_NAME = "kv";
const SETTINGS_KEY = "r2Settings";
const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings";
-let s3ModulePromise = null;
-
-function loadS3Module() {
- if (!s3ModulePromise) {
- s3ModulePromise = import(S3_MODULE_URL);
+function sanitizeBaseDomain(domain) {
+ if (!domain) {
+ return "";
}
- return s3ModulePromise;
+ let value = String(domain).trim().toLowerCase();
+ value = value.replace(/^https?:\/\//, "");
+ value = value.replace(/\/.*$/, "");
+ return value;
+}
+
+function normalizeBucketEntry(entry) {
+ if (!entry || typeof entry !== "object") {
+ return null;
+ }
+ const bucket = String(entry.bucket || "").toLowerCase();
+ if (!bucket) {
+ return null;
+ }
+ const publicBaseUrl = String(entry.publicBaseUrl || "");
+ const domainType = entry.domainType === "custom" ? "custom" : "managed";
+ const lastUpdated = Number.isFinite(entry.lastUpdated)
+ ? entry.lastUpdated
+ : Date.now();
+ return { bucket, publicBaseUrl, domainType, lastUpdated };
+}
+
+function normalizeSettings(raw) {
+ const base = raw && typeof raw === "object" ? raw : {};
+ const buckets = {};
+ if (base.buckets && typeof base.buckets === "object") {
+ for (const [npub, value] of Object.entries(base.buckets)) {
+ const normalizedEntry = normalizeBucketEntry(value);
+ if (normalizedEntry) {
+ buckets[String(npub)] = normalizedEntry;
+ }
+ }
+ }
+
+ return {
+ accountId: String(base.accountId || ""),
+ accessKeyId: String(base.accessKeyId || ""),
+ secretAccessKey: String(base.secretAccessKey || ""),
+ apiToken: String(base.apiToken || ""),
+ zoneId: String(base.zoneId || ""),
+ baseDomain: sanitizeBaseDomain(base.baseDomain || ""),
+ buckets,
+ };
}
function isIndexedDbAvailable() {
@@ -50,33 +87,6 @@ function openSettingsDb() {
});
}
-function normalizeSettings(raw) {
- if (!raw || typeof raw !== "object") {
- return {
- accountId: "",
- accessKeyId: "",
- secretAccessKey: "",
- publicBaseUrlTemplate: "",
- bucketMode: "auto",
- manualBucket: "",
- autoBuckets: {},
- };
- }
-
- return {
- accountId: String(raw.accountId || ""),
- accessKeyId: String(raw.accessKeyId || ""),
- secretAccessKey: String(raw.secretAccessKey || ""),
- publicBaseUrlTemplate: String(raw.publicBaseUrlTemplate || ""),
- bucketMode: raw.bucketMode === "manual" ? "manual" : "auto",
- manualBucket: String(raw.manualBucket || ""),
- autoBuckets:
- raw.autoBuckets && typeof raw.autoBuckets === "object"
- ? { ...raw.autoBuckets }
- : {},
- };
-}
-
export async function loadR2Settings() {
try {
const db = await openSettingsDb();
@@ -170,64 +180,21 @@ export async function clearR2Settings() {
return cleared;
}
-export function bucketForNpub(
- npub,
- { prefix = "bitvid", suffix } = {}
-) {
- const suffixValue = suffix || Math.random().toString(36).slice(2, 8);
- const npubPart = String(npub || "").toLowerCase();
- const raw = `${prefix}-${npubPart}-${suffixValue}`
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, "");
- const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, "");
- return trimmed || `${prefix}-${Date.now()}`;
-}
-
-function ensureHeadersObject(req) {
- if (!req) {
- return;
+export function mergeBucketEntry(settings, npub, entry) {
+ if (!settings || typeof settings !== "object") {
+ return settings;
}
-
- if (req.headers && typeof req.headers.set === "function") {
- req.headers.set("cf-create-bucket-if-missing", "true");
- return;
+ const normalizedEntry = normalizeBucketEntry(entry);
+ if (!normalizedEntry) {
+ return settings;
}
-
- if (req.headers && typeof req.headers === "object") {
- req.headers["cf-create-bucket-if-missing"] = "true";
- return;
- }
-
- if (typeof req.headers === "undefined") {
- req.headers = { "cf-create-bucket-if-missing": "true" };
- }
-}
-
-export async function createR2Client({
- accountId,
- accessKeyId,
- secretAccessKey,
-}) {
- if (!accountId || !accessKeyId || !secretAccessKey) {
- throw new Error("Missing required R2 credentials");
- }
-
- const { S3Client } = await loadS3Module();
- const client = new S3Client({
- region: "auto",
- endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
- credentials: { accessKeyId, secretAccessKey },
- });
-
- client.middlewareStack.add(
- (next) => async (args) => {
- ensureHeadersObject(args.request);
- return next(args);
+ return {
+ ...settings,
+ buckets: {
+ ...(settings.buckets || {}),
+ [npub]: normalizedEntry,
},
- { step: "build", name: "r2AutoCreateBucket" }
- );
-
- return client;
+ };
}
function guessExtension(file) {
@@ -276,141 +243,16 @@ export function buildR2Key(npub, file) {
return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`;
}
-export async function uploadToR2({
- s3,
- bucket,
- key,
- file,
- contentType,
- onProgress,
- concurrency = 4,
-}) {
- if (!s3) {
- throw new Error("S3 client is required");
+export function buildPublicUrl(baseUrl, key) {
+ if (!baseUrl) {
+ return "";
}
- if (!bucket) {
- throw new Error("Bucket is required");
- }
- if (!key) {
- throw new Error("Object key is required");
- }
- if (!file) {
- throw new Error("File is required");
- }
-
- const module = await loadS3Module();
- const {
- CreateMultipartUploadCommand,
- UploadPartCommand,
- CompleteMultipartUploadCommand,
- AbortMultipartUploadCommand,
- } = module;
-
- const resolvedContentType =
- contentType || file.type || "video/mp4";
-
- const createCommand = new CreateMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- ContentType: resolvedContentType,
- CacheControl: resolvedContentType.includes("mpegurl")
- ? "public, max-age=30"
- : "public, max-age=31536000, immutable",
- });
-
- const { UploadId } = await s3.send(createCommand);
- if (!UploadId) {
- throw new Error("Failed to initiate multipart upload");
- }
-
- const PART_SIZE = 8 * 1024 * 1024;
- const total = file.size;
- const totalParts = Math.ceil(total / PART_SIZE);
- const parts = [];
- let assignedPart = 0;
- let uploadedBytes = 0;
- const uploadErrors = [];
-
- const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
- (async () => {
- try {
- for (;;) {
- if (assignedPart >= totalParts) {
- break;
- }
-
- const currentIndex = assignedPart;
- assignedPart += 1;
-
- const partNumber = currentIndex + 1;
- const start = currentIndex * PART_SIZE;
- const end = Math.min(start + PART_SIZE, total);
- const Body = file.slice(start, end);
-
- const command = new UploadPartCommand({
- Bucket: bucket,
- Key: key,
- UploadId,
- PartNumber: partNumber,
- Body,
- });
-
- const { ETag } = await s3.send(command);
- parts.push({ ETag, PartNumber: partNumber });
-
- uploadedBytes += end - start;
- if (typeof onProgress === "function") {
- onProgress(Math.min(1, uploadedBytes / total));
- }
- }
- } catch (err) {
- uploadErrors.push(err);
- assignedPart = totalParts;
- }
- })()
- );
-
- try {
- await Promise.all(workers);
-
- if (uploadErrors.length > 0) {
- throw uploadErrors[0];
- }
-
- parts.sort((a, b) => a.PartNumber - b.PartNumber);
-
- const completeCommand = new CompleteMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- UploadId,
- MultipartUpload: { Parts: parts },
- });
-
- await s3.send(completeCommand);
- } catch (err) {
- try {
- const abortCommand = new AbortMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- UploadId,
- });
- await s3.send(abortCommand);
- } catch (abortErr) {
- console.warn("Failed to abort multipart upload:", abortErr);
- }
- throw err;
- }
-}
-
-export function buildPublicUrl(bucket, key, template) {
- const baseTemplate = (template || `https://${bucket}.r2.dev`).replace(
- /\{bucket\}/g,
- bucket
- );
- const sanitizedBase = baseTemplate.replace(/\/$/, "");
+ const sanitizedBase = String(baseUrl).replace(/\/$/, "");
const encodedKey = key
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `${sanitizedBase}/${encodedKey}`;
}
+
+export { sanitizeBaseDomain };
diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js
new file mode 100644
index 00000000..58c8e58a
--- /dev/null
+++ b/js/storage/r2-mgmt.js
@@ -0,0 +1,131 @@
+// js/storage/r2-mgmt.js
+export function sanitizeBucketName(npub) {
+ const base = (npub || "user")
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/^-+|[-]+$/g, "");
+ const suffix = Date.now().toString(36);
+ const name = `bv-${base || "u"}-${suffix}`.slice(0, 63);
+ return name.length < 3 ? `bv-u-${suffix}` : name;
+}
+
+function buildError(error, fallbackMessage) {
+ if (error instanceof Error) {
+ return error;
+ }
+ const err = new Error(fallbackMessage || "Cloudflare request failed");
+ err.original = error;
+ return err;
+}
+
+// --- Cloudflare API helpers (Bearer token) ---
+async function cfFetch(path, { token, method = "GET", body, headers = {} }) {
+ if (!token) {
+ throw new Error("Cloudflare API token is required");
+ }
+ const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ ...headers,
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ const text = await res.text();
+ let data = {};
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch (parseErr) {
+ if (!res.ok) {
+ const err = new Error(text || `Cloudflare API error ${res.status}`);
+ err.status = res.status;
+ throw err;
+ }
+ throw parseErr;
+ }
+ }
+
+ if (!res.ok || data?.success === false) {
+ const errorMessage =
+ data?.errors?.[0]?.message ||
+ data?.message ||
+ text ||
+ `Cloudflare API error ${res.status}`;
+ const err = new Error(errorMessage);
+ err.status = res.status;
+ err.response = data;
+ throw err;
+ }
+
+ return data;
+}
+
+// 1) Create bucket (idempotent-ish: treat 409 as success)
+export async function ensureBucket({ accountId, bucket, token }) {
+ try {
+ await cfFetch(`/accounts/${accountId}/r2/buckets`, {
+ token,
+ method: "POST",
+ body: { name: bucket },
+ });
+ } catch (error) {
+ const err = buildError(error);
+ if (err.status === 409 || /already exists/i.test(err.message || "")) {
+ return;
+ }
+ throw err;
+ }
+}
+
+// 2) Set CORS so browser PUT/GETs work from your app origins
+export async function putCors({ accountId, bucket, token, origins }) {
+ const filteredOrigins = (origins || []).filter(Boolean);
+ if (filteredOrigins.length === 0) {
+ return;
+ }
+ const rules = [
+ {
+ AllowedOrigins: filteredOrigins,
+ AllowedMethods: ["GET", "HEAD", "PUT", "POST"],
+ AllowedHeaders: ["*"],
+ ExposeHeaders: ["ETag", "Content-Length", "Content-Range"],
+ MaxAgeSeconds: 3600,
+ },
+ ];
+ await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
+ token,
+ method: "PUT",
+ body: { rules },
+ });
+}
+
+// 3) Attach custom domain to bucket (auto-provisions on Cloudflare)
+export async function attachCustomDomain({
+ accountId,
+ bucket,
+ token,
+ zoneId,
+ domain,
+}) {
+ const { result } = await cfFetch(
+ `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`,
+ {
+ token,
+ method: "POST",
+ body: { domain, zoneId, enabled: true, minTLS: "1.2" },
+ }
+ );
+ return `https://${result?.domain || domain}`;
+}
+
+// 4) Or enable r2.dev managed domain as fallback
+export async function enableManagedDomain({ accountId, bucket, token }) {
+ const { result } = await cfFetch(
+ `/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`,
+ { token, method: "PUT", body: { enabled: true } }
+ );
+ return `https://${result?.domain}`;
+}
diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js
new file mode 100644
index 00000000..7912541b
--- /dev/null
+++ b/js/storage/r2-s3.js
@@ -0,0 +1,138 @@
+// js/storage/r2-s3.js
+import {
+ S3Client,
+ CreateMultipartUploadCommand,
+ UploadPartCommand,
+ CompleteMultipartUploadCommand,
+ AbortMultipartUploadCommand,
+} from "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle";
+
+export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
+ if (!accountId || !accessKeyId || !secretAccessKey) {
+ throw new Error("Missing Cloudflare R2 credentials");
+ }
+
+ return new S3Client({
+ region: "auto",
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
+ credentials: { accessKeyId, secretAccessKey },
+ forcePathStyle: true,
+ });
+}
+
+export async function multipartUpload({
+ s3,
+ bucket,
+ key,
+ file,
+ contentType,
+ onProgress,
+ concurrency = 4,
+}) {
+ if (!s3) {
+ throw new Error("S3 client is required");
+ }
+ if (!bucket) {
+ throw new Error("Bucket is required");
+ }
+ if (!key) {
+ throw new Error("Object key is required");
+ }
+ if (!file) {
+ throw new Error("File is required");
+ }
+
+ const { UploadId } = await s3.send(
+ new CreateMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ ContentType: contentType || file.type || "video/mp4",
+ CacheControl: "public, max-age=31536000, immutable",
+ })
+ );
+
+ if (!UploadId) {
+ throw new Error("Failed to start multipart upload");
+ }
+
+ const PART = 8 * 1024 * 1024;
+ const total = file.size;
+ const parts = [];
+ let sent = 0;
+ let partNumber = 1;
+ const totalParts = Math.ceil(total / PART);
+ const errors = [];
+
+ const uploadPart = async () => {
+ const start = sent;
+ if (start >= total) {
+ return null;
+ }
+ const end = Math.min(start + PART, total);
+ const body = file.slice(start, end);
+ const currentPart = partNumber++;
+ sent = end;
+
+ return s3
+ .send(
+ new UploadPartCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId,
+ PartNumber: currentPart,
+ Body: body,
+ })
+ )
+ .then(({ ETag }) => {
+ parts.push({ ETag, PartNumber: currentPart });
+ if (typeof onProgress === "function") {
+ onProgress(end / total);
+ }
+ });
+ };
+
+ const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
+ (async () => {
+ try {
+ while (parts.length < totalParts) {
+ const task = uploadPart();
+ if (!task) {
+ break;
+ }
+ await task;
+ }
+ } catch (error) {
+ errors.push(error);
+ }
+ })()
+ );
+
+ try {
+ await Promise.all(workers);
+ if (errors.length > 0) {
+ throw errors[0];
+ }
+
+ parts.sort((a, b) => a.PartNumber - b.PartNumber);
+
+ await s3.send(
+ new CompleteMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId,
+ MultipartUpload: { Parts: parts },
+ })
+ );
+ } catch (error) {
+ await s3
+ .send(
+ new AbortMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId,
+ })
+ )
+ .catch(() => {});
+ throw error;
+ }
+}
From 7f702708897b82110c7289b417c626f8dc0cb357 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 21:25:20 -0400
Subject: [PATCH 05/82] Hide advanced Cloudflare settings behind toggle
---
components/upload-modal.html | 115 +++++++++++++++++++++--------------
js/app.js | 65 ++++++++++++++++++++
2 files changed, 136 insertions(+), 44 deletions(-)
diff --git a/components/upload-modal.html b/components/upload-modal.html
index 678bf679..484372f3 100644
--- a/components/upload-modal.html
+++ b/components/upload-modal.html
@@ -250,52 +250,79 @@
required
/>
-
-
-
Zone ID (optional)
-
-
- Required when you want us to attach a custom domain on your behalf.
-
-
-
-
Base domain (optional)
-
-
- We'll attach npub.<baseDomain>.
- Leave blank to use the managed *.r2.dev domain.
-
+
+
+
Show advanced options
+
+
+
+
Cloudflare API Token (optional)
+
+
+ Used to create buckets, configure CORS, and attach domains automatically.
+
+
+
+
+
Zone ID (optional)
+
+
+ Required when you want us to attach a custom domain on your behalf.
+
+
+
+
Base domain (optional)
+
+
+ We'll attach npub.<baseDomain>.
+ Leave blank to use the managed *.r2.dev domain.
+
+
+
diff --git a/js/app.js b/js/app.js
index f1a459ac..dc644229 100644
--- a/js/app.js
+++ b/js/app.js
@@ -493,6 +493,11 @@ class bitvidApp {
this.cloudflareMagnetInput = null;
this.cloudflareWsInput = null;
this.cloudflareXsInput = null;
+ this.cloudflareAdvancedToggle = null;
+ this.cloudflareAdvancedToggleLabel = null;
+ this.cloudflareAdvancedToggleIcon = null;
+ this.cloudflareAdvancedFields = null;
+ this.cloudflareAdvancedVisible = false;
this.r2AccountIdInput = null;
this.r2AccessKeyIdInput = null;
this.r2SecretAccessKeyInput = null;
@@ -1124,6 +1129,14 @@ class bitvidApp {
document.getElementById("cloudflareWs") || null;
this.cloudflareXsInput =
document.getElementById("cloudflareXs") || null;
+ this.cloudflareAdvancedToggle =
+ document.getElementById("cloudflareAdvancedToggle") || null;
+ this.cloudflareAdvancedToggleLabel =
+ document.getElementById("cloudflareAdvancedToggleLabel") || null;
+ this.cloudflareAdvancedToggleIcon =
+ document.getElementById("cloudflareAdvancedToggleIcon") || null;
+ this.cloudflareAdvancedFields =
+ document.getElementById("cloudflareAdvancedFields") || null;
this.r2AccountIdInput =
document.getElementById("r2AccountId") || null;
this.r2AccessKeyIdInput =
@@ -1181,6 +1194,16 @@ class bitvidApp {
});
}
+ if (this.cloudflareAdvancedToggle) {
+ this.cloudflareAdvancedToggle.addEventListener("click", () => {
+ this.setCloudflareAdvancedVisibility(
+ !this.cloudflareAdvancedVisible
+ );
+ });
+ }
+
+ this.setCloudflareAdvancedVisibility(this.cloudflareAdvancedVisible);
+
await this.loadCloudflareSettingsFromStorage();
await this.updateCloudflareBucketPreview();
@@ -1235,6 +1258,36 @@ class bitvidApp {
}
}
+ setCloudflareAdvancedVisibility(visible) {
+ const isVisible = Boolean(visible);
+ this.cloudflareAdvancedVisible = isVisible;
+
+ if (this.cloudflareAdvancedFields) {
+ if (isVisible) {
+ this.cloudflareAdvancedFields.classList.remove("hidden");
+ } else {
+ this.cloudflareAdvancedFields.classList.add("hidden");
+ }
+ }
+
+ if (this.cloudflareAdvancedToggle) {
+ this.cloudflareAdvancedToggle.setAttribute(
+ "aria-expanded",
+ isVisible ? "true" : "false"
+ );
+ }
+
+ if (this.cloudflareAdvancedToggleLabel) {
+ this.cloudflareAdvancedToggleLabel.textContent = isVisible
+ ? "Hide advanced options"
+ : "Show advanced options";
+ }
+
+ if (this.cloudflareAdvancedToggleIcon) {
+ this.cloudflareAdvancedToggleIcon.classList.toggle("rotate-90", isVisible);
+ }
+ }
+
setCloudflareSettingsStatus(message = "", variant = "info") {
if (!this.cloudflareSettingsStatus) {
return;
@@ -1389,6 +1442,17 @@ class bitvidApp {
this.r2BaseDomainInput.value = data.baseDomain || "";
}
+ const hasAdvancedValues = Boolean(
+ (data.apiToken && data.apiToken.length > 0) ||
+ (data.zoneId && data.zoneId.length > 0) ||
+ (data.baseDomain && data.baseDomain.length > 0)
+ );
+ if (hasAdvancedValues) {
+ this.setCloudflareAdvancedVisibility(true);
+ } else if (!this.cloudflareAdvancedVisible) {
+ this.setCloudflareAdvancedVisibility(false);
+ }
+
this.setCloudflareSettingsStatus("");
}
@@ -1460,6 +1524,7 @@ class bitvidApp {
await clearR2Settings();
this.cloudflareSettings = await loadR2Settings();
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
+ this.setCloudflareAdvancedVisibility(false);
this.setCloudflareSettingsStatus("Settings cleared.", "success");
await this.updateCloudflareBucketPreview();
} catch (err) {
From 9da4e40ec9381423f00f639719e17389c1a47e6a Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 21:51:22 -0400
Subject: [PATCH 06/82] Improve R2 auto-setup and multipart upload reliability
---
js/app.js | 68 +++++++++++++++----
js/storage/r2-mgmt.js | 147 ++++++++++++++++++++++++++++++++++++------
js/storage/r2-s3.js | 118 +++++++++++++++++----------------
3 files changed, 246 insertions(+), 87 deletions(-)
diff --git a/js/app.js b/js/app.js
index dc644229..cb222031 100644
--- a/js/app.js
+++ b/js/app.js
@@ -29,8 +29,8 @@ import {
sanitizeBucketName,
ensureBucket,
putCors,
- attachCustomDomain,
- enableManagedDomain,
+ attachCustomDomainAndWait,
+ setManagedDomain,
} from "./storage/r2-mgmt.js";
import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
@@ -1592,7 +1592,11 @@ class bitvidApp {
console.warn("Failed to refresh bucket configuration:", err);
}
}
- return { entry, usedManagedFallback: entry.domainType !== "custom" };
+ return {
+ entry,
+ usedManagedFallback: entry.domainType !== "custom",
+ customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
+ };
}
if (!apiToken) {
@@ -1619,37 +1623,71 @@ class bitvidApp {
let publicBaseUrl = entry?.publicBaseUrl || "";
let domainType = entry?.domainType || "managed";
let usedManagedFallback = false;
+ let customDomainStatus = "skipped";
if (baseDomain && zoneId) {
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
try {
- publicBaseUrl = await attachCustomDomain({
+ const custom = await attachCustomDomainAndWait({
accountId,
bucket: bucketName,
token: apiToken,
zoneId,
domain,
+ pollInterval: 2500,
+ timeoutMs: 120000,
});
- domainType = "custom";
+ customDomainStatus = custom?.status || "unknown";
+ if (custom?.active && custom?.url) {
+ publicBaseUrl = custom.url;
+ domainType = "custom";
+ try {
+ await setManagedDomain({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ enabled: false,
+ });
+ } catch (disableErr) {
+ console.warn("Failed to disable managed domain:", disableErr);
+ }
+ } else {
+ usedManagedFallback = true;
+ }
} catch (err) {
if (/already exists/i.test(err.message || "")) {
publicBaseUrl = `https://${domain}`;
domainType = "custom";
+ customDomainStatus = "active";
+ try {
+ await setManagedDomain({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ enabled: false,
+ });
+ } catch (disableErr) {
+ console.warn("Failed to disable managed domain:", disableErr);
+ }
} else {
console.warn("Failed to attach custom domain, falling back:", err);
usedManagedFallback = true;
+ customDomainStatus = "error";
}
}
}
if (!publicBaseUrl) {
- publicBaseUrl = await enableManagedDomain({
+ const managed = await setManagedDomain({
accountId,
bucket: bucketName,
token: apiToken,
+ enabled: true,
});
+ publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
domainType = "managed";
usedManagedFallback = true;
+ customDomainStatus = customDomainStatus === "skipped" ? "managed" : customDomainStatus;
}
const mergedEntry = {
@@ -1662,7 +1700,7 @@ class bitvidApp {
mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry)
);
this.cloudflareSettings = updatedSettings;
- return { entry: mergedEntry, usedManagedFallback };
+ return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
}
async updateCloudflareBucketPreview() {
@@ -1785,10 +1823,18 @@ class bitvidApp {
return;
}
- const statusMessage = bucketResult?.usedManagedFallback &&
- (this.cloudflareSettings?.baseDomain || "")
- ? `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone. Uploading…`
- : `Uploading to ${bucketEntry.bucket}…`;
+ let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
+ if (bucketResult?.usedManagedFallback) {
+ const baseDomain = this.cloudflareSettings?.baseDomain || "";
+ if (baseDomain) {
+ const customStatus = bucketResult?.customDomainStatus
+ ? ` (custom domain status: ${bucketResult.customDomainStatus})`
+ : "";
+ statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
+ } else {
+ statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
+ }
+ }
this.setCloudflareUploadStatus(
statusMessage,
diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js
index 58c8e58a..20434737 100644
--- a/js/storage/r2-mgmt.js
+++ b/js/storage/r2-mgmt.js
@@ -1,12 +1,32 @@
// js/storage/r2-mgmt.js
export function sanitizeBucketName(npub) {
- const base = (npub || "user")
+ let value = String(npub || "")
.toLowerCase()
- .replace(/[^a-z0-9-]/g, "")
+ .replace(/[^a-z0-9-]/g, "-")
+ .replace(/-+/g, "-")
.replace(/^-+|[-]+$/g, "");
- const suffix = Date.now().toString(36);
- const name = `bv-${base || "u"}-${suffix}`.slice(0, 63);
- return name.length < 3 ? `bv-u-${suffix}` : name;
+
+ if (!value) {
+ value = "bitvid-user";
+ }
+
+ if (!/^[a-z0-9]/.test(value)) {
+ value = `bv-${value}`;
+ }
+
+ if (!/[a-z0-9]$/.test(value)) {
+ value = `${value}0`;
+ }
+
+ if (value.length > 63) {
+ value = value.slice(0, 63).replace(/-+$/g, "");
+ }
+
+ while (value.length < 3) {
+ value += "0";
+ }
+
+ return value;
}
function buildError(error, fallbackMessage) {
@@ -88,11 +108,14 @@ export async function putCors({ accountId, bucket, token, origins }) {
}
const rules = [
{
- AllowedOrigins: filteredOrigins,
- AllowedMethods: ["GET", "HEAD", "PUT", "POST"],
- AllowedHeaders: ["*"],
- ExposeHeaders: ["ETag", "Content-Length", "Content-Range"],
- MaxAgeSeconds: 3600,
+ id: "bitvid-default",
+ allowed: {
+ origins: filteredOrigins,
+ methods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
+ headers: ["*"],
+ },
+ expose_headers: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
+ max_age_seconds: 3600,
},
];
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
@@ -102,30 +125,112 @@ export async function putCors({ accountId, bucket, token, origins }) {
});
}
+function extractStatus(payload) {
+ return (
+ payload?.result?.status ||
+ payload?.result?.result?.status ||
+ payload?.status ||
+ payload?.result?.statusText ||
+ null
+ );
+}
+
+async function pollCustomDomain({
+ accountId,
+ bucket,
+ token,
+ domain,
+ pollInterval = 2500,
+ timeoutMs = 120000,
+}) {
+ const path = `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent(
+ domain
+ )}`;
+ const deadline = Date.now() + timeoutMs;
+ let lastPayload = null;
+
+ while (Date.now() < deadline) {
+ lastPayload = await cfFetch(path, { token, method: "GET" }).catch((err) => {
+ if (err?.status === 404) {
+ return null;
+ }
+ throw err;
+ });
+
+ if (!lastPayload) {
+ break;
+ }
+
+ const status = extractStatus(lastPayload);
+ if (status === "active") {
+ return { status, payload: lastPayload };
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ }
+
+ return {
+ status: extractStatus(lastPayload) || "unknown",
+ payload: lastPayload,
+ };
+}
+
// 3) Attach custom domain to bucket (auto-provisions on Cloudflare)
-export async function attachCustomDomain({
+export async function attachCustomDomainAndWait({
accountId,
bucket,
token,
zoneId,
domain,
+ pollInterval,
+ timeoutMs,
}) {
- const { result } = await cfFetch(
- `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`,
- {
+ let shouldPoll = true;
+ try {
+ await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, {
token,
method: "POST",
- body: { domain, zoneId, enabled: true, minTLS: "1.2" },
+ body: { domain, zoneId, enabled: true },
+ });
+ } catch (error) {
+ const err = buildError(error);
+ if (err.status === 409 || /already exists/i.test(err.message || "")) {
+ shouldPoll = true;
+ } else {
+ throw err;
}
- );
- return `https://${result?.domain || domain}`;
+ }
+
+ let status = "unknown";
+ if (shouldPoll) {
+ const pollResult = await pollCustomDomain({
+ accountId,
+ bucket,
+ token,
+ domain,
+ pollInterval,
+ timeoutMs,
+ });
+ status = pollResult.status;
+ }
+
+ const url = `https://${domain}`;
+ return {
+ url,
+ status,
+ active: status === "active",
+ };
}
-// 4) Or enable r2.dev managed domain as fallback
-export async function enableManagedDomain({ accountId, bucket, token }) {
+// 4) Toggle managed r2.dev domain for the bucket
+export async function setManagedDomain({ accountId, bucket, token, enabled }) {
const { result } = await cfFetch(
`/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`,
- { token, method: "PUT", body: { enabled: true } }
+ { token, method: "PUT", body: { enabled: Boolean(enabled) } }
);
- return `https://${result?.domain}`;
+ const domain = result?.domain || result?.result?.domain || "";
+ return {
+ enabled: Boolean(result?.enabled ?? enabled),
+ url: domain ? `https://${domain}` : "",
+ };
}
diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js
index 7912541b..e71f948b 100644
--- a/js/storage/r2-s3.js
+++ b/js/storage/r2-s3.js
@@ -5,7 +5,28 @@ import {
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
-} from "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle";
+} from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle";
+
+function computeCacheControl(key) {
+ if (!key) {
+ return "public, max-age=3600";
+ }
+
+ const lower = String(key).toLowerCase();
+ if (/(?:\.)(m3u8|mpd)$/.test(lower)) {
+ return "public, max-age=60, must-revalidate";
+ }
+
+ if (
+ /\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test(
+ lower
+ )
+ ) {
+ return "public, max-age=31536000, immutable";
+ }
+
+ return "public, max-age=3600";
+}
export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
if (!accountId || !accessKeyId || !secretAccessKey) {
@@ -27,7 +48,6 @@ export async function multipartUpload({
file,
contentType,
onProgress,
- concurrency = 4,
}) {
if (!s3) {
throw new Error("S3 client is required");
@@ -42,79 +62,67 @@ export async function multipartUpload({
throw new Error("File is required");
}
- const { UploadId } = await s3.send(
- new CreateMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- ContentType: contentType || file.type || "video/mp4",
- CacheControl: "public, max-age=31536000, immutable",
- })
+ const createCommand = new CreateMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ ContentType: contentType || file.type || "application/octet-stream",
+ CacheControl: computeCacheControl(key),
+ });
+
+ createCommand.middlewareStack.add(
+ (next) => async (args) => {
+ if (args?.request?.headers) {
+ args.request.headers["cf-create-bucket-if-missing"] = "true";
+ }
+ return next(args);
+ },
+ { step: "build" }
);
+ const { UploadId } = await s3.send(createCommand);
+
if (!UploadId) {
throw new Error("Failed to start multipart upload");
}
- const PART = 8 * 1024 * 1024;
+ const MIN_PART = 5 * 1024 * 1024;
+ const maxParts = 10000;
+ const calculated = Math.ceil(file.size / maxParts);
+ const partSize = Math.max(MIN_PART, calculated);
const total = file.size;
const parts = [];
- let sent = 0;
+ let uploadedBytes = 0;
let partNumber = 1;
- const totalParts = Math.ceil(total / PART);
- const errors = [];
- const uploadPart = async () => {
- const start = sent;
- if (start >= total) {
- return null;
- }
- const end = Math.min(start + PART, total);
- const body = file.slice(start, end);
- const currentPart = partNumber++;
- sent = end;
+ if (typeof onProgress === "function") {
+ onProgress(0);
+ }
- return s3
- .send(
+ try {
+ while (uploadedBytes < total) {
+ const start = uploadedBytes;
+ const end = Math.min(start + partSize, total);
+ const chunk = file.slice(start, end);
+
+ const { ETag } = await s3.send(
new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId,
- PartNumber: currentPart,
- Body: body,
+ PartNumber: partNumber,
+ Body: chunk,
})
- )
- .then(({ ETag }) => {
- parts.push({ ETag, PartNumber: currentPart });
- if (typeof onProgress === "function") {
- onProgress(end / total);
- }
- });
- };
+ );
- const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
- (async () => {
- try {
- while (parts.length < totalParts) {
- const task = uploadPart();
- if (!task) {
- break;
- }
- await task;
- }
- } catch (error) {
- errors.push(error);
+ parts.push({ ETag, PartNumber: partNumber });
+ uploadedBytes = end;
+ partNumber += 1;
+
+ if (typeof onProgress === "function") {
+ onProgress(uploadedBytes / total);
}
- })()
- );
-
- try {
- await Promise.all(workers);
- if (errors.length > 0) {
- throw errors[0];
}
- parts.sort((a, b) => a.PartNumber - b.PartNumber);
-
await s3.send(
new CompleteMultipartUploadCommand({
Bucket: bucket,
From 7890a949497dd83132b3dc9aafc356088b00b30b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 22:24:01 -0400
Subject: [PATCH 07/82] Add quick Cloudflare R2 upload helper to custom modal
---
components/upload-modal.html | 99 +++++++++
js/app.js | 2 +
js/r2-quick.js | 411 +++++++++++++++++++++++++++++++++++
3 files changed, 512 insertions(+)
create mode 100644 js/r2-quick.js
diff --git a/components/upload-modal.html b/components/upload-modal.html
index 484372f3..991c1340 100644
--- a/components/upload-modal.html
+++ b/components/upload-modal.html
@@ -148,6 +148,105 @@
+
+
+ Optional: Upload directly to Cloudflare R2
+
+
+
+
el.classList.remove(cls));
+ const className = STATUS_TONES[tone] || STATUS_TONES.info;
+ if (className) {
+ el.classList.add(className);
+ }
+ el.textContent = message || "";
+}
+
+function setUploadingState(elements, uploading) {
+ const disabled = Boolean(uploading);
+ elements.forEach((el) => {
+ if (el) {
+ el.disabled = disabled;
+ }
+ });
+}
+
+function getCorsOrigins() {
+ const origins = new Set();
+ if (typeof window !== "undefined" && window.location) {
+ const origin = window.location.origin;
+ if (origin && origin !== "null") {
+ origins.add(origin);
+ }
+ if (origin && origin.startsWith("http://")) {
+ origins.add(origin.replace("http://", "https://"));
+ }
+ }
+ origins.add("http://localhost:8000");
+ origins.add("http://127.0.0.1:8000");
+ return Array.from(origins);
+}
+
+function buildQuickKey(title, file, npub) {
+ const now = new Date();
+ const datePart = now.toISOString().slice(0, 10);
+ const baseName = (title || file?.name || "video")
+ .toLowerCase()
+ .replace(/[^a-z0-9.-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ const safeBase = baseName || "video";
+ const originalName = typeof file?.name === "string" ? file.name : "";
+ const extMatch = originalName.match(/\.([^.]+)$/);
+ const ext = extMatch ? extMatch[1].toLowerCase() : "mp4";
+ const sanitizedNpub = String(npub || "user").replace(/[^a-z0-9]/gi, "");
+ return `videos/${datePart}/${sanitizedNpub || "user"}-${safeBase}.${ext}`;
+}
+
+export function initQuickR2Upload(app) {
+ const section = document.getElementById("quickR2Section");
+ if (!section) {
+ return;
+ }
+
+ const elements = {
+ accountId: document.getElementById("quickR2AccountId"),
+ accessKeyId: document.getElementById("quickR2AccessKeyId"),
+ secretAccessKey: document.getElementById("quickR2SecretAccessKey"),
+ apiToken: document.getElementById("quickR2ApiToken"),
+ customDomain: document.getElementById("quickR2CustomDomain"),
+ zoneId: document.getElementById("quickR2ZoneId"),
+ remember: document.getElementById("quickR2Remember"),
+ allowManaged: document.getElementById("quickR2AllowManaged"),
+ file: document.getElementById("quickR2File"),
+ uploadButton: document.getElementById("quickR2UploadButton"),
+ status: document.getElementById("quickR2Status"),
+ hostedUrl: document.getElementById("uploadUrl"),
+ titleInput: document.getElementById("uploadTitle"),
+ form: document.getElementById("uploadForm"),
+ };
+
+ const saved = loadSavedSettings();
+ if (saved && saved.remember) {
+ if (elements.accountId) elements.accountId.value = saved.accountId || "";
+ if (elements.accessKeyId)
+ elements.accessKeyId.value = saved.accessKeyId || "";
+ if (elements.secretAccessKey)
+ elements.secretAccessKey.value = saved.secretAccessKey || "";
+ if (elements.apiToken) elements.apiToken.value = saved.apiToken || "";
+ if (elements.customDomain)
+ elements.customDomain.value = saved.customDomain || "";
+ if (elements.zoneId) elements.zoneId.value = saved.zoneId || "";
+ if (elements.allowManaged)
+ elements.allowManaged.checked = Boolean(saved.allowManaged ?? true);
+ if (elements.remember) elements.remember.checked = true;
+ }
+
+ function maybePersist() {
+ if (!elements.remember) {
+ return;
+ }
+ if (elements.remember.checked) {
+ persistSettings({
+ remember: true,
+ accountId: elements.accountId?.value?.trim() || "",
+ accessKeyId: elements.accessKeyId?.value?.trim() || "",
+ secretAccessKey: elements.secretAccessKey?.value?.trim() || "",
+ apiToken: elements.apiToken?.value?.trim() || "",
+ customDomain: elements.customDomain?.value?.trim() || "",
+ zoneId: elements.zoneId?.value?.trim() || "",
+ allowManaged: Boolean(elements.allowManaged?.checked ?? true),
+ });
+ } else {
+ clearSavedSettings();
+ }
+ }
+
+ elements.remember?.addEventListener("change", () => {
+ if (!elements.remember.checked) {
+ clearSavedSettings();
+ }
+ });
+
+ async function handleUpload() {
+ const file = elements.file?.files?.[0] || null;
+ if (!file) {
+ setStatus(elements.status, "Choose a file to upload.", "error");
+ return;
+ }
+
+ const accountId = elements.accountId?.value?.trim();
+ const accessKeyId = elements.accessKeyId?.value?.trim();
+ const secretAccessKey = elements.secretAccessKey?.value?.trim();
+ const apiToken = elements.apiToken?.value?.trim();
+ const customDomain = sanitizeDomain(
+ elements.customDomain?.value || ""
+ );
+ const zoneId = elements.zoneId?.value?.trim();
+ const allowManaged = Boolean(elements.allowManaged?.checked ?? true);
+
+ if (!accountId || !accessKeyId || !secretAccessKey) {
+ setStatus(
+ elements.status,
+ "Account ID and S3 keys are required for Cloudflare R2.",
+ "error"
+ );
+ return;
+ }
+
+ const npub =
+ typeof app?.safeEncodeNpub === "function" ? app.safeEncodeNpub(app.pubkey) : null;
+ if (!npub) {
+ setStatus(
+ elements.status,
+ "Unable to derive your npub. Connect a Nostr key first.",
+ "error"
+ );
+ return;
+ }
+
+ maybePersist();
+
+ const bucket = sanitizeBucketName(npub);
+ const inputsToDisable = [
+ elements.accountId,
+ elements.accessKeyId,
+ elements.secretAccessKey,
+ elements.apiToken,
+ elements.customDomain,
+ elements.zoneId,
+ elements.remember,
+ elements.allowManaged,
+ elements.file,
+ elements.uploadButton,
+ ];
+
+ setUploadingState(inputsToDisable, true);
+ setStatus(elements.status, `Preparing bucket ${bucket}…`, "info");
+
+ try {
+ if (apiToken) {
+ try {
+ await ensureBucket({ accountId, bucket, token: apiToken });
+ } catch (err) {
+ throw new Error(
+ err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed."
+ );
+ }
+
+ try {
+ await putCors({
+ accountId,
+ bucket,
+ token: apiToken,
+ origins: getCorsOrigins(),
+ });
+ } catch (corsErr) {
+ console.warn("Quick R2 CORS update failed:", corsErr);
+ }
+ }
+
+ let publicBaseUrl = "";
+ let usedManagedFallback = false;
+
+ if (customDomain) {
+ if (apiToken && zoneId) {
+ try {
+ setStatus(
+ elements.status,
+ `Attaching ${customDomain}…`,
+ "info"
+ );
+ const custom = await attachCustomDomainAndWait({
+ accountId,
+ bucket,
+ token: apiToken,
+ zoneId,
+ domain: customDomain,
+ pollInterval: 1500,
+ timeoutMs: 120000,
+ });
+ if (custom?.active && custom?.url) {
+ publicBaseUrl = custom.url;
+ try {
+ await setManagedDomain({
+ accountId,
+ bucket,
+ token: apiToken,
+ enabled: false,
+ });
+ } catch (disableErr) {
+ console.warn("Quick R2 managed domain disable failed:", disableErr);
+ }
+ } else {
+ setStatus(
+ elements.status,
+ `Custom domain pending (${custom?.status || "unknown"}).`,
+ "warning"
+ );
+ }
+ } catch (err) {
+ console.warn("Quick R2 custom domain error:", err);
+ setStatus(
+ elements.status,
+ err?.message
+ ? `Custom domain setup failed: ${err.message}`
+ : "Custom domain setup failed.",
+ "warning"
+ );
+ }
+ }
+
+ if (!publicBaseUrl) {
+ publicBaseUrl = `https://${customDomain}`;
+ }
+ }
+
+ if (!publicBaseUrl && apiToken && allowManaged) {
+ try {
+ setStatus(elements.status, "Enabling managed r2.dev domain…", "info");
+ const managed = await setManagedDomain({
+ accountId,
+ bucket,
+ token: apiToken,
+ enabled: true,
+ });
+ if (managed?.url) {
+ publicBaseUrl = managed.url;
+ usedManagedFallback = true;
+ }
+ } catch (err) {
+ console.warn("Quick R2 managed domain enable failed:", err);
+ setStatus(
+ elements.status,
+ err?.message
+ ? `Managed domain failed: ${err.message}`
+ : "Managed domain failed.",
+ "warning"
+ );
+ }
+ }
+
+ if (!publicBaseUrl) {
+ throw new Error(
+ "No public domain configured. Provide a custom domain or allow the managed r2.dev fallback."
+ );
+ }
+
+ const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
+ const title = elements.titleInput?.value?.trim() || "";
+ const key = buildQuickKey(title, file, npub) || buildR2Key(npub, file);
+ const hostedUrl = buildPublicUrl(publicBaseUrl, key);
+
+ let lastPercent = -1;
+ setStatus(elements.status, `Uploading… 0%`, usedManagedFallback ? "warning" : "info");
+
+ await multipartUpload({
+ s3,
+ bucket,
+ key,
+ file,
+ contentType: file.type,
+ onProgress: (fraction) => {
+ if (typeof fraction !== "number" || !isFinite(fraction)) {
+ return;
+ }
+ const percent = Math.max(0, Math.min(100, Math.round(fraction * 100)));
+ if (percent !== lastPercent) {
+ lastPercent = percent;
+ setStatus(
+ elements.status,
+ `Uploading… ${percent}%`,
+ usedManagedFallback ? "warning" : "info"
+ );
+ }
+ },
+ });
+
+ if (elements.hostedUrl) {
+ elements.hostedUrl.value = hostedUrl;
+ }
+
+ setStatus(
+ elements.status,
+ `Upload complete. Hosted URL set to ${hostedUrl}.`,
+ "success"
+ );
+
+ if (elements.form && typeof elements.form.requestSubmit === "function") {
+ elements.form.requestSubmit();
+ }
+ } catch (err) {
+ console.error("Quick R2 upload failed:", err);
+ setStatus(
+ elements.status,
+ err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
+ "error"
+ );
+ } finally {
+ setUploadingState(inputsToDisable, false);
+ }
+ }
+
+ elements.uploadButton?.addEventListener("click", () => {
+ handleUpload();
+ });
+}
+
+export default initQuickR2Upload;
From 67f80787d04b16c81a316b1974453c050d086373 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 22:48:19 -0400
Subject: [PATCH 08/82] Allow manual Cloudflare bucket fallback
---
js/app.js | 46 +++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 43 insertions(+), 3 deletions(-)
diff --git a/js/app.js b/js/app.js
index a1ef4d7a..3960845d 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1602,9 +1602,49 @@ class bitvidApp {
}
if (!apiToken) {
- throw new Error(
- "Provide a Cloudflare API token to auto-create the bucket and domain."
- );
+ const bucketName = entry?.bucket || sanitizeBucketName(npub);
+ const manualCustomDomain = baseDomain
+ ? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
+ : "";
+
+ let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
+ if (!publicBaseUrl) {
+ publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
+ }
+
+ if (!publicBaseUrl) {
+ throw new Error(
+ "No public bucket domain configured. Add an API token or configure the domain manually."
+ );
+ }
+
+ const manualEntry = {
+ bucket: bucketName,
+ publicBaseUrl,
+ domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
+ lastUpdated: Date.now(),
+ };
+
+ let savedEntry = entry;
+ if (
+ !entry ||
+ entry.bucket !== manualEntry.bucket ||
+ entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
+ entry.domainType !== manualEntry.domainType
+ ) {
+ const updatedSettings = await saveR2Settings(
+ mergeBucketEntry(this.cloudflareSettings, npub, manualEntry)
+ );
+ this.cloudflareSettings = updatedSettings;
+ savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
+ }
+
+ return {
+ entry: savedEntry,
+ usedManagedFallback: manualEntry.domainType !== "custom",
+ customDomainStatus:
+ manualEntry.domainType === "custom" ? "manual" : "managed",
+ };
}
const bucketName = entry?.bucket || sanitizeBucketName(npub);
From 46c2b39aae001b294bfde686c3ae522b344d3f31 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 22:58:13 -0400
Subject: [PATCH 09/82] Ensure R2 CORS when using manual access keys
---
js/app.js | 34 +++++++++++++++++++++++++++++++---
js/storage/r2-s3.js | 37 +++++++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/js/app.js b/js/app.js
index 3960845d..a18669a8 100644
--- a/js/app.js
+++ b/js/app.js
@@ -32,7 +32,11 @@ import {
attachCustomDomainAndWait,
setManagedDomain,
} from "./storage/r2-mgmt.js";
-import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
+import {
+ makeR2Client,
+ multipartUpload,
+ ensureBucketCors,
+} from "./storage/r2-s3.js";
import { initQuickR2Upload } from "./r2-quick.js";
/**
@@ -1568,6 +1572,10 @@ class bitvidApp {
const accountId = (this.cloudflareSettings.accountId || "").trim();
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
+ const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
+ const secretAccessKey =
+ (this.cloudflareSettings.secretAccessKey || "").trim();
+ const corsOrigins = this.getCorsOrigins();
const baseDomain = this.cloudflareSettings.baseDomain || "";
if (!accountId) {
@@ -1588,7 +1596,7 @@ class bitvidApp {
accountId,
bucket: entry.bucket,
token: apiToken,
- origins: this.getCorsOrigins(),
+ origins: corsOrigins,
});
} catch (err) {
console.warn("Failed to refresh bucket configuration:", err);
@@ -1625,6 +1633,26 @@ class bitvidApp {
lastUpdated: Date.now(),
};
+ if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
+ try {
+ const s3 = makeR2Client({
+ accountId,
+ accessKeyId,
+ secretAccessKey,
+ });
+ await ensureBucketCors({
+ s3,
+ bucket: bucketName,
+ origins: corsOrigins,
+ });
+ } catch (corsErr) {
+ console.warn(
+ "Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
+ corsErr
+ );
+ }
+ }
+
let savedEntry = entry;
if (
!entry ||
@@ -1656,7 +1684,7 @@ class bitvidApp {
accountId,
bucket: bucketName,
token: apiToken,
- origins: this.getCorsOrigins(),
+ origins: corsOrigins,
});
} catch (err) {
console.warn("Failed to apply R2 CORS rules:", err);
diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js
index e71f948b..56745b22 100644
--- a/js/storage/r2-s3.js
+++ b/js/storage/r2-s3.js
@@ -5,6 +5,7 @@ import {
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
+ PutBucketCorsCommand,
} from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle";
function computeCacheControl(key) {
@@ -41,6 +42,42 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
});
}
+export async function ensureBucketCors({ s3, bucket, origins }) {
+ if (!s3) {
+ throw new Error("S3 client is required to configure CORS");
+ }
+ if (!bucket) {
+ throw new Error("Bucket name is required to configure CORS");
+ }
+
+ const allowedOrigins = (origins || []).filter(Boolean);
+ if (allowedOrigins.length === 0) {
+ return;
+ }
+
+ const command = new PutBucketCorsCommand({
+ Bucket: bucket,
+ CORSConfiguration: {
+ CORSRules: [
+ {
+ AllowedHeaders: ["*"],
+ AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
+ AllowedOrigins: allowedOrigins,
+ ExposeHeaders: [
+ "ETag",
+ "Content-Length",
+ "Content-Range",
+ "Accept-Ranges",
+ ],
+ MaxAgeSeconds: 3600,
+ },
+ ],
+ },
+ });
+
+ await s3.send(command);
+}
+
export async function multipartUpload({
s3,
bucket,
From c32d2fce12cfd8f327a5d6b8dd1094203f57efd0 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 23:40:41 -0400
Subject: [PATCH 10/82] Ensure manual R2 buckets exist before applying CORS
---
js/storage/r2-s3.js | 32 ++++++++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js
index 56745b22..bfe65b36 100644
--- a/js/storage/r2-s3.js
+++ b/js/storage/r2-s3.js
@@ -1,10 +1,12 @@
// js/storage/r2-s3.js
import {
S3Client,
+ CreateBucketCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
+ HeadBucketCommand,
PutBucketCorsCommand,
} from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle";
@@ -42,6 +44,34 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
});
}
+async function ensureBucketExists({ s3, bucket }) {
+ try {
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
+ return;
+ } catch (error) {
+ const status = error?.$metadata?.httpStatusCode || 0;
+ const code = error?.name || error?.Code || "";
+ if (status !== 404 && code !== "NotFound" && code !== "NoSuchBucket") {
+ throw error;
+ }
+ }
+
+ try {
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
+ } catch (error) {
+ const status = error?.$metadata?.httpStatusCode || 0;
+ const code = error?.name || error?.Code || "";
+ if (
+ status === 409 ||
+ code === "BucketAlreadyOwnedByYou" ||
+ code === "BucketAlreadyExists"
+ ) {
+ return;
+ }
+ throw error;
+ }
+}
+
export async function ensureBucketCors({ s3, bucket, origins }) {
if (!s3) {
throw new Error("S3 client is required to configure CORS");
@@ -55,6 +85,8 @@ export async function ensureBucketCors({ s3, bucket, origins }) {
return;
}
+ await ensureBucketExists({ s3, bucket });
+
const command = new PutBucketCorsCommand({
Bucket: bucket,
CORSConfiguration: {
From 8a278ca0ace8ba1b46515c807f722dfd51b18e6d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 23:46:52 -0400
Subject: [PATCH 11/82] Shorten Cloudflare bucket preview text
---
js/app.js | 32 +++++++++++++++++++++++++++++++-
1 file changed, 31 insertions(+), 1 deletion(-)
diff --git a/js/app.js b/js/app.js
index a18669a8..6a34a3e6 100644
--- a/js/app.js
+++ b/js/app.js
@@ -39,6 +39,22 @@ import {
} from "./storage/r2-s3.js";
import { initQuickR2Upload } from "./r2-quick.js";
+function truncateMiddle(text, maxLength = 72) {
+ if (!text || typeof text !== "string") {
+ return "";
+ }
+
+ if (text.length <= maxLength) {
+ return text;
+ }
+
+ const ellipsis = "…";
+ const charsToShow = maxLength - ellipsis.length;
+ const front = Math.ceil(charsToShow / 2);
+ const back = Math.floor(charsToShow / 2);
+ return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`;
+}
+
/**
* Simple "decryption" placeholder for private videos.
*/
@@ -1803,7 +1819,21 @@ class bitvidApp {
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
- el.textContent = `${entry.bucket} • ${publicUrl}`;
+ const fullPreview = `${entry.bucket} • ${publicUrl}`;
+
+ let displayHostAndPath = truncateMiddle(publicUrl, 96);
+ try {
+ const parsed = new URL(publicUrl);
+ const cleanPath = parsed.pathname.replace(/^\//, "");
+ const truncatedPath = truncateMiddle(cleanPath || sampleKey, 48);
+ displayHostAndPath = `${truncateMiddle(parsed.host, 48)}/${truncatedPath}`;
+ } catch (err) {
+ // ignore URL parse issues and fall back to the raw string
+ }
+
+ const truncatedBucket = truncateMiddle(entry.bucket, 40);
+ el.textContent = `${truncatedBucket} • ${displayHostAndPath}`;
+ el.setAttribute("title", fullPreview);
}
async handleCloudflareUploadSubmit() {
From 1684c7070bf326f31742a50a6680914b97bcb137 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 23:55:40 -0400
Subject: [PATCH 12/82] Shorten bucket slugs and fix CORS refresh
---
js/app.js | 39 ++++++++++++++++++++++++----
js/storage/r2-mgmt.js | 59 ++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 92 insertions(+), 6 deletions(-)
diff --git a/js/app.js b/js/app.js
index 6a34a3e6..b25a7562 100644
--- a/js/app.js
+++ b/js/app.js
@@ -31,6 +31,7 @@ import {
putCors,
attachCustomDomainAndWait,
setManagedDomain,
+ deriveShortSubdomain,
} from "./storage/r2-mgmt.js";
import {
makeR2Client,
@@ -1573,11 +1574,17 @@ class bitvidApp {
}
deriveSubdomainForNpub(npub) {
+ try {
+ return deriveShortSubdomain(npub);
+ } catch (err) {
+ console.warn("Failed to derive short subdomain, falling back:", err);
+ }
+
const base = String(npub || "user")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "")
.replace(/^-+|[-]+$/g, "");
- return base.slice(0, 48) || "user";
+ return base.slice(0, 32) || "user";
}
async ensureBucketConfigForNpub(npub) {
@@ -1617,6 +1624,28 @@ class bitvidApp {
} catch (err) {
console.warn("Failed to refresh bucket configuration:", err);
}
+ } else if (
+ accessKeyId &&
+ secretAccessKey &&
+ corsOrigins.length > 0
+ ) {
+ try {
+ const s3 = makeR2Client({
+ accountId,
+ accessKeyId,
+ secretAccessKey,
+ });
+ await ensureBucketCors({
+ s3,
+ bucket: entry.bucket,
+ origins: corsOrigins,
+ });
+ } catch (err) {
+ console.warn(
+ "Failed to refresh bucket CORS via access keys:",
+ err
+ );
+ }
}
return {
entry,
@@ -1821,17 +1850,17 @@ class bitvidApp {
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
const fullPreview = `${entry.bucket} • ${publicUrl}`;
- let displayHostAndPath = truncateMiddle(publicUrl, 96);
+ let displayHostAndPath = truncateMiddle(publicUrl, 72);
try {
const parsed = new URL(publicUrl);
const cleanPath = parsed.pathname.replace(/^\//, "");
- const truncatedPath = truncateMiddle(cleanPath || sampleKey, 48);
- displayHostAndPath = `${truncateMiddle(parsed.host, 48)}/${truncatedPath}`;
+ const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
+ displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
} catch (err) {
// ignore URL parse issues and fall back to the raw string
}
- const truncatedBucket = truncateMiddle(entry.bucket, 40);
+ const truncatedBucket = truncateMiddle(entry.bucket, 28);
el.textContent = `${truncatedBucket} • ${displayHostAndPath}`;
el.setAttribute("title", fullPreview);
}
diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js
index 20434737..10a6ef81 100644
--- a/js/storage/r2-mgmt.js
+++ b/js/storage/r2-mgmt.js
@@ -1,6 +1,16 @@
// js/storage/r2-mgmt.js
+function computeSlugHash(input) {
+ const text = String(input || "");
+ let hash = 0;
+ for (let i = 0; i < text.length; i += 1) {
+ hash = Math.imul(31, hash) + text.charCodeAt(i);
+ }
+ return (hash >>> 0).toString(36);
+}
+
export function sanitizeBucketName(npub) {
- let value = String(npub || "")
+ const raw = String(npub || "");
+ let value = raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
@@ -10,6 +20,14 @@ export function sanitizeBucketName(npub) {
value = "bitvid-user";
}
+ if (value.length > 32) {
+ const prefix = value.slice(0, 20).replace(/-+$/g, "");
+ const hash = computeSlugHash(raw).slice(0, 8);
+ value = [prefix, hash].filter(Boolean).join("-");
+ }
+
+ value = value.replace(/-+/g, "-").replace(/^-+|[-]+$/g, "");
+
if (!/^[a-z0-9]/.test(value)) {
value = `bv-${value}`;
}
@@ -29,6 +47,45 @@ export function sanitizeBucketName(npub) {
return value;
}
+export function deriveShortSubdomain(npub) {
+ const bucketSlug = sanitizeBucketName(npub);
+ const hash = computeSlugHash(npub).slice(0, 6);
+
+ const prefix = bucketSlug
+ .replace(/^bv-/, "")
+ .slice(0, 18)
+ .replace(/-+$/g, "");
+
+ let candidate = [prefix, hash].filter(Boolean).join("-");
+ candidate = candidate
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|[-]+$/g, "");
+
+ if (!candidate) {
+ candidate = "user";
+ }
+
+ if (candidate.length > 32) {
+ candidate = candidate.slice(0, 32).replace(/-+$/g, "");
+ }
+
+ if (!/^[a-z0-9]/.test(candidate)) {
+ candidate = `u${candidate}`;
+ }
+
+ if (!/[a-z0-9]$/.test(candidate)) {
+ candidate = `${candidate}0`;
+ }
+
+ while (candidate.length < 3) {
+ candidate += "0";
+ }
+
+ return candidate;
+}
+
function buildError(error, fallbackMessage) {
if (error instanceof Error) {
return error;
From e6b6522f6ffb6bb06e7de77e0fb249f9d1fcbd0e Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 12:34:21 -0400
Subject: [PATCH 13/82] Handle legacy video deletions without d tags
---
js/channelProfile.js | 19 +++++++++----------
js/nostr.js | 39 +++++++++++++++++++++++----------------
2 files changed, 32 insertions(+), 26 deletions(-)
diff --git a/js/channelProfile.js b/js/channelProfile.js
index 1a5037cb..531d5ad4 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -218,19 +218,18 @@ async function loadUserVideos(pubkey) {
}
}
- // 3) Convert to "video" objects
- let videos = [];
- for (const evt of events) {
- const vid = sharedConvertEventToVideo(evt);
- if (!vid.invalid && !vid.deleted) {
- videos.push(vid);
- }
- }
+ // 3) Convert to "video" objects and keep everything (including tombstones)
+ const convertedVideos = events
+ .map((evt) => sharedConvertEventToVideo(evt))
+ .filter((vid) => !vid.invalid);
// 4) Deduplicate older overshadowed versions => newest only
- videos = app?.dedupeVideosByRoot?.(videos) ?? dedupeToNewestByRoot(videos);
+ const newestByRoot =
+ app?.dedupeVideosByRoot?.(convertedVideos) ??
+ dedupeToNewestByRoot(convertedVideos);
- // 5) Filter out blacklisted IDs / authors
+ // 5) Filter out tombstones, blacklisted IDs / authors
+ let videos = newestByRoot.filter((video) => !video.deleted);
videos = videos.filter((video) => {
// Event-level blacklisting
if (app.blacklistedEventIds.has(video.id)) return false;
diff --git a/js/nostr.js b/js/nostr.js
index 6df1dffc..4b51be4f 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -854,21 +854,26 @@ class NostrClient {
};
}
- const dTag = baseEvent.tags.find((t) => t[0] === "d");
- if (!dTag) {
- throw new Error(
- 'No "d" tag => cannot revert addressable kind=30078 event.'
- );
- }
- const existingD = dTag[1];
+ const safeTags = Array.isArray(baseEvent.tags) ? baseEvent.tags : [];
+ const dTag = safeTags.find((t) => t[0] === "d");
+ const existingD = dTag ? dTag[1] : null;
- const oldContent = JSON.parse(baseEvent.content || "{}");
+ let oldContent = {};
+ try {
+ oldContent = JSON.parse(baseEvent.content || "{}");
+ } catch (err) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to parse baseEvent.content while reverting:", err);
+ }
+ oldContent = {};
+ }
const oldVersion = oldContent.version ?? 1;
- let finalRootId = oldContent.videoRootId || null;
- if (!finalRootId) {
- finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
- }
+ const finalRootId =
+ oldContent.videoRootId ||
+ (existingD
+ ? `LEGACY:${baseEvent.pubkey}:${existingD}`
+ : baseEvent.id);
const contentObject = {
videoRootId: finalRootId,
@@ -883,14 +888,16 @@ class NostrClient {
mode: oldContent.mode || "live",
};
+ const tags = [["t", "video"]];
+ if (existingD) {
+ tags.push(["d", existingD]);
+ }
+
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
- tags: [
- ["t", "video"],
- ["d", existingD],
- ],
+ tags,
content: JSON.stringify(contentObject),
};
From 2b1d0ca26e7a1e37661213d9f699d136de3014ff Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 12:43:47 -0400
Subject: [PATCH 14/82] Verify CDN playback before marking healthy
---
js/app.js | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/js/app.js b/js/app.js
index b25a7562..decabe6b 100644
--- a/js/app.js
+++ b/js/app.js
@@ -4561,8 +4561,30 @@ class bitvidApp {
return { outcome: "opaque" };
}
+ if (!response.ok) {
+ const fallback = await this.probeUrlWithVideoElement(trimmed);
+ if (fallback && fallback.outcome) {
+ return fallback;
+ }
+ return {
+ outcome: "bad",
+ status: response.status,
+ };
+ }
+
+ const playbackCheck = await this.probeUrlWithVideoElement(trimmed);
+ if (playbackCheck && playbackCheck.outcome) {
+ if (playbackCheck.outcome === "ok") {
+ return {
+ ...playbackCheck,
+ status: response.status,
+ };
+ }
+ return playbackCheck;
+ }
+
return {
- outcome: response.ok ? "ok" : "bad",
+ outcome: "ok",
status: response.status,
};
}
From c6a838ebc21d0c93099fcc3921980fb1d676b1bb Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 12:56:30 -0400
Subject: [PATCH 15/82] Restore CDN playback after badge verification
---
js/app.js | 77 +++++++++++++++++++++++++++++++++++++++----------------
1 file changed, 55 insertions(+), 22 deletions(-)
diff --git a/js/app.js b/js/app.js
index decabe6b..05648e24 100644
--- a/js/app.js
+++ b/js/app.js
@@ -226,12 +226,19 @@ function writeUrlHealthToCache(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS)
* event simultaneously do not all fire off duplicate requests. The stored
* metadata includes the URL so edits invalidate the old request immediately.
*/
-function setInFlightUrlProbe(eventId, url, promise) {
+function buildUrlProbeKey(url, options = {}) {
+ const trimmed = typeof url === "string" ? url : "";
+ const mode = options?.confirmPlayable ? "playable" : "basic";
+ return `${trimmed}::${mode}`;
+}
+
+function setInFlightUrlProbe(eventId, url, promise, options = {}) {
if (!eventId || !promise) {
return;
}
- urlHealthInFlight.set(eventId, { promise, url });
+ const key = buildUrlProbeKey(url, options);
+ urlHealthInFlight.set(eventId, { promise, key });
promise.finally(() => {
const current = urlHealthInFlight.get(eventId);
if (current && current.promise === promise) {
@@ -240,7 +247,7 @@ function setInFlightUrlProbe(eventId, url, promise) {
});
}
-function getInFlightUrlProbe(eventId, url) {
+function getInFlightUrlProbe(eventId, url, options = {}) {
if (!eventId) {
return null;
}
@@ -250,7 +257,8 @@ function getInFlightUrlProbe(eventId, url) {
return null;
}
- if (url && entry.url && entry.url !== url) {
+ const key = buildUrlProbeKey(url, options);
+ if (entry.key && entry.key !== key) {
return null;
}
@@ -3308,7 +3316,8 @@ class bitvidApp {
this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId);
- const existingProbe = getInFlightUrlProbe(eventId, trimmedUrl);
+ const probeOptions = { confirmPlayable: true };
+ const existingProbe = getInFlightUrlProbe(eventId, trimmedUrl, probeOptions);
if (existingProbe) {
existingProbe
.then((entry) => {
@@ -3325,14 +3334,14 @@ class bitvidApp {
return;
}
- const probePromise = this.probeUrl(trimmedUrl)
+ const probePromise = this.probeUrl(trimmedUrl, probeOptions)
.then((result) => {
const outcome = result?.outcome || "error";
let entry;
if (outcome === "ok") {
entry = { status: "healthy", message: "✅ CDN" };
- } else if (outcome === "opaque") {
+ } else if (outcome === "opaque" || outcome === "unknown") {
entry = {
status: "unknown",
message: "⚠️ CDN",
@@ -3360,7 +3369,7 @@ class bitvidApp {
return this.storeUrlHealth(eventId, trimmedUrl, entry);
});
- setInFlightUrlProbe(eventId, trimmedUrl, probePromise);
+ setInFlightUrlProbe(eventId, trimmedUrl, probePromise, probeOptions);
probePromise
.then((entry) => {
@@ -4485,12 +4494,34 @@ class bitvidApp {
});
}
- async probeUrl(url) {
+ async probeUrl(url, options = {}) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return { outcome: "invalid" };
}
+ const confirmPlayable = options?.confirmPlayable === true;
+
+ const confirmWithVideoElement = async () => {
+ if (!confirmPlayable) {
+ return null;
+ }
+
+ try {
+ const result = await this.probeUrlWithVideoElement(trimmed);
+ if (result && result.outcome) {
+ return result;
+ }
+ } catch (err) {
+ console.warn(
+ `[probeUrl] Video element probe threw for ${trimmed}:`,
+ err
+ );
+ }
+
+ return null;
+ };
+
const supportsAbort = typeof AbortController !== "undefined";
const controller = supportsAbort ? new AbortController() : null;
let timeoutId = null;
@@ -4529,11 +4560,13 @@ class bitvidApp {
clearTimeout(timeoutId);
}
console.warn(`[probeUrl] HEAD request failed for ${trimmed}:`, err);
- const fallback = await this.probeUrlWithVideoElement(trimmed);
- if (fallback && fallback.outcome) {
+ const fallback = await confirmWithVideoElement();
+ if (fallback) {
return fallback;
}
- return { outcome: "error", error: err };
+ return confirmPlayable
+ ? { outcome: "error", error: err }
+ : { outcome: "unknown", error: err };
}
if (timeoutId) {
@@ -4541,11 +4574,11 @@ class bitvidApp {
}
if (responseOrTimeout && responseOrTimeout.outcome === "timeout") {
- const fallback = await this.probeUrlWithVideoElement(trimmed);
- if (fallback && fallback.outcome) {
+ const fallback = await confirmWithVideoElement();
+ if (fallback) {
return fallback;
}
- return { outcome: "timeout" };
+ return confirmPlayable ? { outcome: "timeout" } : { outcome: "unknown" };
}
const response = responseOrTimeout;
@@ -4554,16 +4587,16 @@ class bitvidApp {
}
if (response.type === "opaque") {
- const fallback = await this.probeUrlWithVideoElement(trimmed);
- if (fallback && fallback.outcome) {
+ const fallback = await confirmWithVideoElement();
+ if (fallback) {
return fallback;
}
- return { outcome: "opaque" };
+ return { outcome: confirmPlayable ? "opaque" : "unknown" };
}
if (!response.ok) {
- const fallback = await this.probeUrlWithVideoElement(trimmed);
- if (fallback && fallback.outcome) {
+ const fallback = await confirmWithVideoElement();
+ if (fallback) {
return fallback;
}
return {
@@ -4572,8 +4605,8 @@ class bitvidApp {
};
}
- const playbackCheck = await this.probeUrlWithVideoElement(trimmed);
- if (playbackCheck && playbackCheck.outcome) {
+ const playbackCheck = await confirmWithVideoElement();
+ if (playbackCheck) {
if (playbackCheck.outcome === "ok") {
return {
...playbackCheck,
From 801bcd2345e2f94cdfd6c4f415fb9c898e5c3866 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 13:05:27 -0400
Subject: [PATCH 16/82] Reset video element before WebTorrent fallback
---
js/app.js | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/js/app.js b/js/app.js
index 05648e24..3d87f522 100644
--- a/js/app.js
+++ b/js/app.js
@@ -4834,6 +4834,32 @@ class bitvidApp {
fallbackStarted = true;
this.cleanupUrlPlaybackWatchdog();
cleanupHostedUrlStatusListeners();
+
+ if (videoEl) {
+ try {
+ videoEl.pause();
+ } catch (err) {
+ this.log(
+ "[playVideoWithFallback] Ignoring pause error before torrent fallback:",
+ err
+ );
+ }
+ try {
+ videoEl.removeAttribute("src");
+ } catch (err) {
+ // removeAttribute throws in old browsers when the attribute does not exist
+ }
+ videoEl.src = "";
+ videoEl.srcObject = null;
+ try {
+ videoEl.load();
+ } catch (err) {
+ this.log(
+ "[playVideoWithFallback] Ignoring load error before torrent fallback:",
+ err
+ );
+ }
+ }
if (!magnetForPlayback) {
const message =
"Hosted playback failed and no magnet fallback is available.";
From 854216f33115bfe30562fd9490234a06ddadc604 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 13:33:13 -0400
Subject: [PATCH 17/82] Prioritize visible videos for health checks
---
js/gridHealth.js | 71 +++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 64 insertions(+), 7 deletions(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index 11815a6d..aba14ed8 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -236,14 +236,26 @@ function ensureState(container) {
const observer = new IntersectionObserver(
(entries) => {
- entries.forEach((entry) => {
+ const viewportCenter = getViewportCenter();
+ const prioritized = entries
+ .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
+ .map((entry) => ({
+ entry,
+ ratio:
+ typeof entry.intersectionRatio === "number"
+ ? entry.intersectionRatio
+ : 0,
+ distance: calculateDistanceSquared(entry, viewportCenter),
+ }))
+ .sort((a, b) => {
+ if (b.ratio !== a.ratio) {
+ return b.ratio - a.ratio;
+ }
+ return a.distance - b.distance;
+ });
+
+ prioritized.forEach(({ entry }) => {
const card = entry.target;
- if (!(card instanceof HTMLElement)) {
- return;
- }
- if (!entry.isIntersecting) {
- return;
- }
handleCardVisible({ card, pendingByCard });
});
},
@@ -255,6 +267,51 @@ function ensureState(container) {
return state;
}
+function getViewportCenter() {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ const width = Number(window.innerWidth) || 0;
+ const height = Number(window.innerHeight) || 0;
+ if (width <= 0 && height <= 0) {
+ return null;
+ }
+ return {
+ x: width > 0 ? width / 2 : 0,
+ y: height > 0 ? height / 2 : 0,
+ };
+}
+
+function calculateDistanceSquared(entry, viewportCenter) {
+ if (!viewportCenter) {
+ return Number.POSITIVE_INFINITY;
+ }
+ const rect = getIntersectionRect(entry);
+ if (!rect) {
+ return Number.POSITIVE_INFINITY;
+ }
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ const dx = centerX - viewportCenter.x;
+ const dy = centerY - viewportCenter.y;
+ return dx * dx + dy * dy;
+}
+
+function getIntersectionRect(entry) {
+ if (!entry) {
+ return null;
+ }
+ const rect = entry.intersectionRect;
+ if (rect && rect.width > 0 && rect.height > 0) {
+ return rect;
+ }
+ const fallback = entry.boundingClientRect;
+ if (fallback && fallback.width > 0 && fallback.height > 0) {
+ return fallback;
+ }
+ return rect || fallback || null;
+}
+
function setBadge(card, state, details) {
const badge = card.querySelector(".torrent-health-badge");
if (!badge) {
From 1985b160644ea3ba88151199b208624d04b44d64 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 13:49:45 -0400
Subject: [PATCH 18/82] Improve health probe prioritization
---
js/gridHealth.js | 139 +++++++++++++++++++++++++++++++----------------
1 file changed, 91 insertions(+), 48 deletions(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index aba14ed8..b2cf13ca 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -110,7 +110,7 @@ function normalizeResult(result) {
};
}
-function queueProbe(magnet, cacheKey) {
+function queueProbe(magnet, cacheKey, priority = 0) {
if (!magnet) {
return Promise.resolve(null);
}
@@ -125,21 +125,23 @@ function queueProbe(magnet, cacheKey) {
}
const job = probeQueue
- .run(() =>
- torrentClient
- .probePeers(magnet, {
- timeoutMs: PROBE_TIMEOUT_MS,
- maxWebConns: 2,
- polls: PROBE_POLL_COUNT,
- })
- .catch((err) => ({
- healthy: false,
- peers: 0,
- reason: "error",
- error: err,
- appendedTrackers: false,
- hasProbeTrackers: false,
- }))
+ .run(
+ () =>
+ torrentClient
+ .probePeers(magnet, {
+ timeoutMs: PROBE_TIMEOUT_MS,
+ maxWebConns: 2,
+ polls: PROBE_POLL_COUNT,
+ })
+ .catch((err) => ({
+ healthy: false,
+ peers: 0,
+ reason: "error",
+ error: err,
+ appendedTrackers: false,
+ hasProbeTrackers: false,
+ })),
+ priority
)
.then((result) => {
const normalized = normalizeResult(result);
@@ -173,13 +175,35 @@ class ProbeQueue {
this.queue = [];
}
- run(task) {
+ run(task, priority = 0) {
return new Promise((resolve, reject) => {
- this.queue.push({ task, resolve, reject });
+ const normalizedPriority = Number.isFinite(priority) ? priority : 0;
+ const job = {
+ task,
+ resolve,
+ reject,
+ priority: normalizedPriority,
+ };
+ this.enqueue(job);
this.drain();
});
}
+ enqueue(job) {
+ if (!this.queue.length) {
+ this.queue.push(job);
+ return;
+ }
+ const index = this.queue.findIndex(
+ (existing) => existing.priority < job.priority
+ );
+ if (index === -1) {
+ this.queue.push(job);
+ } else {
+ this.queue.splice(index, 0, job);
+ }
+ }
+
drain() {
if (this.running >= this.max) {
return;
@@ -233,36 +257,16 @@ function ensureState(container) {
const pendingByCard = new WeakMap();
const observedCards = new WeakSet();
+ state = { observer: null, pendingByCard, observedCards };
const observer = new IntersectionObserver(
(entries) => {
- const viewportCenter = getViewportCenter();
- const prioritized = entries
- .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
- .map((entry) => ({
- entry,
- ratio:
- typeof entry.intersectionRatio === "number"
- ? entry.intersectionRatio
- : 0,
- distance: calculateDistanceSquared(entry, viewportCenter),
- }))
- .sort((a, b) => {
- if (b.ratio !== a.ratio) {
- return b.ratio - a.ratio;
- }
- return a.distance - b.distance;
- });
-
- prioritized.forEach(({ entry }) => {
- const card = entry.target;
- handleCardVisible({ card, pendingByCard });
- });
+ processObserverEntries(entries, state);
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
);
- state = { observer, pendingByCard, observedCards };
+ state.observer = observer;
containerState.set(container, state);
return state;
}
@@ -297,6 +301,47 @@ function calculateDistanceSquared(entry, viewportCenter) {
return dx * dx + dy * dy;
}
+function computeVisibilityPriority({ ratio, distance }) {
+ const normalizedRatio = Number.isFinite(ratio)
+ ? Math.min(Math.max(ratio, 0), 1)
+ : 0;
+ const distanceScore = Number.isFinite(distance) ? 1 / (1 + distance) : 0;
+ return normalizedRatio * 1000 + distanceScore;
+}
+
+function prioritizeEntries(entries, viewportCenter) {
+ if (!Array.isArray(entries) || entries.length === 0) {
+ return [];
+ }
+ return entries
+ .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
+ .map((entry) => {
+ const ratio =
+ typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
+ const distance = calculateDistanceSquared(entry, viewportCenter);
+ const priority = computeVisibilityPriority({ ratio, distance });
+ return { entry, ratio, distance, priority };
+ })
+ .sort((a, b) => {
+ if (b.priority !== a.priority) {
+ return b.priority - a.priority;
+ }
+ return a.distance - b.distance;
+ });
+}
+
+function processObserverEntries(entries, state) {
+ if (!state || !entries || entries.length === 0) {
+ return;
+ }
+ const viewportCenter = getViewportCenter();
+ const prioritized = prioritizeEntries(entries, viewportCenter);
+ prioritized.forEach(({ entry, priority }) => {
+ const card = entry.target;
+ handleCardVisible({ card, pendingByCard: state.pendingByCard, priority });
+ });
+}
+
function getIntersectionRect(entry) {
if (!entry) {
return null;
@@ -395,7 +440,7 @@ function setBadge(card, state, details) {
}
}
-function handleCardVisible({ card, pendingByCard }) {
+function handleCardVisible({ card, pendingByCard, priority = 0 }) {
if (!(card instanceof HTMLElement)) {
return;
}
@@ -428,7 +473,7 @@ function handleCardVisible({ card, pendingByCard }) {
setBadge(card, "checking");
- const probePromise = queueProbe(magnet, infoHash);
+ const probePromise = queueProbe(magnet, infoHash, priority);
pendingByCard.set(card, probePromise);
@@ -481,6 +526,7 @@ export function attachHealthBadges(container) {
setBadge(card, "unknown");
}
});
+ processObserverEntries(state.observer.takeRecords(), state);
}
export function refreshHealthBadges(container) {
@@ -491,9 +537,6 @@ export function refreshHealthBadges(container) {
if (!state) {
return;
}
- state.observer.takeRecords().forEach((entry) => {
- if (entry.isIntersecting) {
- handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
- }
- });
+ const records = state.observer.takeRecords();
+ processObserverEntries(records, state);
}
From 02170097522e2a97d3524e615c2c8986040cbed6 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 14:23:05 -0400
Subject: [PATCH 19/82] Retry CDN probes after timeouts
---
js/app.js | 52 +++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 41 insertions(+), 11 deletions(-)
diff --git a/js/app.js b/js/app.js
index 3d87f522..7d693751 100644
--- a/js/app.js
+++ b/js/app.js
@@ -78,7 +78,9 @@ const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
// recovers. Results are stored both in-memory and in localStorage so other
// views can reuse them without re-probing.
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
+const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
+const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:";
const urlHealthCache = new Map();
const urlHealthInFlight = new Map();
@@ -3358,7 +3360,12 @@ class bitvidApp {
};
}
- return this.storeUrlHealth(eventId, trimmedUrl, entry);
+ const ttlOverride =
+ entry.status === "timeout" || entry.status === "unknown"
+ ? URL_HEALTH_TIMEOUT_RETRY_MS
+ : undefined;
+
+ return this.storeUrlHealth(eventId, trimmedUrl, entry, ttlOverride);
})
.catch((err) => {
console.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err);
@@ -4507,19 +4514,42 @@ class bitvidApp {
return null;
}
- try {
- const result = await this.probeUrlWithVideoElement(trimmed);
- if (result && result.outcome) {
- return result;
+ const initialTimeout =
+ Number.isFinite(options?.videoProbeTimeoutMs) &&
+ options.videoProbeTimeoutMs > 0
+ ? options.videoProbeTimeoutMs
+ : URL_PROBE_TIMEOUT_MS;
+
+ const attemptWithTimeout = async (timeoutMs) => {
+ try {
+ const result = await this.probeUrlWithVideoElement(trimmed, timeoutMs);
+ if (result && result.outcome) {
+ return result;
+ }
+ } catch (err) {
+ console.warn(
+ `[probeUrl] Video element probe threw for ${trimmed}:`,
+ err
+ );
+ }
+ return null;
+ };
+
+ let result = await attemptWithTimeout(initialTimeout);
+
+ if (
+ result &&
+ result.outcome === "timeout" &&
+ Number.isFinite(URL_PROBE_TIMEOUT_RETRY_MS) &&
+ URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout
+ ) {
+ const retryResult = await attemptWithTimeout(URL_PROBE_TIMEOUT_RETRY_MS);
+ if (retryResult) {
+ result = { ...retryResult, retriedAfterTimeout: true };
}
- } catch (err) {
- console.warn(
- `[probeUrl] Video element probe threw for ${trimmed}:`,
- err
- );
}
- return null;
+ return result;
};
const supportsAbort = typeof AbortController !== "undefined";
From 866e4f11fbe06d18a879687d6357b5f284da8958 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 14:45:48 -0400
Subject: [PATCH 20/82] Hide videos with dead CDN and torrent peers
---
css/style.css | 4 ++++
js/app.js | 20 ++++++++++++++++++++
js/channelProfile.js | 16 ++++++++++++++++
js/gridHealth.js | 16 ++++++++++++++--
js/subscriptions.js | 16 ++++++++++++++++
5 files changed, 70 insertions(+), 2 deletions(-)
diff --git a/css/style.css b/css/style.css
index 208e5552..07aa6a01 100644
--- a/css/style.css
+++ b/css/style.css
@@ -74,6 +74,10 @@ header img {
box-shadow: var(--shadow-md);
}
+.video-card[data-owner-is-viewer="false"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] {
+ display: none;
+}
+
.video-card--enter {
opacity: 0;
animation: video-card-fade-in 220ms ease-out forwards;
diff --git a/js/app.js b/js/app.js
index 7d693751..98b97656 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3243,6 +3243,10 @@ class bitvidApp {
const hadMargin = badgeEl.classList.contains("mt-3");
badgeEl.dataset.urlHealthState = status;
+ const cardEl = badgeEl.closest(".video-card");
+ if (cardEl) {
+ cardEl.dataset.urlHealthState = status;
+ }
badgeEl.setAttribute("aria-live", "polite");
badgeEl.setAttribute("role", status === "offline" ? "alert" : "status");
badgeEl.textContent = message;
@@ -3677,6 +3681,22 @@ class bitvidApp {
template.innerHTML = cardHtml.trim();
const cardEl = template.content.firstElementChild;
if (cardEl) {
+ cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
+ if (typeof video.pubkey === "string" && video.pubkey) {
+ cardEl.dataset.ownerPubkey = video.pubkey;
+ } else if (cardEl.dataset.ownerPubkey) {
+ delete cardEl.dataset.ownerPubkey;
+ }
+
+ cardEl.dataset.urlHealthState = trimmedUrl ? "checking" : "absent";
+ if (magnetProvided && magnetSupported) {
+ cardEl.dataset.streamHealthState = "checking";
+ } else if (magnetProvided) {
+ cardEl.dataset.streamHealthState = "unsupported";
+ } else {
+ cardEl.dataset.streamHealthState = "absent";
+ }
+
const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]");
if (thumbnailEl) {
const markThumbnailAsLoaded = () => {
diff --git a/js/channelProfile.js b/js/channelProfile.js
index 531d5ad4..abeb30bd 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -360,6 +360,13 @@ async function loadUserVideos(pubkey) {
"duration-300"
);
+ cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
+ if (typeof video.pubkey === "string" && video.pubkey) {
+ cardEl.dataset.ownerPubkey = video.pubkey;
+ } else if (cardEl.dataset.ownerPubkey) {
+ delete cardEl.dataset.ownerPubkey;
+ }
+
const rawMagnet =
typeof video.magnet === "string" ? video.magnet : "";
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
@@ -433,6 +440,15 @@ async function loadUserVideos(pubkey) {
delete cardEl.dataset.torrentSupported;
}
+ cardEl.dataset.urlHealthState = trimmedUrl ? "checking" : "absent";
+ if (magnetProvided && magnetSupported) {
+ cardEl.dataset.streamHealthState = "checking";
+ } else if (magnetProvided) {
+ cardEl.dataset.streamHealthState = "unsupported";
+ } else {
+ cardEl.dataset.streamHealthState = "absent";
+ }
+
if (magnetProvided) {
cardEl.dataset.magnet = playbackMagnet;
} else if (cardEl.dataset.magnet) {
diff --git a/js/gridHealth.js b/js/gridHealth.js
index b2cf13ca..a0394eed 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -433,11 +433,23 @@ function setBadge(card, state, details) {
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
badge.setAttribute("role", entry.role);
badge.dataset.streamHealthState = state;
- if (Number.isFinite(peers)) {
- badge.dataset.streamHealthPeers = String(peers);
+ const hasPeerCount = Number.isFinite(peers);
+ const peersTextValue = hasPeerCount ? String(peers) : "";
+ if (hasPeerCount) {
+ badge.dataset.streamHealthPeers = peersTextValue;
} else if (badge.dataset.streamHealthPeers) {
delete badge.dataset.streamHealthPeers;
}
+
+ const card = badge.closest(".video-card");
+ if (card) {
+ card.dataset.streamHealthState = state;
+ if (hasPeerCount) {
+ card.dataset.streamHealthPeers = peersTextValue;
+ } else if (card.dataset.streamHealthPeers) {
+ delete card.dataset.streamHealthPeers;
+ }
+ }
}
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
diff --git a/js/subscriptions.js b/js/subscriptions.js
index 68b42102..216d1ab3 100644
--- a/js/subscriptions.js
+++ b/js/subscriptions.js
@@ -467,6 +467,22 @@ class SubscriptionsManager {
t.innerHTML = cardHtml.trim();
const cardEl = t.content.firstElementChild;
if (cardEl) {
+ cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
+ if (typeof video.pubkey === "string" && video.pubkey) {
+ cardEl.dataset.ownerPubkey = video.pubkey;
+ } else if (cardEl.dataset.ownerPubkey) {
+ delete cardEl.dataset.ownerPubkey;
+ }
+
+ cardEl.dataset.urlHealthState = trimmedUrl ? "checking" : "absent";
+ if (magnetProvided && magnetSupported) {
+ cardEl.dataset.streamHealthState = "checking";
+ } else if (magnetProvided) {
+ cardEl.dataset.streamHealthState = "unsupported";
+ } else {
+ cardEl.dataset.streamHealthState = "absent";
+ }
+
// Leave the data-play-* attributes empty in the literal markup so we can
// assign the raw URL/magnet strings post-parsing without HTML entity
// escaping, mirroring the approach in app.js. The URL is encoded so that
From 0b4523b0be8f3e29b5bd1d3027ecd25b82b7f6a0 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 14:50:56 -0400
Subject: [PATCH 21/82] Fix duplicate card variable in health badge update
---
js/gridHealth.js | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index a0394eed..c1185a73 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -441,13 +441,13 @@ function setBadge(card, state, details) {
delete badge.dataset.streamHealthPeers;
}
- const card = badge.closest(".video-card");
- if (card) {
- card.dataset.streamHealthState = state;
+ const cardEl = badge.closest(".video-card");
+ if (cardEl) {
+ cardEl.dataset.streamHealthState = state;
if (hasPeerCount) {
- card.dataset.streamHealthPeers = peersTextValue;
- } else if (card.dataset.streamHealthPeers) {
- delete card.dataset.streamHealthPeers;
+ cardEl.dataset.streamHealthPeers = peersTextValue;
+ } else if (cardEl.dataset.streamHealthPeers) {
+ delete cardEl.dataset.streamHealthPeers;
}
}
}
From 2dfe20907ba66867557ffc4492cd7f9a67801ba3 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 14:59:45 -0400
Subject: [PATCH 22/82] Treat missing CDN/WebTorrent sources as offline
---
js/app.js | 20 ++++++++++++---
js/channelProfile.js | 20 ++++++++++++---
js/gridHealth.js | 60 ++++++++++++++++++++++++++------------------
js/subscriptions.js | 20 ++++++++++++---
4 files changed, 84 insertions(+), 36 deletions(-)
diff --git a/js/app.js b/js/app.js
index 98b97656..e379a8d6 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3688,13 +3688,25 @@ class bitvidApp {
delete cardEl.dataset.ownerPubkey;
}
- cardEl.dataset.urlHealthState = trimmedUrl ? "checking" : "absent";
+ if (trimmedUrl) {
+ cardEl.dataset.urlHealthState = "checking";
+ if (cardEl.dataset.urlHealthReason) {
+ delete cardEl.dataset.urlHealthReason;
+ }
+ } else {
+ cardEl.dataset.urlHealthState = "offline";
+ cardEl.dataset.urlHealthReason = "missing-source";
+ }
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
- } else if (magnetProvided) {
- cardEl.dataset.streamHealthState = "unsupported";
+ if (cardEl.dataset.streamHealthReason) {
+ delete cardEl.dataset.streamHealthReason;
+ }
} else {
- cardEl.dataset.streamHealthState = "absent";
+ cardEl.dataset.streamHealthState = "unhealthy";
+ cardEl.dataset.streamHealthReason = magnetProvided
+ ? "unsupported"
+ : "missing-source";
}
const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]");
diff --git a/js/channelProfile.js b/js/channelProfile.js
index abeb30bd..c9a08660 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -440,13 +440,25 @@ async function loadUserVideos(pubkey) {
delete cardEl.dataset.torrentSupported;
}
- cardEl.dataset.urlHealthState = trimmedUrl ? "checking" : "absent";
+ if (trimmedUrl) {
+ cardEl.dataset.urlHealthState = "checking";
+ if (cardEl.dataset.urlHealthReason) {
+ delete cardEl.dataset.urlHealthReason;
+ }
+ } else {
+ cardEl.dataset.urlHealthState = "offline";
+ cardEl.dataset.urlHealthReason = "missing-source";
+ }
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
- } else if (magnetProvided) {
- cardEl.dataset.streamHealthState = "unsupported";
+ if (cardEl.dataset.streamHealthReason) {
+ delete cardEl.dataset.streamHealthReason;
+ }
} else {
- cardEl.dataset.streamHealthState = "absent";
+ cardEl.dataset.streamHealthState = "unhealthy";
+ cardEl.dataset.streamHealthReason = magnetProvided
+ ? "unsupported"
+ : "missing-source";
}
if (magnetProvided) {
diff --git a/js/gridHealth.js b/js/gridHealth.js
index c1185a73..997ce041 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -358,10 +358,37 @@ function getIntersectionRect(entry) {
}
function setBadge(card, state, details) {
+ if (!(card instanceof HTMLElement)) {
+ return;
+ }
+
+ const normalizedState =
+ typeof state === "string" && state ? state : "unknown";
+ const peersValue =
+ details && Number.isFinite(details.peers)
+ ? Math.max(0, Number(details.peers))
+ : 0;
+ const hasPeerCount = details ? Number.isFinite(details.peers) : false;
+ const peersTextValue = hasPeerCount ? String(peersValue) : "";
+
+ card.dataset.streamHealthState = normalizedState;
+ if (hasPeerCount) {
+ card.dataset.streamHealthPeers = peersTextValue;
+ } else if (card.dataset.streamHealthPeers) {
+ delete card.dataset.streamHealthPeers;
+ }
+
+ if (details && typeof details.reason === "string" && details.reason) {
+ card.dataset.streamHealthReason = details.reason;
+ } else if (card.dataset.streamHealthReason) {
+ delete card.dataset.streamHealthReason;
+ }
+
const badge = card.querySelector(".torrent-health-badge");
if (!badge) {
return;
}
+
const hadMargin = badge.classList.contains("mt-3");
const baseClasses = [
@@ -409,22 +436,19 @@ function setBadge(card, state, details) {
},
};
- const entry = map[state] || map.unknown;
+ const entry = map[normalizedState] || map.unknown;
entry.classes.forEach((cls) => badge.classList.add(cls));
- const peers =
- details && Number.isFinite(details.peers)
- ? Math.max(0, Number(details.peers))
- : 0;
- const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
+ const peersText =
+ normalizedState === "healthy" && peersValue > 0 ? ` (${peersValue})` : "";
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
const tooltip =
- state === "checking" || state === "unknown"
+ normalizedState === "checking" || normalizedState === "unknown"
? entry.aria
: buildTooltip({
- peers,
+ peers: peersValue,
checkedAt: details?.checkedAt,
reason: details?.reason,
});
@@ -432,24 +456,12 @@ function setBadge(card, state, details) {
badge.setAttribute("title", tooltip);
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
badge.setAttribute("role", entry.role);
- badge.dataset.streamHealthState = state;
- const hasPeerCount = Number.isFinite(peers);
- const peersTextValue = hasPeerCount ? String(peers) : "";
+ badge.dataset.streamHealthState = normalizedState;
if (hasPeerCount) {
badge.dataset.streamHealthPeers = peersTextValue;
} else if (badge.dataset.streamHealthPeers) {
delete badge.dataset.streamHealthPeers;
}
-
- const cardEl = badge.closest(".video-card");
- if (cardEl) {
- cardEl.dataset.streamHealthState = state;
- if (hasPeerCount) {
- cardEl.dataset.streamHealthPeers = peersTextValue;
- } else if (cardEl.dataset.streamHealthPeers) {
- delete cardEl.dataset.streamHealthPeers;
- }
- }
}
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
@@ -458,13 +470,13 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
}
const magnet = card.dataset.magnet || "";
if (!magnet) {
- setBadge(card, "unknown");
+ setBadge(card, "unhealthy", { reason: "missing-source" });
return;
}
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
- setBadge(card, "unknown");
+ setBadge(card, "unhealthy", { reason: "invalid" });
return;
}
@@ -535,7 +547,7 @@ export function attachHealthBadges(container) {
state.observedCards.add(card);
state.observer.observe(card);
if (!card.dataset.magnet) {
- setBadge(card, "unknown");
+ setBadge(card, "unhealthy", { reason: "missing-source" });
}
});
processObserverEntries(state.observer.takeRecords(), state);
diff --git a/js/subscriptions.js b/js/subscriptions.js
index 216d1ab3..9a55fef9 100644
--- a/js/subscriptions.js
+++ b/js/subscriptions.js
@@ -474,13 +474,25 @@ class SubscriptionsManager {
delete cardEl.dataset.ownerPubkey;
}
- cardEl.dataset.urlHealthState = trimmedUrl ? "checking" : "absent";
+ if (trimmedUrl) {
+ cardEl.dataset.urlHealthState = "checking";
+ if (cardEl.dataset.urlHealthReason) {
+ delete cardEl.dataset.urlHealthReason;
+ }
+ } else {
+ cardEl.dataset.urlHealthState = "offline";
+ cardEl.dataset.urlHealthReason = "missing-source";
+ }
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
- } else if (magnetProvided) {
- cardEl.dataset.streamHealthState = "unsupported";
+ if (cardEl.dataset.streamHealthReason) {
+ delete cardEl.dataset.streamHealthReason;
+ }
} else {
- cardEl.dataset.streamHealthState = "absent";
+ cardEl.dataset.streamHealthState = "unhealthy";
+ cardEl.dataset.streamHealthReason = magnetProvided
+ ? "unsupported"
+ : "missing-source";
}
// Leave the data-play-* attributes empty in the literal markup so we can
From 2b0f3547cb2022d86914f1d174a4d01c8282efc4 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 15:11:02 -0400
Subject: [PATCH 23/82] Highlight owner offline videos with critical styling
---
css/style.css | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/css/style.css b/css/style.css
index 07aa6a01..b523fd42 100644
--- a/css/style.css
+++ b/css/style.css
@@ -3,6 +3,7 @@
--color-card: #1e293b;
--color-primary: #fe0032;
--color-secondary: #ff93a5;
+ --color-card-critical: #3f0b13;
--color-text: #f8fafc;
--color-muted: #94a3b8;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
@@ -78,6 +79,15 @@ header img {
display: none;
}
+.video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] {
+ background-color: var(--color-card-critical);
+ box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35), var(--shadow-md);
+}
+
+.video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"]:hover {
+ box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.45), var(--shadow-lg);
+}
+
.video-card--enter {
opacity: 0;
animation: video-card-fade-in 220ms ease-out forwards;
From 12aaf63d3d3a516367ea7fe8b4325235a8bb6ec1 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 15:24:26 -0400
Subject: [PATCH 24/82] Increase WebTorrent health probe concurrency
---
js/gridHealth.js | 65 ++++++++++++++++++++++++------------------------
1 file changed, 32 insertions(+), 33 deletions(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index 997ce041..7aa0cb78 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -165,7 +165,7 @@ function queueProbe(magnet, cacheKey, priority = 0) {
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
-const PROBE_CONCURRENCY = 3;
+const PROBE_CONCURRENCY = 6;
const PROBE_POLL_COUNT = 3;
class ProbeQueue {
@@ -205,43 +205,42 @@ class ProbeQueue {
}
drain() {
- if (this.running >= this.max) {
- return;
- }
- const job = this.queue.shift();
- if (!job) {
- return;
- }
- this.running += 1;
- let finished = false;
- const finalize = () => {
- if (finished) {
+ while (this.running < this.max && this.queue.length > 0) {
+ const job = this.queue.shift();
+ if (!job) {
return;
}
- finished = true;
- this.running -= 1;
- this.drain();
- };
+ this.running += 1;
+ let finished = false;
+ const finalize = () => {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ this.running -= 1;
+ this.drain();
+ };
- let result;
- try {
- result = job.task();
- } catch (err) {
- job.reject(err);
- finalize();
- return;
- }
-
- Promise.resolve(result)
- .then((value) => {
- job.resolve(value);
- })
- .catch((err) => {
+ let result;
+ try {
+ result = job.task();
+ } catch (err) {
job.reject(err);
- })
- .finally(() => {
finalize();
- });
+ continue;
+ }
+
+ Promise.resolve(result)
+ .then((value) => {
+ job.resolve(value);
+ })
+ .catch((err) => {
+ job.reject(err);
+ })
+ .finally(() => {
+ finalize();
+ });
+ }
}
}
From f07ca9966ce08fd5226134081cff701f3faa4faa Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 15:33:41 -0400
Subject: [PATCH 25/82] Defer health checks until video cards are visible
---
js/app.js | 30 ++++++++--
js/channelProfile.js | 33 ++++++++---
js/subscriptions.js | 37 +++++++++---
js/urlHealthObserver.js | 124 ++++++++++++++++++++++++++++++++++++++++
4 files changed, 203 insertions(+), 21 deletions(-)
create mode 100644 js/urlHealthObserver.js
diff --git a/js/app.js b/js/app.js
index e379a8d6..f0b307c8 100644
--- a/js/app.js
+++ b/js/app.js
@@ -11,6 +11,7 @@ import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
import { URL_FIRST_ENABLED } from "./constants.js";
import { trackVideoView } from "./analytics.js";
import { attachHealthBadges } from "./gridHealth.js";
+import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import {
initialWhitelist,
initialBlacklist,
@@ -3693,9 +3694,17 @@ class bitvidApp {
if (cardEl.dataset.urlHealthReason) {
delete cardEl.dataset.urlHealthReason;
}
+ cardEl.dataset.urlHealthEventId = video.id || "";
+ cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
cardEl.dataset.urlHealthState = "offline";
cardEl.dataset.urlHealthReason = "missing-source";
+ if (cardEl.dataset.urlHealthEventId) {
+ delete cardEl.dataset.urlHealthEventId;
+ }
+ if (cardEl.dataset.urlHealthUrl) {
+ delete cardEl.dataset.urlHealthUrl;
+ }
}
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
@@ -3832,11 +3841,18 @@ class bitvidApp {
if (trimmedUrl) {
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
- this.handleUrlHealthBadge({
- video,
- url: trimmedUrl,
- badgeEl,
- });
+ badgeEl.dataset.urlHealthEventId = video.id || "";
+ badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
+ }
+ } else {
+ const badgeEl = cardEl.querySelector("[data-url-health-state]");
+ if (badgeEl) {
+ if (badgeEl.dataset.urlHealthEventId) {
+ delete badgeEl.dataset.urlHealthEventId;
+ }
+ if (badgeEl.dataset.urlHealthUrl) {
+ delete badgeEl.dataset.urlHealthUrl;
+ }
}
}
}
@@ -3852,6 +3868,10 @@ class bitvidApp {
this.videoList.innerHTML = "";
this.videoList.appendChild(fragment);
attachHealthBadges(this.videoList);
+ attachUrlHealthBadges(this.videoList, ({ badgeEl, url, eventId }) => {
+ const video = this.videosMap.get(eventId) || { id: eventId };
+ this.handleUrlHealthBadge({ video, url, badgeEl });
+ });
// Ensure every thumbnail can recover with a fallback image if the primary
// source fails to load or returns a zero-sized response (some CDNs error
diff --git a/js/channelProfile.js b/js/channelProfile.js
index c9a08660..d6adfcdf 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -7,6 +7,7 @@ import {
import { app } from "./app.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { attachHealthBadges } from "./gridHealth.js";
+import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
@@ -445,9 +446,17 @@ async function loadUserVideos(pubkey) {
if (cardEl.dataset.urlHealthReason) {
delete cardEl.dataset.urlHealthReason;
}
+ cardEl.dataset.urlHealthEventId = video.id || "";
+ cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
cardEl.dataset.urlHealthState = "offline";
cardEl.dataset.urlHealthReason = "missing-source";
+ if (cardEl.dataset.urlHealthEventId) {
+ delete cardEl.dataset.urlHealthEventId;
+ }
+ if (cardEl.dataset.urlHealthUrl) {
+ delete cardEl.dataset.urlHealthUrl;
+ }
}
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
@@ -491,14 +500,18 @@ async function loadUserVideos(pubkey) {
}
});
- if (trimmedUrl) {
- const badgeEl = cardEl.querySelector("[data-url-health-state]");
- if (badgeEl) {
- app.handleUrlHealthBadge({
- video,
- url: trimmedUrl,
- badgeEl,
- });
+ const badgeEl = cardEl.querySelector("[data-url-health-state]");
+ if (badgeEl) {
+ if (trimmedUrl) {
+ badgeEl.dataset.urlHealthEventId = video.id || "";
+ badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
+ } else {
+ if (badgeEl.dataset.urlHealthEventId) {
+ delete badgeEl.dataset.urlHealthEventId;
+ }
+ if (badgeEl.dataset.urlHealthUrl) {
+ delete badgeEl.dataset.urlHealthUrl;
+ }
}
}
@@ -508,6 +521,10 @@ async function loadUserVideos(pubkey) {
container.appendChild(fragment);
attachHealthBadges(container);
+ attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
+ const video = app.videosMap.get(eventId) || { id: eventId };
+ app.handleUrlHealthBadge({ video, url, badgeEl });
+ });
window.app.videoList = container;
window.app.attachVideoListHandler();
diff --git a/js/subscriptions.js b/js/subscriptions.js
index 9a55fef9..34e129e0 100644
--- a/js/subscriptions.js
+++ b/js/subscriptions.js
@@ -4,6 +4,7 @@ import {
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr.js";
import { attachHealthBadges } from "./gridHealth.js";
+import { attachUrlHealthBadges } from "./urlHealthObserver.js";
function getAbsoluteShareUrl(nevent) {
if (!nevent) {
@@ -479,9 +480,17 @@ class SubscriptionsManager {
if (cardEl.dataset.urlHealthReason) {
delete cardEl.dataset.urlHealthReason;
}
+ cardEl.dataset.urlHealthEventId = video.id || "";
+ cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
cardEl.dataset.urlHealthState = "offline";
cardEl.dataset.urlHealthReason = "missing-source";
+ if (cardEl.dataset.urlHealthEventId) {
+ delete cardEl.dataset.urlHealthEventId;
+ }
+ if (cardEl.dataset.urlHealthUrl) {
+ delete cardEl.dataset.urlHealthUrl;
+ }
}
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
@@ -519,14 +528,18 @@ class SubscriptionsManager {
delete cardEl.dataset.magnet;
}
- if (trimmedUrl && window.app?.handleUrlHealthBadge) {
- const badgeEl = cardEl.querySelector("[data-url-health-state]");
- if (badgeEl) {
- window.app.handleUrlHealthBadge({
- video,
- url: trimmedUrl,
- badgeEl,
- });
+ const badgeEl = cardEl.querySelector("[data-url-health-state]");
+ if (badgeEl) {
+ if (trimmedUrl) {
+ badgeEl.dataset.urlHealthEventId = video.id || "";
+ badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
+ } else {
+ if (badgeEl.dataset.urlHealthEventId) {
+ delete badgeEl.dataset.urlHealthEventId;
+ }
+ if (badgeEl.dataset.urlHealthUrl) {
+ delete badgeEl.dataset.urlHealthUrl;
+ }
}
}
}
@@ -535,6 +548,14 @@ class SubscriptionsManager {
container.appendChild(fragment);
attachHealthBadges(container);
+ attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
+ if (!window.app?.handleUrlHealthBadge) {
+ return;
+ }
+ const video =
+ window.app.videosMap?.get?.(eventId) || { id: eventId };
+ window.app.handleUrlHealthBadge({ video, url, badgeEl });
+ });
if (window.app) {
window.app.videoList = container;
diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js
new file mode 100644
index 00000000..05e16920
--- /dev/null
+++ b/js/urlHealthObserver.js
@@ -0,0 +1,124 @@
+const containerState = new WeakMap();
+const ROOT_MARGIN = "0px";
+const THRESHOLD = 0.25;
+
+function decodeUrl(value) {
+ if (typeof value !== "string" || !value) {
+ return "";
+ }
+ try {
+ return decodeURIComponent(value);
+ } catch (err) {
+ return value;
+ }
+}
+
+function ensureState(container) {
+ let state = containerState.get(container);
+ if (state) {
+ return state;
+ }
+
+ const observedCards = new WeakSet();
+ const observer = new IntersectionObserver(
+ (entries) => {
+ processEntries(entries, state);
+ },
+ { root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD }
+ );
+
+ state = {
+ observer,
+ observedCards,
+ onCheck: null,
+ };
+ containerState.set(container, state);
+ return state;
+}
+
+function processEntries(entries, state) {
+ if (!state?.onCheck || !Array.isArray(entries) || !entries.length) {
+ return;
+ }
+
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
+ return;
+ }
+
+ const card = entry.target;
+ if (!(card instanceof HTMLElement)) {
+ return;
+ }
+
+ const badgeEl = card.querySelector("[data-url-health-state]");
+ if (!(badgeEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const encodedUrl =
+ card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || "";
+ const eventId =
+ card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || "";
+
+ if (!encodedUrl || !eventId) {
+ return;
+ }
+
+ const currentState =
+ card.dataset.urlHealthState || badgeEl.dataset.urlHealthState;
+ if (currentState && currentState !== "checking") {
+ return;
+ }
+
+ const url = decodeUrl(encodedUrl);
+ if (!url) {
+ return;
+ }
+
+ try {
+ state.onCheck({ card, badgeEl, url, eventId });
+ } catch (err) {
+ console.warn("[urlHealthObserver] onCheck handler failed", err);
+ }
+ });
+}
+
+export function attachUrlHealthBadges(container, onCheck) {
+ if (!(container instanceof HTMLElement)) {
+ return;
+ }
+
+ const state = ensureState(container);
+ if (typeof onCheck === "function") {
+ state.onCheck = onCheck;
+ }
+
+ const cards = container.querySelectorAll(".video-card");
+ cards.forEach((card) => {
+ if (!(card instanceof HTMLElement)) {
+ return;
+ }
+ if (state.observedCards.has(card)) {
+ return;
+ }
+ state.observedCards.add(card);
+ state.observer.observe(card);
+ });
+
+ const records = state.observer.takeRecords();
+ if (records.length) {
+ processEntries(records, state);
+ }
+}
+
+export function refreshUrlHealthBadges(container) {
+ const state = containerState.get(container);
+ if (!state) {
+ return;
+ }
+ const records = state.observer.takeRecords();
+ if (records.length) {
+ processEntries(records, state);
+ }
+}
From 0461d06834c0ce74531755d35b0a0f9906ac6def Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 20:13:47 -0400
Subject: [PATCH 26/82] Tune WebTorrent probe scheduling
---
js/gridHealth.js | 126 ++++++++++++++++++++++++++++++++++-------------
1 file changed, 93 insertions(+), 33 deletions(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index 7aa0cb78..e81515c7 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -165,8 +165,9 @@ function queueProbe(magnet, cacheKey, priority = 0) {
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
-const PROBE_CONCURRENCY = 6;
+const PROBE_CONCURRENCY = 12;
const PROBE_POLL_COUNT = 3;
+const PRIORITY_BASELINE = 1_000_000;
class ProbeQueue {
constructor(max = 2) {
@@ -285,48 +286,107 @@ function getViewportCenter() {
};
}
-function calculateDistanceSquared(entry, viewportCenter) {
- if (!viewportCenter) {
- return Number.POSITIVE_INFINITY;
- }
- const rect = getIntersectionRect(entry);
- if (!rect) {
- return Number.POSITIVE_INFINITY;
- }
- const centerX = rect.left + rect.width / 2;
- const centerY = rect.top + rect.height / 2;
- const dx = centerX - viewportCenter.x;
- const dy = centerY - viewportCenter.y;
- return dx * dx + dy * dy;
-}
-
-function computeVisibilityPriority({ ratio, distance }) {
- const normalizedRatio = Number.isFinite(ratio)
- ? Math.min(Math.max(ratio, 0), 1)
- : 0;
- const distanceScore = Number.isFinite(distance) ? 1 / (1 + distance) : 0;
- return normalizedRatio * 1000 + distanceScore;
-}
-
function prioritizeEntries(entries, viewportCenter) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
- return entries
+ const filtered = entries
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
.map((entry) => {
+ const rect = getIntersectionRect(entry);
+ if (!rect) {
+ return null;
+ }
const ratio =
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
- const distance = calculateDistanceSquared(entry, viewportCenter);
- const priority = computeVisibilityPriority({ ratio, distance });
- return { entry, ratio, distance, priority };
+ const centerY = rect.top + rect.height / 2;
+ const verticalDistance = viewportCenter
+ ? Math.abs(centerY - viewportCenter.y)
+ : Number.POSITIVE_INFINITY;
+ return { entry, ratio, centerY, verticalDistance };
})
- .sort((a, b) => {
- if (b.priority !== a.priority) {
- return b.priority - a.priority;
+ .filter(Boolean);
+
+ if (filtered.length === 0) {
+ return [];
+ }
+
+ if (!viewportCenter) {
+ return filtered
+ .sort((a, b) => {
+ if (b.ratio !== a.ratio) {
+ return b.ratio - a.ratio;
+ }
+ return a.centerY - b.centerY;
+ })
+ .map((item, index) => ({
+ entry: item.entry,
+ priority: PRIORITY_BASELINE - index,
+ }));
+ }
+
+ const ordered = filtered
+ .slice()
+ .sort((a, b) => a.centerY - b.centerY);
+
+ let centerIndex = 0;
+ let minDistance = Number.POSITIVE_INFINITY;
+ for (let i = 0; i < ordered.length; i += 1) {
+ const candidate = ordered[i];
+ if (candidate.verticalDistance < minDistance) {
+ minDistance = candidate.verticalDistance;
+ centerIndex = i;
+ }
+ }
+
+ const prioritized = [];
+ const pushCandidate = (candidate) => {
+ if (!candidate) {
+ return;
+ }
+ prioritized.push(candidate);
+ };
+
+ pushCandidate(ordered[centerIndex]);
+
+ let left = centerIndex - 1;
+ let right = centerIndex + 1;
+ while (left >= 0 || right < ordered.length) {
+ const leftCandidate = left >= 0 ? ordered[left] : null;
+ const rightCandidate = right < ordered.length ? ordered[right] : null;
+
+ if (leftCandidate && rightCandidate) {
+ const leftDistance = leftCandidate.verticalDistance;
+ const rightDistance = rightCandidate.verticalDistance;
+ const distanceDelta = Math.abs(leftDistance - rightDistance);
+ if (distanceDelta <= 0.5) {
+ if (rightCandidate.ratio > leftCandidate.ratio) {
+ pushCandidate(rightCandidate);
+ right += 1;
+ } else {
+ pushCandidate(leftCandidate);
+ left -= 1;
+ }
+ } else if (leftDistance < rightDistance) {
+ pushCandidate(leftCandidate);
+ left -= 1;
+ } else {
+ pushCandidate(rightCandidate);
+ right += 1;
}
- return a.distance - b.distance;
- });
+ } else if (rightCandidate) {
+ pushCandidate(rightCandidate);
+ right += 1;
+ } else if (leftCandidate) {
+ pushCandidate(leftCandidate);
+ left -= 1;
+ }
+ }
+
+ return prioritized.map((candidate, index) => ({
+ entry: candidate.entry,
+ priority: PRIORITY_BASELINE - index,
+ }));
}
function processObserverEntries(entries, state) {
From e0e1eb381ca86a6d1ee191e215651fe6110a926b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 20:17:46 -0400
Subject: [PATCH 27/82] update
---
js/gridHealth.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index e81515c7..3ba94311 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -165,7 +165,7 @@ function queueProbe(magnet, cacheKey, priority = 0) {
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
-const PROBE_CONCURRENCY = 12;
+const PROBE_CONCURRENCY = 24;
const PROBE_POLL_COUNT = 3;
const PRIORITY_BASELINE = 1_000_000;
From f6612236f0ba7df08387aff3f860a154e0505fb5 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 20:20:05 -0400
Subject: [PATCH 28/82] update
---
js/gridHealth.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index 3ba94311..8396ff29 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -165,7 +165,7 @@ function queueProbe(magnet, cacheKey, priority = 0) {
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
-const PROBE_CONCURRENCY = 24;
+const PROBE_CONCURRENCY = 48;
const PROBE_POLL_COUNT = 3;
const PRIORITY_BASELINE = 1_000_000;
From cc627c1e65a188b16e8cf56c334f08a7bb882642 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 20:47:55 -0400
Subject: [PATCH 29/82] update
---
js/gridHealth.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/gridHealth.js b/js/gridHealth.js
index 8396ff29..85a8821c 100644
--- a/js/gridHealth.js
+++ b/js/gridHealth.js
@@ -165,7 +165,7 @@ function queueProbe(magnet, cacheKey, priority = 0) {
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
-const PROBE_CONCURRENCY = 48;
+const PROBE_CONCURRENCY = 96;
const PROBE_POLL_COUNT = 3;
const PRIORITY_BASELINE = 1_000_000;
From e297bd7bb36ce62abad7789aff44edb5188a6399 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 21:00:01 -0400
Subject: [PATCH 30/82] Hide Zap buttons for profiles without lightning
---
js/app.js | 29 +++++++++++++++++++++++++++
js/channelProfile.js | 47 ++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 74 insertions(+), 2 deletions(-)
diff --git a/js/app.js b/js/app.js
index f0b307c8..69f9c1c7 100644
--- a/js/app.js
+++ b/js/app.js
@@ -930,6 +930,7 @@ class bitvidApp {
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
this.shareBtn = document.getElementById("shareBtn") || null;
this.modalZapBtn = document.getElementById("modalZapBtn") || null;
+ this.setModalZapVisibility(false);
// Attach existing event listeners for copy/share
if (this.copyMagnetBtn) {
@@ -2842,6 +2843,22 @@ class bitvidApp {
this.shareBtn.classList.toggle("cursor-not-allowed", !enabled);
}
+ setModalZapVisibility(visible) {
+ if (!this.modalZapBtn) {
+ return;
+ }
+ const shouldShow = !!visible;
+ this.modalZapBtn.classList.toggle("hidden", !shouldShow);
+ this.modalZapBtn.disabled = !shouldShow;
+ this.modalZapBtn.setAttribute("aria-disabled", (!shouldShow).toString());
+ this.modalZapBtn.setAttribute("aria-hidden", (!shouldShow).toString());
+ if (shouldShow) {
+ this.modalZapBtn.removeAttribute("tabindex");
+ } else {
+ this.modalZapBtn.setAttribute("tabindex", "-1");
+ }
+ }
+
getShareUrlBase() {
try {
const current = new URL(window.location.href);
@@ -5218,6 +5235,7 @@ class bitvidApp {
originalMagnet: magnetCandidate,
torrentSupported: magnetSupported,
legacyInfoHash: video.legacyInfoHash || legacyInfoHash,
+ lightningAddress: null,
};
this.currentMagnetUri = sanitizedMagnet || null;
@@ -5233,6 +5251,8 @@ class bitvidApp {
)}`;
window.history.pushState({}, "", pushUrl);
+ this.setModalZapVisibility(false);
+ let lightningAddress = "";
let creatorProfile = {
name: "Unknown",
picture: `https://robohash.org/${video.pubkey}`,
@@ -5243,6 +5263,7 @@ class bitvidApp {
]);
if (userEvents.length > 0 && userEvents[0]?.content) {
const data = JSON.parse(userEvents[0].content);
+ lightningAddress = (data.lud16 || data.lud06 || "").trim();
creatorProfile = {
name: data.name || data.display_name || "Unknown",
picture: data.picture || `https://robohash.org/${video.pubkey}`,
@@ -5252,6 +5273,11 @@ class bitvidApp {
this.log("Error fetching creator profile:", error);
}
+ this.setModalZapVisibility(!!lightningAddress);
+ if (this.currentVideo) {
+ this.currentVideo.lightningAddress = lightningAddress || null;
+ }
+
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (this.videoTitle) {
this.videoTitle.textContent = video.title || "Untitled";
@@ -5295,6 +5321,8 @@ class bitvidApp {
const magnetSupported = isValidMagnetUri(usableMagnet);
const sanitizedMagnet = magnetSupported ? usableMagnet : "";
+ this.setModalZapVisibility(false);
+
trackVideoView({
videoId:
typeof title === "string" && title.trim().length > 0
@@ -5322,6 +5350,7 @@ class bitvidApp {
magnet: sanitizedMagnet,
originalMagnet: trimmedMagnet,
torrentSupported: magnetSupported,
+ lightningAddress: null,
};
this.currentMagnetUri = sanitizedMagnet || null;
diff --git a/js/channelProfile.js b/js/channelProfile.js
index d6adfcdf..9e23492f 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -11,6 +11,35 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
+let cachedZapButton = null;
+
+function getChannelZapButton() {
+ if (cachedZapButton && !document.body.contains(cachedZapButton)) {
+ cachedZapButton = null;
+ }
+ if (!cachedZapButton) {
+ cachedZapButton = document.getElementById("zapButton");
+ }
+ return cachedZapButton;
+}
+
+function setChannelZapVisibility(visible) {
+ const zapButton = getChannelZapButton();
+ if (!zapButton) {
+ return;
+ }
+ const shouldShow = !!visible;
+ zapButton.classList.toggle("hidden", !shouldShow);
+ zapButton.disabled = !shouldShow;
+ zapButton.setAttribute("aria-disabled", (!shouldShow).toString());
+ zapButton.setAttribute("aria-hidden", (!shouldShow).toString());
+ if (shouldShow) {
+ zapButton.removeAttribute("tabindex");
+ } else {
+ zapButton.setAttribute("tabindex", "-1");
+ }
+}
+
/**
* Initialize the channel profile view.
* Called when #view=channel-profile&npub=...
@@ -59,11 +88,13 @@ export async function initChannelProfileView() {
}
function setupZapButton() {
- const zapButton = document.getElementById("zapButton");
+ const zapButton = getChannelZapButton();
if (!zapButton) {
return;
}
+ setChannelZapVisibility(false);
+
if (zapButton.dataset.initialized === "true") {
return;
}
@@ -182,15 +213,27 @@ async function loadUserProfile(pubkey) {
// Lightning Address
const lnEl = document.getElementById("channelLightning");
+ const lightningAddress = (meta.lud16 || meta.lud06 || "").trim();
if (lnEl) {
lnEl.textContent =
- meta.lud16 || meta.lud06 || "No lightning address found.";
+ lightningAddress || "No lightning address found.";
}
+ setChannelZapVisibility(!!lightningAddress);
} else {
console.warn("No metadata found for this user.");
+ setChannelZapVisibility(false);
+ const lnEl = document.getElementById("channelLightning");
+ if (lnEl) {
+ lnEl.textContent = "No lightning address found.";
+ }
}
} catch (err) {
console.error("Failed to fetch user profile data:", err);
+ setChannelZapVisibility(false);
+ const lnEl = document.getElementById("channelLightning");
+ if (lnEl) {
+ lnEl.textContent = "No lightning address found.";
+ }
}
}
From 538526fbf56fd22cd298229143f1e5be9c3b31e6 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 21:11:46 -0400
Subject: [PATCH 31/82] Add channel link to profile modal and refresh view
state
---
components/profile-modal.html | 7 ++++
js/app.js | 60 +++++++++++++++++++++++++++++++++++
js/viewManager.js | 4 +++
3 files changed, 71 insertions(+)
diff --git a/components/profile-modal.html b/components/profile-modal.html
index 1b9c5f99..d4377e4d 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -62,6 +62,13 @@
Loading...
+
+ View your channel
+
Some future profile details or settings could go here.
diff --git a/js/app.js b/js/app.js
index 69f9c1c7..99bb18df 100644
--- a/js/app.js
+++ b/js/app.js
@@ -498,6 +498,7 @@ class bitvidApp {
this.profileLogoutBtn = null;
this.profileModalAvatar = null;
this.profileModalName = null;
+ this.profileChannelLink = null;
// Upload modal elements
this.uploadButton = document.getElementById("uploadButton") || null;
@@ -758,6 +759,32 @@ class bitvidApp {
return entry;
}
+ prepareForViewLoad() {
+ if (this._videoListElement && this._videoListClickHandler) {
+ try {
+ this._videoListElement.removeEventListener(
+ "click",
+ this._videoListClickHandler
+ );
+ } catch (err) {
+ console.warn("[prepareForViewLoad] Failed to detach video list handler:", err);
+ }
+ this._videoListElement = null;
+ this._videoListClickHandler = null;
+ }
+
+ this.videoList = null;
+ this._lastRenderedVideoListElement = null;
+ this.lastRenderedVideoSignature = null;
+ if (this.renderedVideoIds) {
+ this.renderedVideoIds.clear();
+ }
+
+ if (this.mediaLoader && typeof this.mediaLoader.disconnect === "function") {
+ this.mediaLoader.disconnect();
+ }
+ }
+
forceRefreshAllProfiles() {
// 1) Grab the newest set of videos from nostrClient
const activeVideos = nostrClient.getActiveVideos();
@@ -2066,6 +2093,8 @@ class bitvidApp {
document.getElementById("profileModalAvatar") || null;
this.profileModalName =
document.getElementById("profileModalName") || null;
+ this.profileChannelLink =
+ document.getElementById("profileChannelLink") || null;
// Wire up
if (this.closeProfileModal) {
@@ -2081,6 +2110,20 @@ class bitvidApp {
});
}
+ if (this.profileChannelLink) {
+ this.profileChannelLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ const targetNpub = this.profileChannelLink?.dataset?.targetNpub;
+ if (!targetNpub) {
+ return;
+ }
+ if (this.profileModal) {
+ this.profileModal.classList.add("hidden");
+ }
+ window.location.hash = `#view=channel-profile&npub=${targetNpub}`;
+ });
+ }
+
console.log("Profile modal initialization successful");
return true;
} catch (error) {
@@ -2288,6 +2331,18 @@ class bitvidApp {
if (this.profileModalAvatar) {
this.profileModalAvatar.src = picture;
}
+ if (this.profileChannelLink) {
+ const targetNpub = this.safeEncodeNpub(pubkey);
+ if (targetNpub) {
+ this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`;
+ this.profileChannelLink.dataset.targetNpub = targetNpub;
+ this.profileChannelLink.classList.remove("hidden");
+ } else {
+ this.profileChannelLink.removeAttribute("href");
+ delete this.profileChannelLink.dataset.targetNpub;
+ this.profileChannelLink.classList.add("hidden");
+ }
+ }
} catch (error) {
console.error("loadOwnProfile error:", error);
}
@@ -2589,6 +2644,11 @@ class bitvidApp {
if (this.profileButton) {
this.profileButton.classList.add("hidden");
}
+ if (this.profileChannelLink) {
+ this.profileChannelLink.classList.add("hidden");
+ this.profileChannelLink.removeAttribute("href");
+ delete this.profileChannelLink.dataset.targetNpub;
+ }
// Hide the Subscriptions link
if (this.subscriptionsLink) {
diff --git a/js/viewManager.js b/js/viewManager.js
index f22746c7..741cd0a2 100644
--- a/js/viewManager.js
+++ b/js/viewManager.js
@@ -9,6 +9,10 @@ const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
*/
export async function loadView(viewUrl) {
try {
+ if (window.app && typeof window.app.prepareForViewLoad === "function") {
+ window.app.prepareForViewLoad();
+ }
+
const res = await fetch(viewUrl);
if (!res.ok) {
throw new Error(`Failed to load view: ${res.status}`);
From 1b4bf696b77d7da2a6485785dcb6b9abf3c2a4d4 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 21:28:20 -0400
Subject: [PATCH 32/82] Add modal-driven video edit flow
---
components/edit-video-modal.html | 180 ++++++++++++++
css/style.css | 31 +++
js/app.js | 402 +++++++++++++++++++++++++------
3 files changed, 534 insertions(+), 79 deletions(-)
create mode 100644 components/edit-video-modal.html
diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html
new file mode 100644
index 00000000..5275bd60
--- /dev/null
+++ b/components/edit-video-modal.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+ Fields that already have values are locked. Click Edit field to make changes.
+
+
+
+
+
+
+
diff --git a/css/style.css b/css/style.css
index b523fd42..cde5e9ea 100644
--- a/css/style.css
+++ b/css/style.css
@@ -705,3 +705,34 @@ footer a:hover {
align-items: center;
display: flex;
}
+.edit-field-button {
+ align-items: center;
+ background-color: #1f2937;
+ border-radius: 0.5rem;
+ color: #e2e8f0;
+ display: inline-flex;
+ font-size: 0.75rem;
+ font-weight: 600;
+ justify-content: center;
+ padding: 0.5rem 0.85rem;
+ transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
+}
+
+.edit-field-button:hover,
+.edit-field-button:focus {
+ background-color: #334155;
+ color: #f8fafc;
+ transform: translateY(-1px);
+}
+
+.locked-input {
+ background-color: rgba(31, 41, 55, 0.75) !important;
+ border-color: rgba(55, 65, 81, 0.9) !important;
+ color: #94a3b8 !important;
+ cursor: default;
+}
+
+.locked-input:focus {
+ outline: none;
+ box-shadow: none;
+}
diff --git a/js/app.js b/js/app.js
index 99bb18df..0b7d3990 100644
--- a/js/app.js
+++ b/js/app.js
@@ -539,6 +539,16 @@ class bitvidApp {
this.r2ZoneIdInput = null;
this.r2BaseDomainInput = null;
+ // Edit video modal elements
+ this.editVideoModal = null;
+ this.editVideoOverlay = null;
+ this.editVideoForm = null;
+ this.closeEditVideoModalBtn = null;
+ this.cancelEditVideoBtn = null;
+ this.editVideoSubmitBtn = null;
+ this.editVideoFieldButtons = [];
+ this.activeEditVideo = null;
+
// Optional small inline player stats
this.status = document.getElementById("status") || null;
this.progressBar = document.getElementById("progress") || null;
@@ -817,6 +827,9 @@ class bitvidApp {
await this.initUploadModal();
initQuickR2Upload(this);
+ // 2.5 Initialize the edit modal (components/edit-video-modal.html)
+ await this.initEditVideoModal();
+
// 3. (Optional) Initialize the profile modal (components/profile-modal.html)
await this.initProfileModal();
@@ -1280,6 +1293,241 @@ class bitvidApp {
}
}
+ async initEditVideoModal() {
+ try {
+ let modal = document.getElementById("editVideoModal");
+ if (!modal) {
+ const resp = await fetch("components/edit-video-modal.html");
+ if (!resp.ok) {
+ throw new Error(`HTTP error! status: ${resp.status}`);
+ }
+ const html = await resp.text();
+ const modalContainer = document.getElementById("modalContainer");
+ if (!modalContainer) {
+ throw new Error("Modal container element not found!");
+ }
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html;
+ removeTrackingScripts(wrapper);
+ modalContainer.appendChild(wrapper);
+
+ modal = wrapper.querySelector("#editVideoModal");
+ }
+
+ if (!modal) {
+ throw new Error("Edit video modal markup missing after load.");
+ }
+
+ this.editVideoModal = modal;
+ this.editVideoOverlay =
+ modal.querySelector("#editVideoModalOverlay") || null;
+ this.editVideoForm = modal.querySelector("#editVideoForm") || null;
+ this.closeEditVideoModalBtn =
+ modal.querySelector("#closeEditVideoModal") || null;
+ this.cancelEditVideoBtn =
+ modal.querySelector("#cancelEditVideo") || null;
+ this.editVideoSubmitBtn =
+ modal.querySelector("#submitEditVideo") || null;
+ this.editVideoFieldButtons = Array.from(
+ modal.querySelectorAll("[data-edit-target]")
+ );
+
+ if (this.editVideoForm) {
+ this.editVideoForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ this.handleEditVideoSubmit();
+ });
+ }
+
+ if (this.closeEditVideoModalBtn) {
+ this.closeEditVideoModalBtn.addEventListener("click", () => {
+ this.hideEditVideoModal();
+ });
+ }
+
+ if (this.cancelEditVideoBtn) {
+ this.cancelEditVideoBtn.addEventListener("click", () => {
+ this.hideEditVideoModal();
+ });
+ }
+
+ if (this.editVideoOverlay) {
+ this.editVideoOverlay.addEventListener("click", () => {
+ this.hideEditVideoModal();
+ });
+ }
+
+ if (Array.isArray(this.editVideoFieldButtons)) {
+ this.editVideoFieldButtons.forEach((btn) => {
+ btn.addEventListener("click", (event) => {
+ this.handleEditFieldToggle(event);
+ });
+ });
+ }
+
+ this.resetEditVideoForm();
+
+ return true;
+ } catch (error) {
+ console.error("initEditVideoModal failed:", error);
+ this.showError(`Failed to initialize edit modal: ${error.message}`);
+ return false;
+ }
+ }
+
+ resetEditVideoForm() {
+ if (!this.editVideoModal) {
+ this.activeEditVideo = null;
+ return;
+ }
+
+ const fieldIds = [
+ "editVideoTitle",
+ "editVideoUrl",
+ "editVideoMagnet",
+ "editVideoThumbnail",
+ "editVideoDescription",
+ ];
+
+ fieldIds.forEach((id) => {
+ const input = this.editVideoModal.querySelector(`#${id}`);
+ if (input) {
+ input.value = "";
+ input.readOnly = false;
+ input.classList.remove("locked-input");
+ delete input.dataset.originalValue;
+ }
+ const button = this.editVideoModal.querySelector(
+ `[data-edit-target="${id}"]`
+ );
+ if (button) {
+ button.classList.add("hidden");
+ button.dataset.mode = "locked";
+ button.textContent = "Edit field";
+ }
+ });
+
+ this.activeEditVideo = null;
+ }
+
+ showEditVideoModal() {
+ if (this.editVideoModal) {
+ this.editVideoModal.classList.remove("hidden");
+ }
+ }
+
+ hideEditVideoModal() {
+ if (this.editVideoModal) {
+ this.editVideoModal.classList.add("hidden");
+ }
+ this.resetEditVideoForm();
+ }
+
+ populateEditVideoForm(video) {
+ if (!video || !this.editVideoModal) {
+ return;
+ }
+
+ this.resetEditVideoForm();
+
+ const fieldMap = {
+ editVideoTitle: video.title || "",
+ editVideoUrl: video.url || "",
+ editVideoMagnet: video.magnet || "",
+ editVideoThumbnail: video.thumbnail || "",
+ editVideoDescription: video.description || "",
+ };
+
+ Object.entries(fieldMap).forEach(([id, rawValue]) => {
+ const input = this.editVideoModal.querySelector(`#${id}`);
+ const button = this.editVideoModal.querySelector(
+ `[data-edit-target="${id}"]`
+ );
+ const value = typeof rawValue === "string" ? rawValue : "";
+ const hasValue = value.trim().length > 0;
+
+ if (input) {
+ input.value = value;
+ input.dataset.originalValue = value;
+ if (hasValue) {
+ input.readOnly = true;
+ input.classList.add("locked-input");
+ } else {
+ input.readOnly = false;
+ input.classList.remove("locked-input");
+ }
+ }
+
+ if (button) {
+ if (hasValue) {
+ button.classList.remove("hidden");
+ button.dataset.mode = "locked";
+ button.textContent = "Edit field";
+ } else {
+ button.classList.add("hidden");
+ button.dataset.mode = "locked";
+ button.textContent = "Edit field";
+ }
+ }
+ });
+
+ this.activeEditVideo = video;
+ }
+
+ handleEditFieldToggle(event) {
+ const button = event?.currentTarget;
+ if (!button || !this.editVideoModal) {
+ return;
+ }
+
+ const targetId = button.dataset?.editTarget;
+ if (!targetId) {
+ return;
+ }
+
+ const input = this.editVideoModal.querySelector(`#${targetId}`);
+ if (!input) {
+ return;
+ }
+
+ const mode = button.dataset.mode || "locked";
+ if (mode === "locked") {
+ input.readOnly = false;
+ input.classList.remove("locked-input");
+ button.dataset.mode = "editing";
+ button.textContent = "Restore original";
+ if (typeof input.focus === "function") {
+ input.focus();
+ if (typeof input.setSelectionRange === "function") {
+ const length = input.value.length;
+ try {
+ input.setSelectionRange(length, length);
+ } catch (err) {
+ // ignore selection errors (e.g. for input types that do not support it)
+ }
+ }
+ }
+ return;
+ }
+
+ const originalValue = input.dataset?.originalValue || "";
+ input.value = originalValue;
+
+ if (originalValue) {
+ input.readOnly = true;
+ input.classList.add("locked-input");
+ button.dataset.mode = "locked";
+ button.textContent = "Edit field";
+ } else {
+ input.readOnly = false;
+ input.classList.remove("locked-input");
+ button.classList.add("hidden");
+ button.dataset.mode = "locked";
+ button.textContent = "Edit field";
+ }
+ }
+
setUploadMode(mode) {
const normalized = mode === "cloudflare" ? "cloudflare" : "custom";
this.activeUploadMode = normalized;
@@ -2519,6 +2767,73 @@ class bitvidApp {
}
}
+ async handleEditVideoSubmit() {
+ if (!this.activeEditVideo || !this.editVideoModal) {
+ this.showError("No video selected for editing.");
+ return;
+ }
+
+ const fieldValue = (id) => {
+ const el = this.editVideoModal.querySelector(`#${id}`);
+ if (!el || typeof el.value !== "string") {
+ return "";
+ }
+ return el.value.trim();
+ };
+
+ const original = this.activeEditVideo;
+
+ const newTitle = fieldValue("editVideoTitle");
+ const newUrl = fieldValue("editVideoUrl");
+ const newMagnet = fieldValue("editVideoMagnet");
+ const newThumbnail = fieldValue("editVideoThumbnail");
+ const newDescription = fieldValue("editVideoDescription");
+
+ const finalTitle = newTitle || original.title || "";
+ const finalUrl = newUrl || original.url || "";
+ const finalMagnet = newMagnet || original.magnet || "";
+ const finalThumbnail = newThumbnail || original.thumbnail || "";
+ const finalDescription = newDescription || original.description || "";
+
+ if (!finalTitle || (!finalUrl && !finalMagnet)) {
+ this.showError("Title and at least one of URL or Magnet is required.");
+ return;
+ }
+
+ if (finalUrl && !/^https:\/\//i.test(finalUrl)) {
+ this.showError("Hosted video URLs must use HTTPS.");
+ return;
+ }
+
+ const updatedData = {
+ version: original.version || 2,
+ title: finalTitle,
+ magnet: finalMagnet,
+ url: finalUrl,
+ thumbnail: finalThumbnail,
+ description: finalDescription,
+ mode: isDevMode ? "dev" : "live",
+ };
+
+ const originalEvent = {
+ id: original.id,
+ pubkey: original.pubkey,
+ videoRootId: original.videoRootId,
+ };
+
+ try {
+ await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
+ await this.loadVideos();
+ this.videosMap.clear();
+ this.showSuccess("Video updated successfully!");
+ this.hideEditVideoModal();
+ this.forceRefreshAllProfiles();
+ } catch (err) {
+ console.error("Failed to edit video:", err);
+ this.showError("Failed to edit video. Please try again.");
+ }
+ }
+
/**
* Actually handle the upload form submission.
*/
@@ -4339,88 +4654,17 @@ class bitvidApp {
return;
}
- // 3) Prompt the user for updated fields
- const newTitle = prompt("New Title? (blank=keep existing)", video.title);
- if (newTitle === null) {
+ if (!this.editVideoModal) {
+ await this.initEditVideoModal();
+ }
+
+ if (!this.editVideoModal) {
+ this.showError("Edit modal is not available right now.");
return;
}
- const newMagnet = prompt(
- "New Magnet? (blank=keep existing)",
- video.magnet
- );
- if (newMagnet === null) {
- return;
- }
-
- const newUrl = prompt(
- "New URL? (blank=keep existing)",
- video.url || ""
- );
- if (newUrl === null) {
- return;
- }
-
- const newThumb = prompt(
- "New Thumbnail? (blank=keep existing)",
- video.thumbnail
- );
- if (newThumb === null) {
- return;
- }
-
- const newDesc = prompt(
- "New Description? (blank=keep existing)",
- video.description
- );
- if (newDesc === null) {
- return;
- }
- // const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No");
-
- // 4) Build final updated fields (or fallback to existing)
- const title =
- !newTitle || !newTitle.trim() ? video.title : newTitle.trim();
- const magnet =
- !newMagnet || !newMagnet.trim() ? video.magnet : newMagnet.trim();
- const url = !newUrl || !newUrl.trim() ? video.url : newUrl.trim();
- const thumbnail =
- !newThumb || !newThumb.trim() ? video.thumbnail : newThumb.trim();
- const description =
- !newDesc || !newDesc.trim() ? video.description : newDesc.trim();
-
- // 5) Create an object with the new data
- const updatedData = {
- version: video.version || 2,
- // isPrivate: wantPrivate,
- title,
- magnet,
- url,
- thumbnail,
- description,
- mode: isDevMode ? "dev" : "live",
- };
-
- // 6) Build the originalEvent stub, now including videoRootId to avoid extra fetch
- const originalEvent = {
- id: video.id,
- pubkey: video.pubkey,
- videoRootId: video.videoRootId, // <-- pass this if it exists
- };
-
- // 7) Call the editVideo method
- await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
-
- // 8) Refresh local UI
- await this.loadVideos();
-
- // 8.1) Purge the outdated cache
- this.videosMap.clear();
-
- this.showSuccess("Video updated successfully!");
-
- // 9) Also refresh all profile caches so any new name/pic changes are reflected
- this.forceRefreshAllProfiles();
+ this.populateEditVideoForm(video);
+ this.showEditVideoModal();
} catch (err) {
console.error("Failed to edit video:", err);
this.showError("Failed to edit video. Please try again.");
From c773a07cdc657682ab5be5e247877744860b5310 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 21:36:21 -0400
Subject: [PATCH 33/82] Add experimental warning to Cloudflare upload mode
---
components/upload-modal.html | 3 +++
1 file changed, 3 insertions(+)
diff --git a/components/upload-modal.html b/components/upload-modal.html
index 991c1340..329845ab 100644
--- a/components/upload-modal.html
+++ b/components/upload-modal.html
@@ -295,6 +295,9 @@
+
+ This feature is experimental and may not be working correctly.
+
From 875094c8004f14ac8d4acd694c1a1b150f916b87 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 22:02:31 -0400
Subject: [PATCH 34/82] Add revert history modal for video notes
---
components/revert-video-modal.html | 121 ++++
js/app.js | 849 ++++++++++++++++++++++++++++-
js/nostr.js | 128 +++++
3 files changed, 1076 insertions(+), 22 deletions(-)
create mode 100644 components/revert-video-modal.html
diff --git a/components/revert-video-modal.html b/components/revert-video-modal.html
new file mode 100644
index 00000000..87f2e6d7
--- /dev/null
+++ b/components/revert-video-modal.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+ Selecting an older version will mark any newer edits as reverted.
+ The previous revision will become the active note on the relays.
+
+
+ Versions are grouped by their d
+ tag or shared videoRootId. This ensures legacy
+ clients see consistent history when multiple edits were published.
+
+
+
+
+
+
+
Revision history
+
+
+
+
+
+
+
+ Select an earlier revision to inspect its metadata.
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Revert to selected version
+
+
+
+
+
+
diff --git a/js/app.js b/js/app.js
index 0b7d3990..e7c5bb9b 100644
--- a/js/app.js
+++ b/js/app.js
@@ -549,6 +549,27 @@ class bitvidApp {
this.editVideoFieldButtons = [];
this.activeEditVideo = null;
+ // Revert video modal elements
+ this.revertVideoModal = null;
+ this.revertVideoOverlay = null;
+ this.revertVersionsList = null;
+ this.revertVersionDetails = null;
+ this.revertVersionPlaceholder = null;
+ this.revertVersionDetailsDefaultHTML = "";
+ this.revertHistoryCount = null;
+ this.revertSelectionStatus = null;
+ this.revertModalTitle = null;
+ this.revertModalSubtitle = null;
+ this.closeRevertVideoModalBtn = null;
+ this.cancelRevertVideoBtn = null;
+ this.confirmRevertVideoBtn = null;
+ this.activeRevertVideo = null;
+ this.revertHistory = [];
+ this.selectedRevertTarget = null;
+ this.revertModalBusy = false;
+ this.revertConfirmDefaultLabel = "Revert to selected version";
+ this.pendingRevertEntries = [];
+
// Optional small inline player stats
this.status = document.getElementById("status") || null;
this.progressBar = document.getElementById("progress") || null;
@@ -1424,6 +1445,779 @@ class bitvidApp {
this.resetEditVideoForm();
}
+ async initRevertVideoModal() {
+ try {
+ let modal = document.getElementById("revertVideoModal");
+ if (!modal) {
+ const response = await fetch("components/revert-video-modal.html");
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const html = await response.text();
+ const modalContainer = document.getElementById("modalContainer");
+ if (!modalContainer) {
+ throw new Error("Modal container element not found!");
+ }
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html;
+ removeTrackingScripts(wrapper);
+ modalContainer.appendChild(wrapper);
+
+ modal = wrapper.querySelector("#revertVideoModal");
+ }
+
+ if (!modal) {
+ throw new Error("Revert video modal markup missing after load.");
+ }
+
+ this.revertVideoModal = modal;
+ this.revertVideoOverlay =
+ modal.querySelector("#revertVideoModalOverlay") || null;
+ this.revertVersionsList =
+ modal.querySelector("#revertVersionsList") || null;
+ this.revertVersionDetails =
+ modal.querySelector("#revertVersionDetails") || null;
+ this.revertVersionPlaceholder =
+ modal.querySelector("#revertVersionPlaceholder") || null;
+ if (this.revertVersionDetails && !this.revertVersionDetailsDefaultHTML) {
+ this.revertVersionDetailsDefaultHTML = this.revertVersionDetails.innerHTML;
+ }
+ this.revertHistoryCount =
+ modal.querySelector("#revertHistoryCount") || null;
+ this.revertSelectionStatus =
+ modal.querySelector("#revertSelectionStatus") || null;
+ this.revertModalTitle =
+ modal.querySelector("#revertModalTitle") || null;
+ this.revertModalSubtitle =
+ modal.querySelector("#revertModalSubtitle") || null;
+ this.closeRevertVideoModalBtn =
+ modal.querySelector("#closeRevertVideoModal") || null;
+ this.cancelRevertVideoBtn =
+ modal.querySelector("#cancelRevertVideo") || null;
+ this.confirmRevertVideoBtn =
+ modal.querySelector("#confirmRevertVideo") || null;
+
+ if (
+ this.revertVersionsList &&
+ !this.revertVersionsList.dataset.listenerAttached
+ ) {
+ this.revertVersionsList.addEventListener("click", (event) => {
+ this.handleRevertVersionListClick(event);
+ });
+ this.revertVersionsList.dataset.listenerAttached = "true";
+ }
+
+ if (this.closeRevertVideoModalBtn && !this.closeRevertVideoModalBtn.dataset.listenerAttached) {
+ this.closeRevertVideoModalBtn.addEventListener("click", () => {
+ if (!this.revertModalBusy) {
+ this.hideRevertVideoModal();
+ }
+ });
+ this.closeRevertVideoModalBtn.dataset.listenerAttached = "true";
+ }
+
+ if (this.cancelRevertVideoBtn && !this.cancelRevertVideoBtn.dataset.listenerAttached) {
+ this.cancelRevertVideoBtn.addEventListener("click", () => {
+ if (!this.revertModalBusy) {
+ this.hideRevertVideoModal();
+ }
+ });
+ this.cancelRevertVideoBtn.dataset.listenerAttached = "true";
+ }
+
+ if (this.revertVideoOverlay && !this.revertVideoOverlay.dataset.listenerAttached) {
+ this.revertVideoOverlay.addEventListener("click", () => {
+ if (!this.revertModalBusy) {
+ this.hideRevertVideoModal();
+ }
+ });
+ this.revertVideoOverlay.dataset.listenerAttached = "true";
+ }
+
+ if (
+ this.confirmRevertVideoBtn &&
+ !this.confirmRevertVideoBtn.dataset.listenerAttached
+ ) {
+ this.confirmRevertVideoBtn.addEventListener("click", () => {
+ this.handleConfirmRevertSelection();
+ });
+ this.confirmRevertVideoBtn.dataset.listenerAttached = "true";
+ }
+
+ this.resetRevertVideoModal();
+
+ return true;
+ } catch (error) {
+ console.error("initRevertVideoModal failed:", error);
+ this.showError(`Failed to initialize revert modal: ${error.message}`);
+ return false;
+ }
+ }
+
+ resetRevertVideoModal() {
+ this.activeRevertVideo = null;
+ this.revertHistory = [];
+ this.selectedRevertTarget = null;
+ this.revertModalBusy = false;
+ this.pendingRevertEntries = [];
+
+ if (this.revertHistoryCount) {
+ this.revertHistoryCount.textContent = "";
+ }
+
+ if (this.revertSelectionStatus) {
+ this.revertSelectionStatus.textContent =
+ "Select an older revision to inspect its metadata before reverting.";
+ }
+
+ if (this.revertModalTitle) {
+ this.revertModalTitle.textContent = "Revert Video Note";
+ }
+
+ if (this.revertModalSubtitle) {
+ this.revertModalSubtitle.textContent =
+ "Review previous versions before restoring an older state.";
+ }
+
+ if (this.revertVersionsList) {
+ this.revertVersionsList.innerHTML = "";
+ }
+
+ if (this.revertVersionDetails && this.revertVersionDetailsDefaultHTML) {
+ this.revertVersionDetails.innerHTML = this.revertVersionDetailsDefaultHTML;
+ this.revertVersionPlaceholder =
+ this.revertVersionDetails.querySelector("#revertVersionPlaceholder");
+ }
+
+ if (this.confirmRevertVideoBtn) {
+ this.confirmRevertVideoBtn.disabled = true;
+ this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
+ this.confirmRevertVideoBtn.classList.remove("cursor-wait");
+ }
+
+ if (this.cancelRevertVideoBtn) {
+ this.cancelRevertVideoBtn.disabled = false;
+ this.cancelRevertVideoBtn.classList.remove("opacity-60", "cursor-not-allowed");
+ }
+
+ if (this.closeRevertVideoModalBtn) {
+ this.closeRevertVideoModalBtn.disabled = false;
+ this.closeRevertVideoModalBtn.classList.remove("opacity-60", "cursor-not-allowed");
+ }
+ }
+
+ showRevertVideoModal() {
+ if (this.revertVideoModal) {
+ this.revertVideoModal.classList.remove("hidden");
+ }
+ }
+
+ hideRevertVideoModal() {
+ if (this.revertVideoModal) {
+ this.revertVideoModal.classList.add("hidden");
+ }
+ this.resetRevertVideoModal();
+ }
+
+ populateRevertVideoModal(video, history = []) {
+ if (!this.revertVideoModal) {
+ return;
+ }
+
+ this.resetRevertVideoModal();
+
+ if (!video || typeof video !== "object") {
+ if (this.revertSelectionStatus) {
+ this.revertSelectionStatus.textContent =
+ "Unable to load revision history for this note.";
+ }
+ return;
+ }
+
+ this.activeRevertVideo = video;
+
+ const merged = Array.isArray(history) ? history.slice() : [];
+ if (video.id && !merged.some((entry) => entry && entry.id === video.id)) {
+ merged.push(video);
+ }
+
+ const deduped = new Map();
+ for (const entry of merged) {
+ if (!entry || typeof entry !== "object" || !entry.id) {
+ continue;
+ }
+ deduped.set(entry.id, entry);
+ }
+
+ this.revertHistory = Array.from(deduped.values()).sort(
+ (a, b) => b.created_at - a.created_at
+ );
+
+ this.selectedRevertTarget = null;
+ if (this.revertHistory.length > 1 && video.created_at) {
+ // Auto-select the newest non-deleted revision that predates the active
+ // event so the confirmation button immediately conveys its effect.
+ const firstOlder = this.revertHistory.find(
+ (entry) =>
+ entry &&
+ entry.id !== video.id &&
+ entry.deleted !== true &&
+ typeof entry.created_at === "number" &&
+ entry.created_at < video.created_at
+ );
+ if (firstOlder) {
+ this.selectedRevertTarget = firstOlder;
+ }
+ }
+
+ if (this.revertHistoryCount) {
+ this.revertHistoryCount.textContent = `${this.revertHistory.length}`;
+ }
+
+ if (this.revertModalTitle) {
+ this.revertModalTitle.textContent = video.title
+ ? `Revert “${video.title}”`
+ : "Revert Video Note";
+ }
+
+ if (this.revertModalSubtitle) {
+ const subtitleParts = [];
+ const dTagValue = this.extractDTagValue(video.tags);
+ if (dTagValue) {
+ subtitleParts.push(`d=${dTagValue}`);
+ }
+ if (video.videoRootId) {
+ subtitleParts.push(`root=${truncateMiddle(video.videoRootId, 40)}`);
+ }
+ this.revertModalSubtitle.textContent = subtitleParts.length
+ ? `History grouped by ${subtitleParts.join(" • ")}`
+ : "Review previous versions before restoring an older state.";
+ }
+
+ this.renderRevertVersionsList();
+
+ if (this.selectedRevertTarget) {
+ this.renderRevertVersionDetails(this.selectedRevertTarget);
+ }
+
+ if (this.revertHistory.length <= 1) {
+ if (this.revertSelectionStatus) {
+ this.revertSelectionStatus.textContent =
+ "No earlier revisions are available for this note.";
+ }
+ this.updateRevertConfirmationState();
+ return;
+ }
+
+ this.updateRevertConfirmationState();
+ }
+
+ renderRevertVersionsList() {
+ if (!this.revertVersionsList) {
+ return;
+ }
+
+ const history = Array.isArray(this.revertHistory)
+ ? this.revertHistory
+ : [];
+ const selectedId = this.selectedRevertTarget?.id || "";
+ const currentId = this.activeRevertVideo?.id || "";
+
+ this.revertVersionsList.innerHTML = "";
+
+ if (!history.length) {
+ this.revertVersionsList.innerHTML =
+ ' No revisions found.
';
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+
+ history.forEach((entry) => {
+ if (!entry || !entry.id) {
+ return;
+ }
+
+ const isCurrent = entry.id === currentId;
+ const isSelected = entry.id === selectedId;
+ const isDeleted = entry.deleted === true;
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.dataset.revertVersionId = entry.id;
+
+ const classes = [
+ "w-full",
+ "text-left",
+ "rounded-md",
+ "border",
+ "px-3",
+ "py-3",
+ "text-sm",
+ "transition",
+ "duration-150",
+ "focus:outline-none",
+ "focus:ring-2",
+ "focus:ring-offset-2",
+ "focus:ring-offset-gray-900",
+ ];
+
+ if (isSelected) {
+ classes.push(
+ "border-blue-500",
+ "bg-blue-500/20",
+ "text-blue-100",
+ "focus:ring-blue-500"
+ );
+ } else if (isCurrent) {
+ classes.push(
+ "border-green-500/60",
+ "bg-green-500/10",
+ "text-green-100",
+ "focus:ring-green-500/80"
+ );
+ } else if (isDeleted) {
+ classes.push(
+ "border-red-800/70",
+ "bg-red-900/30",
+ "text-red-200/90",
+ "hover:bg-red-900/40"
+ );
+ } else {
+ classes.push(
+ "border-gray-800",
+ "bg-gray-800/60",
+ "hover:bg-gray-700/70",
+ "text-gray-200"
+ );
+ }
+
+ button.className = classes.join(" ");
+ button.setAttribute("aria-pressed", isSelected ? "true" : "false");
+ if (isCurrent) {
+ button.setAttribute("aria-current", "true");
+ }
+
+ const relative = this.formatTimeAgo(entry.created_at);
+ const absolute = this.formatAbsoluteTimestamp(entry.created_at);
+ const versionLabel =
+ entry.version !== undefined ? `v${entry.version}` : "v?";
+
+ const metaParts = [];
+ if (isCurrent) {
+ metaParts.push("Current version");
+ }
+ if (entry.deleted) {
+ metaParts.push("Marked deleted");
+ }
+ if (entry.isPrivate) {
+ metaParts.push("Private");
+ }
+ const meta = metaParts.join(" • ");
+
+ const metaClass = entry.deleted
+ ? "text-red-200/80"
+ : "text-gray-400";
+
+ button.innerHTML = `
+
+
+
${this.escapeHTML(
+ entry.title || "Untitled"
+ )}
+
${this.escapeHTML(
+ relative
+ )} • ${this.escapeHTML(absolute)}
+ ${
+ meta
+ ? `
${this.escapeHTML(meta)}
`
+ : ""
+ }
+
+
+ ${this.escapeHTML(versionLabel)}
+
+
+ `;
+
+ fragment.appendChild(button);
+ });
+
+ this.revertVersionsList.appendChild(fragment);
+ }
+
+ handleRevertVersionListClick(event) {
+ const button = event?.target?.closest?.("[data-revert-version-id]");
+ if (!button || !button.dataset) {
+ return;
+ }
+
+ const versionId = button.dataset.revertVersionId;
+ if (!versionId) {
+ return;
+ }
+
+ const match = (this.revertHistory || []).find(
+ (entry) => entry && entry.id === versionId
+ );
+ if (!match) {
+ return;
+ }
+
+ this.selectedRevertTarget = match;
+ this.renderRevertVersionsList();
+ this.renderRevertVersionDetails(match);
+ this.updateRevertConfirmationState();
+ }
+
+ renderRevertVersionDetails(version) {
+ if (!this.revertVersionDetails) {
+ return;
+ }
+
+ if (!version) {
+ if (this.revertVersionDetailsDefaultHTML) {
+ this.revertVersionDetails.innerHTML =
+ this.revertVersionDetailsDefaultHTML;
+ this.revertVersionPlaceholder =
+ this.revertVersionDetails.querySelector("#revertVersionPlaceholder");
+ }
+ return;
+ }
+
+ const absolute = this.formatAbsoluteTimestamp(version.created_at);
+ const relative = this.formatTimeAgo(version.created_at);
+ const description =
+ typeof version.description === "string" ? version.description : "";
+ const thumbnail =
+ typeof version.thumbnail === "string" ? version.thumbnail.trim() : "";
+ const url = typeof version.url === "string" ? version.url.trim() : "";
+ const magnet =
+ typeof version.magnet === "string" ? version.magnet.trim() : "";
+ const rawMagnet =
+ typeof version.rawMagnet === "string" ? version.rawMagnet.trim() : "";
+ const displayMagnet = magnet || rawMagnet;
+ const isPrivate = version.isPrivate === true;
+ const dTagValue = this.extractDTagValue(version.tags);
+
+ const fallbackThumbnail = this.escapeHTML(FALLBACK_THUMBNAIL_SRC);
+ const thumbnailSrc = thumbnail
+ ? this.escapeHTML(thumbnail)
+ : fallbackThumbnail;
+ const thumbnailAlt = thumbnail
+ ? "Revision thumbnail"
+ : "Fallback thumbnail";
+
+ let urlHtml = '
None ';
+ if (url) {
+ const safeUrl = this.escapeHTML(url);
+ const displayUrl = this.escapeHTML(truncateMiddle(url, 72));
+ urlHtml = `
${displayUrl} `;
+ }
+
+ let magnetHtml = '
None ';
+ if (displayMagnet) {
+ const label = this.escapeHTML(truncateMiddle(displayMagnet, 72));
+ const caption = isPrivate
+ ? '
Encrypted magnet (only decrypted locally for the owner). '
+ : "";
+ magnetHtml = `
${label}${caption}
`;
+ }
+
+ const chips = [];
+ if (version.deleted) {
+ chips.push(
+ '
Marked deleted '
+ );
+ }
+ if (isPrivate) {
+ chips.push(
+ '
Private '
+ );
+ }
+ if (version.version !== undefined) {
+ chips.push(
+ `
Schema v${this.escapeHTML(
+ String(version.version)
+ )} `
+ );
+ }
+
+ const descriptionHtml = description
+ ? `
${this.escapeHTML(
+ description
+ )}
`
+ : '
No description provided.
';
+
+ const rootId =
+ typeof version.videoRootId === "string" ? version.videoRootId : "";
+ const rootDisplay = rootId
+ ? this.escapeHTML(truncateMiddle(rootId, 64))
+ : "";
+ const eventDisplay = version.id
+ ? this.escapeHTML(truncateMiddle(version.id, 64))
+ : "";
+
+ this.revertVersionDetails.innerHTML = `
+
+
+
+
+
+
+
+
${this.escapeHTML(
+ version.title || "Untitled"
+ )}
+
${this.escapeHTML(
+ absolute
+ )} (${this.escapeHTML(relative)})
+
+ ${
+ chips.length
+ ? `
${chips.join("")}
`
+ : ""
+ }
+
+
+
Hosted URL:
+
${urlHtml}
+
+
+
Magnet:
+
${magnetHtml}
+
+
+
+
+
+
+
Description
+ ${descriptionHtml}
+
+
+
+
+
Mode
+ ${this.escapeHTML(version.mode || "live")}
+
+
+
d tag
+
+ ${dTagValue
+ ? `${this.escapeHTML(
+ dTagValue
+ )}`
+ : 'Not provided '}
+
+
+
+
videoRootId
+
+ ${rootDisplay
+ ? `${rootDisplay}`
+ : 'Not provided '}
+
+
+
+
Event ID
+
+ ${eventDisplay
+ ? `${eventDisplay}`
+ : 'Unknown '}
+
+
+
+
+ `;
+ }
+
+ updateRevertConfirmationState() {
+ if (!this.confirmRevertVideoBtn) {
+ return;
+ }
+
+ if (!this.selectedRevertTarget || !this.activeRevertVideo) {
+ this.pendingRevertEntries = [];
+ this.confirmRevertVideoBtn.disabled = true;
+ if (!this.revertModalBusy) {
+ this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
+ }
+ if (this.revertSelectionStatus) {
+ if ((this.revertHistory || []).length > 1) {
+ this.revertSelectionStatus.textContent =
+ "Select an older revision to enable reverting.";
+ }
+ }
+ return;
+ }
+
+ const target = this.selectedRevertTarget;
+ const activePubkey =
+ typeof this.activeRevertVideo.pubkey === "string"
+ ? this.activeRevertVideo.pubkey.toLowerCase()
+ : "";
+
+ const revertCandidates = (this.revertHistory || []).filter((entry) => {
+ if (!entry || entry.id === target.id) {
+ return false;
+ }
+ if (entry.deleted) {
+ return false;
+ }
+ if (typeof entry.created_at !== "number") {
+ return false;
+ }
+ if (entry.created_at <= target.created_at) {
+ return false;
+ }
+ if (!entry.pubkey) {
+ return false;
+ }
+ const entryPubkey =
+ typeof entry.pubkey === "string" ? entry.pubkey.toLowerCase() : "";
+ if (activePubkey && entryPubkey !== activePubkey) {
+ return false;
+ }
+ return true;
+ });
+
+ this.pendingRevertEntries = revertCandidates;
+
+ const disable =
+ this.revertModalBusy ||
+ target.deleted === true ||
+ revertCandidates.length === 0;
+
+ this.confirmRevertVideoBtn.disabled = disable;
+ if (!this.revertModalBusy) {
+ this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
+ }
+
+ if (this.revertSelectionStatus) {
+ if (target.deleted) {
+ this.revertSelectionStatus.textContent =
+ "This revision was previously marked as deleted and cannot become active.";
+ } else if (revertCandidates.length === 0) {
+ this.revertSelectionStatus.textContent =
+ "The selected revision is already the latest active version.";
+ } else {
+ const suffix = revertCandidates.length === 1 ? "revision" : "revisions";
+ this.revertSelectionStatus.textContent = `Reverting will mark ${revertCandidates.length} newer ${suffix} as reverted.`;
+ }
+ }
+ }
+
+ setRevertModalBusy(isBusy, label) {
+ this.revertModalBusy = Boolean(isBusy);
+
+ if (this.confirmRevertVideoBtn) {
+ const disableConfirm =
+ this.revertModalBusy ||
+ !this.selectedRevertTarget ||
+ !this.pendingRevertEntries ||
+ this.pendingRevertEntries.length === 0 ||
+ (this.selectedRevertTarget && this.selectedRevertTarget.deleted === true);
+ this.confirmRevertVideoBtn.disabled = disableConfirm;
+ this.confirmRevertVideoBtn.textContent = this.revertModalBusy
+ ? label || "Reverting…"
+ : this.revertConfirmDefaultLabel;
+ this.confirmRevertVideoBtn.classList.toggle(
+ "cursor-wait",
+ this.revertModalBusy
+ );
+ }
+
+ const toggleDisabledStyles = (button) => {
+ if (!button) {
+ return;
+ }
+ button.disabled = this.revertModalBusy;
+ button.classList.toggle("opacity-60", this.revertModalBusy);
+ button.classList.toggle("cursor-not-allowed", this.revertModalBusy);
+ };
+
+ toggleDisabledStyles(this.cancelRevertVideoBtn);
+ toggleDisabledStyles(this.closeRevertVideoModalBtn);
+
+ if (!this.revertModalBusy) {
+ this.updateRevertConfirmationState();
+ }
+ }
+
+ async handleConfirmRevertSelection() {
+ if (!this.selectedRevertTarget) {
+ return;
+ }
+ if (!this.pubkey) {
+ this.showError("Please login to revert.");
+ return;
+ }
+
+ const entries = Array.isArray(this.pendingRevertEntries)
+ ? this.pendingRevertEntries.slice()
+ : [];
+ if (!entries.length) {
+ this.updateRevertConfirmationState();
+ return;
+ }
+
+ this.setRevertModalBusy(true, "Reverting…");
+
+ try {
+ for (const entry of entries) {
+ await nostrClient.revertVideo(
+ {
+ id: entry.id,
+ pubkey: entry.pubkey,
+ tags: entry.tags,
+ },
+ this.pubkey
+ );
+ }
+
+ await this.loadVideos();
+
+ const timestampLabel = this.formatAbsoluteTimestamp(
+ this.selectedRevertTarget.created_at
+ );
+ this.showSuccess(`Reverted to revision from ${timestampLabel}.`);
+ this.hideRevertVideoModal();
+ this.forceRefreshAllProfiles();
+ } catch (err) {
+ console.error("Failed to revert video:", err);
+ this.showError("Failed to revert video. Please try again.");
+ } finally {
+ this.setRevertModalBusy(false);
+ this.pendingRevertEntries = [];
+ }
+ }
+
+ extractDTagValue(tags) {
+ if (!Array.isArray(tags)) {
+ return "";
+ }
+ for (const tag of tags) {
+ if (!Array.isArray(tag) || tag.length < 2) {
+ continue;
+ }
+ if (tag[0] === "d" && typeof tag[1] === "string") {
+ return tag[1];
+ }
+ }
+ return "";
+ }
+
populateEditVideoForm(video) {
if (!video || !this.editVideoModal) {
return;
@@ -4691,33 +5485,21 @@ class bitvidApp {
return;
}
- // 2) Grab all known events so older overshadowed ones are included
- const allEvents = Array.from(nostrClient.allEvents.values());
-
- // 3) Check for older versions among *all* events, not just the active ones
- if (!this.hasOlderVersion(video, allEvents)) {
- this.showError("No older version exists to revert to.");
- return;
+ if (!this.revertVideoModal) {
+ const initialized = await this.initRevertVideoModal();
+ if (!initialized || !this.revertVideoModal) {
+ this.showError("Revert modal is not available right now.");
+ return;
+ }
}
- if (!confirm(`Revert current version of "${video.title}"?`)) {
- return;
- }
+ const history = await nostrClient.hydrateVideoHistory(video);
- const originalEvent = {
- id: video.id,
- pubkey: video.pubkey,
- tags: video.tags,
- };
-
- await nostrClient.revertVideo(originalEvent, this.pubkey);
-
- await this.loadVideos();
- this.showSuccess("Current version reverted successfully!");
- this.forceRefreshAllProfiles();
+ this.populateRevertVideoModal(video, history);
+ this.showRevertVideoModal();
} catch (err) {
console.error("Failed to revert video:", err);
- this.showError("Failed to revert video. Please try again.");
+ this.showError("Failed to load revision history. Please try again.");
}
}
@@ -5748,6 +6530,29 @@ class bitvidApp {
/**
* Format "time ago" for a given timestamp (in seconds).
*/
+ formatAbsoluteTimestamp(timestamp) {
+ if (!Number.isFinite(timestamp)) {
+ return "Unknown date";
+ }
+
+ const date = new Date(timestamp * 1000);
+ if (Number.isNaN(date.getTime())) {
+ return "Unknown date";
+ }
+
+ try {
+ return date.toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch (err) {
+ return date.toISOString();
+ }
+ }
+
formatTimeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
const intervals = {
diff --git a/js/nostr.js b/js/nostr.js
index 4b51be4f..10a290a9 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -1208,6 +1208,134 @@ class NostrClient {
return null;
}
+ /**
+ * Ensure we have every historical revision for a given video in memory and
+ * return the complete set sorted newest-first. We primarily group revisions
+ * by their shared `videoRootId`, but fall back to the NIP-33 `d` tag when
+ * working with legacy notes. The explicit `d` tag fetch is important because
+ * relays cannot be queried by values that only exist inside the JSON
+ * content. Without this pass, the UI would occasionally miss mid-history
+ * edits that were published from other devices.
+ */
+ async hydrateVideoHistory(video) {
+ if (!video || typeof video !== "object") {
+ return [];
+ }
+
+ const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
+ const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
+ const findDTagValue = (tags = []) => {
+ if (!Array.isArray(tags)) {
+ return "";
+ }
+ for (const tag of tags) {
+ if (!Array.isArray(tag) || tag.length < 2) {
+ continue;
+ }
+ if (tag[0] === "d" && typeof tag[1] === "string") {
+ return tag[1];
+ }
+ }
+ return "";
+ };
+
+ const targetDTag = findDTagValue(video.tags);
+
+ const collectLocalMatches = () => {
+ const seen = new Set();
+ const matches = [];
+ for (const candidate of this.allEvents.values()) {
+ if (!candidate || typeof candidate !== "object") {
+ continue;
+ }
+ if (targetPubkey) {
+ const candidatePubkey = typeof candidate.pubkey === "string"
+ ? candidate.pubkey.toLowerCase()
+ : "";
+ if (candidatePubkey !== targetPubkey) {
+ continue;
+ }
+ }
+
+ const candidateRoot =
+ typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
+ const candidateDTag = findDTagValue(candidate.tags);
+
+ const sameRoot = targetRoot && candidateRoot === targetRoot;
+ const sameD = targetDTag && candidateDTag === targetDTag;
+
+ // Legacy fallbacks: some old posts reused only the "d" tag without a
+ // canonical videoRootId. If neither identifier exists we at least keep
+ // the active event so the caller can surface an informative message.
+ const sameLegacyRoot =
+ !targetRoot && candidateRoot && candidateRoot === video.id;
+
+ if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
+ if (!seen.has(candidate.id)) {
+ seen.add(candidate.id);
+ matches.push(candidate);
+ }
+ }
+ }
+ return matches;
+ };
+
+ let localMatches = collectLocalMatches();
+
+ const shouldFetchFromRelays =
+ localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag;
+
+ if (shouldFetchFromRelays && this.pool) {
+ const filter = {
+ kinds: [30078],
+ "#t": ["video"],
+ "#d": [targetDTag],
+ limit: 200,
+ };
+ if (targetPubkey) {
+ filter.authors = [video.pubkey];
+ }
+
+ try {
+ const perRelay = await Promise.all(
+ this.relays.map(async (url) => {
+ try {
+ const events = await this.pool.list([url], [filter]);
+ return events || [];
+ } catch (err) {
+ if (isDevMode) {
+ console.warn(`[nostr] History fetch failed on ${url}:`, err);
+ }
+ return [];
+ }
+ })
+ );
+
+ const merged = perRelay.flat();
+ for (const evt of merged) {
+ try {
+ const parsed = convertEventToVideo(evt);
+ if (!parsed.invalid) {
+ this.allEvents.set(evt.id, parsed);
+ }
+ } catch (err) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to convert historical event:", err);
+ }
+ }
+ }
+ } catch (err) {
+ if (isDevMode) {
+ console.warn("[nostr] hydrateVideoHistory relay fetch error:", err);
+ }
+ }
+
+ localMatches = collectLocalMatches();
+ }
+
+ return localMatches.sort((a, b) => b.created_at - a.created_at);
+ }
+
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
From fa638e08a5bcfa16e1ca626dc636249208d38987 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 22:11:25 -0400
Subject: [PATCH 35/82] Align scrollbar styling across modals
---
.../iframe_forms/iframe-application-form.html | 9 ++-
.../iframe_forms/iframe-bug-fix-form.html | 9 ++-
.../iframe-content-appeals-form.html | 9 ++-
.../iframe_forms/iframe-feedback-form.html | 9 ++-
.../iframe_forms/iframe-request-form.html | 9 ++-
css/style.css | 64 ++++++++-----------
6 files changed, 58 insertions(+), 51 deletions(-)
diff --git a/components/iframe_forms/iframe-application-form.html b/components/iframe_forms/iframe-application-form.html
index b580c2e2..cfe91fa9 100644
--- a/components/iframe_forms/iframe-application-form.html
+++ b/components/iframe_forms/iframe-application-form.html
@@ -112,16 +112,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
- background: transparent;
+ background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
- background-color: #3b82f6;
+ background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+ }
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
- scrollbar-color: #3b82f6 transparent;
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
diff --git a/components/iframe_forms/iframe-bug-fix-form.html b/components/iframe_forms/iframe-bug-fix-form.html
index f8a186bf..017d7b84 100644
--- a/components/iframe_forms/iframe-bug-fix-form.html
+++ b/components/iframe_forms/iframe-bug-fix-form.html
@@ -112,16 +112,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
- background: transparent;
+ background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
- background-color: #3b82f6;
+ background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+ }
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
- scrollbar-color: #3b82f6 transparent;
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
diff --git a/components/iframe_forms/iframe-content-appeals-form.html b/components/iframe_forms/iframe-content-appeals-form.html
index 5ae9e782..c7f94194 100644
--- a/components/iframe_forms/iframe-content-appeals-form.html
+++ b/components/iframe_forms/iframe-content-appeals-form.html
@@ -105,16 +105,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
- background: transparent;
+ background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
- background-color: #3b82f6;
+ background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+ }
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
- scrollbar-color: #3b82f6 transparent;
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
diff --git a/components/iframe_forms/iframe-feedback-form.html b/components/iframe_forms/iframe-feedback-form.html
index ad787fc8..8bc564c9 100644
--- a/components/iframe_forms/iframe-feedback-form.html
+++ b/components/iframe_forms/iframe-feedback-form.html
@@ -119,16 +119,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
- background: transparent;
+ background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
- background-color: #3b82f6;
+ background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+ }
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
- scrollbar-color: #3b82f6 transparent;
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
diff --git a/components/iframe_forms/iframe-request-form.html b/components/iframe_forms/iframe-request-form.html
index a706157f..23ce8ff4 100644
--- a/components/iframe_forms/iframe-request-form.html
+++ b/components/iframe_forms/iframe-request-form.html
@@ -112,15 +112,18 @@
height: 8px;
}
::-webkit-scrollbar-track {
- background: transparent;
+ background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
- background-color: #3b82f6;
+ background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+ }
* {
scrollbar-width: thin;
- scrollbar-color: #3b82f6 transparent;
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
diff --git a/css/style.css b/css/style.css
index cde5e9ea..0c967619 100644
--- a/css/style.css
+++ b/css/style.css
@@ -10,6 +10,34 @@
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--modal-motion-duration: 160ms;
+ --scrollbar-width: 8px;
+ --scrollbar-track: rgba(255, 255, 255, 0.1);
+ --scrollbar-thumb: rgba(255, 255, 255, 0.3);
+ --scrollbar-thumb-hover: rgba(255, 255, 255, 0.4);
+}
+
+/* Global scrollbar styling */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-thumb) transparent;
+}
+
+*::-webkit-scrollbar {
+ width: var(--scrollbar-width);
+ height: var(--scrollbar-width);
+}
+
+*::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+}
+
+*::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 4px;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
}
/* Core Styles */
@@ -312,24 +340,6 @@ header img {
}
}
-/* Custom Scrollbar */
-.video-info::-webkit-scrollbar {
- width: 8px;
-}
-
-.video-info::-webkit-scrollbar-track {
- background: rgba(255, 255, 255, 0.1);
-}
-
-.video-info::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 4px;
-}
-
-.video-info::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.4);
-}
-
/* Progress Bars */
.progress-bar {
width: 100%;
@@ -468,24 +478,6 @@ footer a:hover {
overflow-y: auto;
}
-/* Disclaimer Modal Scrollbar */
-#disclaimerModal .modal-scroll::-webkit-scrollbar {
- width: 8px;
-}
-
-#disclaimerModal .modal-scroll::-webkit-scrollbar-track {
- background: rgba(255, 255, 255, 0.1);
-}
-
-#disclaimerModal .modal-scroll::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 4px;
-}
-
-#disclaimerModal .modal-scroll::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.4);
-}
-
/* Disclaimer Modal Button Container */
#disclaimerModal .button-container {
padding: 1rem 1.5rem;
From 30c260aa8276b770e845268fc80a2f3b5376fb0d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 22:19:30 -0400
Subject: [PATCH 36/82] Improve NIP-07 login reliability
---
js/app.js | 23 ++++++++++++++++++++++-
js/nostr.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 69 insertions(+), 3 deletions(-)
diff --git a/js/app.js b/js/app.js
index e7c5bb9b..c3cac2fc 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3229,7 +3229,22 @@ class bitvidApp {
// 6) NIP-07 button inside the login modal => call the extension & login
const nip07Button = document.getElementById("loginNIP07");
if (nip07Button) {
+ const originalLabel = nip07Button.textContent;
+ const setLoadingState = (isLoading) => {
+ nip07Button.disabled = isLoading;
+ nip07Button.dataset.loading = isLoading ? "true" : "false";
+ nip07Button.setAttribute("aria-busy", isLoading ? "true" : "false");
+ nip07Button.textContent = isLoading
+ ? "Connecting to NIP-07 extension..."
+ : originalLabel;
+ };
+
nip07Button.addEventListener("click", async () => {
+ if (nip07Button.dataset.loading === "true") {
+ return;
+ }
+
+ setLoadingState(true);
console.log(
"[app.js] loginNIP07 clicked! Attempting extension login..."
);
@@ -3245,7 +3260,13 @@ class bitvidApp {
}
} catch (err) {
console.error("[NIP-07 login error]", err);
- this.showError("Failed to login with NIP-07. Please try again.");
+ const message =
+ err && typeof err.message === "string" && err.message.trim()
+ ? err.message.trim()
+ : "Failed to login with NIP-07. Please try again.";
+ this.showError(message);
+ } finally {
+ setLoadingState(false);
}
});
}
diff --git a/js/nostr.js b/js/nostr.js
index 10a290a9..ecc6fb3b 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -20,6 +20,7 @@ const RELAY_URLS = [
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
+const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds
// To limit error spam
let errorLogCount = 0;
@@ -492,13 +493,57 @@ class NostrClient {
*/
async login() {
try {
- if (!window.nostr) {
+ const extension = window.nostr;
+ if (!extension) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
- const pubkey = await window.nostr.getPublicKey();
+
+ if (typeof extension.getPublicKey !== "function") {
+ throw new Error(
+ "This NIP-07 extension is missing getPublicKey support. Please update the extension."
+ );
+ }
+
+ if (typeof extension.enable === "function") {
+ if (isDevMode) {
+ console.log("Requesting permissions from NIP-07 extension...");
+ }
+ try {
+ await extension.enable();
+ } catch (enableErr) {
+ throw new Error(
+ enableErr && enableErr.message
+ ? enableErr.message
+ : "The NIP-07 extension denied the permission request."
+ );
+ }
+ }
+
+ let timeoutId;
+ const pubkey = await Promise.race([
+ extension.getPublicKey(),
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(
+ new Error(
+ "Timed out waiting for the NIP-07 extension. Check the extension prompt and try again."
+ )
+ );
+ }, NIP07_LOGIN_TIMEOUT_MS);
+ }),
+ ]).finally(() => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ });
+ if (!pubkey || typeof pubkey !== "string") {
+ throw new Error(
+ "The NIP-07 extension did not return a public key. Please try again."
+ );
+ }
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
From c851abc98914b678606816faa98039e4b8d59a44 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 22:39:25 -0400
Subject: [PATCH 37/82] Add comment toggle and magnet hint editing to video
forms
---
components/edit-video-modal.html | 69 +++++++++++
components/upload-modal.html | 33 ++++++
js/app.js | 198 +++++++++++++++++++++++++++----
js/magnet.js | 54 +++++++++
js/nostr.js | 58 +++++++++
5 files changed, 387 insertions(+), 25 deletions(-)
diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html
index 5275bd60..a3541d9c 100644
--- a/components/edit-video-modal.html
+++ b/components/edit-video-modal.html
@@ -94,6 +94,48 @@
+
+
+ Web seed (ws=)
+
+
+
+
+ Edit field
+
+
+
+
+
+
+ .torrent URL (xs=)
+
+
+
+
+ Edit field
+
+
+
+
Magnet link
@@ -157,6 +199,33 @@
+
+
+ Enable comments
+
+
+
+
+
+ Allow replies once comment threads are available.
+
+
+
+ Edit field
+
+
+
+
+
+
+
+
+
Enable comments
+
+ Keep this on to let viewers discuss the video once comment threads launch.
+
+
+
-
+
+
+
diff --git a/torrent/beacon.html b/torrent/beacon.html
index 8c6b6052..c43d3672 100644
--- a/torrent/beacon.html
+++ b/torrent/beacon.html
@@ -44,11 +44,13 @@
From 5365fad888c40c3c5624a54fa9fdce9ecf163611 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 27 Sep 2025 23:55:18 -0400
Subject: [PATCH 43/82] Add profile modal scaffolding and video overflow menus
---
assets/svg/ellipsis.svg | 5 +
components/profile-modal.html | 152 ++++++--
components/video-modal.html | 49 +++
js/app.js | 637 +++++++++++++++++++++++++++++++++-
js/channelProfile.js | 46 ++-
js/subscriptions.js | 46 ++-
6 files changed, 897 insertions(+), 38 deletions(-)
create mode 100644 assets/svg/ellipsis.svg
diff --git a/assets/svg/ellipsis.svg b/assets/svg/ellipsis.svg
new file mode 100644
index 00000000..18556f8c
--- /dev/null
+++ b/assets/svg/ellipsis.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/components/profile-modal.html b/components/profile-modal.html
index d4377e4d..5a3f0f85 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -2,6 +2,10 @@
@@ -27,7 +31,7 @@
-
Profile
+
Profile
-
-
-
-
- Loading...
-
+
+
+
+
+
+ Account
+
+
+ Relays
+
+
+ Blocked
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+ View your channel
+
+
+ Manage your account details and upcoming preferences here.
+
+
+
+
+
+
+ Configure the relays Bitvid will use to discover and publish your videos.
+
+
+
+ Add a relay
+
+
+
+
+ Add relay
+
+
+ Restore defaults
+
+
+
+
+
+
+
+
+
+ You haven’t blocked any creators yet.
+
+
+
+
-
- View your channel
-
-
- Some future profile details or settings could go here.
-
Logout
diff --git a/components/video-modal.html b/components/video-modal.html
index 56df5588..81f5c635 100644
--- a/components/video-modal.html
+++ b/components/video-modal.html
@@ -31,6 +31,55 @@
+
+
+
+
+
+
+
+ Open channel
+
+
+ Copy link
+
+
+ Block creator
+
+
+ Report
+
+
+
+
diff --git a/js/app.js b/js/app.js
index 17c7a6f6..75a261ac 100644
--- a/js/app.js
+++ b/js/app.js
@@ -499,6 +499,26 @@ class bitvidApp {
this.profileModalAvatar = null;
this.profileModalName = null;
this.profileChannelLink = null;
+ this.profileNavButtons = {
+ account: null,
+ relays: null,
+ blocked: null,
+ };
+ this.profilePaneElements = {
+ account: null,
+ relays: null,
+ blocked: null,
+ };
+ this.profileRelayList = null;
+ this.profileBlockedList = null;
+ this.profileBlockedEmpty = null;
+ this.profileRelayInput = null;
+ this.profileAddRelayBtn = null;
+ this.profileRestoreRelaysBtn = null;
+ this.lastFocusedBeforeProfileModal = null;
+ this.boundProfileModalKeydown = null;
+ this.boundProfileModalFocusIn = null;
+ this.profileModalFocusables = [];
// Upload modal elements
this.uploadButton = document.getElementById("uploadButton") || null;
@@ -597,6 +617,8 @@ class bitvidApp {
this.copyMagnetBtn = null;
this.shareBtn = null;
this.modalZapBtn = null;
+ this.modalMoreBtn = null;
+ this.modalMoreMenu = null;
this.modalPosterCleanup = null;
this.cleanupPromise = null;
@@ -618,6 +640,9 @@ class bitvidApp {
// Videos stored as a Map (key=event.id)
this.videosMap = new Map();
+ this.moreMenuGlobalHandlerBound = false;
+ this.boundMoreMenuDocumentClick = null;
+ this.boundMoreMenuDocumentKeydown = null;
// Simple cache for user profiles
this.profileCache = new Map();
this.lastRenderedVideoSignature = null;
@@ -993,6 +1018,8 @@ class bitvidApp {
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
this.shareBtn = document.getElementById("shareBtn") || null;
this.modalZapBtn = document.getElementById("modalZapBtn") || null;
+ this.modalMoreBtn = document.getElementById("modalMoreBtn") || null;
+ this.modalMoreMenu = document.getElementById("moreDropdown-modal") || null;
this.setModalZapVisibility(false);
// Attach existing event listeners for copy/share
@@ -1037,6 +1064,10 @@ class bitvidApp {
this.openCreatorChannel();
});
}
+
+ if (this.playerModal) {
+ this.attachMoreMenuHandlers(this.playerModal);
+ }
}
goToProfile(pubkey) {
@@ -3223,35 +3254,85 @@ class bitvidApp {
document.getElementById("profileModalName") || null;
this.profileChannelLink =
document.getElementById("profileChannelLink") || null;
+ this.profileNavButtons.account =
+ document.getElementById("profileNavAccount") || null;
+ this.profileNavButtons.relays =
+ document.getElementById("profileNavRelays") || null;
+ this.profileNavButtons.blocked =
+ document.getElementById("profileNavBlocked") || null;
+ this.profilePaneElements.account =
+ document.getElementById("profilePaneAccount") || null;
+ this.profilePaneElements.relays =
+ document.getElementById("profilePaneRelays") || null;
+ this.profilePaneElements.blocked =
+ document.getElementById("profilePaneBlocked") || null;
+ this.profileRelayList = document.getElementById("relayList") || null;
+ this.profileBlockedList = document.getElementById("blockedList") || null;
+ this.profileBlockedEmpty =
+ document.getElementById("blockedEmpty") || null;
+ this.profileRelayInput = document.getElementById("relayInput") || null;
+ this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
+ this.profileRestoreRelaysBtn =
+ document.getElementById("restoreRelaysBtn") || null;
// Wire up
- if (this.closeProfileModal) {
+ if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) {
+ this.closeProfileModal.dataset.bound = "true";
this.closeProfileModal.addEventListener("click", () => {
- this.profileModal.classList.add("hidden");
+ this.hideProfileModal();
});
}
- if (this.profileLogoutBtn) {
+ if (this.profileLogoutBtn && !this.profileLogoutBtn.dataset.bound) {
+ this.profileLogoutBtn.dataset.bound = "true";
this.profileLogoutBtn.addEventListener("click", () => {
- // On "Logout" inside the profile modal
this.logout();
- this.profileModal.classList.add("hidden");
+ this.hideProfileModal();
});
}
- if (this.profileChannelLink) {
+ if (this.profileChannelLink && !this.profileChannelLink.dataset.bound) {
+ this.profileChannelLink.dataset.bound = "true";
this.profileChannelLink.addEventListener("click", (event) => {
event.preventDefault();
const targetNpub = this.profileChannelLink?.dataset?.targetNpub;
if (!targetNpub) {
return;
}
- if (this.profileModal) {
- this.profileModal.classList.add("hidden");
- }
+ this.hideProfileModal();
window.location.hash = `#view=channel-profile&npub=${targetNpub}`;
});
}
+ Object.entries(this.profileNavButtons).forEach(([name, button]) => {
+ if (!button || button.dataset.navBound === "true") {
+ return;
+ }
+ button.dataset.navBound = "true";
+ button.addEventListener("click", () => {
+ this.selectProfilePane(name);
+ });
+ });
+
+ if (this.profileAddRelayBtn && !this.profileAddRelayBtn.dataset.bound) {
+ this.profileAddRelayBtn.dataset.bound = "true";
+ this.profileAddRelayBtn.addEventListener("click", () => {
+ this.showSuccess("Relay management coming soon.");
+ });
+ }
+ if (
+ this.profileRestoreRelaysBtn &&
+ this.profileRestoreRelaysBtn.dataset.bound !== "true"
+ ) {
+ this.profileRestoreRelaysBtn.dataset.bound = "true";
+ this.profileRestoreRelaysBtn.addEventListener("click", () => {
+ this.showSuccess("Relay management coming soon.");
+ });
+ }
+
+ this.selectProfilePane("account");
+ this.populateProfileRelays();
+ this.populateBlockedList();
+
console.log("Profile modal initialization successful");
return true;
} catch (error) {
@@ -3261,6 +3342,332 @@ class bitvidApp {
}
}
+ selectProfilePane(name = "account") {
+ const normalized = typeof name === "string" ? name.toLowerCase() : "account";
+ const target = ["account", "relays", "blocked"].includes(normalized)
+ ? normalized
+ : "account";
+
+ Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
+ if (!pane) {
+ return;
+ }
+ const isActive = key === target;
+ pane.classList.toggle("hidden", !isActive);
+ pane.setAttribute("aria-hidden", (!isActive).toString());
+ });
+
+ Object.entries(this.profileNavButtons).forEach(([key, button]) => {
+ if (!button) {
+ return;
+ }
+ const isActive = key === target;
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
+ button.classList.toggle("bg-gray-800", isActive);
+ button.classList.toggle("text-white", isActive);
+ button.classList.toggle("text-gray-400", !isActive);
+ });
+
+ this.updateProfileModalFocusables();
+ }
+
+ updateProfileModalFocusables() {
+ if (!this.profileModal) {
+ this.profileModalFocusables = [];
+ return;
+ }
+
+ const focusableSelectors =
+ 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
+ const candidates = Array.from(
+ this.profileModal.querySelectorAll(focusableSelectors)
+ );
+
+ this.profileModalFocusables = candidates.filter((el) => {
+ if (!(el instanceof HTMLElement)) {
+ return false;
+ }
+ if (el.hasAttribute("disabled")) {
+ return false;
+ }
+ if (el.getAttribute("aria-hidden") === "true") {
+ return false;
+ }
+ if (el.offsetParent === null && el !== document.activeElement) {
+ return false;
+ }
+ return true;
+ });
+ }
+
+ openProfileModal() {
+ if (!this.profileModal) {
+ return;
+ }
+
+ this.selectProfilePane("account");
+ this.populateProfileRelays();
+ this.populateBlockedList();
+
+ this.profileModal.classList.remove("hidden");
+ this.profileModal.setAttribute("aria-hidden", "false");
+
+ this.lastFocusedBeforeProfileModal =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+
+ this.updateProfileModalFocusables();
+
+ const initialTarget =
+ this.profileNavButtons.account instanceof HTMLElement
+ ? this.profileNavButtons.account
+ : this.profileModal;
+
+ window.requestAnimationFrame(() => {
+ if (initialTarget && typeof initialTarget.focus === "function") {
+ initialTarget.focus();
+ }
+ });
+
+ if (!this.boundProfileModalKeydown) {
+ this.boundProfileModalKeydown = (event) => {
+ if (!this.profileModal || this.profileModal.classList.contains("hidden")) {
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ this.hideProfileModal();
+ return;
+ }
+
+ if (event.key !== "Tab") {
+ return;
+ }
+
+ this.updateProfileModalFocusables();
+ if (!this.profileModalFocusables.length) {
+ event.preventDefault();
+ if (typeof this.profileModal.focus === "function") {
+ this.profileModal.focus();
+ }
+ return;
+ }
+
+ const first = this.profileModalFocusables[0];
+ const last = this.profileModalFocusables[this.profileModalFocusables.length - 1];
+ const active = document.activeElement;
+
+ if (event.shiftKey) {
+ if (active === first || !this.profileModal.contains(active)) {
+ event.preventDefault();
+ if (last && typeof last.focus === "function") {
+ last.focus();
+ }
+ }
+ return;
+ }
+
+ if (active === last) {
+ event.preventDefault();
+ if (first && typeof first.focus === "function") {
+ first.focus();
+ }
+ }
+ };
+ }
+
+ if (!this.boundProfileModalFocusIn) {
+ this.boundProfileModalFocusIn = (event) => {
+ if (
+ !this.profileModal ||
+ this.profileModal.classList.contains("hidden") ||
+ this.profileModal.contains(event.target)
+ ) {
+ return;
+ }
+
+ this.updateProfileModalFocusables();
+ const fallback = this.profileModalFocusables[0] || this.profileModal;
+ if (fallback && typeof fallback.focus === "function") {
+ fallback.focus();
+ }
+ };
+ }
+
+ this.profileModal.addEventListener("keydown", this.boundProfileModalKeydown);
+ document.addEventListener("focusin", this.boundProfileModalFocusIn);
+ }
+
+ hideProfileModal() {
+ if (!this.profileModal) {
+ return;
+ }
+
+ if (!this.profileModal.classList.contains("hidden")) {
+ this.profileModal.classList.add("hidden");
+ }
+ this.profileModal.setAttribute("aria-hidden", "true");
+
+ if (this.boundProfileModalKeydown) {
+ this.profileModal.removeEventListener(
+ "keydown",
+ this.boundProfileModalKeydown
+ );
+ }
+ if (this.boundProfileModalFocusIn) {
+ document.removeEventListener("focusin", this.boundProfileModalFocusIn);
+ }
+
+ this.closeAllMoreMenus();
+
+ if (
+ this.lastFocusedBeforeProfileModal &&
+ typeof this.lastFocusedBeforeProfileModal.focus === "function"
+ ) {
+ this.lastFocusedBeforeProfileModal.focus();
+ }
+ this.lastFocusedBeforeProfileModal = null;
+ }
+
+ populateProfileRelays(relayUrls) {
+ if (!this.profileRelayList) {
+ return;
+ }
+
+ const rawRelays =
+ Array.isArray(relayUrls)
+ ? relayUrls
+ : Array.isArray(nostrClient.relays)
+ ? nostrClient.relays
+ : Array.from(nostrClient.relays || []);
+
+ const relays = rawRelays
+ .map((relay) => (typeof relay === "string" ? relay.trim() : ""))
+ .filter((relay) => relay.length > 0);
+
+ this.profileRelayList.innerHTML = "";
+
+ if (!relays.length) {
+ const emptyState = document.createElement("li");
+ emptyState.className =
+ "rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400";
+ emptyState.textContent = "No relays configured.";
+ this.profileRelayList.appendChild(emptyState);
+ return;
+ }
+
+ relays.forEach((relayUrl) => {
+ const item = document.createElement("li");
+ item.className =
+ "flex items-start justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
+
+ const info = document.createElement("div");
+ info.className = "flex-1 min-w-0";
+
+ const urlEl = document.createElement("p");
+ urlEl.className = "text-sm font-medium text-gray-100 break-all";
+ urlEl.textContent = relayUrl;
+
+ const statusEl = document.createElement("p");
+ statusEl.className = "mt-1 text-xs text-gray-400";
+ statusEl.textContent = "Active";
+
+ info.appendChild(urlEl);
+ info.appendChild(statusEl);
+
+ const actions = document.createElement("div");
+ actions.className = "flex items-center gap-2";
+
+ const editBtn = document.createElement("button");
+ editBtn.type = "button";
+ editBtn.className =
+ "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
+ editBtn.textContent = "Edit";
+ editBtn.addEventListener("click", () => {
+ this.showSuccess("Relay management coming soon.");
+ });
+
+ const removeBtn = document.createElement("button");
+ removeBtn.type = "button";
+ removeBtn.className =
+ "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
+ removeBtn.textContent = "Remove";
+ removeBtn.addEventListener("click", () => {
+ this.showSuccess("Relay management coming soon.");
+ });
+
+ actions.appendChild(editBtn);
+ actions.appendChild(removeBtn);
+
+ item.appendChild(info);
+ item.appendChild(actions);
+
+ this.profileRelayList.appendChild(item);
+ });
+ }
+
+ populateBlockedList(blocked = []) {
+ if (!this.profileBlockedList || !this.profileBlockedEmpty) {
+ return;
+ }
+
+ const entries = Array.isArray(blocked) ? blocked : [];
+ this.profileBlockedList.innerHTML = "";
+
+ if (!entries.length) {
+ this.profileBlockedEmpty.classList.remove("hidden");
+ this.profileBlockedList.classList.add("hidden");
+ return;
+ }
+
+ this.profileBlockedEmpty.classList.add("hidden");
+ this.profileBlockedList.classList.remove("hidden");
+
+ entries.forEach((entry) => {
+ const identifier =
+ typeof entry === "string"
+ ? entry
+ : typeof entry?.npub === "string" && entry.npub
+ ? entry.npub
+ : typeof entry?.pubkey === "string"
+ ? entry.pubkey
+ : "";
+
+ if (!identifier) {
+ return;
+ }
+
+ const item = document.createElement("li");
+ item.className =
+ "flex items-center justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
+
+ const label = document.createElement("div");
+ label.className = "min-w-0";
+
+ const title = document.createElement("p");
+ title.className = "text-sm font-medium text-gray-100 break-all";
+ title.textContent = identifier;
+
+ label.appendChild(title);
+
+ const actionBtn = document.createElement("button");
+ actionBtn.type = "button";
+ actionBtn.className =
+ "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
+ actionBtn.textContent = "Remove";
+ actionBtn.addEventListener("click", () => {
+ this.showSuccess("Block management coming soon.");
+ });
+
+ item.appendChild(label);
+ item.appendChild(actionBtn);
+
+ this.profileBlockedList.appendChild(item);
+ });
+ }
+
/**
* Setup general event listeners for logout, modals, etc.
*/
@@ -3275,9 +3682,7 @@ class bitvidApp {
// 2) Profile button
if (this.profileButton) {
this.profileButton.addEventListener("click", () => {
- if (this.profileModal) {
- this.profileModal.classList.remove("hidden");
- }
+ this.openProfileModal();
});
}
@@ -4934,6 +5339,48 @@ class bitvidApp {
`
: "";
+ const moreMenu = `
+
+
+
+
+
+
+
+ Open channel
+
+
+ Copy link
+
+
+ Block creator
+
+
+ Report
+
+
+
+
+ `;
+
+ const cardControls = `
+
+ ${moreMenu}${gearMenu}
+
+ `;
+
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
const trimmedMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
@@ -5057,7 +5504,7 @@ class bitvidApp {
- ${gearMenu}
+ ${cardControls}
${connectionBadgesHtml}
${torrentWarningHtml}
@@ -5265,6 +5712,7 @@ class bitvidApp {
// with HTTP 200 + empty body). We set up the listeners before kicking off
// any lazy-loading observers so cached failures are covered as well.
this.bindThumbnailFallbacks(this.videoList);
+ this.attachMoreMenuHandlers(this.videoList);
// Lazy-load images
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
@@ -5445,6 +5893,169 @@ class bitvidApp {
});
}
+ ensureGlobalMoreMenuHandlers() {
+ if (this.moreMenuGlobalHandlerBound) {
+ return;
+ }
+
+ this.moreMenuGlobalHandlerBound = true;
+
+ this.boundMoreMenuDocumentClick = (event) => {
+ const target = event.target;
+ if (
+ target instanceof HTMLElement &&
+ (target.closest("[data-more-menu-wrapper]") ||
+ target.closest("[data-more-menu]"))
+ ) {
+ return;
+ }
+ this.closeAllMoreMenus();
+ };
+
+ this.boundMoreMenuDocumentKeydown = (event) => {
+ if (event.key === "Escape") {
+ this.closeAllMoreMenus();
+ }
+ };
+
+ document.addEventListener("click", this.boundMoreMenuDocumentClick);
+ document.addEventListener("keydown", this.boundMoreMenuDocumentKeydown);
+ }
+
+ closeAllMoreMenus() {
+ const menus = document.querySelectorAll("[data-more-menu]");
+ menus.forEach((menu) => {
+ if (menu instanceof HTMLElement) {
+ menu.classList.add("hidden");
+ }
+ });
+
+ const buttons = document.querySelectorAll("[data-more-dropdown]");
+ buttons.forEach((btn) => {
+ if (btn instanceof HTMLElement) {
+ btn.setAttribute("aria-expanded", "false");
+ }
+ });
+ }
+
+ attachMoreMenuHandlers(container) {
+ if (!container || typeof container.querySelectorAll !== "function") {
+ return;
+ }
+
+ const buttons = container.querySelectorAll("[data-more-dropdown]");
+ if (!buttons.length) {
+ return;
+ }
+
+ this.ensureGlobalMoreMenuHandlers();
+
+ buttons.forEach((button) => {
+ if (!(button instanceof HTMLElement)) {
+ return;
+ }
+ if (button.dataset.moreMenuToggleBound === "true") {
+ return;
+ }
+ button.dataset.moreMenuToggleBound = "true";
+ button.setAttribute("aria-expanded", "false");
+
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const key = button.getAttribute("data-more-dropdown") || "";
+ const dropdown = document.getElementById(`moreDropdown-${key}`);
+ if (!(dropdown instanceof HTMLElement)) {
+ return;
+ }
+
+ const willOpen = dropdown.classList.contains("hidden");
+ this.closeAllMoreMenus();
+
+ if (willOpen) {
+ dropdown.classList.remove("hidden");
+ button.setAttribute("aria-expanded", "true");
+ }
+ });
+ });
+
+ const actionButtons = container.querySelectorAll(
+ "[data-more-menu] button[data-action]"
+ );
+ actionButtons.forEach((button) => {
+ if (!(button instanceof HTMLElement)) {
+ return;
+ }
+ if (button.dataset.moreMenuActionBound === "true") {
+ return;
+ }
+ button.dataset.moreMenuActionBound = "true";
+
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const { action } = button.dataset;
+ this.handleMoreMenuAction(action, button.dataset);
+ this.closeAllMoreMenus();
+ });
+ });
+ }
+
+ handleMoreMenuAction(action, dataset = {}) {
+ const normalized = typeof action === "string" ? action.trim() : "";
+
+ switch (normalized) {
+ case "open-channel": {
+ const author = dataset.author || "";
+ if (author) {
+ this.goToProfile(author);
+ } else {
+ this.showError("No creator info available.");
+ }
+ break;
+ }
+ case "modal-open-channel": {
+ this.openCreatorChannel();
+ break;
+ }
+ case "copy-link":
+ case "modal-copy-link": {
+ const eventId =
+ dataset.eventId ||
+ (normalized === "modal-copy-link" && this.currentVideo
+ ? this.currentVideo.id
+ : "");
+ if (!eventId) {
+ this.showError("Could not generate link.");
+ break;
+ }
+ const shareUrl = this.buildShareUrlFromEventId(eventId);
+ if (!shareUrl) {
+ this.showError("Could not generate link.");
+ break;
+ }
+ navigator.clipboard
+ .writeText(shareUrl)
+ .then(() => this.showSuccess("Video link copied to clipboard!"))
+ .catch(() => this.showError("Failed to copy the link."));
+ break;
+ }
+ case "block-author":
+ case "modal-block-author": {
+ this.showSuccess("Block management coming soon.");
+ break;
+ }
+ case "report":
+ case "modal-report": {
+ this.showSuccess("Reporting coming soon.");
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
/**
* Updates the modal to reflect current torrent stats.
* We remove the unused torrent.status references,
diff --git a/js/channelProfile.js b/js/channelProfile.js
index 9e23492f..6f5105a6 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -387,6 +387,48 @@ async function loadUserVideos(pubkey) {
`;
}
+ const moreMenu = `
+