From c0b2388b0ca2e3d9c9b0c319f39a906b5759bc7d Mon Sep 17 00:00:00 2001 From: Keep Creating Online <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 1 Feb 2025 18:41:55 -0500 Subject: [PATCH] added revert video function and delete all previous version functions and added better fetching for profile images and names --- src/js/app.js | 349 +++++++++++++++++++++++++++++++++--------------- src/js/nostr.js | 219 +++++++++++++++++++----------- 2 files changed, 377 insertions(+), 191 deletions(-) diff --git a/src/js/app.js b/src/js/app.js index 8dd4f2b..9821278 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -84,6 +84,19 @@ class bitvidApp { document.getElementById("closeLoginModal") || null; } + forceRefreshAllProfiles() { + // 1) Grab the newest set of videos from nostrClient + const activeVideos = nostrClient.getActiveVideos(); + + // 2) Build a unique set of pubkeys + const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey)); + + // 3) For each author, fetchAndRenderProfile with forceRefresh = true + for (const authorPubkey of uniqueAuthors) { + this.fetchAndRenderProfile(authorPubkey, true); + } + } + async init() { try { // 1. Initialize the video modal (components/video-modal.html) @@ -521,7 +534,7 @@ class bitvidApp { this.uploadModal.classList.add("hidden"); } - // Refresh the video list + // *** Refresh to show the newly uploaded video in the grid *** await this.loadVideos(); this.showSuccess("Video shared successfully!"); } catch (err) { @@ -533,17 +546,16 @@ class bitvidApp { /** * Called upon successful login. */ - login(pubkey, saveToStorage = true) { + async login(pubkey, saveToStorage = true) { console.log("[app.js] login() called with pubkey =", pubkey); this.pubkey = pubkey; - // Hide the login button if present + // Hide login button if present if (this.loginButton) { this.loginButton.classList.add("hidden"); } - - // Hide logout / userStatus if you’re not using them + // Optionally hide logout or userStatus if (this.logoutButton) { this.logoutButton.classList.add("hidden"); } @@ -551,7 +563,7 @@ class bitvidApp { this.userStatus.classList.add("hidden"); } - // IMPORTANT: Unhide the Upload & Profile buttons + // Show the upload button, profile button, etc. if (this.uploadButton) { this.uploadButton.classList.remove("hidden"); } @@ -559,25 +571,33 @@ class bitvidApp { this.profileButton.classList.remove("hidden"); } - // Optionally load the user's own profile + // (Optional) load the user's own Nostr profile this.loadOwnProfile(pubkey); - // Save pubkey in localStorage (if desired) + // Save pubkey locally if requested if (saveToStorage) { localStorage.setItem("userPubKey", pubkey); } + + // Refresh the video list so the user sees any private videos, etc. + await this.loadVideos(); + + // Force a fresh fetch of all profile pictures/names + this.forceRefreshAllProfiles(); } /** * Logout logic */ - logout() { + async logout() { nostrClient.logout(); this.pubkey = null; - // Show login again (if it exists) + + // Show the login button again if (this.loginButton) { this.loginButton.classList.remove("hidden"); } + // Hide logout or userStatus if (this.logoutButton) { this.logoutButton.classList.add("hidden"); @@ -588,6 +608,7 @@ class bitvidApp { if (this.userPubKey) { this.userPubKey.textContent = ""; } + // Hide upload & profile if (this.uploadButton) { this.uploadButton.classList.add("hidden"); @@ -595,8 +616,15 @@ class bitvidApp { if (this.profileButton) { this.profileButton.classList.add("hidden"); } + // Clear localStorage localStorage.removeItem("userPubKey"); + + // Refresh the video list so user sees only public videos again + await this.loadVideos(); + + // Force a fresh fetch of all profile pictures/names (public ones in this case) + this.forceRefreshAllProfiles(); } /** @@ -653,15 +681,15 @@ class bitvidApp { // js/app.js async loadVideos() { - console.log("Starting loadVideos (subscription approach)..."); + console.log("Starting loadVideos..."); - // If you had an existing subscription, unsubscribe first: + // 1) If there's an existing subscription, unsubscribe it if (this.videoSubscription) { this.videoSubscription.unsub(); this.videoSubscription = null; } - // Optionally show "loading videos..." message + // 2) Show "Loading..." message if (this.videoList) { this.videoList.innerHTML = `

@@ -669,41 +697,24 @@ class bitvidApp {

`; } - // Clear your local map - this.videosMap.clear(); - try { - // Subscribe to new events from nostrClient + // 3) Force a bulk fetch + await nostrClient.fetchVideos(); + + // 4) Instead of reusing the entire fetched array, + // use getActiveVideos() for the final display: + const newestActive = nostrClient.getActiveVideos(); + this.renderVideoList(newestActive); + + // 5) Subscribe for updates this.videoSubscription = nostrClient.subscribeVideos((video) => { - // If the video is marked deleted, remove it from your local collection - if (video.deleted) { - if (this.videosMap.has(video.id)) { - this.videosMap.delete(video.id); - // Now rebuild the list - const allVideos = Array.from(this.videosMap.values()); - const newestPerRoot = dedupeToNewestByRoot(allVideos); - this.renderVideoList(newestPerRoot); - } - return; - } - - // Skip private videos if they do not belong to the current user - if (video.isPrivate && video.pubkey !== this.pubkey) { - return; - } - - // Only add if it's not in the map - if (!this.videosMap.has(video.id)) { - this.videosMap.set(video.id, video); - // Re-run the dedupe logic - const allVideos = Array.from(this.videosMap.values()); - const newestPerRoot = dedupeToNewestByRoot(allVideos); - this.renderVideoList(newestPerRoot); - } + // Whenever we get a new or updated event, re-render the newest set: + const activeAll = nostrClient.getActiveVideos(); + this.renderVideoList(activeAll); }); } catch (err) { - console.error("Subscription error:", err); - this.showError("Could not load videos via subscription."); + console.error("Could not load videos:", err); + this.showError("Could not load videos from relays."); if (this.videoList) { this.videoList.innerHTML = `

@@ -714,22 +725,40 @@ class bitvidApp { } /** - * Build the DOM for the video list. + * Returns true if there's at least one strictly older version + * (same videoRootId, created_at < current) which is NOT deleted. */ + hasOlderVersion(video, allEvents) { + if (!video || !video.videoRootId) return false; + + const rootId = video.videoRootId; + const currentTs = video.created_at; + + // among ALL known events (including overshadowed), find older, not deleted + const olderMatches = allEvents.filter( + (v) => v.videoRootId === rootId && v.created_at < currentTs && !v.deleted + ); + return olderMatches.length > 0; + } + + // 4) Build the DOM for each video in newestActive 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! -

`; +

+ 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); + // <-- NEW: Convert allEvents map => array to check older overshadowed events + const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); + const htmlList = videos.map((video, index) => { if (!video.id || !video.title) { console.error("Video missing ID/title:", video); @@ -747,45 +776,64 @@ class bitvidApp { : "border-none"; const timeAgo = this.formatTimeAgo(video.created_at); - // Gear menu if canEdit - const gearMenu = canEdit + // 1) Do we have an older version? + let hasOlder = false; + if (canEdit && video.videoRootId) { + hasOlder = this.hasOlderVersion(video, fullAllEventsArray); + } + + // 2) If we do => show revert button + const revertButton = hasOlder ? ` -
- - -
+ ` : ""; - // Build card + // 3) Gear menu + const gearMenu = canEdit + ? ` +
+ + +
+ ` + : ""; + + // 4) Build the card markup... const cardHtml = `
- `; + `; - // Kick off a background fetch for the profile + // Fire off a background fetch for the author's profile this.fetchAndRenderProfile(video.pubkey); return cardHtml; }); + // Filter out any empty strings const valid = htmlList.filter((x) => x.length > 0); if (valid.length === 0) { this.videoList.innerHTML = ` -

- No valid videos to display. -

`; +

+ No valid videos to display. +

`; return; } + // Finally inject into DOM 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)); + async fetchAndRenderProfile(pubkey, forceRefresh = false) { + const now = Date.now(); + + // Check if we already have a cached entry for this pubkey: + const cacheEntry = this.profileCache.get(pubkey); + + // If not forcing refresh, and we have a cache entry less than 60 sec old, use it: + if (!forceRefresh && cacheEntry && now - cacheEntry.timestamp < 60000) { + this.updateProfileInDOM(pubkey, cacheEntry.profile); return; } + + // Otherwise, go fetch from the relay try { const userEvents = await nostrClient.pool.list(nostrClient.relays, [ { kinds: [0], authors: [pubkey], limit: 1 }, @@ -888,11 +946,18 @@ class bitvidApp { name: data.name || data.display_name || "Unknown", picture: data.picture || "assets/jpg/default-profile.jpg", }; - this.profileCache.set(pubkey, profile); + + // Store into the cache with a timestamp + this.profileCache.set(pubkey, { + profile, + timestamp: now, + }); + + // Now update the DOM elements this.updateProfileInDOM(pubkey, profile); } } catch (err) { - console.error("Profile fetch error:", err); + console.error("Profile fetch error for pubkey:", pubkey, err); } } @@ -1027,8 +1092,11 @@ class bitvidApp { */ async handleEditVideo(index) { try { + // 1) Fetch the current list of videos (the newest versions) const all = await nostrClient.fetchVideos(); const video = all[index]; + + // 2) Basic ownership checks if (!this.pubkey) { this.showError("Please login to edit videos."); return; @@ -1038,7 +1106,7 @@ class bitvidApp { return; } - // Prompt for updated fields + // 3) Prompt the user for updated fields const newTitle = prompt("New Title? (blank=keep existing)", video.title); const newMagnet = prompt( "New Magnet? (blank=keep existing)", @@ -1054,7 +1122,7 @@ class bitvidApp { ); const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); - // Build updated data, falling back to old values + // 4) Build final updated fields (or fallback to existing) const title = !newTitle || !newTitle.trim() ? video.title : newTitle.trim(); const magnet = @@ -1064,6 +1132,7 @@ class bitvidApp { const description = !newDesc || !newDesc.trim() ? video.description : newDesc.trim(); + // 5) Create an object with the new data const updatedData = { version: video.version || 2, isPrivate: wantPrivate, @@ -1074,27 +1143,81 @@ class bitvidApp { mode: isDevMode ? "dev" : "live", }; - // IMPORTANT: we only pass id and pubkey to avoid reusing the old d-tag - // (Do NOT pass video.tags!) - const originalEvent = video; + // 6) Build the originalEvent stub, now including videoRootId to avoid extra fetch + const originalEvent = { + id: video.id, + pubkey: video.pubkey, + videoRootId: video.videoRootId, // <-- pass this if it exists + }; + // 7) Call the editVideo method await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); - this.showSuccess("Video updated successfully!"); + // 8) Refresh local UI await this.loadVideos(); + this.showSuccess("Video updated successfully!"); + + // 9) Also refresh all profile caches so any new name/pic changes are reflected + this.forceRefreshAllProfiles(); } catch (err) { - this.log("Failed to edit video:", err.message); + console.error("Failed to edit video:", err); this.showError("Failed to edit video. Please try again."); } } + async handleRevertVideo(index) { + try { + // 1) Still use fetchVideos to get the video in question + const activeVideos = await nostrClient.fetchVideos(); + const video = activeVideos[index]; + + if (!this.pubkey) { + this.showError("Please login to revert."); + return; + } + if (!video || video.pubkey !== this.pubkey) { + this.showError("You do not own this video."); + return; + } + + // 2) Grab all known events so older overshadowed ones are included + const allEvents = Array.from(nostrClient.allEvents.values()); + + // 3) Check for older versions among *all* events, not just the active ones + if (!this.hasOlderVersion(video, allEvents)) { + this.showError("No older version exists to revert to."); + return; + } + + if (!confirm(`Revert current version of "${video.title}"?`)) { + return; + } + + const originalEvent = { + id: video.id, + pubkey: video.pubkey, + tags: video.tags, + }; + + await nostrClient.revertVideo(originalEvent, this.pubkey); + + await this.loadVideos(); + this.showSuccess("Current version reverted successfully!"); + this.forceRefreshAllProfiles(); + } catch (err) { + console.error("Failed to revert video:", err); + this.showError("Failed to revert video. Please try again."); + } + } + /** * Handle "Delete Video" from gear menu. */ - async handleDeleteVideo(index) { + async handleFullDeleteVideo(index) { try { const all = await nostrClient.fetchVideos(); const video = all[index]; + if (!this.pubkey) { this.showError("Please login to delete videos."); return; @@ -1103,23 +1226,27 @@ class bitvidApp { this.showError("You do not own this video."); return; } - if (!confirm(`Delete "${video.title}"? This can't be undone.`)) { + // Make sure the user is absolutely sure: + if ( + !confirm( + `Delete ALL versions of "${video.title}"? This action is permanent.` + ) + ) { return; } - // Only id and pubkey (omit old tags), so that delete doesn't overshadow the old d-tag - const originalEvent = { - id: video.id, - pubkey: video.pubkey, - }; + // We assume video.videoRootId is not empty, or fallback to video.id if needed + const rootId = video.videoRootId || video.id; - await nostrClient.deleteVideo(originalEvent, this.pubkey); + await nostrClient.deleteAllVersions(rootId, this.pubkey); - this.showSuccess("Video deleted successfully!"); + // Reload await this.loadVideos(); + this.showSuccess("All versions deleted successfully!"); + this.forceRefreshAllProfiles(); } catch (err) { - this.log("Failed to delete video:", err.message); - this.showError("Failed to delete video. Please try again."); + console.error("Failed to delete all versions:", err); + this.showError("Failed to delete all versions. Please try again."); } } diff --git a/src/js/nostr.js b/src/js/nostr.js index 46f4cb3..70119b1 100644 --- a/src/js/nostr.js +++ b/src/js/nostr.js @@ -269,55 +269,79 @@ class NostrClient { /** * 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. + * => older version is overshadowed if your dedupe logic only shows newest. */ - 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)."); + async editVideo(originalEventStub, updatedData, pubkey) { + if (!pubkey) { + throw new Error("Not logged in to edit."); + } + if (!originalEventStub.pubkey || originalEventStub.pubkey !== pubkey) { + throw new Error("You do not own this video (pubkey mismatch)."); } - // Use the videoRootId directly from the converted video - const rootId = originalVideo.videoRootId || null; + // 1) Attempt to get the FULL old event details (especially videoRootId) + let baseEvent = originalEventStub; + // If the caller didn't pass .videoRootId, fetch from local or relay: + if (!baseEvent.videoRootId) { + const fetched = await this.getEventById(originalEventStub.id); + if (!fetched) { + throw new Error("Could not retrieve the original event to edit."); + } + baseEvent = fetched; + } + + // 2) We now have baseEvent.videoRootId if it existed + let oldRootId = baseEvent.videoRootId || null; // Decrypt the old magnet if it was private - let oldPlainMagnet = originalVideo.magnet || ""; - if (originalVideo.isPrivate && oldPlainMagnet) { + let oldPlainMagnet = baseEvent.magnet || ""; + if (baseEvent.isPrivate && oldPlainMagnet) { oldPlainMagnet = fakeDecrypt(oldPlainMagnet); } - // Determine new privacy setting - const wantPrivate = - updatedData.isPrivate ?? originalVideo.isPrivate ?? false; + // 3) Decide new privacy + const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false; - // Fallback to old magnet if none provided + // 4) Fallback to old magnet if none was provided let finalPlainMagnet = (updatedData.magnet || "").trim(); if (!finalPlainMagnet) { finalPlainMagnet = oldPlainMagnet; } - // Re-encrypt if user wants private + // 5) Re-encrypt if user wants private let finalMagnet = finalPlainMagnet; if (wantPrivate) { finalMagnet = fakeEncrypt(finalPlainMagnet); } - // If there's no root yet (legacy), generate it - const newRootId = - rootId || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + // 6) If there's no root yet (legacy), use the old event's own ID. + // Otherwise keep the existing rootId. + if (!oldRootId) { + oldRootId = baseEvent.id; + if (isDevMode) { + console.log( + "No existing root => using baseEvent.id as root:", + oldRootId + ); + } + } + + // Generate a brand-new d-tag so it doesn't overshadow the old share link const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`; - // Build updated content + // 7) Build updated content const contentObject = { - videoRootId: newRootId, - version: updatedData.version ?? originalVideo.version ?? 1, + videoRootId: oldRootId, + version: updatedData.version ?? baseEvent.version ?? 1, deleted: false, isPrivate: wantPrivate, - title: updatedData.title ?? originalVideo.title, + title: updatedData.title ?? baseEvent.title, magnet: finalMagnet, - thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail, - description: updatedData.description ?? originalVideo.description, - mode: updatedData.mode ?? originalVideo.mode ?? "live", + thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail, + description: updatedData.description ?? baseEvent.description, + mode: updatedData.mode ?? baseEvent.mode ?? "live", }; const event = { @@ -332,16 +356,24 @@ class NostrClient { }; if (isDevMode) { - console.log("Creating edited event with root ID:", newRootId); + console.log("Creating edited event with root ID:", oldRootId); console.log("Event content:", event.content); } + // 8) Sign and publish the new event try { const signedEvent = await window.nostr.signEvent(event); + if (isDevMode) { + console.log("Signed edited event:", signedEvent); + } + await Promise.all( this.relays.map(async (url) => { try { await this.pool.publish([url], signedEvent); + if (isDevMode) { + console.log(`Edited video published to ${url}`); + } } catch (err) { if (isDevMode) { console.error(`Publish failed to ${url}`, err); @@ -357,28 +389,26 @@ class NostrClient { } /** - * "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc + * "Reverting" => we just mark the most recent content as {deleted:true} and blank out magnet/desc */ - async deleteVideo(originalEvent, pubkey) { + async revertVideo(originalEvent, pubkey) { if (!pubkey) { - throw new Error("Not logged in to delete."); + throw new Error("Not logged in to revert."); } 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: + // If front-end didn't pass the tags array, load the full event: 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."); + throw new Error("Could not fetch the original event for reverting."); } - // 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, @@ -393,41 +423,34 @@ class NostrClient { }; } - // Now try to get the old d-tag + // Check d-tag const dTag = baseEvent.tags.find((t) => t[0] === "d"); if (!dTag) { - throw new Error('No "d" tag => cannot delete addressable kind=30078.'); + throw new Error( + 'No "d" tag => cannot revert addressable kind=30078 event.' + ); } const existingD = dTag[1]; - // After you've parsed oldContent: const oldContent = JSON.parse(baseEvent.content || "{}"); const oldVersion = oldContent.version ?? 1; - // ADD this block to handle the old root or fallback: + // If no root, fallback let finalRootId = oldContent.videoRootId || null; if (!finalRootId) { - // If it’s a legacy video (no root), we can fallback to your - // existing logic used by getActiveKey. For instance, if it had a 'd' tag: - if (dTag) { - // Some devs store it as 'LEGACY:pubkey:dTagValue' - // or you could just store the same as the old approach: - finalRootId = `LEGACY:${baseEvent.pubkey}:${dTag[1]}`; - } else { - finalRootId = `LEGACY:${baseEvent.id}`; - } + finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`; } - // Now build the content object, including videoRootId: + // Build “deleted: true” overshadow event => revert current version const contentObject = { - videoRootId: finalRootId, // <-- CRUCIAL so the delete event shares the same root key + videoRootId: finalRootId, version: oldVersion, - deleted: true, + deleted: true, // mark *this version* as deleted isPrivate: oldContent.isPrivate ?? false, title: oldContent.title || "", magnet: "", thumbnail: "", - description: "Video was deleted by creator.", + description: "This version was reverted by the creator.", mode: oldContent.mode || "live", }; @@ -437,44 +460,80 @@ class NostrClient { created_at: Math.floor(Date.now() / 1000), tags: [ ["t", "video"], - // We reuse the same d => overshadow the original event - ["d", existingD], + ["d", existingD], // re-use same d => overshadow ], 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); - } + const signedEvent = await window.nostr.signEvent(event); + await Promise.all( + this.relays.map(async (url) => { + try { + await this.pool.publish([url], signedEvent); + } catch (err) { + if (isDevMode) { + console.error(`Failed to revert on ${url}`, err); } - }) - ); - return signedEvent; - } catch (err) { - if (isDevMode) { - console.error("Failed to sign deleted event:", err); - } - throw new Error("Failed to sign deleted event."); + } + }) + ); + + return signedEvent; + } + + /** + * "Deleting" => we just mark all content with the same videoRootId as {deleted:true} and blank out magnet/desc + */ + + async deleteAllVersions(videoRootId, pubkey) { + if (!pubkey) { + throw new Error("Not logged in to delete all versions."); } + + // 1) Find all events in our local allEvents that share the same root. + const matchingEvents = []; + for (const [id, vid] of this.allEvents.entries()) { + if ( + vid.videoRootId === videoRootId && + vid.pubkey === pubkey && + !vid.deleted + ) { + matchingEvents.push(vid); + } + } + // If you want to re-check the relay for older versions too, + // you can do a fallback query, but typically your local cache is enough. + + if (!matchingEvents.length) { + throw new Error("No existing events found for that root."); + } + + // 2) For each event, create a "deleted: true" overshadow + // by re-using the same d-tag so it cannot appear again. + for (const vid of matchingEvents) { + await this.revertVideo( + { + // re-using revertVideo logic + id: vid.id, + pubkey: vid.pubkey, + content: JSON.stringify({ + version: vid.version, + deleted: vid.deleted, + isPrivate: vid.isPrivate, + title: vid.title, + magnet: vid.magnet, + thumbnail: vid.thumbnail, + description: vid.description, + mode: vid.mode, + }), + tags: vid.tags, + }, + pubkey + ); + } + + // Optionally return some status + return true; } /**