From f47212ebb2eb69c5fee39dcd91d2022fb2a56aa4 Mon Sep 17 00:00:00 2001 From: Keep Creating Online <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:57:09 -0500 Subject: [PATCH] update --- src/css/style.css | 2 +- src/index.html | 73 +-- src/js/app copy.js | 1359 ---------------------------------------- src/js/nostr copy 2.js | 608 ------------------ src/js/nostr copy.js | 684 -------------------- 5 files changed, 30 insertions(+), 2696 deletions(-) delete mode 100644 src/js/app copy.js delete mode 100644 src/js/nostr copy 2.js delete mode 100644 src/js/nostr copy.js diff --git a/src/css/style.css b/src/css/style.css index 832c06f..743c83f 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1,7 +1,7 @@ :root { --color-bg: #0f172a; --color-card: #1e293b; - --color-primary: #f43f5e; + --color-primary: #fe0032; --color-secondary: #ff93a5; --color-text: #f8fafc; --color-muted: #94a3b8; diff --git a/src/index.html b/src/index.html index 6403b3f..ebb85f3 100644 --- a/src/index.html +++ b/src/index.html @@ -45,65 +45,53 @@ class="container mx-auto px-4 py-8 min-h-screen flex flex-col" > - - - - - + + + - - - + + login - - - - + + - + - - + + + + + - - - - - - + - + - + @@ -140,7 +128,6 @@ - - bitvid is a decentralized video platform where content is shared @@ -233,7 +219,6 @@ - { - this.hideModal(); - }); - - // Setup scroll-based nav hide - const modalNav = document.getElementById("modalNav"); - const playerModal = document.getElementById("playerModal"); - if (!modalNav || !playerModal) { - throw new Error("Modal nav (#modalNav) or #playerModal not found!"); - } - let lastScrollY = 0; - 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; - }); - - console.log("Video modal initialization successful"); - return true; - } catch (error) { - console.error("initModal failed:", error); - this.showError(`Failed to initialize video modal: ${error.message}`); - return false; - } - } - - /** - * After we load the video modal, store references in `this.*`. - */ - updateModalElements() { - this.playerModal = document.getElementById("playerModal") || null; - this.modalVideo = document.getElementById("modalVideo") || null; - this.modalStatus = document.getElementById("modalStatus") || null; - this.modalProgress = document.getElementById("modalProgress") || null; - this.modalPeers = document.getElementById("modalPeers") || null; - this.modalSpeed = document.getElementById("modalSpeed") || null; - this.modalDownloaded = document.getElementById("modalDownloaded") || null; - this.closePlayerBtn = document.getElementById("closeModal") || null; - - this.videoTitle = document.getElementById("videoTitle") || null; - this.videoDescription = document.getElementById("videoDescription") || null; - this.videoTimestamp = document.getElementById("videoTimestamp") || null; - this.creatorAvatar = document.getElementById("creatorAvatar") || null; - this.creatorName = document.getElementById("creatorName") || null; - this.creatorNpub = document.getElementById("creatorNpub") || null; - this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null; - this.shareBtn = document.getElementById("shareBtn") || null; - - // Attach the event listeners for the copy/share buttons - if (this.copyMagnetBtn) { - this.copyMagnetBtn.addEventListener("click", () => { - this.handleCopyMagnet(); - }); - } - - // UPDATED: This share button just copies the ?v= URL to the clipboard: - if (this.shareBtn) { - this.shareBtn.addEventListener("click", () => { - if (!this.currentVideo) { - this.showError("No video is loaded to share."); - return; - } - try { - const nevent = window.NostrTools.nip19.neventEncode({ - id: this.currentVideo.id, - }); - const shareUrl = `${window.location.origin}${window.location.pathname}?v=${nevent}`; - navigator.clipboard - .writeText(shareUrl) - .then(() => this.showSuccess("Video link copied to clipboard!")) - .catch(() => this.showError("Failed to copy the link.")); - } catch (err) { - console.error("Error generating share link:", err); - this.showError("Could not generate link."); - } - }); - } - } - - /** - * Show the modal and set the "Please stand by" poster on the video. - */ - showModalWithPoster() { - if (this.playerModal) { - this.playerModal.style.display = "flex"; - this.playerModal.classList.remove("hidden"); - } - if (this.modalVideo) { - this.modalVideo.poster = "assets/gif/please-stand-by.gif"; - } - } - - /** - * Initialize the upload modal (upload-modal.html). - */ - async initUploadModal() { - try { - const resp = await fetch("components/upload-modal.html"); - if (!resp.ok) { - throw new Error(`HTTP error! status: ${resp.status}`); - } - const html = await resp.text(); - - const modalContainer = document.getElementById("modalContainer"); - if (!modalContainer) { - throw new Error("Modal container element not found!"); - } - // Append the upload modal markup - const wrapper = document.createElement("div"); - wrapper.innerHTML = html; - modalContainer.appendChild(wrapper); - - // Grab references - this.uploadModal = document.getElementById("uploadModal") || null; - this.closeUploadModalBtn = - document.getElementById("closeUploadModal") || null; - this.uploadForm = document.getElementById("uploadForm") || null; - - // Optional: if close button found, wire up - if (this.closeUploadModalBtn) { - this.closeUploadModalBtn.addEventListener("click", () => { - if (this.uploadModal) { - this.uploadModal.classList.add("hidden"); - } - }); - } - // If the form is found, wire up - if (this.uploadForm) { - this.uploadForm.addEventListener("submit", (e) => { - e.preventDefault(); - this.handleUploadSubmit(); - }); - } - - console.log("Upload modal initialization successful"); - return true; - } catch (error) { - console.error("initUploadModal failed:", error); - this.showError(`Failed to initialize upload modal: ${error.message}`); - return false; - } - } - - /** - * (Optional) Initialize a separate profile modal (profile-modal.html). - */ - async initProfileModal() { - try { - console.log("Starting profile modal initialization..."); - const resp = await fetch("components/profile-modal.html"); - if (!resp.ok) { - // If you don't have a profile modal, comment this entire method out. - throw new Error(`HTTP error! status: ${resp.status}`); - } - const html = await resp.text(); - - const modalContainer = document.getElementById("modalContainer"); - if (!modalContainer) { - throw new Error("Modal container element not found!"); - } - const wrapper = document.createElement("div"); - wrapper.innerHTML = html; - modalContainer.appendChild(wrapper); - - // Now references - this.profileModal = document.getElementById("profileModal") || null; - this.closeProfileModal = - document.getElementById("closeProfileModal") || null; - this.profileLogoutBtn = - document.getElementById("profileLogoutBtn") || null; - this.profileModalAvatar = - document.getElementById("profileModalAvatar") || null; - this.profileModalName = - document.getElementById("profileModalName") || null; - - // Wire up - if (this.closeProfileModal) { - this.closeProfileModal.addEventListener("click", () => { - this.profileModal.classList.add("hidden"); - }); - } - if (this.profileLogoutBtn) { - this.profileLogoutBtn.addEventListener("click", () => { - // On "Logout" inside the profile modal - this.logout(); - this.profileModal.classList.add("hidden"); - }); - } - - console.log("Profile modal initialization successful"); - return true; - } catch (error) { - console.error("initProfileModal failed:", error); - // Not critical if missing - return false; - } - } - - /** - * Setup general event listeners for login, logout, modals, etc. - */ - setupEventListeners() { - // Login - if (this.loginButton) { - this.loginButton.addEventListener("click", async () => { - try { - const pubkey = await nostrClient.login(); - this.login(pubkey, true); - } catch (err) { - this.showError("Failed to login. Please try again."); - } - }); - } - - // Logout - if (this.logoutButton) { - this.logoutButton.addEventListener("click", () => { - this.logout(); - }); - } - - // Profile button (if used) - if (this.profileButton) { - this.profileButton.addEventListener("click", () => { - if (this.profileModal) { - this.profileModal.classList.remove("hidden"); - } - }); - } - - // Upload button => show upload modal - if (this.uploadButton) { - this.uploadButton.addEventListener("click", () => { - if (this.uploadModal) { - this.uploadModal.classList.remove("hidden"); - } - }); - } - - // Cleanup on page unload - window.addEventListener("beforeunload", async () => { - await this.cleanup(); - }); - - // Handle back/forward navigation => hide video modal - window.addEventListener("popstate", async () => { - console.log("[popstate] user navigated back/forward; cleaning modal..."); - await this.hideModal(); - }); - } - - /** - * Attempt to load the user's own profile from Nostr (kind:0). - */ - async loadOwnProfile(pubkey) { - try { - const events = await nostrClient.pool.list(nostrClient.relays, [ - { kinds: [0], authors: [pubkey], limit: 1 }, - ]); - let displayName = "User"; - let picture = "assets/jpg/default-profile.jpg"; - - if (events.length && events[0].content) { - const data = JSON.parse(events[0].content); - displayName = data.name || data.display_name || "User"; - picture = data.picture || "assets/jpg/default-profile.jpg"; - } - - // If you have a top-bar avatar (profileAvatar) - if (this.profileAvatar) { - this.profileAvatar.src = picture; - } - // If you want to show name somewhere - if (this.profileModalName) { - this.profileModalName.textContent = displayName; - } - if (this.profileModalAvatar) { - this.profileModalAvatar.src = picture; - } - } catch (error) { - console.error("loadOwnProfile error:", error); - } - } - - /** - * Actually handle the upload form submission. - */ - async handleUploadSubmit() { - if (!this.pubkey) { - this.showError("Please login to post a video."); - return; - } - - const titleEl = document.getElementById("uploadTitle"); - const magnetEl = document.getElementById("uploadMagnet"); - const thumbEl = document.getElementById("uploadThumbnail"); - const descEl = document.getElementById("uploadDescription"); - const privEl = document.getElementById("uploadIsPrivate"); - - const formData = { - version: 2, - title: titleEl?.value.trim() || "", - magnet: magnetEl?.value.trim() || "", - thumbnail: thumbEl?.value.trim() || "", - description: descEl?.value.trim() || "", - mode: isDevMode ? "dev" : "live", - isPrivate: privEl?.checked || false, - }; - - if (!formData.title || !formData.magnet) { - this.showError("Title and Magnet are required."); - return; - } - - try { - await nostrClient.publishVideo(formData, this.pubkey); - - // Clear fields - if (titleEl) titleEl.value = ""; - if (magnetEl) magnetEl.value = ""; - if (thumbEl) thumbEl.value = ""; - if (descEl) descEl.value = ""; - if (privEl) privEl.checked = false; - - // Hide the modal - if (this.uploadModal) { - this.uploadModal.classList.add("hidden"); - } - - // Refresh the video list - await this.loadVideos(); - this.showSuccess("Video shared successfully!"); - } catch (err) { - console.error("Failed to publish video:", err); - this.showError("Failed to share video. Please try again later."); - } - } - - /** - * Called upon successful login. - */ - login(pubkey, saveToStorage = true) { - this.pubkey = pubkey; - - // Hide login button if present - if (this.loginButton) { - this.loginButton.classList.add("hidden"); - } - // We can hide logout or userStatus if we want (or they might not exist) - if (this.logoutButton) { - this.logoutButton.classList.add("hidden"); - } - if (this.userStatus) { - this.userStatus.classList.add("hidden"); - } - - // Show the upload button, profile button, etc. - if (this.uploadButton) { - this.uploadButton.classList.remove("hidden"); - } - if (this.profileButton) { - this.profileButton.classList.remove("hidden"); - } - - // If you want to fetch your own profile to update UI - this.loadOwnProfile(pubkey); - - if (saveToStorage) { - localStorage.setItem("userPubKey", pubkey); - } - } - - /** - * Logout logic - */ - logout() { - nostrClient.logout(); - this.pubkey = null; - // Show login again (if it exists) - if (this.loginButton) { - this.loginButton.classList.remove("hidden"); - } - // Hide logout or userStatus - if (this.logoutButton) { - this.logoutButton.classList.add("hidden"); - } - if (this.userStatus) { - this.userStatus.classList.add("hidden"); - } - if (this.userPubKey) { - this.userPubKey.textContent = ""; - } - // Hide upload & profile - if (this.uploadButton) { - this.uploadButton.classList.add("hidden"); - } - if (this.profileButton) { - this.profileButton.classList.add("hidden"); - } - // Clear localStorage - localStorage.removeItem("userPubKey"); - } - - /** - * Cleanup resources on unload or modal close. - */ - async cleanup() { - try { - // If there's a small inline player - if (this.videoElement) { - this.videoElement.pause(); - this.videoElement.src = ""; - this.videoElement.load(); - } - // If there's a modal video - if (this.modalVideo) { - this.modalVideo.pause(); - this.modalVideo.src = ""; - this.modalVideo.load(); - } - // Tell webtorrent to cleanup - await torrentClient.cleanup(); - } catch (err) { - console.error("Cleanup error:", err); - } - } - - /** - * Hide the video modal. - */ - async hideModal() { - // 1) Clear intervals - if (this.activeIntervals && this.activeIntervals.length) { - this.activeIntervals.forEach((id) => clearInterval(id)); - this.activeIntervals = []; - } - - // 2) Cleanup resources (this stops the torrent, etc.) - await this.cleanup(); - - // 3) Hide the modal - if (this.playerModal) { - this.playerModal.style.display = "none"; - this.playerModal.classList.add("hidden"); - } - this.currentMagnetUri = null; - - // 4) Revert ?v= param in the URL - window.history.replaceState({}, "", window.location.pathname); - } - - /** - * Subscribe to new videos & render them. - */ - async loadVideos() { - console.log("Starting loadVideos (subscription approach)..."); - - if (this.videoSubscription) { - this.videoSubscription.unsub(); - this.videoSubscription = null; - } - - // Clear the listing (if #videoList is present) - if (this.videoList) { - this.videoList.innerHTML = ` - - Loading videos... - `; - } - - this.videosMap.clear(); - - try { - // Subscribe to new video events - this.videoSubscription = nostrClient.subscribeVideos((video) => { - // Skip private if not the owner - if (video.isPrivate && video.pubkey !== this.pubkey) { - return; - } - // Only render if new - if (!this.videosMap.has(video.id)) { - this.videosMap.set(video.id, video); - // Convert map to array, re-render - const all = Array.from(this.videosMap.values()); - this.renderVideoList(all); - } - }); - } catch (err) { - console.error("Subscription error:", err); - this.showError("Could not load videos via subscription."); - if (this.videoList) { - this.videoList.innerHTML = ` - - No videos available at this time. - `; - } - } - } - - /** - * Build the DOM for the video list. - */ - async renderVideoList(videos) { - if (!this.videoList) return; - - if (!videos || videos.length === 0) { - this.videoList.innerHTML = ` - - No public videos available yet. Be the first to upload one! - `; - return; - } - - // Sort newest first - videos.sort((a, b) => b.created_at - a.created_at); - - const htmlList = videos.map((video, index) => { - if (!video.id || !video.title) { - console.error("Video missing ID/title:", video); - return ""; - } - - const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); - const shareUrl = `${window.location.pathname}?v=${encodeURIComponent( - nevent - )}`; - const canEdit = video.pubkey === this.pubkey; - const highlightClass = - video.isPrivate && canEdit - ? "border-2 border-yellow-500" - : "border-none"; - const timeAgo = this.formatTimeAgo(video.created_at); - - // Gear menu if canEdit - const gearMenu = canEdit - ? ` - - - - - - - - Edit - - - Delete - - - - - ` - : ""; - - // Build card - const cardHtml = ` - - - - - - - - - ${this.escapeHTML(video.title)} - - - - - - - - - Loading name... - - - ${timeAgo} - - - - ${gearMenu} - - - - `; - - // Kick off a background fetch for the profile - this.fetchAndRenderProfile(video.pubkey); - - return cardHtml; - }); - - const valid = htmlList.filter((x) => x.length > 0); - if (valid.length === 0) { - this.videoList.innerHTML = ` - - No valid videos to display. - `; - return; - } - - this.videoList.innerHTML = valid.join(""); - } - - /** - * Retrieve the profile for a given pubkey (kind:0) and update the DOM. - */ - async fetchAndRenderProfile(pubkey) { - if (this.profileCache.has(pubkey)) { - this.updateProfileInDOM(pubkey, this.profileCache.get(pubkey)); - return; - } - try { - const userEvents = await nostrClient.pool.list(nostrClient.relays, [ - { kinds: [0], authors: [pubkey], limit: 1 }, - ]); - if (userEvents.length > 0 && userEvents[0].content) { - const data = JSON.parse(userEvents[0].content); - const profile = { - name: data.name || data.display_name || "Unknown", - picture: data.picture || "assets/jpg/default-profile.jpg", - }; - this.profileCache.set(pubkey, profile); - this.updateProfileInDOM(pubkey, profile); - } - } catch (err) { - console.error("Profile fetch error:", err); - } - } - - /** - * Update all DOM elements that match this pubkey, e.g. .author-pic[data-pubkey=...] - */ - updateProfileInDOM(pubkey, profile) { - const picEls = document.querySelectorAll( - `.author-pic[data-pubkey="${pubkey}"]` - ); - picEls.forEach((el) => { - el.src = profile.picture; - }); - const nameEls = document.querySelectorAll( - `.author-name[data-pubkey="${pubkey}"]` - ); - nameEls.forEach((el) => { - el.textContent = profile.name; - }); - } - - /** - * Plays a video given its magnet URI. - * We simply look up which event has this magnet - * and then delegate to playVideoByEventId for - * consistent modal and metadata handling. - */ - async playVideo(magnetURI) { - try { - if (!magnetURI) { - this.showError("Invalid Magnet URI."); - return; - } - - const decodedMagnet = decodeURIComponent(magnetURI); - - // If we are already playing this exact magnet, do nothing. - if (this.currentMagnetUri === decodedMagnet) { - this.log("Same video requested - already playing"); - return; - } - - // Look up the video in our subscription map - let matchedVideo = Array.from(this.videosMap.values()).find( - (v) => v.magnet === decodedMagnet - ); - - // If not found in the map, do a fallback fetch - if (!matchedVideo) { - const allVideos = await nostrClient.fetchVideos(); - matchedVideo = allVideos.find((v) => v.magnet === decodedMagnet); - } - - if (!matchedVideo) { - this.showError("No matching video found."); - return; - } - - // Update our tracking - this.currentMagnetUri = decodedMagnet; - - // Hand off to the method that already sets modal fields and streams - await this.playVideoByEventId(matchedVideo.id); - } catch (error) { - console.error("Error in playVideo:", error); - this.showError(`Playback error: ${error.message}`); - } - } - - /** - * Updates the modal to reflect current torrent stats. - * We remove the unused torrent.status references, - * and do not re-trigger recursion here (no setTimeout). - */ - updateTorrentStatus(torrent) { - console.log("[DEBUG] updateTorrentStatus called with torrent:", torrent); - - if (!torrent) { - console.log("[DEBUG] torrent is null/undefined!"); - return; - } - - // Log only fields that actually exist on the torrent: - console.log("[DEBUG] torrent.progress =", torrent.progress); - console.log("[DEBUG] torrent.numPeers =", torrent.numPeers); - console.log("[DEBUG] torrent.downloadSpeed =", torrent.downloadSpeed); - console.log("[DEBUG] torrent.downloaded =", torrent.downloaded); - console.log("[DEBUG] torrent.length =", torrent.length); - console.log("[DEBUG] torrent.ready =", torrent.ready); - - // Use "Complete" vs. "Downloading" as the textual status. - if (this.modalStatus) { - const fullyDownloaded = torrent.progress >= 1; - this.modalStatus.textContent = fullyDownloaded - ? "Complete" - : "Downloading"; - } - - // Update the progress bar - if (this.modalProgress) { - const percent = (torrent.progress * 100).toFixed(2); - this.modalProgress.style.width = `${percent}%`; - } - - // Update peers count - if (this.modalPeers) { - this.modalPeers.textContent = `Peers: ${torrent.numPeers}`; - } - - // Update speed in KB/s - if (this.modalSpeed) { - const kb = (torrent.downloadSpeed / 1024).toFixed(2); - this.modalSpeed.textContent = `${kb} KB/s`; - } - - // Update downloaded / total - if (this.modalDownloaded) { - const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2); - const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2); - this.modalDownloaded.textContent = `${downloadedMb} MB / ${lengthMb} MB`; - } - - // If you want to show a different text at 100% or if "ready" - // you can do it here: - if (torrent.ready && this.modalStatus) { - this.modalStatus.textContent = "Ready to play"; - } - } - - /** - * Handle "Edit Video" from gear menu. - */ - async handleEditVideo(index) { - try { - const all = await nostrClient.fetchVideos(); - const video = all[index]; - if (!this.pubkey) { - this.showError("Please login to edit videos."); - return; - } - if (!video || video.pubkey !== this.pubkey) { - this.showError("You do not own this video."); - return; - } - const newTitle = prompt("New Title? (blank=keep existing)", video.title); - const newMagnet = prompt( - "New Magnet? (blank=keep existing)", - video.magnet - ); - const newThumb = prompt( - "New Thumbnail? (blank=keep existing)", - video.thumbnail - ); - const newDesc = prompt( - "New Description? (blank=keep existing)", - video.description - ); - - const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); - const title = - !newTitle || !newTitle.trim() ? video.title : newTitle.trim(); - const magnet = - !newMagnet || !newMagnet.trim() ? video.magnet : newMagnet.trim(); - const thumbnail = - !newThumb || !newThumb.trim() ? video.thumbnail : newThumb.trim(); - const description = - !newDesc || !newDesc.trim() ? video.description : newDesc.trim(); - - const updatedData = { - version: video.version || 2, - isPrivate: wantPrivate, - title, - magnet, - thumbnail, - description, - mode: isDevMode ? "dev" : "live", - }; - 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."); - } - } - - /** - * Handle "Delete Video" from gear menu. - */ - async handleDeleteVideo(index) { - try { - const all = await nostrClient.fetchVideos(); - const video = all[index]; - if (!this.pubkey) { - this.showError("Please login to delete videos."); - return; - } - if (!video || video.pubkey !== this.pubkey) { - this.showError("You do not own this video."); - return; - } - if (!confirm(`Delete "${video.title}"? This can't be undone.`)) { - return; - } - const originalEvent = { - id: video.id, - pubkey: video.pubkey, - tags: video.tags, - }; - await nostrClient.deleteVideo(originalEvent, this.pubkey); - this.showSuccess("Video deleted successfully!"); - await this.loadVideos(); - } catch (err) { - this.log("Failed to delete video:", err.message); - this.showError("Failed to delete video. Please try again."); - } - } - - /** - * If there's a ?v= param in the URL, auto-open that video. - */ - checkUrlParams() { - const urlParams = new URLSearchParams(window.location.search); - const maybeNevent = urlParams.get("v"); - if (!maybeNevent) return; // no link param - - try { - const decoded = window.NostrTools.nip19.decode(maybeNevent); - if (decoded.type === "nevent" && decoded.data.id) { - const eventId = decoded.data.id; - // 1) check local map - let localMatch = this.videosMap.get(eventId); - if (localMatch) { - this.playVideoByEventId(eventId); - } else { - // 2) fallback => getOldEventById - this.getOldEventById(eventId) - .then((video) => { - if (video) { - this.playVideoByEventId(eventId); - } else { - this.showError("No matching video found for that link."); - } - }) - .catch((err) => { - console.error("Error fetching older event by ID:", err); - this.showError("Could not load videos for the share link."); - }); - } - } - } catch (err) { - console.error("Error decoding nevent:", err); - this.showError("Invalid share link."); - } - } - - /** - * Helper to open a video by event ID (like ?v=...). - */ - async playVideoByEventId(eventId) { - try { - // 1) Check local subscription map - let video = this.videosMap.get(eventId); - - // 2) If not in local map, attempt fallback fetch from getOldEventById - if (!video) { - video = await this.getOldEventById(eventId); - } - - // 3) If still no luck, show error and return - if (!video) { - this.showError("Video not found."); - return; - } - - // 4) Decrypt magnet if private & owned - if ( - video.isPrivate && - video.pubkey === this.pubkey && - !video.alreadyDecrypted - ) { - this.log("Decrypting private magnet link..."); - video.magnet = fakeDecrypt(video.magnet); - video.alreadyDecrypted = true; - } - - // 5) Show the modal - this.currentVideo = video; - this.currentMagnetUri = video.magnet; - this.showModalWithPoster(); - - // 6) Update ?v= param in the URL - const nevent = window.NostrTools.nip19.neventEncode({ id: eventId }); - const newUrl = - window.location.pathname + `?v=${encodeURIComponent(nevent)}`; - window.history.pushState({}, "", newUrl); - - // 7) Optionally fetch the author profile - 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 }, - ]); - if (userEvents.length > 0 && userEvents[0]?.content) { - const data = JSON.parse(userEvents[0].content); - creatorProfile = { - name: data.name || data.display_name || "Unknown", - picture: data.picture || `https://robohash.org/${video.pubkey}`, - }; - } - } catch (error) { - this.log("Error fetching creator profile:", error); - } - - // 8) Render video details in modal - const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; - if (this.videoTitle) - this.videoTitle.textContent = video.title || "Untitled"; - if (this.videoDescription) { - this.videoDescription.textContent = - video.description || "No description available."; - } - if (this.videoTimestamp) { - this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); - } - if (this.creatorName) { - this.creatorName.textContent = creatorProfile.name; - } - if (this.creatorNpub) { - this.creatorNpub.textContent = `${creatorNpub.slice( - 0, - 8 - )}...${creatorNpub.slice(-4)}`; - } - if (this.creatorAvatar) { - this.creatorAvatar.src = creatorProfile.picture; - this.creatorAvatar.alt = creatorProfile.name; - } - - // 9) Stream torrent - this.log("Starting video stream with:", video.magnet); - const realTorrent = await torrentClient.streamVideo( - video.magnet, - this.modalVideo - ); - - // 10) Start intervals to update stats - const updateInterval = setInterval(() => { - if (!document.body.contains(this.modalVideo)) { - clearInterval(updateInterval); - return; - } - this.updateTorrentStatus(realTorrent); - }, 1000); - this.activeIntervals.push(updateInterval); - - // (Optional) Mirror small inline stats into the modal - const mirrorInterval = setInterval(() => { - if (!document.body.contains(this.modalVideo)) { - clearInterval(mirrorInterval); - 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) { - this.modalStatus.textContent = status.textContent; - } - if (progress && this.modalProgress) { - this.modalProgress.style.width = progress.style.width; - } - if (peers && this.modalPeers) { - this.modalPeers.textContent = peers.textContent; - } - if (speed && this.modalSpeed) { - this.modalSpeed.textContent = speed.textContent; - } - if (downloaded && this.modalDownloaded) { - this.modalDownloaded.textContent = downloaded.textContent; - } - }, 1000); - this.activeIntervals.push(mirrorInterval); - } catch (error) { - this.log("Error in playVideoByEventId:", error); - this.showError(`Playback error: ${error.message}`); - } - } - - /** - * Simple helper to safely encode an npub. - */ - safeEncodeNpub(pubkey) { - try { - return window.NostrTools.nip19.npubEncode(pubkey); - } catch (err) { - return null; - } - } - - /** - * Attempts to fetch an older event by its ID if we can't find it in - * this.videosMap or from a bulk fetch. Uses nostrClient.getEventById. - */ - async getOldEventById(eventId) { - // Already in our map? - let video = this.videosMap.get(eventId); - if (video) { - return video; - } - - // Try the bulk fetch (fetchVideos) to see if it’s there - const allFromBulk = await nostrClient.fetchVideos(); - video = allFromBulk.find((v) => v.id === eventId); - if (video) { - // Store it so we can open it instantly next time - this.videosMap.set(video.id, video); - return video; - } - - // Final fallback: direct single-event fetch - const single = await nostrClient.getEventById(eventId); - if (single && !single.deleted) { - this.videosMap.set(single.id, single); - return single; - } - - return null; - } - - /** - * Format "time ago" for a given timestamp (in seconds). - */ - formatTimeAgo(timestamp) { - const seconds = Math.floor(Date.now() / 1000 - timestamp); - const intervals = { - year: 31536000, - month: 2592000, - week: 604800, - day: 86400, - hour: 3600, - minute: 60, - }; - for (const [unit, secInUnit] of Object.entries(intervals)) { - const int = Math.floor(seconds / secInUnit); - if (int >= 1) { - return `${int} ${unit}${int > 1 ? "s" : ""} ago`; - } - } - return "just now"; - } - - escapeHTML(unsafe) { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - showError(msg) { - console.error(msg); - if (this.errorContainer) { - this.errorContainer.textContent = msg; - this.errorContainer.classList.remove("hidden"); - setTimeout(() => { - this.errorContainer.classList.add("hidden"); - this.errorContainer.textContent = ""; - }, 5000); - } else { - alert(msg); - } - } - - showSuccess(msg) { - console.log(msg); - if (this.successContainer) { - this.successContainer.textContent = msg; - this.successContainer.classList.remove("hidden"); - setTimeout(() => { - this.successContainer.classList.add("hidden"); - this.successContainer.textContent = ""; - }, 5000); - } else { - alert(msg); - } - } - - log(msg) { - console.log(msg); - } - - /** - * Copies the current video's magnet link to the clipboard. - */ - handleCopyMagnet() { - if (!this.currentVideo || !this.currentVideo.magnet) { - this.showError("No magnet link to copy."); - return; - } - try { - navigator.clipboard.writeText(this.currentVideo.magnet); - this.showSuccess("Magnet link copied to clipboard!"); - } catch (err) { - console.error("Failed to copy magnet link:", err); - this.showError("Could not copy magnet link. Please copy it manually."); - } - } -} - -export const app = new bitvidApp(); -app.init(); -window.app = app; diff --git a/src/js/nostr copy 2.js b/src/js/nostr copy 2.js deleted file mode 100644 index 1e245c1..0000000 --- a/src/js/nostr copy 2.js +++ /dev/null @@ -1,608 +0,0 @@ -// 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", - "wss://relay.nostr.band", -]; - -// Just a helper to keep error spam in check -let errorLogCount = 0; -const MAX_ERROR_LOGS = 100; -function logErrorOnce(message, eventContent = null) { - if (errorLogCount < MAX_ERROR_LOGS) { - console.error(message); - if (eventContent) { - console.log(`Event Content: ${eventContent}`); - } - errorLogCount++; - } - if (errorLogCount === MAX_ERROR_LOGS) { - console.error( - "Maximum error log limit reached. Further errors will be suppressed." - ); - } -} - -/** - * Example "encryption" that just reverses strings. - * In real usage, swap with actual crypto. - */ -function fakeEncrypt(magnet) { - return magnet.split("").reverse().join(""); -} -function fakeDecrypt(encrypted) { - return encrypted.split("").reverse().join(""); -} - -/** - * Convert a raw Nostr event => your "video" object. - */ -function convertEventToVideo(event) { - const content = JSON.parse(event.content || "{}"); - return { - id: event.id, - // We store a 'videoRootId' in content so we can group multiple edits - videoRootId: content.videoRootId || null, - version: content.version ?? 1, - 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, - }; -} - -/** - * Key each "active" video by its root ID => so you only store - * the newest version for each root. But for older events w/o videoRootId, - * or w/o 'd' tag, we handle fallback logic below. - */ -function getActiveKey(video) { - // If it has a videoRootId, we use that - if (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"); - if (dTag) { - return `${video.pubkey}:${dTag[1]}`; - } - return `LEGACY:${video.id}`; -} - -class NostrClient { - constructor() { - this.pool = null; - this.pubkey = null; - this.relays = RELAY_URLS; - - // All events—old or new—so older share links still work - this.allEvents = new Map(); - - // "activeMap" holds only the newest version for each root ID (or fallback). - this.activeMap = new Map(); - } - - /** - * Connect to all configured relays - */ - async init() { - if (isDevMode) console.log("Connecting to relays..."); - - try { - this.pool = new window.NostrTools.SimplePool(); - const results = await this.connectToRelays(); - const successfulRelays = results - .filter((r) => r.success) - .map((r) => r.url); - if (successfulRelays.length === 0) throw new Error("No relays connected"); - if (isDevMode) { - console.log(`Connected to ${successfulRelays.length} relay(s)`); - } - } catch (err) { - console.error("Nostr init failed:", err); - throw err; - } - } - - async connectToRelays() { - return Promise.all( - this.relays.map( - (url) => - new Promise((resolve) => { - const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]); - const timeout = setTimeout(() => { - sub.unsub(); - resolve({ url, success: false }); - }, 5000); - - const succeed = () => { - clearTimeout(timeout); - sub.unsub(); - resolve({ url, success: true }); - }; - - sub.on("event", succeed); - sub.on("eose", succeed); - }) - ) - ); - } - - /** - * Attempt Nostr extension login or abort - */ - async login() { - try { - if (!window.nostr) { - console.log("No Nostr extension found"); - throw new Error( - "Please install a Nostr extension (Alby, nos2x, etc.)." - ); - } - - const pubkey = await window.nostr.getPublicKey(); - const npub = window.NostrTools.nip19.npubEncode(pubkey); - - if (isDevMode) { - console.log("Got pubkey:", pubkey); - console.log("Converted to npub:", npub); - console.log("Whitelist:", accessControl.getWhitelist()); - console.log("Blacklist:", accessControl.getBlacklist()); - } - - // Access control check - if (!accessControl.canAccess(npub)) { - if (accessControl.isBlacklisted(npub)) { - throw new Error("Your account has been blocked on this platform."); - } else { - throw new Error("Access restricted to whitelisted users only."); - } - } - - this.pubkey = pubkey; - if (isDevMode) { - console.log("Logged in with extension. Pubkey:", this.pubkey); - } - return this.pubkey; - } catch (e) { - console.error("Login error:", e); - throw e; - } - } - - logout() { - this.pubkey = null; - 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 - */ - async publishVideo(videoData, pubkey) { - if (!pubkey) throw new Error("Not logged in to publish video."); - - if (isDevMode) { - console.log("Publishing new video with data:", videoData); - } - - let finalMagnet = videoData.magnet; - if (videoData.isPrivate) { - finalMagnet = fakeEncrypt(finalMagnet); - } - - // new "videoRootId" ensures all future edits know they're from the same root - const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - - const contentObject = { - videoRootId, - version: videoData.version ?? 1, - deleted: false, - isPrivate: videoData.isPrivate ?? false, - title: videoData.title || "", - magnet: finalMagnet, - thumbnail: videoData.thumbnail || "", - description: videoData.description || "", - mode: videoData.mode || "live", - }; - - const event = { - kind: 30078, - pubkey, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["t", "video"], - ["d", dTagValue], - ], - content: JSON.stringify(contentObject), - }; - - if (isDevMode) { - console.log("Publish event with brand-new root:", videoRootId); - console.log("Event content:", event.content); - } - - try { - const signedEvent = await window.nostr.signEvent(event); - if (isDevMode) console.log("Signed event:", signedEvent); - - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - if (isDevMode) console.log(`Video published to ${url}`); - } catch (err) { - if (isDevMode) console.error(`Failed to publish: ${url}`, err); - } - }) - ); - - return signedEvent; - } catch (err) { - if (isDevMode) console.error("Failed to sign/publish:", err); - throw err; - } - } - - /** - * Edits a video by creating a *new event* with a brand-new d tag, - * but reuses the same videoRootId as the original. - * => old link remains pinned to the old event, new link is a fresh ID. - */ - async editVideo(originalVideo, updatedData, pubkey) { - if (!pubkey) throw new Error("Not logged in to edit."); - if (originalVideo.pubkey !== pubkey) { - throw new Error("You do not own this video (different pubkey)."); - } - - // Use the videoRootId from the originalVideo directly - const rootId = originalVideo.videoRootId || null; - - // Determine if the original magnet was encrypted and decrypt if necessary - let oldPlainMagnet = originalVideo.magnet || ""; - if (originalVideo.isPrivate && oldPlainMagnet) { - oldPlainMagnet = fakeDecrypt(oldPlainMagnet); - } - - // Determine new privacy setting and magnet - const wantPrivate = - updatedData.isPrivate ?? originalVideo.isPrivate ?? false; - let finalPlainMagnet = (updatedData.magnet || "").trim(); - if (!finalPlainMagnet) { - finalPlainMagnet = oldPlainMagnet; // Fallback to original if not provided - } - - let finalMagnet = finalPlainMagnet; - if (wantPrivate) { - finalMagnet = fakeEncrypt(finalPlainMagnet); - } - - // Use existing rootId or generate a new one (if original lacked it) - const newRootId = - rootId || `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`; - - // Construct the new content object using originalVideo's properties where applicable - const contentObject = { - videoRootId: newRootId, - version: updatedData.version ?? originalVideo.version ?? 1, - deleted: false, - isPrivate: wantPrivate, - title: updatedData.title ?? originalVideo.title, - magnet: finalMagnet, - thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail, - description: updatedData.description ?? originalVideo.description, - mode: updatedData.mode ?? originalVideo.mode ?? "live", - }; - - const event = { - kind: 30078, - pubkey, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["t", "video"], - ["d", newD], // New dTag for the edit - ], - content: JSON.stringify(contentObject), - }; - - if (isDevMode) { - console.log("Creating edited event with root ID:", newRootId); - console.log("Event content:", event.content); - } - - try { - const signedEvent = await window.nostr.signEvent(event); - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - } catch (err) { - if (isDevMode) console.error(`Publish failed to ${url}`, err); - } - }) - ); - return signedEvent; - } catch (err) { - console.error("Edit failed:", err); - throw err; - } - } - - /** - * "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc - */ - async deleteVideo(originalEvent, pubkey) { - if (!pubkey) { - throw new Error("Not logged in to delete."); - } - if (originalEvent.pubkey !== pubkey) { - throw new Error("Not your event (pubkey mismatch)."); - } - - // If front-end didn't pass the tags array, load the full event from local or from the relay: - let baseEvent = originalEvent; - if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) { - const fetched = await this.getEventById(originalEvent.id); - if (!fetched) { - throw new Error("Could not fetch the original event for deletion."); - } - // Rebuild baseEvent as a raw Nostr event that includes .tags and .content - baseEvent = { - id: fetched.id, - pubkey: fetched.pubkey, - // put the raw JSON content back into string form: - content: JSON.stringify({ - version: fetched.version, - deleted: fetched.deleted, - isPrivate: fetched.isPrivate, - title: fetched.title, - magnet: fetched.magnet, - thumbnail: fetched.thumbnail, - description: fetched.description, - mode: fetched.mode, - }), - tags: fetched.tags, - }; - } - - // Now try to get the old d-tag - const dTag = baseEvent.tags.find((t) => t[0] === "d"); - if (!dTag) { - throw new Error('No "d" tag => cannot delete addressable kind=30078.'); - } - const existingD = dTag[1]; - - // Parse out the old content so we can preserve version, isPrivate, etc. - const oldContent = JSON.parse(baseEvent.content || "{}"); - const oldVersion = oldContent.version ?? 1; - - // Mark it "deleted" and clear out magnet, thumbnail, etc. - const contentObject = { - version: oldVersion, - deleted: true, - isPrivate: oldContent.isPrivate ?? false, - title: oldContent.title || "", - magnet: "", - thumbnail: "", - description: "Video was deleted by creator.", - mode: oldContent.mode || "live", - }; - - const event = { - kind: 30078, - pubkey, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["t", "video"], - // We reuse the same d => overshadow the original event - ["d", existingD], - ], - content: JSON.stringify(contentObject), - }; - - if (isDevMode) { - console.log("Deleting video => mark 'deleted:true'.", event.content); - } - - try { - const signedEvent = await window.nostr.signEvent(event); - if (isDevMode) { - console.log("Signed deleted event:", signedEvent); - } - - // Publish everywhere - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - if (isDevMode) { - console.log(`Delete event published to ${url}`); - } - } catch (err) { - if (isDevMode) { - console.error(`Failed to publish deleted event to ${url}:`, err); - } - } - }) - ); - return signedEvent; - } catch (err) { - if (isDevMode) { - console.error("Failed to sign deleted event:", err); - } - throw new Error("Failed to sign deleted event."); - } - } - - /** - * Subscribes to *all* video events. We store them in this.allEvents so older - * notes remain accessible by ID, plus we maintain this.activeMap for the newest - * version of each root (or fallback). - */ - subscribeVideos(onVideo) { - const filter = { - kinds: [30078], - "#t": ["video"], - limit: 500, - since: 0, - }; - if (isDevMode) { - console.log("[subscribeVideos] Subscribing with filter:", filter); - } - - const sub = this.pool.sub(this.relays, [filter]); - sub.on("event", (event) => { - try { - const video = convertEventToVideo(event); - this.allEvents.set(event.id, video); - - // If it’s marked deleted, remove from active map if it’s the active version - if (video.deleted) { - const activeKey = getActiveKey(video); - const existing = this.activeMap.get(activeKey); - if (existing && existing.id === video.id) { - this.activeMap.delete(activeKey); - } - return; - } - - // Not deleted => see if it’s the newest - const activeKey = getActiveKey(video); - const prevActive = this.activeMap.get(activeKey); - if (!prevActive) { - // brand new => set it - this.activeMap.set(activeKey, video); - onVideo(video); - } else { - // compare timestamps - if (video.created_at > prevActive.created_at) { - this.activeMap.set(activeKey, video); - onVideo(video); - } - } - } catch (err) { - if (isDevMode) { - console.error("[subscribeVideos] Error processing event:", err); - } - } - }); - - sub.on("eose", () => { - if (isDevMode) { - console.log("[subscribeVideos] Reached EOSE for all relays"); - } - }); - - return sub; - } - - /** - * Bulk fetch from all relays, store in allEvents, rebuild activeMap - */ - async fetchVideos() { - const filter = { - kinds: [30078], - "#t": ["video"], - limit: 300, - since: 0, - }; - - const localAll = new Map(); - try { - await Promise.all( - this.relays.map(async (url) => { - const events = await this.pool.list([url], [filter]); - for (const evt of events) { - const vid = convertEventToVideo(evt); - localAll.set(evt.id, vid); - } - }) - ); - - // Merge into this.allEvents - for (const [id, vid] of localAll.entries()) { - this.allEvents.set(id, vid); - } - - // Rebuild activeMap - this.activeMap.clear(); - for (const [id, video] of this.allEvents.entries()) { - if (video.deleted) continue; - const activeKey = getActiveKey(video); - const existing = this.activeMap.get(activeKey); - if (!existing || video.created_at > existing.created_at) { - this.activeMap.set(activeKey, video); - } - } - - // Return an array of newest for each root - const activeVideos = Array.from(this.activeMap.values()).sort( - (a, b) => b.created_at - a.created_at - ); - return activeVideos; - } catch (err) { - console.error("fetchVideos error:", err); - return []; - } - } - - /** - * Attempt to fetch an event by ID from local cache, then from the relays - */ - async getEventById(eventId) { - const local = this.allEvents.get(eventId); - if (local) { - return local; - } - // direct fetch if missing - try { - for (const url of this.relays) { - const maybeEvt = await this.pool.get([url], { ids: [eventId] }); - if (maybeEvt && maybeEvt.id === eventId) { - const video = convertEventToVideo(maybeEvt); - this.allEvents.set(eventId, video); - return video; - } - } - } catch (err) { - if (isDevMode) { - console.error("getEventById direct fetch error:", err); - } - } - return null; // not found - } - - /** - * Return newest versions from activeMap if you want to skip older events - */ - getActiveVideos() { - return Array.from(this.activeMap.values()).sort( - (a, b) => b.created_at - a.created_at - ); - } -} - -export const nostrClient = new NostrClient(); diff --git a/src/js/nostr copy.js b/src/js/nostr copy.js deleted file mode 100644 index 6d0be5a..0000000 --- a/src/js/nostr copy.js +++ /dev/null @@ -1,684 +0,0 @@ -// 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", - "wss://relay.nostr.band", -]; - -// 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(""); -} - -/** - * Convert a raw Nostr event into your "video" object. - */ -function convertEventToVideo(event) { - const content = JSON.parse(event.content || "{}"); - return { - 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", - deleted: content.deleted === true, - pubkey: event.pubkey, - created_at: event.created_at, - tags: event.tags, - }; -} - -/** - * Return a combined key for (pubkey, dTagValue). - * If there's no `d` tag, we fallback to a special key so - * those older events still appear in the grid. - */ -function getPubkeyDKey(evt) { - const dTag = evt.tags.find((t) => t[0] === "d"); - if (dTag) { - return `${evt.pubkey}:${dTag[1]}`; - } else { - // NEW: older events didn't have a d-tag, so use an alternative key - // Example: "npubXYZ:no-d:id-of-event" - return `${evt.pubkey}:no-d:${evt.id}`; - } -} - -class NostrClient { - constructor() { - this.pool = null; - this.pubkey = null; - this.relays = RELAY_URLS; - - // All events, old or new, keyed by event.id - this.allEvents = new Map(); - - // Only the "active" (non-deleted) newest version per (pubkey + dTag OR fallback) - this.activeMap = new Map(); - } - - /** - * 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; - } - } - - // Connects to each relay, ensuring they're alive - 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); - - 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; - } - } - - logout() { - this.pubkey = null; - 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."); - } - } - - /** - * 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); - } - - const version = videoData.version ?? 1; - const uniqueD = `${Date.now()}-${Math.random() - .toString(36) - .substring(2, 10)}`; - 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. - */ - 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); - } - - const dTag = originalEvent.tags.find((tag) => tag[0] === "d"); - if (!dTag) { - throw new Error('No "d" tag => cannot edit as addressable kind=30078.'); - } - const existingD = dTag[1]; - - const oldContent = JSON.parse(originalEvent.content || "{}"); - const oldVersion = oldContent.version ?? 1; - const oldDeleted = oldContent.deleted === true; - const newVersion = updatedVideoData.version ?? oldVersion; - - const oldWasPrivate = oldContent.isPrivate === true; - let oldPlainMagnet = oldContent.magnet || ""; - if (oldWasPrivate && oldPlainMagnet) { - oldPlainMagnet = fakeDecrypt(oldPlainMagnet); - } - - const newIsPrivate = - typeof updatedVideoData.isPrivate === "boolean" - ? updatedVideoData.isPrivate - : oldContent.isPrivate ?? false; - - const userTypedMagnet = (updatedVideoData.magnet || "").trim(); - const finalPlainMagnet = userTypedMagnet || oldPlainMagnet; - - let finalMagnet = finalPlainMagnet; - if (newIsPrivate) { - finalMagnet = fakeEncrypt(finalPlainMagnet); - } - - 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); - } - - 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" - */ - 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('No "d" tag => cannot delete 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); - } - } - }) - ); - - return signedEvent; - } catch (error) { - if (isDevMode) { - console.error("Failed to sign deleted event:", error); - } - throw new Error("Failed to sign deleted event."); - } - } - - /** - * Subscribes to all video events from all relays. - * We store them in `allEvents` (so old IDs are still available), - * and we also maintain `activeMap` for the newest versions of each pubkey-dKey. - * - * @param {Function} onVideo - Callback for each newly recognized "active" video - */ - subscribeVideos(onVideo) { - const filter = { - kinds: [30078], - "#t": ["video"], - limit: 500, - since: 0, // we want from the beginning - }; - - if (isDevMode) { - console.log("[subscribeVideos] Subscribing with filter:", filter); - } - - const sub = this.pool.sub(this.relays, [filter]); - - sub.on("event", (event) => { - try { - // Convert event => video object - const video = convertEventToVideo(event); - - // Always store it in allEvents - this.allEvents.set(event.id, video); - - // If deleted, remove from activeMap if it's the active version - if (video.deleted) { - const key = getPubkeyDKey(event); // might be no-d if no 'd' tag - const activeVid = this.activeMap.get(key); - if (activeVid && activeVid.id === event.id) { - this.activeMap.delete(key); - } - return; - } - - // It's not deleted => see if we should set it as active - const key = getPubkeyDKey(event); // might be "npubXYZ:no-d:ID" - const existingActive = this.activeMap.get(key); - if (!existingActive) { - // brand new => store it - this.activeMap.set(key, video); - onVideo(video); - } else { - // We have an active version; check timestamps - if (video.created_at > existingActive.created_at) { - // It's newer => overwrite - this.activeMap.set(key, video); - onVideo(video); - } else { - // It's an older event => ignore from "active" perspective - // but still in allEvents for old links - } - } - } catch (err) { - if (isDevMode) { - console.error("[subscribeVideos] Error parsing event:", err); - } - } - }); - - sub.on("eose", () => { - if (isDevMode) { - console.log("[subscribeVideos] Reached EOSE for all relays"); - } - // Optionally notify that the initial load is done - }); - - return sub; - } - - /** - * Bulk fetch of videos from all relays. Then we build the `activeMap` - * so your grid can show all old & new events (even if no 'd' tag). - */ - async fetchVideos() { - const filter = { - kinds: [30078], - "#t": ["video"], - // Increase limit if you want more than 300 - limit: 300, - since: 0, - }; - - const localAll = new Map(); - - try { - await Promise.all( - this.relays.map(async (url) => { - const events = await this.pool.list([url], [filter]); - for (const evt of events) { - const video = convertEventToVideo(evt); - localAll.set(evt.id, video); - } - }) - ); - - // Merge localAll into our global allEvents - for (const [id, vid] of localAll.entries()) { - this.allEvents.set(id, vid); - } - - // Re-build activeMap - this.activeMap.clear(); - for (const [id, video] of this.allEvents.entries()) { - if (video.deleted) continue; // skip - const key = getPubkeyDKey({ - id, - tags: video.tags, - pubkey: video.pubkey, - }); - const existing = this.activeMap.get(key); - if (!existing || video.created_at > existing.created_at) { - this.activeMap.set(key, video); - } - } - - // Return sorted "active" array for your grid - const activeVideos = Array.from(this.activeMap.values()).sort( - (a, b) => b.created_at - a.created_at - ); - return activeVideos; - } catch (err) { - console.error("fetchVideos error:", err); - return []; - } - } - - /** - * Get an event by ID from our local cache (allEvents) if present. - * If missing, do a direct pool.get() for that ID. This ensures older - * "archived" events might still be loaded from the relays. - */ - async getEventById(eventId) { - const local = this.allEvents.get(eventId); - if (local) { - return local; - } - - // NEW: do a direct fetch if not found - try { - for (const url of this.relays) { - const maybeEvt = await this.pool.get([url], { ids: [eventId] }); - if (maybeEvt && maybeEvt.id === eventId) { - const video = convertEventToVideo(maybeEvt); - // store in allEvents - this.allEvents.set(eventId, video); - return video; - } - } - } catch (err) { - if (isDevMode) { - console.error("getEventById direct fetch error:", err); - } - } - - // not found - return null; - } - - /** - * Return the "active" videos, i.e. latest for each (pubkey+d or fallback). - */ - getActiveVideos() { - return Array.from(this.activeMap.values()).sort( - (a, b) => b.created_at - a.created_at - ); - } - - 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); - } - return isValid; - } catch (error) { - if (isDevMode) { - console.error("Error validating video:", error); - } - return false; - } - } -} - -export const nostrClient = new NostrClient();
bitvid is a decentralized video platform where content is shared @@ -233,7 +219,6 @@
- Loading videos... -
- No videos available at this time. -
- No public videos available yet. Be the first to upload one! -
- Loading name... -
- No valid videos to display. -