added v2 note restrictions to enable upgrade path from v2 onward.

This commit is contained in:
Keep Creating Online
2025-02-04 00:23:37 -05:00
parent 139e279e35
commit 80d90387b8
12 changed files with 208 additions and 195 deletions

View File

@@ -1,4 +1,3 @@
{ {
"liveServer.settings.root": "/src" "liveServer.settings.root": "./src"
} }

View File

@@ -67,7 +67,7 @@
<div class="p-6"> <div class="p-6">
<div class="w-full" style="height: 80vh"> <div class="w-full" style="height: 80vh">
<iframe <iframe
src="components/iframe_forms/iframe-application-form.html" src="./components/iframe_forms/iframe-application-form.html"
class="w-full h-full" class="w-full h-full"
frameborder="0" frameborder="0"
style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)" style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)"

View File

@@ -56,7 +56,7 @@
<div class="p-6"> <div class="p-6">
<div class="w-full" style="height: 80vh"> <div class="w-full" style="height: 80vh">
<iframe <iframe
src="/components/iframe_forms/iframe-bug-fix-form.html" src="./components/iframe_forms/iframe-bug-fix-form.html"
class="w-full h-full" class="w-full h-full"
frameborder="0" frameborder="0"
style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)" style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)"

View File

@@ -53,7 +53,7 @@
<div class="p-6"> <div class="p-6">
<div class="w-full" style="height: 80vh"> <div class="w-full" style="height: 80vh">
<iframe <iframe
src="/components/iframe_forms/iframe-content-appeals-form.html" src="./components/iframe_forms/iframe-content-appeals-form.html"
class="w-full h-full" class="w-full h-full"
frameborder="0" frameborder="0"
style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)" style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)"

View File

@@ -53,7 +53,7 @@
<div class="p-6"> <div class="p-6">
<div class="w-full" style="height: 80vh"> <div class="w-full" style="height: 80vh">
<iframe <iframe
src="/components/iframe_forms/iframe-request-form.html" src="./components/iframe_forms/iframe-request-form.html"
class="w-full h-full" class="w-full h-full"
frameborder="0" frameborder="0"
style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)" style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)"

View File

@@ -57,7 +57,7 @@
<div class="p-6"> <div class="p-6">
<div class="w-full" style="height: 80vh"> <div class="w-full" style="height: 80vh">
<iframe <iframe
src="/components/iframe_forms/iframe-feedback-form.html" src="./components/iframe_forms/iframe-feedback-form.html"
class="w-full h-full" class="w-full h-full"
frameborder="0" frameborder="0"
style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)" style="border: none; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2)"

View File

@@ -633,6 +633,10 @@
<!-- Other Scripts --> <!-- Other Scripts -->
<script src="js/libs/nostr.bundle.js"></script> <script src="js/libs/nostr.bundle.js"></script>
<script type="module">
import { nip19, SimplePool } from "https://esm.sh/nostr-tools@1.8.3";
window.NostrTools = { nip19, SimplePool };
</script>
<script type="module" src="js/config.js"></script> <script type="module" src="js/config.js"></script>
<script type="module" src="js/lists.js"></script> <script type="module" src="js/lists.js"></script>
<script type="module" src="js/accessControl.js"></script> <script type="module" src="js/accessControl.js"></script>

View File

