updated directory structure to fix root Service Worker issues and performance improvements

This commit is contained in:
Keep Creating Online
2025-02-05 16:54:40 -05:00
parent 03c28dacab
commit b6b61e4e15
94 changed files with 2265 additions and 79 deletions

154
js/accessControl.js Normal file
View File

@@ -0,0 +1,154 @@
// js/accessControl.js
import { isDevMode, isWhitelistEnabled } from "./config.js";
import { initialWhitelist, initialBlacklist } from "./lists.js";
class AccessControl {
constructor() {
// Debug logging for initialization
console.log("DEBUG: AccessControl constructor called");
console.log("DEBUG: initialWhitelist from import:", initialWhitelist);
console.log("DEBUG: typeof initialWhitelist:", typeof initialWhitelist);
console.log("DEBUG: initialWhitelist length:", initialWhitelist.length);
// Initialize empty sets
this.whitelist = new Set(initialWhitelist);
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
// Debug the sets
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
// Save to localStorage
this.saveWhitelist();
this.saveBlacklist();
}
// Rest of the class remains the same...
loadWhitelist() {
try {
const stored = localStorage.getItem("bitvid_whitelist");
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error("Error loading whitelist:", error);
return [];
}
}
loadBlacklist() {
try {
const stored = localStorage.getItem("bitvid_blacklist");
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error("Error loading blacklist:", error);
return [];
}
}
saveWhitelist() {
try {
localStorage.setItem(
"bitvid_whitelist",
JSON.stringify([...this.whitelist])
);
} catch (error) {
console.error("Error saving whitelist:", error);
}
}
saveBlacklist() {
try {
localStorage.setItem(
"bitvid_blacklist",
JSON.stringify([...this.blacklist])
);
} catch (error) {
console.error("Error saving blacklist:", error);
}
}
addToWhitelist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.whitelist.add(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Added ${npub} to whitelist`);
}
removeFromWhitelist(npub) {
this.whitelist.delete(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Removed ${npub} from whitelist`);
}
addToBlacklist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.blacklist.add(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Added ${npub} to blacklist`);
}
removeFromBlacklist(npub) {
this.blacklist.delete(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Removed ${npub} from blacklist`);
}
isWhitelisted(npub) {
const result = this.whitelist.has(npub);
if (isDevMode)
console.log(
`Checking if ${npub} is whitelisted:`,
result,
"Current whitelist:",
[...this.whitelist]
);
return result;
}
isBlacklisted(npub) {
return this.blacklist.has(npub);
}
canAccess(npub) {
if (this.isBlacklisted(npub)) {
return false;
}
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
return canAccess;
}
filterVideos(videos) {
return videos.filter((video) => {
try {
const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
return !this.isBlacklisted(npub);
} catch (error) {
console.error("Error filtering video:", error);
return false;
}
});
}
isValidNpub(npub) {
try {
return npub.startsWith("npub1") && npub.length === 63;
} catch (error) {
return false;
}
}
getWhitelist() {
return [...this.whitelist];
}
getBlacklist() {
return [...this.blacklist];
}
}
export const accessControl = new AccessControl();

1681
js/app.js Normal file

File diff suppressed because it is too large Load Diff

4
js/config.js Normal file
View File

@@ -0,0 +1,4 @@
// js/config.js
export const isDevMode = true; // Set to false for production
export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users

39
js/disclaimer.js Normal file
View File

@@ -0,0 +1,39 @@
// js/disclaimer.js
class DisclaimerModal {
constructor() {
// Initialize elements when the disclaimer HTML is in the DOM.
this.init();
}
init() {
this.modal = document.getElementById("disclaimerModal");
this.acceptButton = document.getElementById("acceptDisclaimer");
if (this.acceptButton) {
this.acceptButton.addEventListener("click", () => {
this.hide();
});
}
}
hide() {
if (this.modal) {
this.modal.classList.add("hidden");
}
localStorage.setItem("hasSeenDisclaimer", "true");
}
show() {
// In case the modal hasn't been initialized yet.
if (!this.modal) {
this.init();
}
if (!localStorage.getItem("hasSeenDisclaimer") && this.modal) {
this.modal.classList.remove("hidden");
}
}
}
// Create and export a default instance.
const disclaimerModal = new DisclaimerModal();
export default disclaimerModal;

258
js/index.js Normal file
View File

