diff --git a/refactoring/about.html b/refactoring/about.html new file mode 100644 index 0000000..327bd3d --- /dev/null +++ b/refactoring/about.html @@ -0,0 +1,193 @@ + + +
+ + ++ No public videos available yet. Be the first to upload one! +
`; + return; + } + + this.log("Processing filtered videos:", displayedVideos); + + displayedVideos.forEach((video, index) => { + this.log(`Video ${index} details:`, { + id: video.id, + title: video.title, + magnet: video.magnet, + isPrivate: video.isPrivate, + pubkey: video.pubkey, + created_at: video.created_at, + }); + }); + + // Now render only the displayedVideos + await this.renderVideoList(displayedVideos); + this.log(`Rendered ${displayedVideos.length} videos successfully`); + } catch (error) { + this.log("Failed to fetch videos:", error); + this.showError( + "An error occurred while loading videos. Please try again later." + ); + this.videoList.innerHTML = ` ++ No videos available at the moment. Please try again later. +
`; + } + } + + /** + * Renders the given list of videos. If a video is private and belongs to the user, + * highlight with a special border (e.g. border-yellow-500). + */ + async renderVideoList(videos) { + try { + console.log("RENDER VIDEO LIST - Start", { + videosReceived: videos, + videosCount: videos ? videos.length : "N/A", + videosType: typeof videos, + }); + + if (!videos) { + console.error("NO VIDEOS RECEIVED"); + this.videoList.innerHTML = `No videos found.
`; + return; + } + + const videoArray = Array.isArray(videos) ? videos : [videos]; + + if (videoArray.length === 0) { + console.error("VIDEO ARRAY IS EMPTY"); + this.videoList.innerHTML = `No videos available.
`; + return; + } + + // Sort by creation date + videoArray.sort((a, b) => b.created_at - a.created_at); + + // Prepare to fetch user profiles + const userProfiles = new Map(); + const uniquePubkeys = [...new Set(videoArray.map((v) => v.pubkey))]; + + for (const pubkey of uniquePubkeys) { + try { + const userEvents = await nostrClient.pool.list(nostrClient.relays, [ + { + kinds: [0], + authors: [pubkey], + limit: 1, + }, + ]); + + if (userEvents[0]?.content) { + const profile = JSON.parse(userEvents[0].content); + userProfiles.set(pubkey, { + name: profile.name || profile.display_name || "Unknown", + picture: profile.picture || `https://robohash.org/${pubkey}`, + }); + } else { + userProfiles.set(pubkey, { + name: "Unknown", + picture: `https://robohash.org/${pubkey}`, + }); + } + } catch (error) { + console.error(`Profile fetch error for ${pubkey}:`, error); + userProfiles.set(pubkey, { + name: "Unknown", + picture: `https://robohash.org/${pubkey}`, + }); + } + } + + // Build HTML for each video + const renderedVideos = videoArray + .map((video, index) => { + try { + if (!this.validateVideo(video, index)) { + console.error(`Invalid video: ${video.title}`); + return ""; + } + + const profile = userProfiles.get(video.pubkey) || { + name: "Unknown", + picture: `https://robohash.org/${video.pubkey}`, + }; + const timeAgo = this.formatTimeAgo(video.created_at); + + // If user is the owner + const canEdit = video.pubkey === this.pubkey; + + // If it's private + user owns it => highlight with a special border + const highlightClass = + video.isPrivate && canEdit + ? "border-2 border-yellow-500" + : "border-none"; // normal case + + // Gear menu (unchanged) + const gearMenu = canEdit + ? ` ++ ${this.escapeHTML(profile.name)} +
+No valid videos to display.
`; + return; + } + + this.videoList.innerHTML = renderedVideos.join(""); + console.log("Videos rendered successfully"); + } catch (error) { + console.error("Rendering error:", error); + this.videoList.innerHTML = `Error loading videos.
`; + } + } + + /** + * Validates a video object + */ + validateVideo(video, index) { + const validationResults = { + hasId: Boolean(video?.id), + isValidId: typeof video?.id === "string" && video.id.trim().length > 0, + hasVideo: Boolean(video), + hasTitle: Boolean(video?.title), + hasMagnet: Boolean(video?.magnet), + hasMode: Boolean(video?.mode), + hasPubkey: Boolean(video?.pubkey), + isValidTitle: typeof video?.title === "string" && video.title.length > 0, + isValidMagnet: + typeof video?.magnet === "string" && video.magnet.length > 0, + isValidMode: + typeof video?.mode === "string" && ["dev", "live"].includes(video.mode), + }; + + const passed = Object.values(validationResults).every(Boolean); + console.log( + `Video ${video?.title} validation results:`, + validationResults, + passed ? "PASSED" : "FAILED" + ); + + return passed; + } + + /** + * Gets a user-friendly error message. + */ + getErrorMessage(error) { + if (error.message.includes("404")) { + return "Service worker not found. Please check server configuration."; + } else if (error.message.includes("Brave")) { + return "Please disable Brave Shields for this site to play videos."; + } else if (error.message.includes("timeout")) { + return "Connection timeout. Please check your internet connection."; + } else { + return "Failed to play video. Please try again."; + } + } + + /** + * Shows an error message to the user. + */ + showError(message) { + if (this.errorContainer) { + this.errorContainer.textContent = message; + this.errorContainer.classList.remove("hidden"); + setTimeout(() => { + this.errorContainer.classList.add("hidden"); + this.errorContainer.textContent = ""; + }, 5000); + } else { + alert(message); + } + } + + /** + * Shows a success message to the user. + */ + showSuccess(message) { + if (this.successContainer) { + this.successContainer.textContent = message; + this.successContainer.classList.remove("hidden"); + setTimeout(() => { + this.successContainer.classList.add("hidden"); + this.successContainer.textContent = ""; + }, 5000); + } else { + alert(message); + } + } + + /** + * Escapes HTML to prevent XSS. + */ + escapeHTML(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + /** + * Logs messages to console. + */ + log(message) { + console.log(message); + } + + /** + * Plays a video given its magnet URI. + * This method handles the logic to initiate torrent download and play the video. + */ + async playVideo(magnetURI) { + try { + if (!magnetURI) { + this.showError("Invalid Magnet URI."); + return; + } + + const decodedMagnet = decodeURIComponent(magnetURI); + + if (this.currentMagnetUri === decodedMagnet) { + this.log("Same video requested - already playing"); + return; + } + this.currentMagnetUri = decodedMagnet; + + this.playerModal.style.display = "flex"; + this.playerModal.classList.remove("hidden"); + + // Re-fetch the latest from relays + const videos = await nostrClient.fetchVideos(); + const video = videos.find((v) => v.magnet === decodedMagnet); + + if (!video) { + this.showError("Video data not found."); + return; + } + + // Decrypt only once if user owns it + if ( + video.isPrivate && + video.pubkey === this.pubkey && + !video.alreadyDecrypted + ) { + this.log("User owns a private video => decrypting magnet link..."); + video.magnet = fakeDecrypt(video.magnet); + // Mark it so we don't do it again + video.alreadyDecrypted = true; + } + + const finalMagnet = video.magnet; + + // Profile fetch + let creatorProfile = { + name: "Unknown", + picture: `https://robohash.org/${video.pubkey}`, + }; + try { + const userEvents = await nostrClient.pool.list(nostrClient.relays, [ + { + kinds: [0], + authors: [video.pubkey], + limit: 1, + }, + ]); + + // Ensure userEvents isn't empty before accessing [0] + if (userEvents.length > 0 && userEvents[0]?.content) { + const profile = JSON.parse(userEvents[0].content); + creatorProfile = { + name: profile.name || profile.display_name || "Unknown", + picture: profile.picture || `https://robohash.org/${video.pubkey}`, + }; + } + } catch (error) { + this.log("Error fetching creator profile:", error); + } + + let creatorNpub = "Unknown"; + try { + creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey); + } catch (error) { + this.log("Error converting pubkey to npub:", error); + creatorNpub = video.pubkey; + } + + this.videoTitle.textContent = video.title || "Untitled"; + this.videoDescription.textContent = + video.description || "No description available."; + this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); + + this.creatorName.textContent = creatorProfile.name; + this.creatorNpub.textContent = `${creatorNpub.slice( + 0, + 8 + )}...${creatorNpub.slice(-4)}`; + this.creatorAvatar.src = creatorProfile.picture; + this.creatorAvatar.alt = creatorProfile.name; + + this.log("Starting video stream with:", finalMagnet); + await torrentClient.streamVideo(finalMagnet, this.modalVideo); + + const updateInterval = setInterval(() => { + if (!document.body.contains(this.modalVideo)) { + clearInterval(updateInterval); + return; + } + + const status = document.getElementById("status"); + const progress = document.getElementById("progress"); + const peers = document.getElementById("peers"); + const speed = document.getElementById("speed"); + const downloaded = document.getElementById("downloaded"); + + if (status) this.modalStatus.textContent = status.textContent; + if (progress) this.modalProgress.style.width = progress.style.width; + if (peers) this.modalPeers.textContent = peers.textContent; + if (speed) this.modalSpeed.textContent = speed.textContent; + if (downloaded) + this.modalDownloaded.textContent = downloaded.textContent; + }, 1000); + } catch (error) { + this.log("Error in playVideo:", error); + this.showError(`Playback error: ${error.message}`); + } + } + + updateTorrentStatus(torrent) { + if (!torrent) return; + + this.modalStatus.textContent = torrent.status; + this.modalProgress.style.width = `${(torrent.progress * 100).toFixed(2)}%`; + this.modalPeers.textContent = `Peers: ${torrent.numPeers}`; + this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed( + 2 + )} KB/s`; + this.modalDownloaded.textContent = `${( + torrent.downloaded / + (1024 * 1024) + ).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`; + + if (torrent.ready) { + this.modalStatus.textContent = "Ready to play"; + } else { + setTimeout(() => this.updateTorrentStatus(torrent), 1000); + } + } + + /** + * Allows the user to edit a video note (only if they are the owner). + * We reuse the note's existing d tag via nostrClient.editVideo. + */ + async handleEditVideo(index) { + try { + const videos = await nostrClient.fetchVideos(); + const video = videos[index]; + + if (!this.pubkey) { + this.showError("Please login to edit videos."); + return; + } + if (video.pubkey !== this.pubkey) { + this.showError("You do not own this video."); + return; + } + + // Prompt for new fields or keep old + const newTitle = prompt( + "New Title? (Leave blank to keep existing)", + video.title + ); + const newMagnet = prompt( + "New Magnet? (Leave blank to keep existing)", + video.magnet + ); + const newThumbnail = prompt( + "New Thumbnail? (Leave blank to keep existing)", + video.thumbnail + ); + const newDescription = prompt( + "New Description? (Leave blank to keep existing)", + video.description + ); + + // Ask user if they want the note private or public + const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); + + // Fallback to old if user typed nothing + const title = + newTitle === null || newTitle.trim() === "" + ? video.title + : newTitle.trim(); + const magnet = + newMagnet === null || newMagnet.trim() === "" + ? video.magnet + : newMagnet.trim(); + const thumbnail = + newThumbnail === null || newThumbnail.trim() === "" + ? video.thumbnail + : newThumbnail.trim(); + const description = + newDescription === null || newDescription.trim() === "" + ? video.description + : newDescription.trim(); + + // Build final updated data + const updatedData = { + version: video.version || 2, // keep old version or set 2 + isPrivate: wantPrivate, + title, + magnet, + thumbnail, + description, + mode: isDevMode ? "dev" : "live", + }; + + // Edit + const originalEvent = { + id: video.id, + pubkey: video.pubkey, + tags: video.tags, + }; + await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); + this.showSuccess("Video updated successfully!"); + await this.loadVideos(); + } catch (err) { + this.log("Failed to edit video:", err.message); + this.showError("Failed to edit video. Please try again later."); + } + } + + /** + * ADDED FOR VERSIONING/PRIVATE/DELETE: + * Allows the user to delete (soft-delete) a video by marking it as deleted. + */ + async handleDeleteVideo(index) { + try { + const videos = await nostrClient.fetchVideos(); + const video = videos[index]; + + if (!this.pubkey) { + this.showError("Please login to delete videos."); + return; + } + if (video.pubkey !== this.pubkey) { + this.showError("You do not own this video."); + return; + } + + if ( + !confirm( + `Are you sure you want to delete "${video.title}"? This action cannot be undone.` + ) + ) { + return; + } + + const originalEvent = { + id: video.id, + pubkey: video.pubkey, + tags: video.tags, + }; + + await nostrClient.deleteVideo(originalEvent, this.pubkey); + this.showSuccess("Video deleted (hidden) successfully!"); + await this.loadVideos(); + } catch (err) { + this.log("Failed to delete video:", err.message); + this.showError("Failed to delete video. Please try again later."); + } + } +} + +export const app = new bitvidApp(); +app.init(); +window.app = app; diff --git a/refactoring/js/components/Navigation.js b/refactoring/js/components/Navigation.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/components/UserManager.js b/refactoring/js/components/UserManager.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/components/VideoForm.js b/refactoring/js/components/VideoForm.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/components/VideoList.js b/refactoring/js/components/VideoList.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/components/VideoPlayer.js b/refactoring/js/components/VideoPlayer.js new file mode 100644 index 0000000..be56674 --- /dev/null +++ b/refactoring/js/components/VideoPlayer.js @@ -0,0 +1,187 @@ +// js/components/VideoPlayer.js + +export class VideoPlayer { + constructor() { + // Initialize these as null - they'll be set after modal loads + this.playerModal = null; + this.modalVideo = null; + this.modalStatus = null; + this.modalProgress = null; + this.modalPeers = null; + this.modalSpeed = null; + this.modalDownloaded = null; + this.closePlayerBtn = null; + this.videoTitle = null; + this.videoDescription = null; + this.videoTimestamp = null; + this.creatorAvatar = null; + this.creatorName = null; + this.creatorNpub = null; + this.currentMagnetUri = null; + } + + async initModal() { + try { + console.log("Starting modal initialization..."); + const response = await fetch("components/video-modal.html"); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + console.log("Modal HTML loaded successfully"); + + const modalContainer = document.getElementById("modalContainer"); + if (!modalContainer) { + throw new Error("Modal container element not found!"); + } + + modalContainer.innerHTML = html; + console.log("Modal HTML inserted into DOM"); + + this.updateModalElements(); + await this.setupEventListeners(); + + console.log("Modal initialization completed successfully"); + return true; + } catch (error) { + console.error("Modal initialization failed:", error); + throw error; + } + } + + updateModalElements() { + // Update Modal Elements + this.playerModal = document.getElementById("playerModal"); + this.modalVideo = document.getElementById("modalVideo"); + this.modalStatus = document.getElementById("modalStatus"); + this.modalProgress = document.getElementById("modalProgress"); + this.modalPeers = document.getElementById("modalPeers"); + this.modalSpeed = document.getElementById("modalSpeed"); + this.modalDownloaded = document.getElementById("modalDownloaded"); + this.closePlayerBtn = document.getElementById("closeModal"); + + // Update Video Info Elements + this.videoTitle = document.getElementById("videoTitle"); + this.videoDescription = document.getElementById("videoDescription"); + this.videoTimestamp = document.getElementById("videoTimestamp"); + + // Update Creator Info Elements + this.creatorAvatar = document.getElementById("creatorAvatar"); + this.creatorName = document.getElementById("creatorName"); + this.creatorNpub = document.getElementById("creatorNpub"); + + this.setupScrollBehavior(); + } + + setupScrollBehavior() { + // Add scroll behavior for nav + let lastScrollY = 0; + const modalNav = document.getElementById("modalNav"); + + if (this.playerModal && modalNav) { + this.playerModal.addEventListener("scroll", (e) => { + const currentScrollY = e.target.scrollTop; + const shouldShowNav = + currentScrollY <= lastScrollY || currentScrollY < 50; + modalNav.style.transform = shouldShowNav + ? "translateY(0)" + : "translateY(-100%)"; + lastScrollY = currentScrollY; + }); + } + } + + async setupEventListeners() { + // Set up modal close handler + if (this.closePlayerBtn) { + this.closePlayerBtn.addEventListener("click", () => this.hide()); + } + + // Close Modal by clicking outside content + if (this.playerModal) { + this.playerModal.addEventListener("click", async (e) => { + if (e.target === this.playerModal) { + await this.hide(); + } + }); + } + + // Video error handling + if (this.modalVideo) { + this.setupVideoEventListeners(); + } + } + + setupVideoEventListeners() { + this.modalVideo.addEventListener("error", (e) => { + const error = e.target.error; + console.log("Modal video error:", error); + if (error) { + console.log("Error code:", error.code); + console.log("Error message:", error.message); + // You'll need to implement showError or pass it as a callback + // this.showError(`Video playback error: ${error.message || "Unknown error"}`); + } + }); + + this.modalVideo.addEventListener("loadstart", () => { + console.log("Video loadstart event fired"); + }); + + this.modalVideo.addEventListener("loadedmetadata", () => { + console.log("Video loadedmetadata event fired"); + }); + + this.modalVideo.addEventListener("canplay", () => { + console.log("Video canplay event fired"); + }); + } + + async hide() { + await this.cleanup(); + if (this.playerModal) { + this.playerModal.style.display = "none"; + this.playerModal.classList.add("hidden"); + } + } + + async cleanup() { + if (this.modalVideo) { + this.modalVideo.pause(); + this.modalVideo.src = ""; + this.modalVideo.load(); + } + } + + show() { + if (this.playerModal) { + this.playerModal.style.display = "flex"; + this.playerModal.classList.remove("hidden"); + } + } + + updateTorrentStatus(torrent) { + if (!torrent) return; + + this.modalStatus.textContent = torrent.status; + this.modalProgress.style.width = `${(torrent.progress * 100).toFixed(2)}%`; + this.modalPeers.textContent = `Peers: ${torrent.numPeers}`; + this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed( + 2 + )} KB/s`; + this.modalDownloaded.textContent = `${( + torrent.downloaded / + (1024 * 1024) + ).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`; + + if (torrent.ready) { + this.modalStatus.textContent = "Ready to play"; + } else { + setTimeout(() => this.updateTorrentStatus(torrent), 1000); + } + } +} + +export const videoPlayer = new VideoPlayer(); diff --git a/refactoring/js/config.js b/refactoring/js/config.js new file mode 100644 index 0000000..7363993 --- /dev/null +++ b/refactoring/js/config.js @@ -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 diff --git a/refactoring/js/config/config.js b/refactoring/js/config/config.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/disclaimer.js b/refactoring/js/disclaimer.js new file mode 100644 index 0000000..435ea40 --- /dev/null +++ b/refactoring/js/disclaimer.js @@ -0,0 +1,29 @@ +class DisclaimerModal { + constructor() { + this.modal = document.getElementById("disclaimerModal"); + this.acceptButton = document.getElementById("acceptDisclaimer"); + this.hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer"); + + this.setupEventListeners(); + } + + setupEventListeners() { + const closeModal = () => { + this.modal.style.display = "none"; + document.body.style.overflow = "unset"; + localStorage.setItem("hasSeenDisclaimer", "true"); + }; + + // Only keep the accept button event listener + this.acceptButton.addEventListener("click", closeModal); + } + + show() { + if (!this.hasSeenDisclaimer) { + this.modal.style.display = "flex"; + document.body.style.overflow = "hidden"; + } + } +} + +export const disclaimerModal = new DisclaimerModal(); diff --git a/refactoring/js/libs/nostr.bundle.js b/refactoring/js/libs/nostr.bundle.js new file mode 100644 index 0000000..4342b2b --- /dev/null +++ b/refactoring/js/libs/nostr.bundle.js @@ -0,0 +1,8676 @@ +"use strict"; +var NostrTools = (() => { + var __create = Object.create; + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __getProtoOf = Object.getPrototypeOf; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + }; + var __commonJS = (cb, mod3) => function __require() { + return mod3 || (0, cb[__getOwnPropNames(cb)[0]])((mod3 = { exports: {} }).exports, mod3), mod3.exports; + }; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toESM = (mod3, isNodeMode, target) => (target = mod3 != null ? __create(__getProtoOf(mod3)) : {}, __copyProps( + isNodeMode || !mod3 || !mod3.__esModule ? __defProp(target, "default", { value: mod3, enumerable: true }) : target, + mod3 + )); + var __toCommonJS = (mod3) => __copyProps(__defProp({}, "__esModule", { value: true }), mod3); + + //+ No public videos available yet. Be the first to upload one! +
`; + return; + } + + this.log("Processing filtered videos:", displayedVideos); + + displayedVideos.forEach((video, index) => { + this.log(`Video ${index} details:`, { + id: video.id, + title: video.title, + magnet: video.magnet, + isPrivate: video.isPrivate, + pubkey: video.pubkey, + created_at: video.created_at, + }); + }); + + // Now render only the displayedVideos + await this.renderVideoList(displayedVideos); + this.log(`Rendered ${displayedVideos.length} videos successfully`); + } catch (error) { + this.log("Failed to fetch videos:", error); + this.showError( + "An error occurred while loading videos. Please try again later." + ); + this.videoList.innerHTML = ` ++ No videos available at the moment. Please try again later. +
`; + } + } + + /** + * Renders the given list of videos. If a video is private and belongs to the user, + * highlight with a special border (e.g. border-yellow-500). + */ + async renderVideoList(videos) { + try { + console.log("RENDER VIDEO LIST - Start", { + videosReceived: videos, + videosCount: videos ? videos.length : "N/A", + videosType: typeof videos, + }); + + if (!videos) { + console.error("NO VIDEOS RECEIVED"); + this.videoList.innerHTML = `No videos found.
`; + return; + } + + const videoArray = Array.isArray(videos) ? videos : [videos]; + + if (videoArray.length === 0) { + console.error("VIDEO ARRAY IS EMPTY"); + this.videoList.innerHTML = `No videos available.
`; + return; + } + + // Sort by creation date + videoArray.sort((a, b) => b.created_at - a.created_at); + + // Prepare to fetch user profiles + const userProfiles = new Map(); + const uniquePubkeys = [...new Set(videoArray.map((v) => v.pubkey))]; + + for (const pubkey of uniquePubkeys) { + try { + const userEvents = await nostrClient.pool.list(nostrClient.relays, [ + { + kinds: [0], + authors: [pubkey], + limit: 1, + }, + ]); + + if (userEvents[0]?.content) { + const profile = JSON.parse(userEvents[0].content); + userProfiles.set(pubkey, { + name: profile.name || profile.display_name || "Unknown", + picture: profile.picture || `https://robohash.org/${pubkey}`, + }); + } else { + userProfiles.set(pubkey, { + name: "Unknown", + picture: `https://robohash.org/${pubkey}`, + }); + } + } catch (error) { + console.error(`Profile fetch error for ${pubkey}:`, error); + userProfiles.set(pubkey, { + name: "Unknown", + picture: `https://robohash.org/${pubkey}`, + }); + } + } + + // Build HTML for each video + const renderedVideos = videoArray + .map((video, index) => { + try { + if (!this.validateVideo(video, index)) { + console.error(`Invalid video: ${video.title}`); + return ""; + } + + const profile = userProfiles.get(video.pubkey) || { + name: "Unknown", + picture: `https://robohash.org/${video.pubkey}`, + }; + const timeAgo = this.formatTimeAgo(video.created_at); + + // If user is the owner + const canEdit = video.pubkey === this.pubkey; + + // If it's private + user owns it => highlight with a special border + const highlightClass = + video.isPrivate && canEdit + ? "border-2 border-yellow-500" + : "border-none"; // normal case + + // Gear menu (unchanged) + const gearMenu = canEdit + ? ` ++ ${this.escapeHTML(profile.name)} +
+No valid videos to display.
`; + return; + } + + this.videoList.innerHTML = renderedVideos.join(""); + console.log("Videos rendered successfully"); + } catch (error) { + console.error("Rendering error:", error); + this.videoList.innerHTML = `Error loading videos.
`; + } + } + + /** + * Validates a video object + */ + validateVideo(video, index) { + const validationResults = { + hasId: Boolean(video?.id), + isValidId: typeof video?.id === "string" && video.id.trim().length > 0, + hasVideo: Boolean(video), + hasTitle: Boolean(video?.title), + hasMagnet: Boolean(video?.magnet), + hasMode: Boolean(video?.mode), + hasPubkey: Boolean(video?.pubkey), + isValidTitle: typeof video?.title === "string" && video.title.length > 0, + isValidMagnet: + typeof video?.magnet === "string" && video.magnet.length > 0, + isValidMode: + typeof video?.mode === "string" && ["dev", "live"].includes(video.mode), + }; + + const passed = Object.values(validationResults).every(Boolean); + console.log( + `Video ${video?.title} validation results:`, + validationResults, + passed ? "PASSED" : "FAILED" + ); + + return passed; + } + + /** + * Gets a user-friendly error message. + */ + getErrorMessage(error) { + if (error.message.includes("404")) { + return "Service worker not found. Please check server configuration."; + } else if (error.message.includes("Brave")) { + return "Please disable Brave Shields for this site to play videos."; + } else if (error.message.includes("timeout")) { + return "Connection timeout. Please check your internet connection."; + } else { + return "Failed to play video. Please try again."; + } + } + + /** + * Shows an error message to the user. + */ + showError(message) { + if (this.errorContainer) { + this.errorContainer.textContent = message; + this.errorContainer.classList.remove("hidden"); + setTimeout(() => { + this.errorContainer.classList.add("hidden"); + this.errorContainer.textContent = ""; + }, 5000); + } else { + alert(message); + } + } + + /** + * Shows a success message to the user. + */ + showSuccess(message) { + if (this.successContainer) { + this.successContainer.textContent = message; + this.successContainer.classList.remove("hidden"); + setTimeout(() => { + this.successContainer.classList.add("hidden"); + this.successContainer.textContent = ""; + }, 5000); + } else { + alert(message); + } + } + + /** + * Escapes HTML to prevent XSS. + */ + escapeHTML(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + /** + * Logs messages to console. + */ + log(message) { + console.log(message); + } + + /** + * Plays a video given its magnet URI. + * This method handles the logic to initiate torrent download and play the video. + */ + async playVideo(magnetURI) { + try { + if (!magnetURI) { + this.showError("Invalid Magnet URI."); + return; + } + + const decodedMagnet = decodeURIComponent(magnetURI); + + if (this.currentMagnetUri === decodedMagnet) { + this.log("Same video requested - already playing"); + return; + } + this.currentMagnetUri = decodedMagnet; + + this.playerModal.style.display = "flex"; + this.playerModal.classList.remove("hidden"); + + // Re-fetch the latest from relays + const videos = await nostrClient.fetchVideos(); + const video = videos.find((v) => v.magnet === decodedMagnet); + + if (!video) { + this.showError("Video data not found."); + return; + } + + // Decrypt only once if user owns it + if ( + video.isPrivate && + video.pubkey === this.pubkey && + !video.alreadyDecrypted + ) { + this.log("User owns a private video => decrypting magnet link..."); + video.magnet = fakeDecrypt(video.magnet); + // Mark it so we don't do it again + video.alreadyDecrypted = true; + } + + const finalMagnet = video.magnet; + + // Profile fetch + let creatorProfile = { + name: "Unknown", + picture: `https://robohash.org/${video.pubkey}`, + }; + try { + const userEvents = await nostrClient.pool.list(nostrClient.relays, [ + { + kinds: [0], + authors: [video.pubkey], + limit: 1, + }, + ]); + + // Ensure userEvents isn't empty before accessing [0] + if (userEvents.length > 0 && userEvents[0]?.content) { + const profile = JSON.parse(userEvents[0].content); + creatorProfile = { + name: profile.name || profile.display_name || "Unknown", + picture: profile.picture || `https://robohash.org/${video.pubkey}`, + }; + } + } catch (error) { + this.log("Error fetching creator profile:", error); + } + + let creatorNpub = "Unknown"; + try { + creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey); + } catch (error) { + this.log("Error converting pubkey to npub:", error); + creatorNpub = video.pubkey; + } + + this.videoTitle.textContent = video.title || "Untitled"; + this.videoDescription.textContent = + video.description || "No description available."; + this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); + + this.creatorName.textContent = creatorProfile.name; + this.creatorNpub.textContent = `${creatorNpub.slice( + 0, + 8 + )}...${creatorNpub.slice(-4)}`; + this.creatorAvatar.src = creatorProfile.picture; + this.creatorAvatar.alt = creatorProfile.name; + + this.log("Starting video stream with:", finalMagnet); + await torrentClient.streamVideo(finalMagnet, this.modalVideo); + + const updateInterval = setInterval(() => { + if (!document.body.contains(this.modalVideo)) { + clearInterval(updateInterval); + return; + } + + const status = document.getElementById("status"); + const progress = document.getElementById("progress"); + const peers = document.getElementById("peers"); + const speed = document.getElementById("speed"); + const downloaded = document.getElementById("downloaded"); + + if (status) this.modalStatus.textContent = status.textContent; + if (progress) this.modalProgress.style.width = progress.style.width; + if (peers) this.modalPeers.textContent = peers.textContent; + if (speed) this.modalSpeed.textContent = speed.textContent; + if (downloaded) + this.modalDownloaded.textContent = downloaded.textContent; + }, 1000); + } catch (error) { + this.log("Error in playVideo:", error); + this.showError(`Playback error: ${error.message}`); + } + } + + updateTorrentStatus(torrent) { + if (!torrent) return; + + this.modalStatus.textContent = torrent.status; + this.modalProgress.style.width = `${(torrent.progress * 100).toFixed(2)}%`; + this.modalPeers.textContent = `Peers: ${torrent.numPeers}`; + this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed( + 2 + )} KB/s`; + this.modalDownloaded.textContent = `${( + torrent.downloaded / + (1024 * 1024) + ).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`; + + if (torrent.ready) { + this.modalStatus.textContent = "Ready to play"; + } else { + setTimeout(() => this.updateTorrentStatus(torrent), 1000); + } + } + + /** + * Allows the user to edit a video note (only if they are the owner). + * We reuse the note's existing d tag via nostrClient.editVideo. + */ + async handleEditVideo(index) { + try { + const videos = await nostrClient.fetchVideos(); + const video = videos[index]; + + if (!this.pubkey) { + this.showError("Please login to edit videos."); + return; + } + if (video.pubkey !== this.pubkey) { + this.showError("You do not own this video."); + return; + } + + // Prompt for new fields or keep old + const newTitle = prompt( + "New Title? (Leave blank to keep existing)", + video.title + ); + const newMagnet = prompt( + "New Magnet? (Leave blank to keep existing)", + video.magnet + ); + const newThumbnail = prompt( + "New Thumbnail? (Leave blank to keep existing)", + video.thumbnail + ); + const newDescription = prompt( + "New Description? (Leave blank to keep existing)", + video.description + ); + + // Ask user if they want the note private or public + const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); + + // Fallback to old if user typed nothing + const title = + newTitle === null || newTitle.trim() === "" + ? video.title + : newTitle.trim(); + const magnet = + newMagnet === null || newMagnet.trim() === "" + ? video.magnet + : newMagnet.trim(); + const thumbnail = + newThumbnail === null || newThumbnail.trim() === "" + ? video.thumbnail + : newThumbnail.trim(); + const description = + newDescription === null || newDescription.trim() === "" + ? video.description + : newDescription.trim(); + + // Build final updated data + const updatedData = { + version: video.version || 2, // keep old version or set 2 + isPrivate: wantPrivate, + title, + magnet, + thumbnail, + description, + mode: isDevMode ? "dev" : "live", + }; + + // Edit + const originalEvent = { + id: video.id, + pubkey: video.pubkey, + tags: video.tags, + }; + await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); + this.showSuccess("Video updated successfully!"); + await this.loadVideos(); + } catch (err) { + this.log("Failed to edit video:", err.message); + this.showError("Failed to edit video. Please try again later."); + } + } + + /** + * ADDED FOR VERSIONING/PRIVATE/DELETE: + * Allows the user to delete (soft-delete) a video by marking it as deleted. + */ + async handleDeleteVideo(index) { + try { + const videos = await nostrClient.fetchVideos(); + const video = videos[index]; + + if (!this.pubkey) { + this.showError("Please login to delete videos."); + return; + } + if (video.pubkey !== this.pubkey) { + this.showError("You do not own this video."); + return; + } + + if ( + !confirm( + `Are you sure you want to delete "${video.title}"? This action cannot be undone.` + ) + ) { + return; + } + + const originalEvent = { + id: video.id, + pubkey: video.pubkey, + tags: video.tags, + }; + + await nostrClient.deleteVideo(originalEvent, this.pubkey); + this.showSuccess("Video deleted (hidden) successfully!"); + await this.loadVideos(); + } catch (err) { + this.log("Failed to delete video:", err.message); + this.showError("Failed to delete video. Please try again later."); + } + } +} + +export const app = new bitvidApp(); +app.init(); +window.app = app; diff --git a/refactoring/js/old/config.js b/refactoring/js/old/config.js new file mode 100644 index 0000000..7363993 --- /dev/null +++ b/refactoring/js/old/config.js @@ -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 diff --git a/refactoring/js/old/disclaimer.js b/refactoring/js/old/disclaimer.js new file mode 100644 index 0000000..435ea40 --- /dev/null +++ b/refactoring/js/old/disclaimer.js @@ -0,0 +1,29 @@ +class DisclaimerModal { + constructor() { + this.modal = document.getElementById("disclaimerModal"); + this.acceptButton = document.getElementById("acceptDisclaimer"); + this.hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer"); + + this.setupEventListeners(); + } + + setupEventListeners() { + const closeModal = () => { + this.modal.style.display = "none"; + document.body.style.overflow = "unset"; + localStorage.setItem("hasSeenDisclaimer", "true"); + }; + + // Only keep the accept button event listener + this.acceptButton.addEventListener("click", closeModal); + } + + show() { + if (!this.hasSeenDisclaimer) { + this.modal.style.display = "flex"; + document.body.style.overflow = "hidden"; + } + } +} + +export const disclaimerModal = new DisclaimerModal(); diff --git a/refactoring/js/old/lists.js b/refactoring/js/old/lists.js new file mode 100644 index 0000000..51e65a0 --- /dev/null +++ b/refactoring/js/old/lists.js @@ -0,0 +1,13 @@ +// js/lists.js + +const npubs = [ + "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", + "npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", + "npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2", +]; + +console.log("DEBUG: lists.js loaded, npubs:", npubs); + +export const initialWhitelist = npubs; +export const initialBlacklist = [""]; diff --git a/refactoring/js/old/nostr.js b/refactoring/js/old/nostr.js new file mode 100644 index 0000000..71b24c4 --- /dev/null +++ b/refactoring/js/old/nostr.js @@ -0,0 +1,624 @@ +// js/nostr.js + +import { isDevMode } from "./config.js"; +import { accessControl } from "./accessControl.js"; + +const RELAY_URLS = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", +]; + +// Rate limiting for error logs +let errorLogCount = 0; +const MAX_ERROR_LOGS = 100; // Adjust as needed + +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." + ); + } +} + +/** + * A very naive "encryption" function that just reverses the string. + * In a real app, use a proper crypto library (AES-GCM, ECDH, etc.). + */ +function fakeEncrypt(magnet) { + return magnet.split("").reverse().join(""); +} +function fakeDecrypt(encrypted) { + return encrypted.split("").reverse().join(""); +} + +class NostrClient { + constructor() { + this.pool = null; + this.pubkey = null; + this.relays = RELAY_URLS; + } + + /** + * Initializes the Nostr client by connecting to 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; + } + } + + // Helper method to handle relay connections + 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); + }) + ) + ); + } + + /** + * Logs in the user using a Nostr extension or by entering an NSEC key. + */ + async login() { + try { + if (!window.nostr) { + console.log("No Nostr extension found"); + throw new Error( + "Please install a Nostr extension (like Alby or nos2x)." + ); + } + + const pubkey = await window.nostr.getPublicKey(); + const npub = window.NostrTools.nip19.npubEncode(pubkey); + + // Debug logs + if (isDevMode) { + console.log("Got pubkey:", pubkey); + console.log("Converted to npub:", npub); + console.log("Whitelist:", accessControl.getWhitelist()); + console.log("Blacklist:", accessControl.getBlacklist()); + console.log("Is whitelisted?", accessControl.isWhitelisted(npub)); + console.log("Is blacklisted?", accessControl.isBlacklisted(npub)); + } + + // Check access control + if (!accessControl.canAccess(npub)) { + if (accessControl.isBlacklisted(npub)) { + throw new Error( + "Your account has been blocked from accessing this platform." + ); + } else { + throw new Error( + "Access is currently restricted to whitelisted users only." + ); + } + } + + this.pubkey = pubkey; + if (isDevMode) + console.log( + "Successfully logged in with extension. Public key:", + this.pubkey + ); + return this.pubkey; + } catch (e) { + console.error("Login error:", e); + throw e; + } + } + + /** + * Logs out the user. + */ + logout() { + this.pubkey = null; + if (isDevMode) console.log("User logged out."); + } + + /** + * Decodes an NSEC key. + */ + decodeNsec(nsec) { + try { + const { data } = window.NostrTools.nip19.decode(nsec); + return data; + } catch (error) { + throw new Error("Invalid NSEC key."); + } + } + + /** + * Publishes a new video event to all relays (creates a brand-new note). + */ + async publishVideo(videoData, pubkey) { + if (!pubkey) { + throw new Error("User is not logged in."); + } + + if (isDevMode) { + console.log("Publishing video with data:", videoData); + } + + // If user sets "isPrivate = true", encrypt the magnet + let finalMagnet = videoData.magnet; + if (videoData.isPrivate === true) { + finalMagnet = fakeEncrypt(finalMagnet); + } + + // Default version is 1 if not specified + const version = videoData.version ?? 1; + + const uniqueD = `${Date.now()}-${Math.random() + .toString(36) + .substring(2, 10)}`; + + // Always mark "deleted" false for new posts + const contentObject = { + version, + deleted: false, + isPrivate: videoData.isPrivate || false, + title: videoData.title, + magnet: finalMagnet, + thumbnail: videoData.thumbnail, + description: videoData.description, + mode: videoData.mode, + }; + + const event = { + kind: 30078, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["t", "video"], + ["d", uniqueD], + ], + content: JSON.stringify(contentObject), + }; + + if (isDevMode) { + console.log("Event content after stringify:", event.content); + console.log("Using d tag:", uniqueD); + } + + 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(`Event published to ${url}`); + } + } catch (err) { + if (isDevMode) { + console.error(`Failed to publish to ${url}:`, err.message); + } + } + }) + ); + + return signedEvent; + } catch (error) { + if (isDevMode) { + console.error("Failed to sign event:", error.message); + } + throw new Error("Failed to sign event."); + } + } + + /** + * Edits an existing video event by reusing the same "d" tag. + * Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet. + */ + // Minimal fix: ensures we only ever encrypt once per edit operation + async editVideo(originalEvent, updatedVideoData, pubkey) { + if (!pubkey) { + throw new Error("User is not logged in."); + } + if (originalEvent.pubkey !== pubkey) { + throw new Error("You do not own this event (different pubkey)."); + } + + if (isDevMode) { + console.log("Editing video event:", originalEvent); + console.log("New video data:", updatedVideoData); + } + + // Grab the d tag from the original event + const dTag = originalEvent.tags.find((tag) => tag[0] === "d"); + if (!dTag) { + throw new Error( + 'This event has no "d" tag, cannot edit as addressable kind=30078.' + ); + } + const existingD = dTag[1]; + + // Parse old content + const oldContent = JSON.parse(originalEvent.content || "{}"); + if (isDevMode) { + console.log("Old content:", oldContent); + } + + // Keep old version & deleted status + const oldVersion = oldContent.version ?? 1; + const oldDeleted = oldContent.deleted === true; + const newVersion = updatedVideoData.version ?? oldVersion; + + const oldWasPrivate = oldContent.isPrivate === true; + + // 1) If old was private, decrypt the old magnet once => oldPlainMagnet + let oldPlainMagnet = oldContent.magnet || ""; + if (oldWasPrivate && oldPlainMagnet) { + oldPlainMagnet = fakeDecrypt(oldPlainMagnet); + } + + // 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate + const newIsPrivate = + typeof updatedVideoData.isPrivate === "boolean" + ? updatedVideoData.isPrivate + : oldContent.isPrivate ?? false; + + // 3) The user might type a new magnet or keep oldPlainMagnet + const userTypedMagnet = (updatedVideoData.magnet || "").trim(); + const finalPlainMagnet = userTypedMagnet || oldPlainMagnet; + + // 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext + let finalMagnet = finalPlainMagnet; + if (newIsPrivate) { + finalMagnet = fakeEncrypt(finalPlainMagnet); + } + + // Build updated content + const contentObject = { + version: newVersion, + deleted: oldDeleted, + isPrivate: newIsPrivate, + title: updatedVideoData.title, + magnet: finalMagnet, + thumbnail: updatedVideoData.thumbnail, + description: updatedVideoData.description, + mode: updatedVideoData.mode, + }; + + if (isDevMode) { + console.log("Building updated content object:", contentObject); + } + + const event = { + kind: 30078, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["t", "video"], + ["d", existingD], + ], + content: JSON.stringify(contentObject), + }; + + if (isDevMode) { + console.log("Reusing d tag:", existingD); + console.log("Updated event content:", event.content); + } + + try { + const signedEvent = await window.nostr.signEvent(event); + if (isDevMode) { + console.log("Signed edited event:", signedEvent); + } + + // Publish to all relays + await Promise.all( + this.relays.map(async (url) => { + try { + await this.pool.publish([url], signedEvent); + if (isDevMode) { + console.log( + `Edited event published to ${url} (d="${existingD}")` + ); + } + } catch (err) { + if (isDevMode) { + console.error( + `Failed to publish edited event to ${url}:`, + err.message + ); + } + } + }) + ); + + return signedEvent; + } catch (error) { + if (isDevMode) { + console.error("Failed to sign edited event:", error.message); + } + throw new Error("Failed to sign edited event."); + } + } + + /** + * Soft-delete or hide an existing video by marking content as "deleted: true" + * and republishing with same (kind=30078, pubkey, d) address. + */ + async deleteVideo(originalEvent, pubkey) { + if (!pubkey) { + throw new Error("User is not logged in."); + } + if (originalEvent.pubkey !== pubkey) { + throw new Error("You do not own this event (different pubkey)."); + } + + if (isDevMode) { + console.log("Deleting video event:", originalEvent); + } + + const dTag = originalEvent.tags.find((tag) => tag[0] === "d"); + if (!dTag) { + throw new Error( + 'This event has no "d" tag, cannot delete as addressable kind=30078.' + ); + } + const existingD = dTag[1]; + + const oldContent = JSON.parse(originalEvent.content || "{}"); + const oldVersion = oldContent.version ?? 1; + + const contentObject = { + version: oldVersion, + deleted: true, + title: oldContent.title || "", + magnet: "", + thumbnail: "", + description: "This video has been deleted.", + mode: oldContent.mode || "live", + isPrivate: oldContent.isPrivate || false, + }; + + const event = { + kind: 30078, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["t", "video"], + ["d", existingD], + ], + content: JSON.stringify(contentObject), + }; + + if (isDevMode) { + console.log("Reusing d tag for delete:", existingD); + console.log("Deleted event content:", event.content); + } + + try { + const signedEvent = await window.nostr.signEvent(event); + if (isDevMode) { + console.log("Signed deleted event:", signedEvent); + } + + await Promise.all( + this.relays.map(async (url) => { + try { + await this.pool.publish([url], signedEvent); + if (isDevMode) { + console.log( + `Deleted event published to ${url} (d="${existingD}")` + ); + } + } catch (err) { + if (isDevMode) { + console.error( + `Failed to publish deleted event to ${url}:`, + err.message + ); + } + } + }) + ); + + return signedEvent; + } catch (error) { + if (isDevMode) { + console.error("Failed to sign deleted event:", error.message); + } + throw new Error("Failed to sign deleted event."); + } + } + + /** + * Fetches videos from all configured relays. + */ + async fetchVideos() { + const filter = { + kinds: [30078], + "#t": ["video"], + limit: 1000, + since: 0, + }; + + const videoEvents = new Map(); + + if (isDevMode) { + console.log("[fetchVideos] Starting fetch from all relays..."); + console.log("[fetchVideos] Filter:", filter); + } + + try { + await Promise.all( + this.relays.map(async (url) => { + if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`); + + try { + const events = await this.pool.list([url], [filter]); + + if (isDevMode) { + console.log(`Events from ${url}:`, events.length); + if (events.length > 0) { + events.forEach((evt, idx) => { + console.log( + `[fetchVideos] [${url}] Event[${idx}] ID: ${evt.id} | pubkey: ${evt.pubkey} | created_at: ${evt.created_at}` + ); + }); + } + } + + events.forEach((event) => { + try { + const content = JSON.parse(event.content); + + // If deleted == true, it overrides older notes + if (content.deleted === true) { + videoEvents.delete(event.id); + return; + } + + // If we haven't seen this event.id before, store it + if (!videoEvents.has(event.id)) { + videoEvents.set(event.id, { + id: event.id, + version: content.version ?? 1, + isPrivate: content.isPrivate ?? false, + title: content.title || "", + magnet: content.magnet || "", + thumbnail: content.thumbnail || "", + description: content.description || "", + mode: content.mode || "live", + pubkey: event.pubkey, + created_at: event.created_at, + tags: event.tags, + }); + } + } catch (parseError) { + if (isDevMode) { + console.error( + "[fetchVideos] Event parsing error:", + parseError + ); + } + } + }); + } catch (relayError) { + if (isDevMode) { + console.error( + `[fetchVideos] Error fetching from ${url}:`, + relayError + ); + } + } + }) + ); + + const videos = Array.from(videoEvents.values()).sort( + (a, b) => b.created_at - a.created_at + ); + + // Apply access control filtering + const filteredVideos = accessControl.filterVideos(videos); + + if (isDevMode) { + console.log("[fetchVideos] All relays have responded."); + console.log( + `[fetchVideos] Total unique video events: ${videoEvents.size}` + ); + console.log( + `[fetchVideos] Videos after filtering: ${filteredVideos.length}` + ); + } + + return filteredVideos; + } catch (error) { + if (isDevMode) { + console.error("FETCH VIDEOS ERROR:", error); + } + return []; + } + } + + /** + * Validates video content structure. + */ + isValidVideo(content) { + try { + const isValid = + content && + typeof content === "object" && + typeof content.title === "string" && + content.title.length > 0 && + typeof content.magnet === "string" && + content.magnet.length > 0 && + typeof content.mode === "string" && + ["dev", "live"].includes(content.mode) && + (typeof content.thumbnail === "string" || + typeof content.thumbnail === "undefined") && + (typeof content.description === "string" || + typeof content.description === "undefined"); + + if (isDevMode && !isValid) { + console.log("Invalid video content:", content); + console.log("Validation details:", { + hasTitle: typeof content.title === "string", + hasMagnet: typeof content.magnet === "string", + hasMode: typeof content.mode === "string", + validThumbnail: + typeof content.thumbnail === "string" || + typeof content.thumbnail === "undefined", + validDescription: + typeof content.description === "string" || + typeof content.description === "undefined", + }); + } + + return isValid; + } catch (error) { + if (isDevMode) { + console.error("Error validating video:", error); + } + return false; + } + } +} + +export const nostrClient = new NostrClient(); diff --git a/refactoring/js/old/webtorrent.js b/refactoring/js/old/webtorrent.js new file mode 100644 index 0000000..5800a21 --- /dev/null +++ b/refactoring/js/old/webtorrent.js @@ -0,0 +1,286 @@ +// js/webtorrent.js + +import WebTorrent from 'https://esm.sh/webtorrent' + +export class TorrentClient { + constructor() { + this.client = new WebTorrent() + this.currentTorrent = null + this.TIMEOUT_DURATION = 60000 // 60 seconds + this.statsInterval = null + } + + log(msg) { + console.log(msg) + } + + async isBrave() { + return (navigator.brave?.isBrave && await navigator.brave.isBrave()) || false + } + + 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 registration of registrations) { + await registration.unregister() + } + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + const currentPath = window.location.pathname + const basePath = currentPath.substring(0, currentPath.lastIndexOf('/') + 1) + + this.log('Registering service worker...') + const registration = await navigator.serviceWorker.register('./sw.min.js', { + scope: basePath, + 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') + } + + this.log('Service worker ready') + return registration + } catch (error) { + this.log('Service worker setup error:', error) + throw error + } + } + + formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` + } + + async streamVideo(magnetURI, videoElement) { + try { + // Setup service worker first + const registration = await this.setupServiceWorker() + + if (!registration || !registration.active) { + throw new Error('Service worker setup failed') + } + + // Create WebTorrent server AFTER service worker is ready + this.client.createServer({ controller: registration }) + this.log('WebTorrent server created') + + return new Promise((resolve, reject) => { + this.log('Starting torrent download') + this.client.add(magnetURI, torrent => { + this.log('Torrent added: ' + torrent.name) + const status = document.getElementById('status') + const progress = document.getElementById('progress') + const peers = document.getElementById('peers') + const speed = document.getElementById('speed') + const downloaded = document.getElementById('downloaded') + + if (status) status.textContent = `Loading ${torrent.name}...` + + const file = torrent.files.find(file => + file.name.endsWith('.mp4') || + file.name.endsWith('.webm') || + file.name.endsWith('.mkv') + ) + + if (!file) { + const error = new Error('No compatible video file found in torrent') + this.log(error.message) + if (status) status.textContent = 'Error: No video file found' + reject(error) + return + } + + videoElement.muted = true + videoElement.crossOrigin = 'anonymous' + + videoElement.addEventListener('error', (e) => { + const error = e.target.error + this.log('Video error:', error) + if (error) { + this.log('Error code:', error.code) + this.log('Error message:', error.message) + } + if (status) status.textContent = 'Error playing video. Try disabling Brave Shields.' + }) + + videoElement.addEventListener('canplay', () => { + const playPromise = videoElement.play() + if (playPromise !== undefined) { + playPromise + .then(() => this.log('Autoplay started')) + .catch(err => { + this.log('Autoplay failed:', err) + if (status) status.textContent = 'Click to play video' + videoElement.addEventListener('click', () => { + videoElement.play() + .then(() => this.log('Play started by user')) + .catch(err => this.log('Play failed:', err)) + }, { once: true }) + }) + } + }) + + videoElement.addEventListener('loadedmetadata', () => { + this.log('Video metadata loaded') + if (videoElement.duration === Infinity || isNaN(videoElement.duration)) { + this.log('Invalid duration, attempting to fix...') + videoElement.currentTime = 1e101 + videoElement.currentTime = 0 + } + }) + + try { + file.streamTo(videoElement) + this.log('Streaming started') + + // Update stats every second + this.statsInterval = setInterval(() => { + if (!document.body.contains(videoElement)) { + clearInterval(this.statsInterval) + return + } + + const percentage = torrent.progress * 100 + if (progress) progress.style.width = `${percentage}%` + if (peers) peers.textContent = `Peers: ${torrent.numPeers}` + if (speed) speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s` + if (downloaded) downloaded.textContent = + `${this.formatBytes(torrent.downloaded)} / ${this.formatBytes(torrent.length)}` + + if (status) { + status.textContent = torrent.progress === 1 + ? `${torrent.name}` + : `Loading ${torrent.name}...` + } + }, 1000) + + this.currentTorrent = torrent + resolve() + } catch (error) { + this.log('Streaming error:', error) + if (status) status.textContent = 'Error starting video stream' + reject(error) + } + + torrent.on('error', err => { + this.log('Torrent error:', err) + if (status) status.textContent = 'Error loading video' + clearInterval(this.statsInterval) + reject(err) + }) + }) + }) + } catch (error) { + this.log('Failed to setup video streaming:', error) + throw error + } + } + + async cleanup() { + try { + if (this.statsInterval) { + clearInterval(this.statsInterval) + } + if (this.currentTorrent) { + this.currentTorrent.destroy() + } + if (this.client) { + await this.client.destroy() + this.client = new WebTorrent() // Create a new client for future use + } + } catch (error) { + this.log('Cleanup error:', error) + } + } +} + +export const torrentClient = new TorrentClient() \ No newline at end of file diff --git a/refactoring/js/services/nostr/client.js b/refactoring/js/services/nostr/client.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/services/nostr/events.js b/refactoring/js/services/nostr/events.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/services/nostr/profiles.js b/refactoring/js/services/nostr/profiles.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/services/nostr/relays.js b/refactoring/js/services/nostr/relays.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/services/torrent/client.js b/refactoring/js/services/torrent/client.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/services/torrent/stats.js b/refactoring/js/services/torrent/stats.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/services/torrent/stream.js b/refactoring/js/services/torrent/stream.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/utils/Notifications.js b/refactoring/js/utils/Notifications.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/utils/htmlUtils.js b/refactoring/js/utils/htmlUtils.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/utils/logger.js b/refactoring/js/utils/logger.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/utils/timeUtils.js b/refactoring/js/utils/timeUtils.js new file mode 100644 index 0000000..e69de29 diff --git a/refactoring/js/webtorrent.js b/refactoring/js/webtorrent.js new file mode 100644 index 0000000..5800a21 --- /dev/null +++ b/refactoring/js/webtorrent.js @@ -0,0 +1,286 @@ +// js/webtorrent.js + +import WebTorrent from 'https://esm.sh/webtorrent' + +export class TorrentClient { + constructor() { + this.client = new WebTorrent() + this.currentTorrent = null + this.TIMEOUT_DURATION = 60000 // 60 seconds + this.statsInterval = null + } + + log(msg) { + console.log(msg) + } + + async isBrave() { + return (navigator.brave?.isBrave && await navigator.brave.isBrave()) || false + } + + 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 registration of registrations) { + await registration.unregister() + } + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + const currentPath = window.location.pathname + const basePath = currentPath.substring(0, currentPath.lastIndexOf('/') + 1) + + this.log('Registering service worker...') + const registration = await navigator.serviceWorker.register('./sw.min.js', { + scope: basePath, + 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') + } + + this.log('Service worker ready') + return registration + } catch (error) { + this.log('Service worker setup error:', error) + throw error + } + } + + formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` + } + + async streamVideo(magnetURI, videoElement) { + try { + // Setup service worker first + const registration = await this.setupServiceWorker() + + if (!registration || !registration.active) { + throw new Error('Service worker setup failed') + } + + // Create WebTorrent server AFTER service worker is ready + this.client.createServer({ controller: registration }) + this.log('WebTorrent server created') + + return new Promise((resolve, reject) => { + this.log('Starting torrent download') + this.client.add(magnetURI, torrent => { + this.log('Torrent added: ' + torrent.name) + const status = document.getElementById('status') + const progress = document.getElementById('progress') + const peers = document.getElementById('peers') + const speed = document.getElementById('speed') + const downloaded = document.getElementById('downloaded') + + if (status) status.textContent = `Loading ${torrent.name}...` + + const file = torrent.files.find(file => + file.name.endsWith('.mp4') || + file.name.endsWith('.webm') || + file.name.endsWith('.mkv') + ) + + if (!file) { + const error = new Error('No compatible video file found in torrent') + this.log(error.message) + if (status) status.textContent = 'Error: No video file found' + reject(error) + return + } + + videoElement.muted = true + videoElement.crossOrigin = 'anonymous' + + videoElement.addEventListener('error', (e) => { + const error = e.target.error + this.log('Video error:', error) + if (error) { + this.log('Error code:', error.code) + this.log('Error message:', error.message) + } + if (status) status.textContent = 'Error playing video. Try disabling Brave Shields.' + }) + + videoElement.addEventListener('canplay', () => { + const playPromise = videoElement.play() + if (playPromise !== undefined) { + playPromise + .then(() => this.log('Autoplay started')) + .catch(err => { + this.log('Autoplay failed:', err) + if (status) status.textContent = 'Click to play video' + videoElement.addEventListener('click', () => { + videoElement.play() + .then(() => this.log('Play started by user')) + .catch(err => this.log('Play failed:', err)) + }, { once: true }) + }) + } + }) + + videoElement.addEventListener('loadedmetadata', () => { + this.log('Video metadata loaded') + if (videoElement.duration === Infinity || isNaN(videoElement.duration)) { + this.log('Invalid duration, attempting to fix...') + videoElement.currentTime = 1e101 + videoElement.currentTime = 0 + } + }) + + try { + file.streamTo(videoElement) + this.log('Streaming started') + + // Update stats every second + this.statsInterval = setInterval(() => { + if (!document.body.contains(videoElement)) { + clearInterval(this.statsInterval) + return + } + + const percentage = torrent.progress * 100 + if (progress) progress.style.width = `${percentage}%` + if (peers) peers.textContent = `Peers: ${torrent.numPeers}` + if (speed) speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s` + if (downloaded) downloaded.textContent = + `${this.formatBytes(torrent.downloaded)} / ${this.formatBytes(torrent.length)}` + + if (status) { + status.textContent = torrent.progress === 1 + ? `${torrent.name}` + : `Loading ${torrent.name}...` + } + }, 1000) + + this.currentTorrent = torrent + resolve() + } catch (error) { + this.log('Streaming error:', error) + if (status) status.textContent = 'Error starting video stream' + reject(error) + } + + torrent.on('error', err => { + this.log('Torrent error:', err) + if (status) status.textContent = 'Error loading video' + clearInterval(this.statsInterval) + reject(err) + }) + }) + }) + } catch (error) { + this.log('Failed to setup video streaming:', error) + throw error + } + } + + async cleanup() { + try { + if (this.statsInterval) { + clearInterval(this.statsInterval) + } + if (this.currentTorrent) { + this.currentTorrent.destroy() + } + if (this.client) { + await this.client.destroy() + this.client = new WebTorrent() // Create a new client for future use + } + } catch (error) { + this.log('Cleanup error:', error) + } + } +} + +export const torrentClient = new TorrentClient() \ No newline at end of file diff --git a/refactoring/roadmap.html b/refactoring/roadmap.html new file mode 100644 index 0000000..f63b6f2 --- /dev/null +++ b/refactoring/roadmap.html @@ -0,0 +1,171 @@ + + + + + +