@@ -99,6 +99,13 @@ class bitvidApp {
async init() { async init() {
try { try {
// Force update of any registered service workers to ensure latest code is used.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.update());
});
}
// 1. Initialize the video modal (components/video-modal.html) // 1. Initialize the video modal (components/video-modal.html)
await this.initModal(); await this.initModal();
this.updateModalElements(); this.updateModalElements();
@@ -766,7 +773,7 @@ class bitvidApp {
if (!videos || videos.length === 0) { if (!videos || videos.length === 0) {
this.videoList.innerHTML = ` this.videoList.innerHTML = `
<p class="text-center text-gray-500"> <p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No public videos available yet. Be the first to upload one! No public videos available yet. Be the first to upload one!
</p>`; </p>`;
return; return;
@@ -1175,6 +1182,10 @@ class bitvidApp {
// 8) Refresh local UI // 8) Refresh local UI
await this.loadVideos(); await this.loadVideos();
// 8.1) Purge the outdated cache
this.videosMap.clear();
this.showSuccess("Video updated successfully!"); this.showSuccess("Video updated successfully!");
// 9) Also refresh all profile caches so any new name/pic changes are reflected // 9) Also refresh all profile caches so any new name/pic changes are reflected
@@ -1315,18 +1326,15 @@ class bitvidApp {
try { try {
// 1) Check local subscription map // 1) Check local subscription map
let video = this.videosMap.get(eventId); let video = this.videosMap.get(eventId);
// 2) If not in local map, attempt fallback fetch from getOldEventById // 2) If not in local map, attempt fallback fetch from getOldEventById
if (!video) { if (!video) {
video = await this.getOldEventById(eventId); video = await this.getOldEventById(eventId);
} }
// 3) If still no luck, show error and return // 3) If still no luck, show error and return
if (!video) { if (!video) {
this.showError("Video not found."); this.showError("Video not found.");
return; return;
} }
// 4) Decrypt magnet if private & owned // 4) Decrypt magnet if private & owned
if ( if (
video.isPrivate && video.isPrivate &&
@@ -1337,18 +1345,15 @@ class bitvidApp {
video.magnet = fakeDecrypt(video.magnet); video.magnet = fakeDecrypt(video.magnet);
video.alreadyDecrypted = true; video.alreadyDecrypted = true;
} }
// 5) Show the modal // 5) Show the modal
this.currentVideo = video; this.currentVideo = video;
this.currentMagnetUri = video.magnet; this.currentMagnetUri = video.magnet;
this.showModalWithPoster(); this.showModalWithPoster();
// 6) Update ?v= param in the URL // 6) Update ?v= param in the URL
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId }); const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const newUrl = const newUrl =
window.location.pathname + `?v=${encodeURIComponent(nevent)}`; window.location.pathname + `?v=${encodeURIComponent(nevent)}`;
window.history.pushState({}, "", newUrl); window.history.pushState({}, "", newUrl);
// 7) Optionally fetch the author profile // 7) Optionally fetch the author profile
let creatorProfile = { let creatorProfile = {
name: "Unknown", name: "Unknown",
@@ -1368,7 +1373,6 @@ class bitvidApp {
} catch (error) { } catch (error) {
this.log("Error fetching creator profile:", error); this.log("Error fetching creator profile:", error);
} }
// 8) Render video details in modal // 8) Render video details in modal
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (this.videoTitle) if (this.videoTitle)
@@ -1393,15 +1397,16 @@ class bitvidApp {
this.creatorAvatar.src = creatorProfile.picture; this.creatorAvatar.src = creatorProfile.picture;
this.creatorAvatar.alt = creatorProfile.name; this.creatorAvatar.alt = creatorProfile.name;
} }
// 9) Clean up any existing torrent instance before starting a new stream.
// 9) Stream torrent await torrentClient.cleanup();
this.log("Starting video stream with:", video.magnet); // 10) Append a cache-busting parameter to the magnet URI.
const cacheBustedMagnet = video.magnet + "&ts=" + Date.now();
this.log("Starting video stream with:", cacheBustedMagnet);
const realTorrent = await torrentClient.streamVideo( const realTorrent = await torrentClient.streamVideo(
video.magnet, cacheBustedMagnet,
this.modalVideo this.modalVideo
); );
// 11) Start intervals to update stats
// 10) Start intervals to update stats
const updateInterval = setInterval(() => { const updateInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) { if (!document.body.contains(this.modalVideo)) {
clearInterval(updateInterval); clearInterval(updateInterval);
@@ -1410,7 +1415,6 @@ class bitvidApp {
this.updateTorrentStatus(realTorrent); this.updateTorrentStatus(realTorrent);
}, 1000); }, 1000);
this.activeIntervals.push(updateInterval); this.activeIntervals.push(updateInterval);
// (Optional) Mirror small inline stats into the modal // (Optional) Mirror small inline stats into the modal
const mirrorInterval = setInterval(() => { const mirrorInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) { if (!document.body.contains(this.modalVideo)) {

View File

@@ -10,6 +10,7 @@ const npubs = [
"npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney "npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney
"npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro "npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster "npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
]; ];
console.log("DEBUG: lists.js loaded, npubs:", npubs); console.log("DEBUG: lists.js loaded, npubs:", npubs);

View File

@@ -3,15 +3,18 @@
import { isDevMode } from "./config.js"; import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js"; import { accessControl } from "./accessControl.js";
/**
* The usual relays
*/
const RELAY_URLS = [ const RELAY_URLS = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://nos.lol", "wss://nos.lol",
"wss://relay.snort.social", "wss://relay.snort.social",
"wss://nostr.wine", "wss://relay.primal.net",
"wss://relay.nostr.band", "wss://relay.nostr.band",
]; ];
// Just a helper to keep error spam in check // To limit error spam
let errorLogCount = 0; let errorLogCount = 0;
const MAX_ERROR_LOGS = 100; const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) { function logErrorOnce(message, eventContent = null) {
@@ -31,7 +34,7 @@ function logErrorOnce(message, eventContent = null) {
/** /**
* Example "encryption" that just reverses strings. * Example "encryption" that just reverses strings.
* In real usage, swap with actual crypto. * In real usage, replace with actual crypto.
*/ */
function fakeEncrypt(magnet) { function fakeEncrypt(magnet) {
return magnet.split("").reverse().join(""); return magnet.split("").reverse().join("");
@@ -42,39 +45,61 @@ function fakeDecrypt(encrypted) {
/** /**
* Convert a raw Nostr event => your "video" object. * Convert a raw Nostr event => your "video" object.
* CHANGED: skip if version <2
*/ */
function convertEventToVideo(event) { function convertEventToVideo(event) {
const content = JSON.parse(event.content || "{}"); try {
return { const content = JSON.parse(event.content || "{}");
id: event.id,
// If content.videoRootId is missing, use event.id as a fallback // Example checks:
videoRootId: content.videoRootId || event.id, const isSupportedVersion = content.version >= 2;
version: content.version ?? 1, const hasRequiredFields = !!(content.title && content.magnet);
isPrivate: content.isPrivate ?? false,
title: content.title || "", if (!isSupportedVersion) {
magnet: content.magnet || "", return {
thumbnail: content.thumbnail || "", id: event.id,
description: content.description || "", invalid: true,
mode: content.mode || "live", reason: "version <2",
deleted: content.deleted === true, };
pubkey: event.pubkey, }
created_at: event.created_at, if (!hasRequiredFields) {
tags: event.tags, 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" };
}
} }
/** /**
* Key each "active" video by its root ID => so you only store * If the video has videoRootId => use that as the “group key”.
* the newest version for each root. But for older events w/o videoRootId, * Otherwise fallback to (pubkey + dTag), or if no dTag => “LEGACY:id”
* or w/o 'd' tag, we handle fallback logic below.
*/ */
function getActiveKey(video) { function getActiveKey(video) {
// If it has a videoRootId, we use that
if (video.videoRootId) { if (video.videoRootId) {
return `ROOT:${video.videoRootId}`; return `ROOT:${video.videoRootId}`;
} }
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
// This is a fallback approach so older events appear in the "active map".
const dTag = video.tags?.find((t) => t[0] === "d"); const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) { if (dTag) {
return `${video.pubkey}:${dTag[1]}`; return `${video.pubkey}:${dTag[1]}`;
@@ -88,15 +113,15 @@ class NostrClient {
this.pubkey = null; this.pubkey = null;
this.relays = RELAY_URLS; this.relays = RELAY_URLS;
// All events—old or new—so older share links still work // Store all events so older links still work
this.allEvents = new Map(); this.allEvents = new Map();
// "activeMap" holds only the newest version for each root ID (or fallback). // activeMap holds only the newest version for each root
this.activeMap = new Map(); this.activeMap = new Map();
} }
/** /**
* Connect to all configured relays * Connect to the configured relays
*/ */
async init() { async init() {
if (isDevMode) console.log("Connecting to relays..."); if (isDevMode) console.log("Connecting to relays...");
@@ -107,7 +132,9 @@ class NostrClient {
const successfulRelays = results const successfulRelays = results
.filter((r) => r.success) .filter((r) => r.success)
.map((r) => r.url); .map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected"); if (successfulRelays.length === 0) {
throw new Error("No relays connected");
}
if (isDevMode) { if (isDevMode) {
console.log(`Connected to ${successfulRelays.length} relay(s)`); console.log(`Connected to ${successfulRelays.length} relay(s)`);
} }
@@ -133,7 +160,6 @@ class NostrClient {
sub.unsub(); sub.unsub();
resolve({ url, success: true }); resolve({ url, success: true });
}; };
sub.on("event", succeed); sub.on("event", succeed);
sub.on("eose", succeed); sub.on("eose", succeed);
}) })
@@ -142,7 +168,7 @@ class NostrClient {
} }
/** /**
* Attempt Nostr extension login or abort * Attempt login with a Nostr extension
*/ */
async login() { async login() {
try { try {
@@ -152,7 +178,6 @@ class NostrClient {
"Please install a Nostr extension (Alby, nos2x, etc.)." "Please install a Nostr extension (Alby, nos2x, etc.)."
); );
} }
const pubkey = await window.nostr.getPublicKey(); const pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey); const npub = window.NostrTools.nip19.npubEncode(pubkey);
@@ -162,8 +187,7 @@ class NostrClient {
console.log("Whitelist:", accessControl.getWhitelist()); console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist()); console.log("Blacklist:", accessControl.getBlacklist());
} }
// Access control
// Access control check
if (!accessControl.canAccess(npub)) { if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) { if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform."); throw new Error("Your account has been blocked on this platform.");
@@ -171,15 +195,14 @@ class NostrClient {
throw new Error("Access restricted to whitelisted users only."); throw new Error("Access restricted to whitelisted users only.");
} }
} }
this.pubkey = pubkey; this.pubkey = pubkey;
if (isDevMode) { if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey); console.log("Logged in with extension. Pubkey:", this.pubkey);
} }
return this.pubkey; return this.pubkey;
} catch (e) { } catch (err) {
console.error("Login error:", e); console.error("Login error:", err);
throw e; throw err;
} }
} }
@@ -188,17 +211,9 @@ class NostrClient {
if (isDevMode) console.log("User logged out."); if (isDevMode) console.log("User logged out.");
} }
decodeNsec(nsec) {
try {
const { data } = window.NostrTools.nip19.decode(nsec);
return data;
} catch (error) {
throw new Error("Invalid NSEC key.");
}
}
/** /**
* Publish a *new* video with a brand-new d tag & brand-new videoRootId * Publish a new video
* CHANGED: Force version=2 for all new notes
*/ */
async publishVideo(videoData, pubkey) { async publishVideo(videoData, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video."); if (!pubkey) throw new Error("Not logged in to publish video.");
@@ -212,13 +227,13 @@ class NostrClient {
finalMagnet = fakeEncrypt(finalMagnet); finalMagnet = fakeEncrypt(finalMagnet);
} }
// new "videoRootId" ensures all future edits know they're from the same root // brand-new root & d
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const contentObject = { const contentObject = {
videoRootId, videoRootId,
version: videoData.version ?? 1, version: 2, // forcibly set version=2
deleted: false, deleted: false,
isPrivate: videoData.isPrivate ?? false, isPrivate: videoData.isPrivate ?? false,
title: videoData.title || "", title: videoData.title || "",
@@ -258,7 +273,6 @@ class NostrClient {
} }
}) })
); );
return signedEvent; return signedEvent;
} catch (err) { } catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err); if (isDevMode) console.error("Failed to sign/publish:", err);
@@ -267,11 +281,7 @@ class NostrClient {
} }
/** /**
* Edits a video by creating a *new event* with a brand-new d tag, * Edits a video => old style
* but reuses the same videoRootId as the original.
*
* => old link remains pinned to the old event, new link is a fresh ID.
* => older version is overshadowed if your dedupe logic only shows newest.
*/ */
async editVideo(originalEventStub, updatedData, pubkey) { async editVideo(originalEventStub, updatedData, pubkey) {
if (!pubkey) { if (!pubkey) {
@@ -281,9 +291,7 @@ class NostrClient {
throw new Error("You do not own this video (pubkey mismatch)."); throw new Error("You do not own this video (pubkey mismatch).");
} }
// 1) Attempt to get the FULL old event details (especially videoRootId)
let baseEvent = originalEventStub; let baseEvent = originalEventStub;
// If the caller didn't pass .videoRootId, fetch from local or relay:
if (!baseEvent.videoRootId) { if (!baseEvent.videoRootId) {
const fetched = await this.getEventById(originalEventStub.id); const fetched = await this.getEventById(originalEventStub.id);
if (!fetched) { if (!fetched) {
@@ -292,32 +300,25 @@ class NostrClient {
baseEvent = fetched; baseEvent = fetched;
} }
// 2) We now have baseEvent.videoRootId if it existed
let oldRootId = baseEvent.videoRootId || null; let oldRootId = baseEvent.videoRootId || null;
// Decrypt the old magnet if it was private // Decrypt old magnet if private
let oldPlainMagnet = baseEvent.magnet || ""; let oldPlainMagnet = baseEvent.magnet || "";
if (baseEvent.isPrivate && oldPlainMagnet) { if (baseEvent.isPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet); oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
} }
// 3) Decide new privacy
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false; const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
// 4) Fallback to old magnet if none was provided
let finalPlainMagnet = (updatedData.magnet || "").trim(); let finalPlainMagnet = (updatedData.magnet || "").trim();
if (!finalPlainMagnet) { if (!finalPlainMagnet) {
finalPlainMagnet = oldPlainMagnet; finalPlainMagnet = oldPlainMagnet;
} }
// 5) Re-encrypt if user wants private
let finalMagnet = finalPlainMagnet; let finalMagnet = finalPlainMagnet;
if (wantPrivate) { if (wantPrivate) {
finalMagnet = fakeEncrypt(finalPlainMagnet); finalMagnet = fakeEncrypt(finalPlainMagnet);
} }
// 6) If there's no root yet (legacy), use the old event's own ID.
// Otherwise keep the existing rootId.
if (!oldRootId) { if (!oldRootId) {
oldRootId = baseEvent.id; oldRootId = baseEvent.id;
if (isDevMode) { if (isDevMode) {
@@ -328,10 +329,8 @@ class NostrClient {
} }
} }
// Generate a brand-new d-tag so it doesn't overshadow the old share link
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`; const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
// 7) Build updated content
const contentObject = { const contentObject = {
videoRootId: oldRootId, videoRootId: oldRootId,
version: updatedData.version ?? baseEvent.version ?? 1, version: updatedData.version ?? baseEvent.version ?? 1,
@@ -350,7 +349,7 @@ class NostrClient {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["t", "video"], ["t", "video"],
["d", newD], // new share link ["d", newD],
], ],
content: JSON.stringify(contentObject), content: JSON.stringify(contentObject),
}; };
@@ -360,7 +359,6 @@ class NostrClient {
console.log("Event content:", event.content); console.log("Event content:", event.content);
} }
// 8) Sign and publish the new event
try { try {
const signedEvent = await window.nostr.signEvent(event); const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) { if (isDevMode) {
@@ -389,7 +387,7 @@ class NostrClient {
} }
/** /**
* "Reverting" => we just mark the most recent content as {deleted:true} and blank out magnet/desc * revertVideo => old style
*/ */
async revertVideo(originalEvent, pubkey) { async revertVideo(originalEvent, pubkey) {
if (!pubkey) { if (!pubkey) {
@@ -399,7 +397,6 @@ class NostrClient {
throw new Error("Not your event (pubkey mismatch)."); throw new Error("Not your event (pubkey mismatch).");
} }
// If front-end didn't pass the tags array, load the full event:
let baseEvent = originalEvent; let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) { if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id); const fetched = await this.getEventById(originalEvent.id);
@@ -423,7 +420,6 @@ class NostrClient {
}; };
} }
// Check d-tag
const dTag = baseEvent.tags.find((t) => t[0] === "d"); const dTag = baseEvent.tags.find((t) => t[0] === "d");
if (!dTag) { if (!dTag) {
throw new Error( throw new Error(
@@ -435,17 +431,15 @@ class NostrClient {
const oldContent = JSON.parse(baseEvent.content || "{}"); const oldContent = JSON.parse(baseEvent.content || "{}");
const oldVersion = oldContent.version ?? 1; const oldVersion = oldContent.version ?? 1;
// If no root, fallback
let finalRootId = oldContent.videoRootId || null; let finalRootId = oldContent.videoRootId || null;
if (!finalRootId) { if (!finalRootId) {
finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`; finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
} }
// Build “deleted: true” overshadow event => revert current version
const contentObject = { const contentObject = {
videoRootId: finalRootId, videoRootId: finalRootId,
version: oldVersion, version: oldVersion,
deleted: true, // mark *this version* as deleted deleted: true,
isPrivate: oldContent.isPrivate ?? false, isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "", title: oldContent.title || "",
magnet: "", magnet: "",
@@ -460,7 +454,7 @@ class NostrClient {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["t", "video"], ["t", "video"],
["d", existingD], // re-use same d => overshadow ["d", existingD],
], ],
content: JSON.stringify(contentObject), content: JSON.stringify(contentObject),
}; };
@@ -471,9 +465,7 @@ class NostrClient {
try { try {
await this.pool.publish([url], signedEvent); await this.pool.publish([url], signedEvent);
} catch (err) { } catch (err) {
if (isDevMode) { if (isDevMode) console.error(`Failed to revert on ${url}`, err);
console.error(`Failed to revert on ${url}`, err);
}
} }
}) })
); );
@@ -482,15 +474,13 @@ class NostrClient {
} }
/** /**
* "Deleting" => we just mark all content with the same videoRootId as {deleted:true} and blank out magnet/desc * deleteAllVersions => old style
*/ */
async deleteAllVersions(videoRootId, pubkey) { async deleteAllVersions(videoRootId, pubkey) {
if (!pubkey) { if (!pubkey) {
throw new Error("Not logged in to delete all versions."); throw new Error("Not logged in to delete all versions.");
} }
// 1) Find all events in our local allEvents that share the same root.
const matchingEvents = []; const matchingEvents = [];
for (const [id, vid] of this.allEvents.entries()) { for (const [id, vid] of this.allEvents.entries()) {
if ( if (
@@ -501,19 +491,13 @@ class NostrClient {
matchingEvents.push(vid); matchingEvents.push(vid);
} }
} }
// If you want to re-check the relay for older versions too,
// you can do a fallback query, but typically your local cache is enough.
if (!matchingEvents.length) { if (!matchingEvents.length) {
throw new Error("No existing events found for that root."); throw new Error("No existing events found for that root.");
} }
// 2) For each event, create a "deleted: true" overshadow
// by re-using the same d-tag so it cannot appear again.
for (const vid of matchingEvents) { for (const vid of matchingEvents) {
await this.revertVideo( await this.revertVideo(
{ {
// re-using revertVideo logic
id: vid.id, id: vid.id,
pubkey: vid.pubkey, pubkey: vid.pubkey,
content: JSON.stringify({ content: JSON.stringify({
@@ -531,15 +515,11 @@ class NostrClient {
pubkey pubkey
); );
} }
// Optionally return some status
return true; return true;
} }
/** /**
* Subscribes to *all* video events. We store them in this.allEvents so older * subscribeVideos => old approach
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
* version of each root (or fallback).
*/ */
subscribeVideos(onVideo) { subscribeVideos(onVideo) {
const filter = { const filter = {
@@ -553,37 +533,28 @@ class NostrClient {
} }
const sub = this.pool.sub(this.relays, [filter]); const sub = this.pool.sub(this.relays, [filter]);
// Accumulate invalid
const invalidDuringSub = [];
sub.on("event", (event) => { sub.on("event", (event) => {
try { try {
const video = convertEventToVideo(event); const video = convertEventToVideo(event);
this.allEvents.set(event.id, video); if (video.invalid) {
invalidDuringSub.push({ id: video.id, reason: video.reason });
// If its marked deleted, remove from active map if its the active version return;
// NEW CODE }
if (video.deleted) { // normal logic here
const activeKey = getActiveKey(video); this.allEvents.set(event.id, video);
// Don't compare IDs—just remove that key from the active map if (video.deleted) {
this.activeMap.delete(activeKey); const activeKey = getActiveKey(video);
this.activeMap.delete(activeKey);
// (Optional) If you want a debug log:
// console.log(`[DELETE] Removed activeKey=${activeKey}`);
return; return;
} }
// Not deleted => see if its the newest
const activeKey = getActiveKey(video); const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey); const prevActive = this.activeMap.get(activeKey);
if (!prevActive) { if (!prevActive || video.created_at > prevActive.created_at) {
// brand new => set it
this.activeMap.set(activeKey, video); this.activeMap.set(activeKey, video);
onVideo(video); onVideo(video);
} else {
// compare timestamps
if (video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video);
}
} }
} catch (err) { } catch (err) {
if (isDevMode) { if (isDevMode) {
@@ -593,6 +564,12 @@ class NostrClient {
}); });
sub.on("eose", () => { sub.on("eose", () => {
if (isDevMode && invalidDuringSub.length > 0) {
console.warn(
`[subscribeVideos] found ${invalidDuringSub.length} invalid v2 notes:`,
invalidDuringSub
);
}
if (isDevMode) { if (isDevMode) {
console.log("[subscribeVideos] Reached EOSE for all relays"); console.log("[subscribeVideos] Reached EOSE for all relays");
} }
@@ -602,7 +579,7 @@ class NostrClient {
} }
/** /**
* Bulk fetch from all relays, store in allEvents, rebuild activeMap * fetchVideos => old approach
*/ */
async fetchVideos() { async fetchVideos() {
const filter = { const filter = {
@@ -613,39 +590,51 @@ class NostrClient {
}; };
const localAll = new Map(); const localAll = new Map();
// NEW: track invalid
const invalidNotes = [];
try { try {
// 1) Fetch all events from each relay
await Promise.all( await Promise.all(
this.relays.map(async (url) => { this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]); const events = await this.pool.list([url], [filter]);
for (const evt of events) { for (const evt of events) {
const vid = convertEventToVideo(evt); const vid = convertEventToVideo(evt);
localAll.set(evt.id, vid); if (vid.invalid) {
// Accumulate if invalid
invalidNotes.push({ id: vid.id, reason: vid.reason });
} else {
// Only add if good
localAll.set(evt.id, vid);
}
} }
}) })
); );
// 2) Merge into this.allEvents // Merge into allEvents
for (const [id, vid] of localAll.entries()) { for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid); this.allEvents.set(id, vid);
} }
// 3) Rebuild activeMap // Rebuild activeMap
this.activeMap.clear(); this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) { for (const [id, video] of this.allEvents.entries()) {
// Skip if the video is marked deleted
if (video.deleted) continue; if (video.deleted) continue;
const activeKey = getActiveKey(video); const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey); const existing = this.activeMap.get(activeKey);
// If there's no existing entry or this is newer, set/replace
if (!existing || video.created_at > existing.created_at) { if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video); this.activeMap.set(activeKey, video);
} }
} }
// 4) Return newest version for each root in descending order // 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( const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at (a, b) => b.created_at - a.created_at
); );
@@ -657,14 +646,13 @@ class NostrClient {
} }
/** /**
* Attempt to fetch an event by ID from local cache, then from the relays * getEventById => old approach
*/ */
async getEventById(eventId) { async getEventById(eventId) {
const local = this.allEvents.get(eventId); const local = this.allEvents.get(eventId);
if (local) { if (local) {
return local; return local;
} }
// direct fetch if missing
try { try {
for (const url of this.relays) { for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] }); const maybeEvt = await this.pool.get([url], { ids: [eventId] });
@@ -679,12 +667,9 @@ class NostrClient {
console.error("getEventById direct fetch error:", err); console.error("getEventById direct fetch error:", err);
} }
} }
return null; // not found return null;
} }
/**
* Return newest versions from activeMap if you want to skip older events
*/
getActiveVideos() { getActiveVideos() {
return Array.from(this.activeMap.values()).sort( return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at (a, b) => b.created_at - a.created_at

View File

@@ -1,5 +1,3 @@
// js/webtorrent.js
import WebTorrent from "./webtorrent.min.js"; import WebTorrent from "./webtorrent.min.js";
export class TorrentClient { export class TorrentClient {
@@ -41,7 +39,6 @@ export class TorrentClient {
return false; return false;
}; };
// If it's already active, resolve immediately
if (checkActivation()) return; if (checkActivation()) return;
registration.addEventListener("activate", () => { registration.addEventListener("activate", () => {
@@ -59,9 +56,6 @@ export class TorrentClient {
}); });
} }
// ------------------------------------------------------------------
// setupServiceWorker: Registers /sw.min.js at the root with scope "/"
// ------------------------------------------------------------------
async setupServiceWorker() { async setupServiceWorker() {
try { try {
const isBraveBrowser = await this.isBrave(); const isBraveBrowser = await this.isBrave();
@@ -73,31 +67,32 @@ export class TorrentClient {
throw new Error("Service Worker not supported or disabled"); throw new Error("Service Worker not supported or disabled");
} }
// (Optional) Brave config check
if (isBraveBrowser) { if (isBraveBrowser) {
this.log("Checking Brave configuration..."); this.log("Checking Brave configuration...");
if (!navigator.serviceWorker) { if (!navigator.serviceWorker) {
throw new Error("Please enable Service Workers in Brave Shield settings"); throw new Error(
"Please enable Service Workers in Brave Shield settings"
);
} }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Please enable WebRTC in Brave Shield settings"); throw new Error("Please enable WebRTC in Brave Shield settings");
} }
// Unregister any existing service workers
const registrations = await navigator.serviceWorker.getRegistrations(); const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) { for (const reg of registrations) {
await reg.unregister(); await reg.unregister();
} }
// Short delay to ensure old workers are removed
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} }
// Register sw.min.js from the root (Netlify serves it at /sw.min.js)
this.log("Registering service worker at /sw.min.js..."); this.log("Registering service worker at /sw.min.js...");
const registration = await navigator.serviceWorker.register("/sw.min.js", { const registration = await navigator.serviceWorker.register(
scope: "/", "./sw.min.js",
updateViaCache: "none", {
}); scope: "./",
updateViaCache: "none",
}
);
this.log("Service worker registered"); this.log("Service worker registered");
if (registration.installing) { if (registration.installing) {
@@ -123,7 +118,6 @@ export class TorrentClient {
await this.waitForServiceWorkerActivation(registration); await this.waitForServiceWorkerActivation(registration);
this.log("Service worker activated"); this.log("Service worker activated");
// Ensure the service worker is fully ready
const readyRegistration = await Promise.race([ const readyRegistration = await Promise.race([
navigator.serviceWorker.ready, navigator.serviceWorker.ready,
new Promise((_, reject) => new Promise((_, reject) =>
@@ -138,6 +132,9 @@ export class TorrentClient {
throw new Error("Service worker not active after ready state"); throw new Error("Service worker not active after ready state");
} }
// Force the SW to check for updates
registration.update();
this.log("Service worker ready"); this.log("Service worker ready");
return registration; return registration;
} catch (error) { } catch (error) {
@@ -256,9 +253,11 @@ export class TorrentClient {
} }
// Create the WebTorrent server with the registered service worker. // Create the WebTorrent server with the registered service worker.
// (If you need to specify a custom URL prefix for torrent streaming, // Force the server to use '/webtorrent' as the URL prefix.
// pass a pathPrefix option here.) this.client.createServer({
this.client.createServer({ controller: registration }); controller: registration,
pathPrefix: "/webtorrent",
});
this.log("WebTorrent server created"); this.log("WebTorrent server created");
const isFirefoxBrowser = this.isFirefox(); const isFirefoxBrowser = this.isFirefox();

63
src/sw.min.js vendored
View File

@@ -3,15 +3,22 @@
let cancelled = false; let cancelled = false;
// Handle skip waiting message // Handle messages from clients
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") { if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting(); self.skipWaiting();
} }
if (event.data && event.data.type === "CLEAR_CACHES") {
caches
.keys()
.then((cacheNames) =>
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
);
}
}); });
// Immediately install and activate // Immediately install and skip waiting
self.addEventListener("install", () => { self.addEventListener("install", (event) => {
self.skipWaiting(); self.skipWaiting();
}); });
@@ -21,23 +28,24 @@
Promise.all([ Promise.all([
clients.claim(), clients.claim(),
self.skipWaiting(), self.skipWaiting(),
caches.keys().then((cacheNames) => caches
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))) .keys()
), .then((cacheNames) =>
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
),
]) ])
); );
}); });
// Handle fetch events // Handle fetch events
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
const responsePromise = (() => { const requestURL = event.request.url;
const requestURL = event.request.url; // Only handle WebTorrent streaming requests; let other requests proceed normally.
// Only handle WebTorrent streaming requests if (!requestURL.includes("/webtorrent/")) {
// Since our SW is registered with scope "/" the expected URL prefix is "/webtorrent/" return;
if (!requestURL.includes("/webtorrent/")) { }
return null;
}
const responsePromise = (async () => {
// Handle keepalive requests // Handle keepalive requests
if (requestURL.includes("/webtorrent/keepalive/")) { if (requestURL.includes("/webtorrent/keepalive/")) {
return new Response(); return new Response();
@@ -57,14 +65,10 @@
// Handle streaming requests // Handle streaming requests
return (async function ({ request }) { return (async function ({ request }) {
const { url, method, headers, destination } = request; const { url, method, headers, destination } = request;
// Get all window clients
const windowClients = await clients.matchAll({ const windowClients = await clients.matchAll({
type: "window", type: "window",
includeUncontrolled: true, includeUncontrolled: true,
}); });
// Create a message channel and wait for a response from a client
const [clientResponse, port] = await new Promise((resolve) => { const [clientResponse, port] = await new Promise((resolve) => {
for (const client of windowClients) { for (const client of windowClients) {
const channel = new MessageChannel(); const channel = new MessageChannel();
@@ -92,13 +96,26 @@
port.onmessage = null; port.onmessage = null;
}; };
// If the response is not a streaming request, return a normal response // Clone and update headers to prevent caching.
const responseHeaders = new Headers(clientResponse.headers);
responseHeaders.set(
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0"
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
// If the response is not a streaming request, return it directly.
if (clientResponse.body !== "STREAM") { if (clientResponse.body !== "STREAM") {
closeChannel(); closeChannel();
return new Response(clientResponse.body, clientResponse); return new Response(clientResponse.body, {
status: clientResponse.status,
statusText: clientResponse.statusText,
headers: responseHeaders,
});
} }
// Otherwise, handle streaming response using a ReadableStream // Otherwise, stream the response via a ReadableStream.
return new Response( return new Response(
new ReadableStream({ new ReadableStream({
pull(controller) { pull(controller) {
@@ -128,7 +145,11 @@
closeChannel(); closeChannel();
}, },
}), }),
clientResponse {
status: clientResponse.status,
statusText: clientResponse.statusText,
headers: responseHeaders,
}
); );
})(event); })(event);
})(); })();