@@ -0,0 +1,258 @@
// js/index.js
// 1) Load modals (login, application, etc.)
async function loadModal(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
document
.getElementById("modalContainer")
.insertAdjacentHTML("beforeend", html);
console.log(url, "loaded");
} catch (err) {
console.error(err);
}
}
// 2) Load sidebar
async function loadSidebar(url, containerId) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
document.getElementById(containerId).innerHTML = html;
console.log(url, "loaded into", containerId);
} catch (err) {
console.error(err);
}
}
// 3) Load the disclaimer (now separate)
async function loadDisclaimer(url, containerId) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
document.getElementById(containerId).insertAdjacentHTML("beforeend", html);
console.log(url, "disclaimer loaded into", containerId);
} catch (err) {
console.error(err);
}
}
// 4) Load everything: modals, sidebar, disclaimers
Promise.all([
// Existing modals
loadModal("components/login-modal.html"),
loadModal("components/application-form.html"),
loadModal("components/content-appeals-form.html"),
// New forms
loadModal("components/general-feedback-form.html"),
loadModal("components/feature-request-form.html"),
loadModal("components/bug-fix-form.html"),
])
.then(() => {
console.log("Modals loaded.");
return loadSidebar("components/sidebar.html", "sidebarContainer");
})
.then(() => {
console.log("Sidebar loaded.");
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
const sidebar = document.getElementById("sidebar");
const app = document.getElementById("app"); // <-- new
if (mobileMenuBtn && sidebar && app) {
mobileMenuBtn.addEventListener("click", () => {
sidebar.classList.toggle("hidden");
sidebar.classList.toggle("-translate-x-full");
// Toggle the class on #app so it shifts right
app.classList.toggle("sidebar-open");
});
}
return import("./sidebar.js").then((module) => {
module.setupSidebarNavigation();
});
})
.then(() => {
// Now load the disclaimer
return loadDisclaimer("components/disclaimer.html", "modalContainer");
})
.then(() => {
console.log("Disclaimer loaded.");
// 1) Login button => open login modal
const loginNavBtn = document.getElementById("loginButton");
if (loginNavBtn) {
loginNavBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.remove("hidden");
}
});
}
// 2) Close login modal
const closeLoginBtn = document.getElementById("closeLoginModal");
if (closeLoginBtn) {
closeLoginBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
});
}
// 3) “Application Form” => open application form
const openAppFormBtn = document.getElementById("openApplicationModal");
if (openAppFormBtn) {
openAppFormBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
}
});
}
// 4) Close application form
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
if (closeNostrFormBtn) {
closeNostrFormBtn.addEventListener("click", () => {
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.add("hidden");
}
// If user hasn't seen disclaimer, show it
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
}
}
});
}
// 5) ?modal=appeals => open content appeals form
const urlParams = new URLSearchParams(window.location.search);
const modalParam = urlParams.get("modal");
if (modalParam === "appeals") {
const appealsModal = document.getElementById("contentAppealsModal");
if (appealsModal) {
appealsModal.classList.remove("hidden");
}
const closeAppealsBtn = document.getElementById(
"closeContentAppealsModal"
);
if (closeAppealsBtn) {
closeAppealsBtn.addEventListener("click", () => {
appealsModal.classList.add("hidden");
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
}
}
});
}
} else if (modalParam === "application") {
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
}
} else {
// If there's no special param, disclaimers can show if user hasn't seen them
const hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer");
if (!hasSeenDisclaimer) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
}
}
}
// 6) Close content appeals modal if needed
const closeAppealsBtn = document.getElementById("closeContentAppealsModal");
if (closeAppealsBtn) {
closeAppealsBtn.addEventListener("click", () => {
const appealsModal = document.getElementById("contentAppealsModal");
if (appealsModal) {
appealsModal.classList.add("hidden");
}
});
}
// 7) Disclaimer 'I Understand' Button
const acceptDisclaimerBtn = document.getElementById("acceptDisclaimer");
if (acceptDisclaimerBtn) {
acceptDisclaimerBtn.addEventListener("click", () => {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.add("hidden");
}
localStorage.setItem("hasSeenDisclaimer", "true");
});
}
// 8) Query param checks for the three new forms
if (modalParam === "feedback") {
const feedbackModal = document.getElementById("generalFeedbackModal");
if (feedbackModal) {
feedbackModal.classList.remove("hidden");
}
} else if (modalParam === "feature") {
const featureModal = document.getElementById("featureRequestModal");
if (featureModal) {
featureModal.classList.remove("hidden");
}
} else if (modalParam === "bug") {
const bugModal = document.getElementById("bugFixModal");
if (bugModal) {
bugModal.classList.remove("hidden");
}
}
// 9) Close buttons for the new forms
const closeFeedbackBtn = document.getElementById(
"closeGeneralFeedbackModal"
);
if (closeFeedbackBtn) {
closeFeedbackBtn.addEventListener("click", () => {
const feedbackModal = document.getElementById("generalFeedbackModal");
if (feedbackModal) {
feedbackModal.classList.add("hidden");
}
});
}
const closeFeatureBtn = document.getElementById("closeFeatureRequestModal");
if (closeFeatureBtn) {
closeFeatureBtn.addEventListener("click", () => {
const featureModal = document.getElementById("featureRequestModal");
if (featureModal) {
featureModal.classList.add("hidden");
}
});
}
const closeBugBtn = document.getElementById("closeBugFixModal");
if (closeBugBtn) {
closeBugBtn.addEventListener("click", () => {
const bugModal = document.getElementById("bugFixModal");
if (bugModal) {
bugModal.classList.add("hidden");
}
});
}
});

