Merge pull request #12 from PR0M3TH3AN/unstable

increased nostr profile info fetch speed and reliability
This commit is contained in:
thePR0M3TH3AN
2025-02-09 09:45:59 -05:00
committed by GitHub
2 changed files with 253 additions and 150 deletions

303
js/app.js
View File

@@ -655,6 +655,53 @@ class bitvidApp {
} }
} }
async batchFetchProfiles(authorSet) {
const pubkeys = Array.from(authorSet);
if (!pubkeys.length) return;
const filter = {
kinds: [0],
authors: pubkeys,
limit: pubkeys.length,
};
try {
// Query each relay
const results = await Promise.all(
nostrClient.relays.map(relayUrl =>
nostrClient.pool.list([relayUrl], [filter])
)
);
const allProfileEvents = results.flat();
// Keep only the newest per author
const newestEvents = new Map();
for (const evt of allProfileEvents) {
if (!newestEvents.has(evt.pubkey) ||
evt.created_at > newestEvents.get(evt.pubkey).created_at) {
newestEvents.set(evt.pubkey, evt);
}
}
// Update the cache & DOM
for (const [pubkey, evt] of newestEvents.entries()) {
try {
const data = JSON.parse(evt.content);
const profile = {
name: data.name || data.display_name || "Unknown",
picture: data.picture || "assets/svg/default-profile.svg",
};
this.profileCache.set(pubkey, { profile, timestamp: Date.now() });
this.updateProfileInDOM(pubkey, profile);
} catch (err) {
console.error("Profile parse error:", err);
}
}
} catch (err) {
console.error("Batch profile fetch error:", err);
}
}
updateProfileInDOM(pubkey, profile) { updateProfileInDOM(pubkey, profile) {
// For any .author-pic[data-pubkey=...] // For any .author-pic[data-pubkey=...]
const picEls = document.querySelectorAll( const picEls = document.querySelectorAll(
@@ -925,14 +972,11 @@ class bitvidApp {
this.renderVideoList(filteredVideos); this.renderVideoList(filteredVideos);
}); });
// *** IMPORTANT ***: Unsubscribe once we get the historical EOSE
// so that we do not hold an open subscription forever:
if (this.videoSubscription) { if (this.videoSubscription) {
this.videoSubscription.on("eose", () => {
this.videoSubscription.unsub(); console.log("[loadVideos] subscription remains open to get live updates.");
console.log("[loadVideos] unsubscribed after EOSE");
});
} }
} else { } else {
// Already subscribed: just show what's cached // Already subscribed: just show what's cached
const allCached = nostrClient.getActiveVideos(); const allCached = nostrClient.getActiveVideos();
@@ -958,6 +1002,29 @@ class bitvidApp {
} }
} }
async loadOlderVideos(lastTimestamp) {
// 1) Use nostrClient to fetch older slices
const olderVideos = await nostrClient.fetchOlderVideos(lastTimestamp);
if (!olderVideos || olderVideos.length === 0) {
this.showSuccess("No more older videos found.");
return;
}
// 2) Merge them into the clients allEvents / activeMap
for (const v of olderVideos) {
nostrClient.allEvents.set(v.id, v);
// If its the newest version for its root, update activeMap
const rootKey = v.videoRootId || v.id;
// You can call getActiveKey(v) if you want to match your codes approach.
// Then re-check if this one is newer than whats stored, etc.
}
// 3) Re-render
const all = nostrClient.getActiveVideos();
this.renderVideoList(all);
}
/** /**
* Returns true if there's at least one strictly older version * Returns true if there's at least one strictly older version
* (same videoRootId, created_at < current) which is NOT deleted. * (same videoRootId, created_at < current) which is NOT deleted.
@@ -977,29 +1044,35 @@ class bitvidApp {
async renderVideoList(videos) { async renderVideoList(videos) {
if (!this.videoList) return; if (!this.videoList) return;
// Check if there's anything to show // Check if there's anything to show
if (!videos || videos.length === 0) { if (!videos || videos.length === 0) {
this.videoList.innerHTML = ` this.videoList.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500"> <p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No public videos available yet. Be the first to upload one! No public videos available yet. Be the first to upload one!
</p>`; </p>`;
return; return;
} }
// Sort newest first // Sort newest first
videos.sort((a, b) => b.created_at - a.created_at); videos.sort((a, b) => b.created_at - a.created_at);
// Convert allEvents to an array for checking older overshadowed events // Convert allEvents to an array for checking older overshadowed events
const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// 1) Collect authors here so we can fetch profiles in one go
const authorSet = new Set();
videos.forEach((video, index) => { videos.forEach((video, index) => {
if (!video.id || !video.title) { if (!video.id || !video.title) {
console.error("Video missing ID/title:", video); console.error("Video missing ID/title:", video);
return; return;
} }
// Track this author's pubkey for the batch fetch later
authorSet.add(video.pubkey);
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent( const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent nevent
@@ -1010,138 +1083,134 @@ class bitvidApp {
? "border-2 border-yellow-500" ? "border-2 border-yellow-500"
: "border-none"; : "border-none";
const timeAgo = this.formatTimeAgo(video.created_at); const timeAgo = this.formatTimeAgo(video.created_at);
// Check if there's an older version (for revert button) // Check if there's an older version (for revert button)
let hasOlder = false; let hasOlder = false;
if (canEdit && video.videoRootId) { if (canEdit && video.videoRootId) {
hasOlder = this.hasOlderVersion(video, fullAllEventsArray); hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
} }
const revertButton = hasOlder const revertButton = hasOlder
? ` ? `
<button <button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-revert-index="${index}" data-revert-index="${index}"
> >
Revert Revert
</button> </button>
` `
: ""; : "";
// Gear menu (only shown if canEdit) // Gear menu (only shown if canEdit)
const gearMenu = canEdit const gearMenu = canEdit
? ` ? `
<div class="relative inline-block ml-3 overflow-visible"> <div class="relative inline-block ml-3 overflow-visible">
<button <button
type="button" type="button"
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500" class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-settings-dropdown="${index}" data-settings-dropdown="${index}"
> >
<img <img
src="assets/svg/video-settings-gear.svg" src="assets/svg/video-settings-gear.svg"
alt="Settings" alt="Settings"
class="w-5 h-5" class="w-5 h-5"
/> />
</button> </button>
<div <div
id="settingsDropdown-${index}" id="settingsDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50" class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
> >
<div class="py-1"> <div class="py-1">
<button <button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-edit-index="${index}" data-edit-index="${index}"
> >
Edit Edit
</button> </button>
${revertButton} ${revertButton}
<button <button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-delete-all-index="${index}" data-delete-all-index="${index}"
> >
Delete All Delete All
</button> </button>
</div>
</div> </div>
</div> </div>
</div> `
`
: ""; : "";
// Card markup // Card markup
const cardHtml = ` const cardHtml = `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}"> <div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<a <a
href="${shareUrl}" href="${shareUrl}"
data-play-magnet="${encodeURIComponent(video.magnet)}"
class="block cursor-pointer relative group"
>
<div class="ratio-16-9">
<img
src="assets/jpg/video-thumbnail-fallback.jpg"
data-lazy="${this.escapeHTML(video.thumbnail)}"
alt="${this.escapeHTML(video.title)}"
/>
</div>
</a>
<div class="p-4">
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
data-play-magnet="${encodeURIComponent(video.magnet)}" data-play-magnet="${encodeURIComponent(video.magnet)}"
class="block cursor-pointer relative group"
> >
${this.escapeHTML(video.title)} <div class="ratio-16-9">
</h3> <img
<div class="flex items-center justify-between"> src="assets/jpg/video-thumbnail-fallback.jpg"
<div class="flex items-center space-x-3"> data-lazy="${this.escapeHTML(video.thumbnail)}"
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center"> alt="${this.escapeHTML(video.title)}"
<img />
class="author-pic" </div>
data-pubkey="${video.pubkey}" </a>
src="assets/svg/default-profile.svg" <div class="p-4">
alt="Placeholder" <h3
/> class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
</div> data-play-magnet="${encodeURIComponent(video.magnet)}"
<div class="min-w-0"> >
<p ${this.escapeHTML(video.title)}
class="text-sm text-gray-400 author-name" </h3>
data-pubkey="${video.pubkey}" <div class="flex items-center justify-between">
> <div class="flex items-center space-x-3">
Loading name... <div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center">
</p> <img
<div class="flex items-center text-xs text-gray-500 mt-1"> class="author-pic"
<span>${timeAgo}</span> data-pubkey="${video.pubkey}"
src="assets/svg/default-profile.svg"
alt="Placeholder"
/>
</div>
<div class="min-w-0">
<p
class="text-sm text-gray-400 author-name"
data-pubkey="${video.pubkey}"
>
Loading name...
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
</div>
</div> </div>
</div> </div>
${gearMenu}
</div> </div>
${gearMenu}
</div> </div>
</div> </div>
</div> `;
`;
// Turn the HTML into an element // Turn the HTML into an element
const template = document.createElement("template"); const template = document.createElement("template");
template.innerHTML = cardHtml.trim(); template.innerHTML = cardHtml.trim();
const cardEl = template.content.firstElementChild; const cardEl = template.content.firstElementChild;
// Fetch the author's profile info in the background
this.fetchAndRenderProfile(video.pubkey);
// Add the finished card to our fragment
fragment.appendChild(cardEl); fragment.appendChild(cardEl);
}); });
// Clear the list and add our fragment // Clear the list and add our fragment
this.videoList.innerHTML = ""; this.videoList.innerHTML = "";
this.videoList.appendChild(fragment); this.videoList.appendChild(fragment);
// Lazy-load images // Lazy-load images
const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => this.mediaLoader.observe(el)); lazyEls.forEach((el) => this.mediaLoader.observe(el));
// ------------------------------- // -------------------------------
// Gear menu / button event listeners // Gear menu / button event listeners
// ------------------------------- // -------------------------------
// Toggle the gear menu // Toggle the gear menu
const gearButtons = this.videoList.querySelectorAll( const gearButtons = this.videoList.querySelectorAll(
"[data-settings-dropdown]" "[data-settings-dropdown]"
@@ -1155,7 +1224,7 @@ class bitvidApp {
} }
}); });
}); });
// Edit button // Edit button
const editButtons = this.videoList.querySelectorAll("[data-edit-index]"); const editButtons = this.videoList.querySelectorAll("[data-edit-index]");
editButtons.forEach((button) => { editButtons.forEach((button) => {
@@ -1163,25 +1232,21 @@ class bitvidApp {
const index = button.getAttribute("data-edit-index"); const index = button.getAttribute("data-edit-index");
const dropdown = document.getElementById(`settingsDropdown-${index}`); const dropdown = document.getElementById(`settingsDropdown-${index}`);
if (dropdown) dropdown.classList.add("hidden"); if (dropdown) dropdown.classList.add("hidden");
// Assuming you have a method like this in your code:
this.handleEditVideo(index); this.handleEditVideo(index);
}); });
}); });
// Revert button // Revert button
const revertButtons = this.videoList.querySelectorAll( const revertButtons = this.videoList.querySelectorAll("[data-revert-index]");
"[data-revert-index]"
);
revertButtons.forEach((button) => { revertButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const index = button.getAttribute("data-revert-index"); const index = button.getAttribute("data-revert-index");
const dropdown = document.getElementById(`settingsDropdown-${index}`); const dropdown = document.getElementById(`settingsDropdown-${index}`);
if (dropdown) dropdown.classList.add("hidden"); if (dropdown) dropdown.classList.add("hidden");
// Assuming you have a method like this in your code:
this.handleRevertVideo(index); this.handleRevertVideo(index);
}); });
}); });
// Delete All button // Delete All button
const deleteAllButtons = this.videoList.querySelectorAll( const deleteAllButtons = this.videoList.querySelectorAll(
"[data-delete-all-index]" "[data-delete-all-index]"
@@ -1191,12 +1256,14 @@ class bitvidApp {
const index = button.getAttribute("data-delete-all-index"); const index = button.getAttribute("data-delete-all-index");
const dropdown = document.getElementById(`settingsDropdown-${index}`); const dropdown = document.getElementById(`settingsDropdown-${index}`);
if (dropdown) dropdown.classList.add("hidden"); if (dropdown) dropdown.classList.add("hidden");
// Assuming you have a method like this in your code:
this.handleFullDeleteVideo(index); this.handleFullDeleteVideo(index);
}); });
}); });
// 2) After building cards, do one batch profile fetch
this.batchFetchProfiles(authorSet);
} }
/** /**
* Updates the modal to reflect current torrent stats. * Updates the modal to reflect current torrent stats.
* We remove the unused torrent.status references, * We remove the unused torrent.status references,

View File

@@ -544,19 +544,31 @@ class NostrClient {
return true; return true;
} }
/**
* Saves all known events to localStorage (or a different storage if you prefer).
*/
saveLocalData() {
// Convert our allEvents map into a plain object for JSON storage
const allEventsObject = {};
for (const [id, vid] of this.allEvents.entries()) {
allEventsObject[id] = vid;
}
localStorage.setItem("bitvidEvents", JSON.stringify(allEventsObject));
}
/** /**
* Subscribe to *all* videos (old and new) with a single subscription, * Subscribe to *all* videos (old and new) with a single subscription,
* then call onVideo() each time a new or updated event arrives. * buffering incoming events to avoid excessive DOM updates.
*/ */
subscribeVideos(onVideo) { subscribeVideos(onVideo) {
const filter = { const filter = {
kinds: [30078], kinds: [30078],
"#t": ["video"], "#t": ["video"],
// Remove or adjust limit if you prefer, // Adjust limit/time as desired
// and set since=0 to retrieve historical events:
limit: 500, limit: 500,
since: 0, since: 0,
}; };
if (isDevMode) { if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter); console.log("[subscribeVideos] Subscribing with filter:", filter);
} }
@@ -564,37 +576,61 @@ class NostrClient {
const sub = this.pool.sub(this.relays, [filter]); const sub = this.pool.sub(this.relays, [filter]);
const invalidDuringSub = []; const invalidDuringSub = [];
// We'll collect events here instead of processing them instantly
let eventBuffer = [];
// 1) On each incoming event, just push to the buffer
sub.on("event", (event) => { sub.on("event", (event) => {
try { eventBuffer.push(event);
const video = convertEventToVideo(event);
if (video.invalid) {
invalidDuringSub.push({ id: video.id, reason: video.reason });
return;
}
// Store in allEvents
this.allEvents.set(event.id, video);
// If it's a "deleted" note, remove from activeMap
if (video.deleted) {
const activeKey = getActiveKey(video);
this.activeMap.delete(activeKey);
return;
}
// Otherwise, if it's newer than what we have, update activeMap
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video); // trigger the callback that re-renders
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error processing event:", err);
}
}
}); });
// 2) Process buffered events on a setInterval (e.g., every second)
const processInterval = setInterval(() => {
if (eventBuffer.length > 0) {
// Copy and clear the buffer
const toProcess = eventBuffer.slice();
eventBuffer = [];
// Now handle each event
for (const evt of toProcess) {
try {
const video = convertEventToVideo(evt);
if (video.invalid) {
invalidDuringSub.push({ id: video.id, reason: video.reason });
continue;
}
// Store in allEvents
this.allEvents.set(evt.id, video);
// If it's a "deleted" note, remove from activeMap
if (video.deleted) {
const activeKey = getActiveKey(video);
this.activeMap.delete(activeKey);
continue;
}
// Otherwise, if it's newer than what we have, update activeMap
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video); // Trigger the callback that re-renders
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error processing event:", err);
}
}
}
// Optionally, save data to local storage after processing the batch
this.saveLocalData();
}
}, 1000);
// You can still use sub.on("eose") if needed
sub.on("eose", () => { sub.on("eose", () => {
if (isDevMode && invalidDuringSub.length > 0) { if (isDevMode && invalidDuringSub.length > 0) {
console.warn( console.warn(
@@ -609,7 +645,7 @@ class NostrClient {
} }
}); });
// Return the subscription object directly. // Return the subscription object if you need to unsub manually later
return sub; return sub;
} }