8676
js/libs/nostr.bundle.js Normal file

File diff suppressed because it is too large Load Diff

24
js/lists.js Normal file
View File

@@ -0,0 +1,24 @@
// js/lists.js
// Whitelist of npubs that can access the video upload functions
const npubs = [
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", // bitvid
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", // thePR0M3TH3AN
"npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", // Ghost Grid Network
"npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2", // Chain Reaction
"npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx", // ODELL
"npub1qxduthz4p8v5zsux524df569pt7lj0d36dyqadnta2val64dtnhsr50h64", // NosToons
"npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney
"npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
];
console.log("DEBUG: lists.js loaded, npubs:", npubs);
// Blacklist of npubs that events will not be displayed in the bitvid official client
export const initialWhitelist = npubs;
export const initialBlacklist = [""];
// Block specific events with the nevent
export const initialEventBlacklist = [""];

715
js/nostr.js Normal file
View File

@@ -0,0 +1,715 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
/**
* The usual relays
*/
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://relay.primal.net",
"wss://relay.nostr.band",
];
// To limit error spam
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
if (eventContent) {
console.log(`Event Content: ${eventContent}`);
}
errorLogCount++;
}
if (errorLogCount === MAX_ERROR_LOGS) {
console.error(
"Maximum error log limit reached. Further errors will be suppressed."
);
}
}
/**
* Example "encryption" that just reverses strings.
* In real usage, replace with actual crypto.
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
}
function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
/**
* Convert a raw Nostr event => your "video" object.
* CHANGED: skip if version <2
*/
function convertEventToVideo(event) {
try {
const content = JSON.parse(event.content || "{}");
// Example checks:
const isSupportedVersion = content.version >= 2;
const hasRequiredFields = !!(content.title && content.magnet);
if (!isSupportedVersion) {
return {
id: event.id,
invalid: true,
reason: "version <2",
};
}
if (!hasRequiredFields) {
return {
id: event.id,
invalid: true,
reason: "missing title/magnet",
};
}
return {
id: event.id,
videoRootId: content.videoRootId || event.id,
version: content.version,
isPrivate: content.isPrivate ?? false,
title: content.title ?? "",
magnet: content.magnet ?? "",
thumbnail: content.thumbnail ?? "",
description: content.description ?? "",
mode: content.mode ?? "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
invalid: false,
};
} catch (err) {
// JSON parse error
return { id: event.id, invalid: true, reason: "json parse error" };
}
}
/**
* If the video has videoRootId => use that as the “group key”.
* Otherwise fallback to (pubkey + dTag), or if no dTag => “LEGACY:id”
*/
function getActiveKey(video) {
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
// Store all events so older links still work
this.allEvents = new Map();
// “activeMap” holds only the newest version for each root
this.activeMap = new Map();
}
/**
* Connect to the configured relays
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) {
throw new Error("No relays connected");
}
if (isDevMode) {
console.log(`Connected to ${successfulRelays.length} relay(s)`);
}
} catch (err) {
console.error("Nostr init failed:", err);
throw err;
}
}
async connectToRelays() {
return Promise.all(
this.relays.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, 5000);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
}
/**
* Attempt login with a Nostr extension
*/
async login() {
try {
if (!window.nostr) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
}
// Access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to whitelisted users only.");
}
}
this.pubkey = pubkey;
if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey);
}
return this.pubkey;
} catch (err) {
console.error("Login error:", err);
throw err;
}
}
logout() {
this.pubkey = null;
if (isDevMode) console.log("User logged out.");
}
/**
* Publish a new video
* CHANGED: Force version=2 for all new notes
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
if (isDevMode) {
console.log("Publishing new video with data:", videoData);
}
let finalMagnet = videoData.magnet;
if (videoData.isPrivate) {
finalMagnet = fakeEncrypt(finalMagnet);
}
// brand-new root & d
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const contentObject = {
videoRootId,
version: 2, // forcibly set version=2
deleted: false,
isPrivate: videoData.isPrivate ?? false,
title: videoData.title || "",
magnet: finalMagnet,
thumbnail: videoData.thumbnail || "",
description: videoData.description || "",
mode: videoData.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", dTagValue],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Publish event with brand-new root:", videoRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) console.log("Signed event:", signedEvent);
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) console.log(`Video published to ${url}`);
} catch (err) {
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
}
})
);
return signedEvent;
} catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err);
throw err;
}
}
/**
* Edits a video by creating a *new event* with a brand-new d tag,
* but reuses the same videoRootId as the original.
*
* This version forces version=2 for the original note and uses
* lowercase comparison for public keys.
*/
async editVideo(originalEventStub, updatedData, userPubkey) {
if (!userPubkey) {
throw new Error("Not logged in to edit.");
}
// Convert the provided pubkey to lowercase
const userPubkeyLower = userPubkey.toLowerCase();
// Use getEventById to fetch the full original event details
const baseEvent = await this.getEventById(originalEventStub.id);
if (!baseEvent) {
throw new Error("Could not retrieve the original event to edit.");
}
// Check that the original event is version 2 or higher
if (baseEvent.version < 2) {
throw new Error(
"This video is not in the supported version for editing."
);
}
// Ownership check (compare lowercase hex public keys)
if (
!baseEvent.pubkey ||
baseEvent.pubkey.toLowerCase() !== userPubkeyLower
) {
throw new Error("You do not own this video (pubkey mismatch).");
}
// Decrypt the old magnet if the note is private
let oldPlainMagnet = baseEvent.magnet || "";
if (baseEvent.isPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
// Determine if the updated note should be private
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
let finalPlainMagnet = (updatedData.magnet || "").trim() || oldPlainMagnet;
let finalMagnet = wantPrivate
? fakeEncrypt(finalPlainMagnet)
: finalPlainMagnet;
// Use the existing videoRootId (or fall back to the base event's ID)
const oldRootId = baseEvent.videoRootId || baseEvent.id;
// Generate a new d-tag so that the edit gets its own share link
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
// Build the updated content object
const contentObject = {
videoRootId: oldRootId,
version: updatedData.version ?? baseEvent.version ?? 2,
deleted: false,
isPrivate: wantPrivate,
title: updatedData.title ?? baseEvent.title,
magnet: finalMagnet,
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
description: updatedData.description ?? baseEvent.description,
mode: updatedData.mode ?? baseEvent.mode ?? "live",
};
const event = {
kind: 30078,
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
pubkey: userPubkeyLower,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", newD], // new share link tag
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Creating edited event with root ID:", oldRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(`Edited video published to ${url}`);
}
} catch (err) {
if (isDevMode) {
console.error(`Publish failed to ${url}`, err);
}
}
})
);
return signedEvent;
} catch (err) {
console.error("Edit failed:", err);
throw err;
}
}
/**
* revertVideo => old style
*/
async revertVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to revert.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("Not your event (pubkey mismatch).");
}
let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id);
if (!fetched) {
throw new Error("Could not fetch the original event for reverting.");
}
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
title: fetched.title,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
mode: fetched.mode,
}),
tags: fetched.tags,
};
}
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 oldContent = JSON.parse(baseEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
let finalRootId = oldContent.videoRootId || null;
if (!finalRootId) {
finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
}
const contentObject = {
videoRootId: finalRootId,
version: oldVersion,
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "",
magnet: "",
thumbnail: "",
description: "This version was reverted by the creator.",
mode: oldContent.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD],
],
content: JSON.stringify(contentObject),
};
const signedEvent = await window.nostr.signEvent(event);
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
} catch (err) {
if (isDevMode) console.error(`Failed to revert on ${url}`, err);
}
})
);
return signedEvent;
}
/**
* "Deleting" => Mark all content with the same videoRootId as {deleted:true}
* and blank out magnet/desc.
*
* This version now asks for confirmation before proceeding.
*/
async deleteAllVersions(videoRootId, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to delete all versions.");
}
// Ask for confirmation before proceeding
if (
!window.confirm(
"Are you sure you want to delete all versions of this video? This action cannot be undone."
)
) {
console.log("Deletion cancelled by user.");
return null; // Cancel deletion if user clicks "Cancel"
}
// 1) Find all events in our local allEvents that share the same root.
const matchingEvents = [];
for (const [id, vid] of this.allEvents.entries()) {
if (
vid.videoRootId === videoRootId &&
vid.pubkey === pubkey &&
!vid.deleted
) {
matchingEvents.push(vid);
}
}
if (!matchingEvents.length) {
throw new Error("No existing events found for that root.");
}
// 2) For each event, create a "revert" event to mark it as deleted.
// This will prompt the user (via the extension) to sign the deletion.
for (const vid of matchingEvents) {
await this.revertVideo(
{
id: vid.id,
pubkey: vid.pubkey,
content: JSON.stringify({
version: vid.version,
deleted: vid.deleted,
isPrivate: vid.isPrivate,
title: vid.title,
magnet: vid.magnet,
thumbnail: vid.thumbnail,
description: vid.description,
mode: vid.mode,
}),
tags: vid.tags,
},
pubkey
);
}
return true;
}
/**
* Subscribe to *all* videos (old and new) with a single subscription,
* then call onVideo() each time a new or updated event arrives.
*/
subscribeVideos(onVideo) {
const filter = {
kinds: [30078],
"#t": ["video"],
// Remove or adjust limit if you prefer,
// and set since=0 to retrieve historical events:
limit: 500,
since: 0,
};
if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter);
}
const sub = this.pool.sub(this.relays, [filter]);
const invalidDuringSub = [];
sub.on("event", (event) => {
try {
const video = convertEventToVideo(event);
if (video.invalid) {
invalidDuringSub.push({ id: video.id, reason: video.reason });
return;
}
// Store in allEvents
this.allEvents.set(event.id, video);
// If it's a "deleted" note, remove from activeMap
if (video.deleted) {
const activeKey = getActiveKey(video);
this.activeMap.delete(activeKey);
return;
}
// Otherwise, if it's newer than what we have, update activeMap
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video); // trigger the callback that re-renders
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error processing event:", err);
}
}
});
sub.on("eose", () => {
if (isDevMode && invalidDuringSub.length > 0) {
console.warn(
`[subscribeVideos] found ${invalidDuringSub.length} invalid v2 notes:`,
invalidDuringSub
);
}
if (isDevMode) {
console.log(
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
);
}
});
// Return the subscription object directly.
return sub;
}
/**
* fetchVideos => old approach
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 300,
since: 0,
};
const localAll = new Map();
// NEW: track invalid
const invalidNotes = [];
try {
await Promise.all(
this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]);
for (const evt of events) {
const vid = convertEventToVideo(evt);
if (vid.invalid) {
// Accumulate if invalid
invalidNotes.push({ id: vid.id, reason: vid.reason });
} else {
// Only add if good
localAll.set(evt.id, vid);
}
}
})
);
// Merge into allEvents
for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid);
}
// Rebuild activeMap
this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) {
if (video.deleted) continue;
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
// OPTIONAL: Log invalid stats
if (invalidNotes.length > 0 && isDevMode) {
console.warn(
`Skipped ${invalidNotes.length} invalid v2 notes:\n`,
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
);
}
const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
return activeVideos;
} catch (err) {
console.error("fetchVideos error:", err);
return [];
}
}
/**
* getEventById => old approach
*/
async getEventById(eventId) {
const local = this.allEvents.get(eventId);
if (local) {
return local;
}
try {
for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
if (maybeEvt && maybeEvt.id === eventId) {
const video = convertEventToVideo(maybeEvt);
this.allEvents.set(eventId, video);
return video;
}
}
} catch (err) {
if (isDevMode) {
console.error("getEventById direct fetch error:", err);
}
}
return null;
}
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
}
export const nostrClient = new NostrClient();

46
js/sidebar.js Normal file
View File

@@ -0,0 +1,46 @@
import { loadView } from "./viewManager.js";
/**
* Wire up the sidebar links.
* Home => loads the "most-recent-videos" partial and re-renders videos
* Explore => loads explore.html with a "Coming Soon" message
* Subscriptions => loads subscriptions.html with a "Coming Soon" message
*/
export function setupSidebarNavigation() {
// 1) Home
const homeLink = document.querySelector('a[href="#view=most-recent-videos"]');
if (homeLink) {
homeLink.addEventListener("click", (e) => {
e.preventDefault();
loadView("views/most-recent-videos.html").then(() => {
// Once the partial is loaded, reassign #videoList + call loadVideos
if (window.app && window.app.loadVideos) {
window.app.videoList = document.getElementById("videoList");
window.app.loadVideos();
}
});
});
}
// 2) Explore
const exploreLink = document.querySelector('a[href="#view=explore"]');
if (exploreLink) {
exploreLink.addEventListener("click", (e) => {
e.preventDefault();
loadView("views/explore.html");
// We just show the partial. No dynamic videos needed yet.
});
}
// 3) Subscriptions
const subscriptionsLink = document.querySelector(
'a[href="#view=subscriptions"]'
);
if (subscriptionsLink) {
subscriptionsLink.addEventListener("click", (e) => {
e.preventDefault();
loadView("views/subscriptions.html");
// Also "Coming Soon" in that partial for now.
});
}
}

17
js/viewManager.js Normal file
View File

@@ -0,0 +1,17 @@
// js/viewManager.js
// Load a partial view by URL into the #viewContainer
export async function loadView(viewUrl) {
try {
const res = await fetch(viewUrl);
if (!res.ok) {
throw new Error(`Failed to load view: ${res.status}`);
}
const html = await res.text();
document.getElementById("viewContainer").innerHTML = html;
} catch (err) {
console.error("View loading error:", err);
document.getElementById("viewContainer").innerHTML =
"<p class='text-center text-red-500'>Failed to load content.</p>";
}
}

315
js/webtorrent.js Normal file
View File

@@ -0,0 +1,315 @@
import WebTorrent from "./webtorrent.min.js";
export class TorrentClient {
constructor() {
this.client = null; // Do NOT instantiate right away
this.currentTorrent = null;
this.TIMEOUT_DURATION = 60000; // 60 seconds
}
log(msg) {
console.log(msg);
}
async isBrave() {
return (
(navigator.brave?.isBrave && (await navigator.brave.isBrave())) || false
);
}
isFirefox() {
return /firefox/i.test(window.navigator.userAgent);
}
async waitForServiceWorkerActivation(registration) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Service worker activation timeout"));
}, this.TIMEOUT_DURATION);
this.log("Waiting for service worker activation...");
const checkActivation = () => {
if (registration.active) {
clearTimeout(timeout);
this.log("Service worker is active");
resolve(registration);
return true;
}
return false;
};
if (checkActivation()) return;
registration.addEventListener("activate", () => {
checkActivation();
});
if (registration.waiting) {
this.log("Service worker is waiting, sending skip waiting message");
registration.waiting.postMessage({ type: "SKIP_WAITING" });
}
registration.addEventListener("statechange", () => {
checkActivation();
});
});
}
async setupServiceWorker() {
try {
const isBraveBrowser = await this.isBrave();
if (!window.isSecureContext) {
throw new Error("HTTPS or localhost required");
}
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
throw new Error("Service Worker not supported or disabled");
}
if (isBraveBrowser) {
this.log("Checking Brave configuration...");
if (!navigator.serviceWorker) {
throw new Error(
"Please enable Service Workers in Brave Shield settings"
);
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Please enable WebRTC in Brave Shield settings");
}
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
this.log("Registering service worker at /sw.min.js...");
const registration = await navigator.serviceWorker.register(
"/sw.min.js",
{
scope: "/",
updateViaCache: "none",
}
);
this.log("Service worker registered");
if (registration.installing) {
this.log("Waiting for installation...");
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Installation timeout"));
}, this.TIMEOUT_DURATION);
registration.installing.addEventListener("statechange", (e) => {
this.log("Service worker state:", e.target.state);
if (
e.target.state === "activated" ||
e.target.state === "redundant"
) {
clearTimeout(timeout);
resolve();
}
});
});
}
await this.waitForServiceWorkerActivation(registration);
this.log("Service worker activated");
const readyRegistration = await Promise.race([
navigator.serviceWorker.ready,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Service worker ready timeout")),
this.TIMEOUT_DURATION
)
),
]);
if (!readyRegistration.active) {
throw new Error("Service worker not active after ready state");
}
// Force the SW to check for updates
registration.update();
this.log("Service worker ready");
return registration;
} catch (error) {
this.log("Service worker setup error:", error);
throw error;
}
}
// Minimal handleChromeTorrent
handleChromeTorrent(torrent, videoElement, resolve, reject) {
torrent.on("warning", (err) => {
if (err && typeof err.message === "string") {
if (
err.message.includes("CORS") ||
err.message.includes("Access-Control-Allow-Origin")
) {
console.warn(
"CORS warning detected. Attempting to remove the failing webseed/tracker."
);
if (torrent._opts?.urlList?.length) {
torrent._opts.urlList = torrent._opts.urlList.filter((url) => {
return !url.includes("distribution.bbb3d.renderfarming.net");
});
console.warn("Cleaned up webseeds =>", torrent._opts.urlList);
}
if (torrent._opts?.announce?.length) {
torrent._opts.announce = torrent._opts.announce.filter((url) => {
return !url.includes("fastcast.nz");
});
console.warn("Cleaned up trackers =>", torrent._opts.announce);
}
}
}
});
const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name));
if (!file) {
return reject(new Error("No compatible video file found in torrent"));
}
videoElement.muted = false;
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
this.log("Video error:", e.target.error);
});
videoElement.addEventListener("canplay", () => {
videoElement.play().catch((err) => {
this.log("Autoplay failed:", err);
});
});
try {
file.streamTo(videoElement);
this.currentTorrent = torrent;
resolve(torrent);
} catch (err) {
this.log("Streaming error (Chrome path):", err);
reject(err);
}
torrent.on("error", (err) => {
this.log("Torrent error (Chrome path):", err);
reject(err);
});
}
// Minimal handleFirefoxTorrent
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
const file = torrent.files.find((f) =>
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
);
if (!file) {
return reject(new Error("No compatible video file found in torrent"));
}
videoElement.muted = true;
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
this.log("Video error (Firefox path):", e.target.error);
});
videoElement.addEventListener("canplay", () => {
videoElement.play().catch((err) => {
this.log("Autoplay failed:", err);
});
});
try {
file.streamTo(videoElement, { highWaterMark: 32 * 1024 });
this.currentTorrent = torrent;
resolve(torrent);
} catch (err) {
this.log("Streaming error (Firefox path):", err);
reject(err);
}
torrent.on("error", (err) => {
this.log("Torrent error (Firefox path):", err);
reject(err);
});
}
/**
* Initiates streaming of a torrent magnet to a <video> element.
* Ensures the service worker is registered first.
*/
async streamVideo(magnetURI, videoElement) {
try {
// 1) Instantiate client on-demand:
if (!this.client) {
this.client = new WebTorrent();
}
// 2) Setup service worker
const registration = await this.setupServiceWorker();
if (!registration || !registration.active) {
throw new Error("Service worker setup failed");
}
// 3) Create the WebTorrent server with the registered service worker.
// Force the server to use '/webtorrent' as the URL prefix.
this.client.createServer({
controller: registration,
pathPrefix: location.origin + "/webtorrent",
});
this.log("WebTorrent server created");
const isFirefoxBrowser = this.isFirefox();
return new Promise((resolve, reject) => {
if (isFirefoxBrowser) {
this.log("Starting torrent download (Firefox path)");
this.client.add(
magnetURI,
{ strategy: "sequential", maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
}
);
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
});
}
});
} catch (error) {
this.log("Failed to setup video streaming:", error);
throw error;
}
}
/**
* Clean up resources.
*/
async cleanup() {
try {
if (this.currentTorrent) {
this.currentTorrent.destroy();
}
// Destroy client entirely and set to null
if (this.client) {
await this.client.destroy();
this.client = null;
}
} catch (error) {
this.log("Cleanup error:", error);
}
}
}
export const torrentClient = new TorrentClient();

2
js/webtorrent.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/webtorrent.min.js.map Normal file

File diff suppressed because one or more lines are too long