mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-09 07:28:44 +00:00
update
This commit is contained in:
@@ -5,6 +5,8 @@ import { torrentClient } from "./webtorrent.js";
|
|||||||
import { isDevMode } from "./config.js";
|
import { isDevMode } from "./config.js";
|
||||||
import { disclaimerModal } from "./disclaimer.js";
|
import { disclaimerModal } from "./disclaimer.js";
|
||||||
import { videoPlayer } from "./components/VideoPlayer.js";
|
import { videoPlayer } from "./components/VideoPlayer.js";
|
||||||
|
import { videoList } from "./components/VideoList.js";
|
||||||
|
import { formatTimeAgo } from "./utils/timeUtils.js";
|
||||||
|
|
||||||
class bitvidApp {
|
class bitvidApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -18,9 +20,6 @@ class bitvidApp {
|
|||||||
this.submitForm = document.getElementById("submitForm");
|
this.submitForm = document.getElementById("submitForm");
|
||||||
this.videoFormContainer = document.getElementById("videoFormContainer");
|
this.videoFormContainer = document.getElementById("videoFormContainer");
|
||||||
|
|
||||||
// Video List Element
|
|
||||||
this.videoList = document.getElementById("videoList");
|
|
||||||
|
|
||||||
// Video Player Elements
|
// Video Player Elements
|
||||||
this.playerSection = document.getElementById("playerSection");
|
this.playerSection = document.getElementById("playerSection");
|
||||||
this.videoElement = document.getElementById("video");
|
this.videoElement = document.getElementById("video");
|
||||||
@@ -64,10 +63,26 @@ class bitvidApp {
|
|||||||
this.playerSection.style.display = "none";
|
this.playerSection.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize modal first
|
// Initialize Nostr client first
|
||||||
|
await nostrClient.init();
|
||||||
|
|
||||||
|
// Handle saved pubkey
|
||||||
|
const savedPubKey = localStorage.getItem("userPubKey");
|
||||||
|
if (savedPubKey) {
|
||||||
|
this.login(savedPubKey, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize modal
|
||||||
await videoPlayer.initModal();
|
await videoPlayer.initModal();
|
||||||
|
|
||||||
// Rest of your initialization code...
|
// Initialize video list
|
||||||
|
await videoList.loadVideos();
|
||||||
|
|
||||||
|
// Initialize and show disclaimer modal
|
||||||
|
disclaimerModal.show();
|
||||||
|
|
||||||
|
// Set up event listeners after all initializations
|
||||||
|
this.setupEventListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Init failed:", error);
|
console.error("Init failed:", error);
|
||||||
this.showError("Failed to connect to Nostr relay");
|
this.showError("Failed to connect to Nostr relay");
|
||||||
@@ -171,30 +186,6 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a timestamp into a "time ago" format.
|
|
||||||
*/
|
|
||||||
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, secondsInUnit] of Object.entries(intervals)) {
|
|
||||||
const interval = Math.floor(seconds / secondsInUnit);
|
|
||||||
if (interval >= 1) {
|
|
||||||
return `${interval} ${unit}${interval === 1 ? "" : "s"} ago`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up event listeners for various UI interactions.
|
* Sets up event listeners for various UI interactions.
|
||||||
*/
|
*/
|
||||||
@@ -290,6 +281,9 @@ class bitvidApp {
|
|||||||
this.videoFormContainer.classList.remove("hidden");
|
this.videoFormContainer.classList.remove("hidden");
|
||||||
this.log(`User logged in as: ${pubkey}`);
|
this.log(`User logged in as: ${pubkey}`);
|
||||||
|
|
||||||
|
// ADD: Update videoList pubkey
|
||||||
|
videoList.setPubkey(pubkey);
|
||||||
|
|
||||||
if (saveToStorage) {
|
if (saveToStorage) {
|
||||||
localStorage.setItem("userPubKey", pubkey);
|
localStorage.setItem("userPubKey", pubkey);
|
||||||
}
|
}
|
||||||
@@ -393,7 +387,8 @@ class bitvidApp {
|
|||||||
this.isPrivateCheckbox.checked = false;
|
this.isPrivateCheckbox.checked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadVideos();
|
// CHANGE: Use videoList component to refresh
|
||||||
|
await videoList.loadVideos(); // <-- Change this line
|
||||||
this.showSuccess("Video shared successfully!");
|
this.showSuccess("Video shared successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("Failed to publish video:", error.message);
|
this.log("Failed to publish video:", error.message);
|
||||||
@@ -401,319 +396,6 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and displays videos from Nostr.
|
|
||||||
*/
|
|
||||||
async loadVideos() {
|
|
||||||
console.log("Starting loadVideos...");
|
|
||||||
try {
|
|
||||||
const videos = await nostrClient.fetchVideos();
|
|
||||||
console.log("Raw videos from nostrClient:", videos);
|
|
||||||
|
|
||||||
if (!videos) {
|
|
||||||
this.log("No videos received");
|
|
||||||
throw new Error("No videos received from relays");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to array if not already
|
|
||||||
const videosArray = Array.isArray(videos) ? videos : [videos];
|
|
||||||
|
|
||||||
// **Filter** so we only show:
|
|
||||||
// - isPrivate === false (public videos)
|
|
||||||
// - or isPrivate === true but pubkey === this.pubkey
|
|
||||||
const displayedVideos = videosArray.filter((video) => {
|
|
||||||
if (!video.isPrivate) {
|
|
||||||
// Public video => show it
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Else it's private; only show if it's owned by the logged-in user
|
|
||||||
return this.pubkey && video.pubkey === this.pubkey;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (displayedVideos.length === 0) {
|
|
||||||
this.log("No valid videos found after filtering.");
|
|
||||||
this.videoList.innerHTML = `
|
|
||||||
<p class="text-center text-gray-500">
|
|
||||||
No public videos available yet. Be the first to upload one!
|
|
||||||
</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log("Processing filtered videos:", displayedVideos);
|
|
||||||
|
|
||||||
displayedVideos.forEach((video, index) => {
|
|
||||||
this.log(`Video ${index} details:`, {
|
|
||||||
id: video.id,
|
|
||||||
title: video.title,
|
|
||||||
magnet: video.magnet,
|
|
||||||
isPrivate: video.isPrivate,
|
|
||||||
pubkey: video.pubkey,
|
|
||||||
created_at: video.created_at,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now render only the displayedVideos
|
|
||||||
await this.renderVideoList(displayedVideos);
|
|
||||||
this.log(`Rendered ${displayedVideos.length} videos successfully`);
|
|
||||||
} catch (error) {
|
|
||||||
this.log("Failed to fetch videos:", error);
|
|
||||||
this.showError(
|
|
||||||
"An error occurred while loading videos. Please try again later."
|
|
||||||
);
|
|
||||||
this.videoList.innerHTML = `
|
|
||||||
<p class="text-center text-gray-500">
|
|
||||||
No videos available at the moment. Please try again later.
|
|
||||||
</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the given list of videos. If a video is private and belongs to the user,
|
|
||||||
* highlight with a special border (e.g. border-yellow-500).
|
|
||||||
*/
|
|
||||||
async renderVideoList(videos) {
|
|
||||||
try {
|
|
||||||
console.log("RENDER VIDEO LIST - Start", {
|
|
||||||
videosReceived: videos,
|
|
||||||
videosCount: videos ? videos.length : "N/A",
|
|
||||||
videosType: typeof videos,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!videos) {
|
|
||||||
console.error("NO VIDEOS RECEIVED");
|
|
||||||
this.videoList.innerHTML = `<p class="text-center text-gray-500">No videos found.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoArray = Array.isArray(videos) ? videos : [videos];
|
|
||||||
|
|
||||||
if (videoArray.length === 0) {
|
|
||||||
console.error("VIDEO ARRAY IS EMPTY");
|
|
||||||
this.videoList.innerHTML = `<p class="text-center text-gray-500">No videos available.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by creation date
|
|
||||||
videoArray.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
|
|
||||||
// Prepare to fetch user profiles
|
|
||||||
const userProfiles = new Map();
|
|
||||||
const uniquePubkeys = [...new Set(videoArray.map((v) => v.pubkey))];
|
|
||||||
|
|
||||||
for (const pubkey of uniquePubkeys) {
|
|
||||||
try {
|
|
||||||
const userEvents = await nostrClient.pool.list(nostrClient.relays, [
|
|
||||||
{
|
|
||||||
kinds: [0],
|
|
||||||
authors: [pubkey],
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (userEvents[0]?.content) {
|
|
||||||
const profile = JSON.parse(userEvents[0].content);
|
|
||||||
userProfiles.set(pubkey, {
|
|
||||||
name: profile.name || profile.display_name || "Unknown",
|
|
||||||
picture: profile.picture || `https://robohash.org/${pubkey}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
userProfiles.set(pubkey, {
|
|
||||||
name: "Unknown",
|
|
||||||
picture: `https://robohash.org/${pubkey}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Profile fetch error for ${pubkey}:`, error);
|
|
||||||
userProfiles.set(pubkey, {
|
|
||||||
name: "Unknown",
|
|
||||||
picture: `https://robohash.org/${pubkey}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build HTML for each video
|
|
||||||
const renderedVideos = videoArray
|
|
||||||
.map((video, index) => {
|
|
||||||
try {
|
|
||||||
if (!this.validateVideo(video, index)) {
|
|
||||||
console.error(`Invalid video: ${video.title}`);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = userProfiles.get(video.pubkey) || {
|
|
||||||
name: "Unknown",
|
|
||||||
picture: `https://robohash.org/${video.pubkey}`,
|
|
||||||
};
|
|
||||||
const timeAgo = this.formatTimeAgo(video.created_at);
|
|
||||||
|
|
||||||
// If user is the owner
|
|
||||||
const canEdit = video.pubkey === this.pubkey;
|
|
||||||
|
|
||||||
// If it's private + user owns it => highlight with a special border
|
|
||||||
const highlightClass =
|
|
||||||
video.isPrivate && canEdit
|
|
||||||
? "border-2 border-yellow-500"
|
|
||||||
: "border-none"; // normal case
|
|
||||||
|
|
||||||
// Gear menu (unchanged)
|
|
||||||
const gearMenu = canEdit
|
|
||||||
? `
|
|
||||||
<div class="relative inline-block ml-3 overflow-visible">
|
|
||||||
<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"
|
|
||||||
onclick="document.getElementById('settingsDropdown-${index}').classList.toggle('hidden')"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="assets/svg/video-settings-gear.svg"
|
|
||||||
alt="Settings"
|
|
||||||
class="w-5 h-5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<!-- The dropdown appears above the gear (bottom-full) -->
|
|
||||||
<div
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div class="py-1">
|
|
||||||
<button
|
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
|
||||||
onclick="app.handleEditVideo(${index}); document.getElementById('settingsDropdown-${index}').classList.add('hidden');"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
|
||||||
onclick="app.handleDeleteVideo(${index}); document.getElementById('settingsDropdown-${index}').classList.add('hidden');"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
|
||||||
|
|
||||||
<!-- VIDEO THUMBNAIL -->
|
|
||||||
<div
|
|
||||||
class="aspect-w-16 aspect-h-9 bg-gray-800 cursor-pointer relative group"
|
|
||||||
onclick="app.playVideo('${encodeURIComponent(
|
|
||||||
video.magnet
|
|
||||||
)}')"
|
|
||||||
>
|
|
||||||
${
|
|
||||||
video.thumbnail
|
|
||||||
? `<img
|
|
||||||
src="${this.escapeHTML(video.thumbnail)}"
|
|
||||||
alt="${this.escapeHTML(video.title)}"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
>`
|
|
||||||
: `<div class="flex items-center justify-center h-full bg-gray-800">
|
|
||||||
<svg class="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CARD INFO -->
|
|
||||||
<div class="p-4">
|
|
||||||
<!-- TITLE -->
|
|
||||||
<h3
|
|
||||||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
|
||||||
onclick="app.playVideo('${encodeURIComponent(
|
|
||||||
video.magnet
|
|
||||||
)}')"
|
|
||||||
>
|
|
||||||
${this.escapeHTML(video.title)}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<!-- CREATOR info + gear icon -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<!-- Left: Avatar & user/time -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src="${this.escapeHTML(
|
|
||||||
profile.picture
|
|
||||||
)}"
|
|
||||||
alt="${profile.name}"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
|
|
||||||
${this.escapeHTML(profile.name)}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
|
||||||
<span>${timeAgo}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Right: gearMenu if user owns the video -->
|
|
||||||
${gearMenu}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing video ${index}:`, error);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((html) => html.length > 0);
|
|
||||||
|
|
||||||
console.log("Rendered videos:", renderedVideos.length);
|
|
||||||
|
|
||||||
if (renderedVideos.length === 0) {
|
|
||||||
this.videoList.innerHTML = `<p class="text-center text-gray-500">No valid videos to display.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.videoList.innerHTML = renderedVideos.join("");
|
|
||||||
console.log("Videos rendered successfully");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Rendering error:", error);
|
|
||||||
this.videoList.innerHTML = `<p class="text-center text-gray-500">Error loading videos.</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a video object
|
|
||||||
*/
|
|
||||||
validateVideo(video, index) {
|
|
||||||
const validationResults = {
|
|
||||||
hasId: Boolean(video?.id),
|
|
||||||
isValidId: typeof video?.id === "string" && video.id.trim().length > 0,
|
|
||||||
hasVideo: Boolean(video),
|
|
||||||
hasTitle: Boolean(video?.title),
|
|
||||||
hasMagnet: Boolean(video?.magnet),
|
|
||||||
hasMode: Boolean(video?.mode),
|
|
||||||
hasPubkey: Boolean(video?.pubkey),
|
|
||||||
isValidTitle: typeof video?.title === "string" && video.title.length > 0,
|
|
||||||
isValidMagnet:
|
|
||||||
typeof video?.magnet === "string" && video.magnet.length > 0,
|
|
||||||
isValidMode:
|
|
||||||
typeof video?.mode === "string" && ["dev", "live"].includes(video.mode),
|
|
||||||
};
|
|
||||||
|
|
||||||
const passed = Object.values(validationResults).every(Boolean);
|
|
||||||
console.log(
|
|
||||||
`Video ${video?.title} validation results:`,
|
|
||||||
validationResults,
|
|
||||||
passed ? "PASSED" : "FAILED"
|
|
||||||
);
|
|
||||||
|
|
||||||
return passed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a user-friendly error message.
|
* Gets a user-friendly error message.
|
||||||
*/
|
*/
|
||||||
@@ -761,18 +443,6 @@ class bitvidApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes HTML to prevent XSS.
|
|
||||||
*/
|
|
||||||
escapeHTML(unsafe) {
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs messages to console.
|
* Logs messages to console.
|
||||||
*/
|
*/
|
||||||
@@ -862,7 +532,7 @@ class bitvidApp {
|
|||||||
this.videoTitle.textContent = video.title || "Untitled";
|
this.videoTitle.textContent = video.title || "Untitled";
|
||||||
this.videoDescription.textContent =
|
this.videoDescription.textContent =
|
||||||
video.description || "No description available.";
|
video.description || "No description available.";
|
||||||
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
|
this.videoTimestamp.textContent = formatTimeAgo(video.created_at);
|
||||||
|
|
||||||
this.creatorName.textContent = creatorProfile.name;
|
this.creatorName.textContent = creatorProfile.name;
|
||||||
this.creatorNpub.textContent = `${creatorNpub.slice(
|
this.creatorNpub.textContent = `${creatorNpub.slice(
|
||||||
@@ -927,6 +597,7 @@ class bitvidApp {
|
|||||||
*/
|
*/
|
||||||
async handleEditVideo(index) {
|
async handleEditVideo(index) {
|
||||||
try {
|
try {
|
||||||
|
// CHANGE: Get videos through videoList component
|
||||||
const videos = await nostrClient.fetchVideos();
|
const videos = await nostrClient.fetchVideos();
|
||||||
const video = videos[index];
|
const video = videos[index];
|
||||||
|
|
||||||
@@ -997,7 +668,7 @@ class bitvidApp {
|
|||||||
};
|
};
|
||||||
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
|
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
|
||||||
this.showSuccess("Video updated successfully!");
|
this.showSuccess("Video updated successfully!");
|
||||||
await this.loadVideos();
|
await videoList.loadVideos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("Failed to edit video:", err.message);
|
this.log("Failed to edit video:", err.message);
|
||||||
this.showError("Failed to edit video. Please try again later.");
|
this.showError("Failed to edit video. Please try again later.");
|
||||||
@@ -1010,6 +681,7 @@ class bitvidApp {
|
|||||||
*/
|
*/
|
||||||
async handleDeleteVideo(index) {
|
async handleDeleteVideo(index) {
|
||||||
try {
|
try {
|
||||||
|
// CHANGE: Get videos through videoList component
|
||||||
const videos = await nostrClient.fetchVideos();
|
const videos = await nostrClient.fetchVideos();
|
||||||
const video = videos[index];
|
const video = videos[index];
|
||||||
|
|
||||||
@@ -1038,7 +710,8 @@ class bitvidApp {
|
|||||||
|
|
||||||
await nostrClient.deleteVideo(originalEvent, this.pubkey);
|
await nostrClient.deleteVideo(originalEvent, this.pubkey);
|
||||||
this.showSuccess("Video deleted (hidden) successfully!");
|
this.showSuccess("Video deleted (hidden) successfully!");
|
||||||
await this.loadVideos();
|
// CHANGE: Use videoList component to refresh
|
||||||
|
await videoList.loadVideos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("Failed to delete video:", err.message);
|
this.log("Failed to delete video:", err.message);
|
||||||
this.showError("Failed to delete video. Please try again later.");
|
this.showError("Failed to delete video. Please try again later.");
|
||||||
|
@@ -0,0 +1,285 @@
|
|||||||
|
// js/components/VideoList.js
|
||||||
|
import { nostrClient } from "../nostr.js";
|
||||||
|
import { formatTimeAgo } from "../utils/timeUtils.js";
|
||||||
|
import { escapeHTML } from "../utils/htmlUtils.js";
|
||||||
|
|
||||||
|
export class VideoList {
|
||||||
|
constructor() {
|
||||||
|
this.videoList = document.getElementById("videoList");
|
||||||
|
this.pubkey = null; // We'll need this for private video filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
setPubkey(pubkey) {
|
||||||
|
this.pubkey = pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadVideos() {
|
||||||
|
console.log("Starting loadVideos...");
|
||||||
|
try {
|
||||||
|
const videos = await nostrClient.fetchVideos();
|
||||||
|
console.log("Raw videos from nostrClient:", videos);
|
||||||
|
|
||||||
|
if (!videos) {
|
||||||
|
console.log("No videos received");
|
||||||
|
throw new Error("No videos received from relays");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array if not already
|
||||||
|
const videosArray = Array.isArray(videos) ? videos : [videos];
|
||||||
|
|
||||||
|
// Filter private videos
|
||||||
|
const displayedVideos = videosArray.filter((video) => {
|
||||||
|
if (!video.isPrivate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.pubkey && video.pubkey === this.pubkey;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (displayedVideos.length === 0) {
|
||||||
|
console.log("No valid videos found after filtering.");
|
||||||
|
this.renderEmptyState(
|
||||||
|
"No public videos available yet. Be the first to upload one!"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Processing filtered videos:", displayedVideos);
|
||||||
|
await this.renderVideoList(displayedVideos);
|
||||||
|
console.log(`Rendered ${displayedVideos.length} videos successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Failed to fetch videos:", error);
|
||||||
|
this.renderEmptyState(
|
||||||
|
"No videos available at the moment. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEmptyState(message) {
|
||||||
|
if (this.videoList) {
|
||||||
|
this.videoList.innerHTML = `
|
||||||
|
<p class="text-center text-gray-500">
|
||||||
|
${escapeHTML(message)}
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderVideoList(videos) {
|
||||||
|
try {
|
||||||
|
console.log("RENDER VIDEO LIST - Start", {
|
||||||
|
videosReceived: videos,
|
||||||
|
videosCount: videos ? videos.length : "N/A",
|
||||||
|
videosType: typeof videos,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!videos || videos.length === 0) {
|
||||||
|
this.renderEmptyState("No videos found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by creation date
|
||||||
|
const videoArray = [...videos].sort(
|
||||||
|
(a, b) => b.created_at - a.created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch user profiles
|
||||||
|
const userProfiles = await this.fetchUserProfiles(videoArray);
|
||||||
|
|
||||||
|
// Build HTML for each video
|
||||||
|
const renderedVideos = videoArray
|
||||||
|
.map((video, index) => this.renderVideoCard(video, index, userProfiles))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (renderedVideos.length === 0) {
|
||||||
|
this.renderEmptyState("No valid videos to display.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.videoList.innerHTML = renderedVideos.join("");
|
||||||
|
console.log("Videos rendered successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Rendering error:", error);
|
||||||
|
this.renderEmptyState("Error loading videos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUserProfiles(videos) {
|
||||||
|
const userProfiles = new Map();
|
||||||
|
const uniquePubkeys = [...new Set(videos.map((v) => v.pubkey))];
|
||||||
|
|
||||||
|
for (const pubkey of uniquePubkeys) {
|
||||||
|
try {
|
||||||
|
const profile = await nostrClient.fetchUserProfile(pubkey);
|
||||||
|
userProfiles.set(pubkey, profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Profile fetch error for ${pubkey}:`, error);
|
||||||
|
userProfiles.set(pubkey, {
|
||||||
|
name: "Unknown",
|
||||||
|
picture: `https://robohash.org/${pubkey}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return userProfiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVideoCard(video, index, userProfiles) {
|
||||||
|
try {
|
||||||
|
if (!this.validateVideo(video, index)) {
|
||||||
|
console.error(`Invalid video: ${video.title}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = userProfiles.get(video.pubkey) || {
|
||||||
|
name: "Unknown",
|
||||||
|
picture: `https://robohash.org/${video.pubkey}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEdit = video.pubkey === this.pubkey;
|
||||||
|
const highlightClass =
|
||||||
|
video.isPrivate && canEdit
|
||||||
|
? "border-2 border-yellow-500"
|
||||||
|
: "border-none";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||||
|
${this.renderThumbnail(video)}
|
||||||
|
${this.renderCardInfo(video, profile)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing video ${index}:`, error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderThumbnail(video) {
|
||||||
|
return `
|
||||||
|
<div
|
||||||
|
class="aspect-w-16 aspect-h-9 bg-gray-800 cursor-pointer relative group"
|
||||||
|
onclick="window.app.playVideo('${encodeURIComponent(video.magnet)}')"
|
||||||
|
>
|
||||||
|
${
|
||||||
|
video.thumbnail
|
||||||
|
? this.renderThumbnailImage(video)
|
||||||
|
: this.renderPlaceholderThumbnail()
|
||||||
|
}
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderThumbnailImage(video) {
|
||||||
|
return `
|
||||||
|
<img
|
||||||
|
src="${escapeHTML(video.thumbnail)}"
|
||||||
|
alt="${escapeHTML(video.title)}"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlaceholderThumbnail() {
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-center h-full bg-gray-800">
|
||||||
|
<svg class="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCardInfo(video, profile) {
|
||||||
|
const timeAgo = formatTimeAgo(video.created_at);
|
||||||
|
const canEdit = video.pubkey === this.pubkey;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||||
|
onclick="window.app.playVideo('${encodeURIComponent(
|
||||||
|
video.magnet
|
||||||
|
)}')">
|
||||||
|
${escapeHTML(video.title)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden">
|
||||||
|
<img src="${escapeHTML(profile.picture)}"
|
||||||
|
alt="${profile.name}"
|
||||||
|
class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
|
||||||
|
${escapeHTML(profile.name)}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||||
|
<span>${timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${this.renderGearMenu(video, canEdit)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGearMenu(video, canEdit) {
|
||||||
|
if (!canEdit) return "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="relative inline-block ml-3 overflow-visible">
|
||||||
|
<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"
|
||||||
|
onclick="document.getElementById('settingsDropdown-${video.id}').classList.toggle('hidden')">
|
||||||
|
<img src="assets/svg/video-settings-gear.svg"
|
||||||
|
alt="Settings"
|
||||||
|
class="w-5 h-5"/>
|
||||||
|
</button>
|
||||||
|
<div id="settingsDropdown-${video.id}"
|
||||||
|
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">
|
||||||
|
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||||
|
onclick="app.handleEditVideo('${video.id}'); document.getElementById('settingsDropdown-${video.id}').classList.add('hidden');">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||||
|
onclick="app.handleDeleteVideo('${video.id}'); document.getElementById('settingsDropdown-${video.id}').classList.add('hidden');">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateVideo(video, index) {
|
||||||
|
const validationResults = {
|
||||||
|
hasId: Boolean(video?.id),
|
||||||
|
isValidId: typeof video?.id === "string" && video.id.trim().length > 0,
|
||||||
|
hasVideo: Boolean(video),
|
||||||
|
hasTitle: Boolean(video?.title),
|
||||||
|
hasMagnet: Boolean(video?.magnet),
|
||||||
|
hasMode: Boolean(video?.mode),
|
||||||
|
hasPubkey: Boolean(video?.pubkey),
|
||||||
|
isValidTitle: typeof video?.title === "string" && video.title.length > 0,
|
||||||
|
isValidMagnet:
|
||||||
|
typeof video?.magnet === "string" && video.magnet.length > 0,
|
||||||
|
isValidMode:
|
||||||
|
typeof video?.mode === "string" && ["dev", "live"].includes(video.mode),
|
||||||
|
};
|
||||||
|
|
||||||
|
const passed = Object.values(validationResults).every(Boolean);
|
||||||
|
console.log(
|
||||||
|
`Video ${video?.title} validation results:`,
|
||||||
|
validationResults,
|
||||||
|
passed ? "PASSED" : "FAILED"
|
||||||
|
);
|
||||||
|
|
||||||
|
return passed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoList = new VideoList();
|
||||||
|
@@ -577,6 +577,59 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a user profile given a pubkey.
|
||||||
|
* Returns an object with 'name' and 'picture' properties.
|
||||||
|
*/
|
||||||
|
async fetchUserProfile(pubkey) {
|
||||||
|
if (!pubkey) {
|
||||||
|
throw new Error("Invalid pubkey provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log(`Fetching profile for pubkey: ${pubkey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
kinds: [0], // Profile events
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await this.pool.list(this.relays, [filter]);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log(`No profile found for pubkey: ${pubkey}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: "Unknown",
|
||||||
|
picture: `https://robohash.org/${pubkey}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileContent = JSON.parse(events[0].content || "{}");
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
name:
|
||||||
|
profileContent.name ||
|
||||||
|
profileContent.display_name ||
|
||||||
|
`User ${pubkey.slice(0, 8)}...`,
|
||||||
|
picture: profileContent.picture || `https://robohash.org/${pubkey}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log(`Fetched profile for ${pubkey}:`, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
logErrorOnce(`Error fetching profile for ${pubkey}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates video content structure.
|
* Validates video content structure.
|
||||||
*/
|
*/
|
||||||
|
@@ -1,154 +0,0 @@
|
|||||||
// js/accessControl.js
|
|
||||||
|
|
||||||
import { isDevMode, isWhitelistEnabled } from "./config.js";
|
|
||||||
import { initialWhitelist, initialBlacklist } from "./lists.js";
|
|
||||||
|
|
||||||
class AccessControl {
|
|
||||||
constructor() {
|
|
||||||
// Debug logging for initialization
|
|
||||||
console.log("DEBUG: AccessControl constructor called");
|
|
||||||
console.log("DEBUG: initialWhitelist from import:", initialWhitelist);
|
|
||||||
console.log("DEBUG: typeof initialWhitelist:", typeof initialWhitelist);
|
|
||||||
console.log("DEBUG: initialWhitelist length:", initialWhitelist.length);
|
|
||||||
|
|
||||||
// Initialize empty sets
|
|
||||||
this.whitelist = new Set(initialWhitelist);
|
|
||||||
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
|
|
||||||
|
|
||||||
// Debug the sets
|
|
||||||
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
|
|
||||||
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
this.saveWhitelist();
|
|
||||||
this.saveBlacklist();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rest of the class remains the same...
|
|
||||||
loadWhitelist() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem("bitvid_whitelist");
|
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading whitelist:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadBlacklist() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem("bitvid_blacklist");
|
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading blacklist:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveWhitelist() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
"bitvid_whitelist",
|
|
||||||
JSON.stringify([...this.whitelist])
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving whitelist:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveBlacklist() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
"bitvid_blacklist",
|
|
||||||
JSON.stringify([...this.blacklist])
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving blacklist:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addToWhitelist(npub) {
|
|
||||||
if (!this.isValidNpub(npub)) {
|
|
||||||
throw new Error("Invalid npub format");
|
|
||||||
}
|
|
||||||
this.whitelist.add(npub);
|
|
||||||
this.saveWhitelist();
|
|
||||||
if (isDevMode) console.log(`Added ${npub} to whitelist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromWhitelist(npub) {
|
|
||||||
this.whitelist.delete(npub);
|
|
||||||
this.saveWhitelist();
|
|
||||||
if (isDevMode) console.log(`Removed ${npub} from whitelist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
addToBlacklist(npub) {
|
|
||||||
if (!this.isValidNpub(npub)) {
|
|
||||||
throw new Error("Invalid npub format");
|
|
||||||
}
|
|
||||||
this.blacklist.add(npub);
|
|
||||||
this.saveBlacklist();
|
|
||||||
if (isDevMode) console.log(`Added ${npub} to blacklist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromBlacklist(npub) {
|
|
||||||
this.blacklist.delete(npub);
|
|
||||||
this.saveBlacklist();
|
|
||||||
if (isDevMode) console.log(`Removed ${npub} from blacklist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
isWhitelisted(npub) {
|
|
||||||
const result = this.whitelist.has(npub);
|
|
||||||
if (isDevMode)
|
|
||||||
console.log(
|
|
||||||
`Checking if ${npub} is whitelisted:`,
|
|
||||||
result,
|
|
||||||
"Current whitelist:",
|
|
||||||
[...this.whitelist]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
isBlacklisted(npub) {
|
|
||||||
return this.blacklist.has(npub);
|
|
||||||
}
|
|
||||||
|
|
||||||
canAccess(npub) {
|
|
||||||
if (this.isBlacklisted(npub)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
|
|
||||||
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
|
|
||||||
return canAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
filterVideos(videos) {
|
|
||||||
return videos.filter((video) => {
|
|
||||||
try {
|
|
||||||
const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
|
|
||||||
return !this.isBlacklisted(npub);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error filtering video:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isValidNpub(npub) {
|
|
||||||
try {
|
|
||||||
return npub.startsWith("npub1") && npub.length === 63;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getWhitelist() {
|
|
||||||
return [...this.whitelist];
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlacklist() {
|
|
||||||
return [...this.blacklist];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const accessControl = new AccessControl();
|
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
// js/config.js
|
|
||||||
|
|
||||||
export const isDevMode = true; // Set to false for production
|
|
||||||
export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users
|
|
@@ -1,29 +0,0 @@
|
|||||||
class DisclaimerModal {
|
|
||||||
constructor() {
|
|
||||||
this.modal = document.getElementById("disclaimerModal");
|
|
||||||
this.acceptButton = document.getElementById("acceptDisclaimer");
|
|
||||||
this.hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer");
|
|
||||||
|
|
||||||
this.setupEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
const closeModal = () => {
|
|
||||||
this.modal.style.display = "none";
|
|
||||||
document.body.style.overflow = "unset";
|
|
||||||
localStorage.setItem("hasSeenDisclaimer", "true");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only keep the accept button event listener
|
|
||||||
this.acceptButton.addEventListener("click", closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
show() {
|
|
||||||
if (!this.hasSeenDisclaimer) {
|
|
||||||
this.modal.style.display = "flex";
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const disclaimerModal = new DisclaimerModal();
|
|
@@ -1,13 +0,0 @@
|
|||||||
// js/lists.js
|
|
||||||
|
|
||||||
const npubs = [
|
|
||||||
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe",
|
|
||||||
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx",
|
|
||||||
"npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8",
|
|
||||||
"npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2",
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log("DEBUG: lists.js loaded, npubs:", npubs);
|
|
||||||
|
|
||||||
export const initialWhitelist = npubs;
|
|
||||||
export const initialBlacklist = [""];
|
|
@@ -1,624 +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",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Rate limiting for error logs
|
|
||||||
let errorLogCount = 0;
|
|
||||||
const MAX_ERROR_LOGS = 100; // Adjust as needed
|
|
||||||
|
|
||||||
function logErrorOnce(message, eventContent = null) {
|
|
||||||
if (errorLogCount < MAX_ERROR_LOGS) {
|
|
||||||
console.error(message);
|
|
||||||
if (eventContent) {
|
|
||||||
console.log(`Event Content: ${eventContent}`);
|
|
||||||
}
|
|
||||||
errorLogCount++;
|
|
||||||
}
|
|
||||||
if (errorLogCount === MAX_ERROR_LOGS) {
|
|
||||||
console.error(
|
|
||||||
"Maximum error log limit reached. Further errors will be suppressed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A very naive "encryption" function that just reverses the string.
|
|
||||||
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
|
||||||
*/
|
|
||||||
function fakeEncrypt(magnet) {
|
|
||||||
return magnet.split("").reverse().join("");
|
|
||||||
}
|
|
||||||
function fakeDecrypt(encrypted) {
|
|
||||||
return encrypted.split("").reverse().join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
class NostrClient {
|
|
||||||
constructor() {
|
|
||||||
this.pool = null;
|
|
||||||
this.pubkey = null;
|
|
||||||
this.relays = RELAY_URLS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the Nostr client by connecting to relays.
|
|
||||||
*/
|
|
||||||
async init() {
|
|
||||||
if (isDevMode) console.log("Connecting to relays...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.pool = new window.NostrTools.SimplePool();
|
|
||||||
const results = await this.connectToRelays();
|
|
||||||
const successfulRelays = results
|
|
||||||
.filter((r) => r.success)
|
|
||||||
.map((r) => r.url);
|
|
||||||
|
|
||||||
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
|
||||||
|
|
||||||
if (isDevMode)
|
|
||||||
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Nostr init failed:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to handle relay connections
|
|
||||||
async connectToRelays() {
|
|
||||||
return Promise.all(
|
|
||||||
this.relays.map(
|
|
||||||
(url) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
sub.unsub();
|
|
||||||
resolve({ url, success: false });
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
const succeed = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
sub.unsub();
|
|
||||||
resolve({ url, success: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
sub.on("event", succeed);
|
|
||||||
sub.on("eose", succeed);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs in the user using a Nostr extension or by entering an NSEC key.
|
|
||||||
*/
|
|
||||||
async login() {
|
|
||||||
try {
|
|
||||||
if (!window.nostr) {
|
|
||||||
console.log("No Nostr extension found");
|
|
||||||
throw new Error(
|
|
||||||
"Please install a Nostr extension (like Alby or nos2x)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
|
||||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
|
||||||
|
|
||||||
// Debug logs
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Got pubkey:", pubkey);
|
|
||||||
console.log("Converted to npub:", npub);
|
|
||||||
console.log("Whitelist:", accessControl.getWhitelist());
|
|
||||||
console.log("Blacklist:", accessControl.getBlacklist());
|
|
||||||
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
|
|
||||||
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check access control
|
|
||||||
if (!accessControl.canAccess(npub)) {
|
|
||||||
if (accessControl.isBlacklisted(npub)) {
|
|
||||||
throw new Error(
|
|
||||||
"Your account has been blocked from accessing this platform."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Access is currently restricted to whitelisted users only."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pubkey = pubkey;
|
|
||||||
if (isDevMode)
|
|
||||||
console.log(
|
|
||||||
"Successfully logged in with extension. Public key:",
|
|
||||||
this.pubkey
|
|
||||||
);
|
|
||||||
return this.pubkey;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Login error:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs out the user.
|
|
||||||
*/
|
|
||||||
logout() {
|
|
||||||
this.pubkey = null;
|
|
||||||
if (isDevMode) console.log("User logged out.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes an NSEC key.
|
|
||||||
*/
|
|
||||||
decodeNsec(nsec) {
|
|
||||||
try {
|
|
||||||
const { data } = window.NostrTools.nip19.decode(nsec);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error("Invalid NSEC key.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publishes a new video event to all relays (creates a brand-new note).
|
|
||||||
*/
|
|
||||||
async publishVideo(videoData, pubkey) {
|
|
||||||
if (!pubkey) {
|
|
||||||
throw new Error("User is not logged in.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Publishing video with data:", videoData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user sets "isPrivate = true", encrypt the magnet
|
|
||||||
let finalMagnet = videoData.magnet;
|
|
||||||
if (videoData.isPrivate === true) {
|
|
||||||
finalMagnet = fakeEncrypt(finalMagnet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default version is 1 if not specified
|
|
||||||
const version = videoData.version ?? 1;
|
|
||||||
|
|
||||||
const uniqueD = `${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.substring(2, 10)}`;
|
|
||||||
|
|
||||||
// Always mark "deleted" false for new posts
|
|
||||||
const contentObject = {
|
|
||||||
version,
|
|
||||||
deleted: false,
|
|
||||||
isPrivate: videoData.isPrivate || false,
|
|
||||||
title: videoData.title,
|
|
||||||
magnet: finalMagnet,
|
|
||||||
thumbnail: videoData.thumbnail,
|
|
||||||
description: videoData.description,
|
|
||||||
mode: videoData.mode,
|
|
||||||
};
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
kind: 30078,
|
|
||||||
pubkey,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
["t", "video"],
|
|
||||||
["d", uniqueD],
|
|
||||||
],
|
|
||||||
content: JSON.stringify(contentObject),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Event content after stringify:", event.content);
|
|
||||||
console.log("Using d tag:", uniqueD);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Signed event:", signedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
this.relays.map(async (url) => {
|
|
||||||
try {
|
|
||||||
await this.pool.publish([url], signedEvent);
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log(`Event published to ${url}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error(`Failed to publish to ${url}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return signedEvent;
|
|
||||||
} catch (error) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error("Failed to sign event:", error.message);
|
|
||||||
}
|
|
||||||
throw new Error("Failed to sign event.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits an existing video event by reusing the same "d" tag.
|
|
||||||
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
|
||||||
*/
|
|
||||||
// Minimal fix: ensures we only ever encrypt once per edit operation
|
|
||||||
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
|
||||||
if (!pubkey) {
|
|
||||||
throw new Error("User is not logged in.");
|
|
||||||
}
|
|
||||||
if (originalEvent.pubkey !== pubkey) {
|
|
||||||
throw new Error("You do not own this event (different pubkey).");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Editing video event:", originalEvent);
|
|
||||||
console.log("New video data:", updatedVideoData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grab the d tag from the original event
|
|
||||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
|
||||||
if (!dTag) {
|
|
||||||
throw new Error(
|
|
||||||
'This event has no "d" tag, cannot edit as addressable kind=30078.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const existingD = dTag[1];
|
|
||||||
|
|
||||||
// Parse old content
|
|
||||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Old content:", oldContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep old version & deleted status
|
|
||||||
const oldVersion = oldContent.version ?? 1;
|
|
||||||
const oldDeleted = oldContent.deleted === true;
|
|
||||||
const newVersion = updatedVideoData.version ?? oldVersion;
|
|
||||||
|
|
||||||
const oldWasPrivate = oldContent.isPrivate === true;
|
|
||||||
|
|
||||||
// 1) If old was private, decrypt the old magnet once => oldPlainMagnet
|
|
||||||
let oldPlainMagnet = oldContent.magnet || "";
|
|
||||||
if (oldWasPrivate && oldPlainMagnet) {
|
|
||||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
|
|
||||||
const newIsPrivate =
|
|
||||||
typeof updatedVideoData.isPrivate === "boolean"
|
|
||||||
? updatedVideoData.isPrivate
|
|
||||||
: oldContent.isPrivate ?? false;
|
|
||||||
|
|
||||||
// 3) The user might type a new magnet or keep oldPlainMagnet
|
|
||||||
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
|
||||||
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
|
||||||
|
|
||||||
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
|
|
||||||
let finalMagnet = finalPlainMagnet;
|
|
||||||
if (newIsPrivate) {
|
|
||||||
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build updated content
|
|
||||||
const contentObject = {
|
|
||||||
version: newVersion,
|
|
||||||
deleted: oldDeleted,
|
|
||||||
isPrivate: newIsPrivate,
|
|
||||||
title: updatedVideoData.title,
|
|
||||||
magnet: finalMagnet,
|
|
||||||
thumbnail: updatedVideoData.thumbnail,
|
|
||||||
description: updatedVideoData.description,
|
|
||||||
mode: updatedVideoData.mode,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Building updated content object:", contentObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
kind: 30078,
|
|
||||||
pubkey,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
["t", "video"],
|
|
||||||
["d", existingD],
|
|
||||||
],
|
|
||||||
content: JSON.stringify(contentObject),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Reusing d tag:", existingD);
|
|
||||||
console.log("Updated event content:", event.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Signed edited event:", signedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to all relays
|
|
||||||
await Promise.all(
|
|
||||||
this.relays.map(async (url) => {
|
|
||||||
try {
|
|
||||||
await this.pool.publish([url], signedEvent);
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log(
|
|
||||||
`Edited event published to ${url} (d="${existingD}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error(
|
|
||||||
`Failed to publish edited event to ${url}:`,
|
|
||||||
err.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return signedEvent;
|
|
||||||
} catch (error) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error("Failed to sign edited event:", error.message);
|
|
||||||
}
|
|
||||||
throw new Error("Failed to sign edited event.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
|
||||||
* and republishing with same (kind=30078, pubkey, d) address.
|
|
||||||
*/
|
|
||||||
async deleteVideo(originalEvent, pubkey) {
|
|
||||||
if (!pubkey) {
|
|
||||||
throw new Error("User is not logged in.");
|
|
||||||
}
|
|
||||||
if (originalEvent.pubkey !== pubkey) {
|
|
||||||
throw new Error("You do not own this event (different pubkey).");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Deleting video event:", originalEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
|
||||||
if (!dTag) {
|
|
||||||
throw new Error(
|
|
||||||
'This event has no "d" tag, cannot delete as addressable kind=30078.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const existingD = dTag[1];
|
|
||||||
|
|
||||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
|
||||||
const oldVersion = oldContent.version ?? 1;
|
|
||||||
|
|
||||||
const contentObject = {
|
|
||||||
version: oldVersion,
|
|
||||||
deleted: true,
|
|
||||||
title: oldContent.title || "",
|
|
||||||
magnet: "",
|
|
||||||
thumbnail: "",
|
|
||||||
description: "This video has been deleted.",
|
|
||||||
mode: oldContent.mode || "live",
|
|
||||||
isPrivate: oldContent.isPrivate || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
kind: 30078,
|
|
||||||
pubkey,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
["t", "video"],
|
|
||||||
["d", existingD],
|
|
||||||
],
|
|
||||||
content: JSON.stringify(contentObject),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Reusing d tag for delete:", existingD);
|
|
||||||
console.log("Deleted event content:", event.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Signed deleted event:", signedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
this.relays.map(async (url) => {
|
|
||||||
try {
|
|
||||||
await this.pool.publish([url], signedEvent);
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log(
|
|
||||||
`Deleted event published to ${url} (d="${existingD}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error(
|
|
||||||
`Failed to publish deleted event to ${url}:`,
|
|
||||||
err.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return signedEvent;
|
|
||||||
} catch (error) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error("Failed to sign deleted event:", error.message);
|
|
||||||
}
|
|
||||||
throw new Error("Failed to sign deleted event.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches videos from all configured relays.
|
|
||||||
*/
|
|
||||||
async fetchVideos() {
|
|
||||||
const filter = {
|
|
||||||
kinds: [30078],
|
|
||||||
"#t": ["video"],
|
|
||||||
limit: 1000,
|
|
||||||
since: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const videoEvents = new Map();
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("[fetchVideos] Starting fetch from all relays...");
|
|
||||||
console.log("[fetchVideos] Filter:", filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
this.relays.map(async (url) => {
|
|
||||||
if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await this.pool.list([url], [filter]);
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log(`Events from ${url}:`, events.length);
|
|
||||||
if (events.length > 0) {
|
|
||||||
events.forEach((evt, idx) => {
|
|
||||||
console.log(
|
|
||||||
`[fetchVideos] [${url}] Event[${idx}] ID: ${evt.id} | pubkey: ${evt.pubkey} | created_at: ${evt.created_at}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
events.forEach((event) => {
|
|
||||||
try {
|
|
||||||
const content = JSON.parse(event.content);
|
|
||||||
|
|
||||||
// If deleted == true, it overrides older notes
|
|
||||||
if (content.deleted === true) {
|
|
||||||
videoEvents.delete(event.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we haven't seen this event.id before, store it
|
|
||||||
if (!videoEvents.has(event.id)) {
|
|
||||||
videoEvents.set(event.id, {
|
|
||||||
id: event.id,
|
|
||||||
version: content.version ?? 1,
|
|
||||||
isPrivate: content.isPrivate ?? false,
|
|
||||||
title: content.title || "",
|
|
||||||
magnet: content.magnet || "",
|
|
||||||
thumbnail: content.thumbnail || "",
|
|
||||||
description: content.description || "",
|
|
||||||
mode: content.mode || "live",
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
tags: event.tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error(
|
|
||||||
"[fetchVideos] Event parsing error:",
|
|
||||||
parseError
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (relayError) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error(
|
|
||||||
`[fetchVideos] Error fetching from ${url}:`,
|
|
||||||
relayError
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const videos = Array.from(videoEvents.values()).sort(
|
|
||||||
(a, b) => b.created_at - a.created_at
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply access control filtering
|
|
||||||
const filteredVideos = accessControl.filterVideos(videos);
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("[fetchVideos] All relays have responded.");
|
|
||||||
console.log(
|
|
||||||
`[fetchVideos] Total unique video events: ${videoEvents.size}`
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[fetchVideos] Videos after filtering: ${filteredVideos.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredVideos;
|
|
||||||
} catch (error) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error("FETCH VIDEOS ERROR:", error);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates video content structure.
|
|
||||||
*/
|
|
||||||
isValidVideo(content) {
|
|
||||||
try {
|
|
||||||
const isValid =
|
|
||||||
content &&
|
|
||||||
typeof content === "object" &&
|
|
||||||
typeof content.title === "string" &&
|
|
||||||
content.title.length > 0 &&
|
|
||||||
typeof content.magnet === "string" &&
|
|
||||||
content.magnet.length > 0 &&
|
|
||||||
typeof content.mode === "string" &&
|
|
||||||
["dev", "live"].includes(content.mode) &&
|
|
||||||
(typeof content.thumbnail === "string" ||
|
|
||||||
typeof content.thumbnail === "undefined") &&
|
|
||||||
(typeof content.description === "string" ||
|
|
||||||
typeof content.description === "undefined");
|
|
||||||
|
|
||||||
if (isDevMode && !isValid) {
|
|
||||||
console.log("Invalid video content:", content);
|
|
||||||
console.log("Validation details:", {
|
|
||||||
hasTitle: typeof content.title === "string",
|
|
||||||
hasMagnet: typeof content.magnet === "string",
|
|
||||||
hasMode: typeof content.mode === "string",
|
|
||||||
validThumbnail:
|
|
||||||
typeof content.thumbnail === "string" ||
|
|
||||||
typeof content.thumbnail === "undefined",
|
|
||||||
validDescription:
|
|
||||||
typeof content.description === "string" ||
|
|
||||||
typeof content.description === "undefined",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
} catch (error) {
|
|
||||||
if (isDevMode) {
|
|
||||||
console.error("Error validating video:", error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const nostrClient = new NostrClient();
|
|
@@ -1,286 +0,0 @@
|
|||||||
// js/webtorrent.js
|
|
||||||
|
|
||||||
import WebTorrent from 'https://esm.sh/webtorrent'
|
|
||||||
|
|
||||||
export class TorrentClient {
|
|
||||||
constructor() {
|
|
||||||
this.client = new WebTorrent()
|
|
||||||
this.currentTorrent = null
|
|
||||||
this.TIMEOUT_DURATION = 60000 // 60 seconds
|
|
||||||
this.statsInterval = null
|
|
||||||
}
|
|
||||||
|
|
||||||
log(msg) {
|
|
||||||
console.log(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
async isBrave() {
|
|
||||||
return (navigator.brave?.isBrave && await navigator.brave.isBrave()) || false
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForServiceWorkerActivation(registration) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error('Service worker activation timeout'))
|
|
||||||
}, this.TIMEOUT_DURATION)
|
|
||||||
|
|
||||||
this.log('Waiting for service worker activation...')
|
|
||||||
|
|
||||||
const checkActivation = () => {
|
|
||||||
if (registration.active) {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
this.log('Service worker is active')
|
|
||||||
resolve(registration)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkActivation()) return
|
|
||||||
|
|
||||||
registration.addEventListener('activate', () => {
|
|
||||||
checkActivation()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (registration.waiting) {
|
|
||||||
this.log('Service worker is waiting, sending skip waiting message')
|
|
||||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
|
||||||
}
|
|
||||||
|
|
||||||
registration.addEventListener('statechange', () => {
|
|
||||||
checkActivation()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupServiceWorker() {
|
|
||||||
try {
|
|
||||||
const isBraveBrowser = await this.isBrave()
|
|
||||||
|
|
||||||
if (!window.isSecureContext) {
|
|
||||||
throw new Error('HTTPS or localhost required')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker) {
|
|
||||||
throw new Error('Service Worker not supported or disabled')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBraveBrowser) {
|
|
||||||
this.log('Checking Brave configuration...')
|
|
||||||
|
|
||||||
if (!navigator.serviceWorker) {
|
|
||||||
throw new Error('Please enable Service Workers in Brave Shield settings')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
||||||
throw new Error('Please enable WebRTC in Brave Shield settings')
|
|
||||||
}
|
|
||||||
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister()
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPath = window.location.pathname
|
|
||||||
const basePath = currentPath.substring(0, currentPath.lastIndexOf('/') + 1)
|
|
||||||
|
|
||||||
this.log('Registering service worker...')
|
|
||||||
const registration = await navigator.serviceWorker.register('./sw.min.js', {
|
|
||||||
scope: basePath,
|
|
||||||
updateViaCache: 'none'
|
|
||||||
})
|
|
||||||
this.log('Service worker registered')
|
|
||||||
|
|
||||||
if (registration.installing) {
|
|
||||||
this.log('Waiting for installation...')
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error('Installation timeout'))
|
|
||||||
}, this.TIMEOUT_DURATION)
|
|
||||||
|
|
||||||
registration.installing.addEventListener('statechange', (e) => {
|
|
||||||
this.log('Service worker state:', e.target.state)
|
|
||||||
if (e.target.state === 'activated' || e.target.state === 'redundant') {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.waitForServiceWorkerActivation(registration)
|
|
||||||
this.log('Service worker activated')
|
|
||||||
|
|
||||||
const readyRegistration = await Promise.race([
|
|
||||||
navigator.serviceWorker.ready,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Service worker ready timeout')), this.TIMEOUT_DURATION)
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!readyRegistration.active) {
|
|
||||||
throw new Error('Service worker not active after ready state')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('Service worker ready')
|
|
||||||
return registration
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Service worker setup error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async streamVideo(magnetURI, videoElement) {
|
|
||||||
try {
|
|
||||||
// Setup service worker first
|
|
||||||
const registration = await this.setupServiceWorker()
|
|
||||||
|
|
||||||
if (!registration || !registration.active) {
|
|
||||||
throw new Error('Service worker setup failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create WebTorrent server AFTER service worker is ready
|
|
||||||
this.client.createServer({ controller: registration })
|
|
||||||
this.log('WebTorrent server created')
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.log('Starting torrent download')
|
|
||||||
this.client.add(magnetURI, torrent => {
|
|
||||||
this.log('Torrent added: ' + torrent.name)
|
|
||||||
const status = document.getElementById('status')
|
|
||||||
const progress = document.getElementById('progress')
|
|
||||||
const peers = document.getElementById('peers')
|
|
||||||
const speed = document.getElementById('speed')
|
|
||||||
const downloaded = document.getElementById('downloaded')
|
|
||||||
|
|
||||||
if (status) status.textContent = `Loading ${torrent.name}...`
|
|
||||||
|
|
||||||
const file = torrent.files.find(file =>
|
|
||||||
file.name.endsWith('.mp4') ||
|
|
||||||
file.name.endsWith('.webm') ||
|
|
||||||
file.name.endsWith('.mkv')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
const error = new Error('No compatible video file found in torrent')
|
|
||||||
this.log(error.message)
|
|
||||||
if (status) status.textContent = 'Error: No video file found'
|
|
||||||
reject(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
videoElement.muted = true
|
|
||||||
videoElement.crossOrigin = 'anonymous'
|
|
||||||
|
|
||||||
videoElement.addEventListener('error', (e) => {
|
|
||||||
const error = e.target.error
|
|
||||||
this.log('Video error:', error)
|
|
||||||
if (error) {
|
|
||||||
this.log('Error code:', error.code)
|
|
||||||
this.log('Error message:', error.message)
|
|
||||||
}
|
|
||||||
if (status) status.textContent = 'Error playing video. Try disabling Brave Shields.'
|
|
||||||
})
|
|
||||||
|
|
||||||
videoElement.addEventListener('canplay', () => {
|
|
||||||
const playPromise = videoElement.play()
|
|
||||||
if (playPromise !== undefined) {
|
|
||||||
playPromise
|
|
||||||
.then(() => this.log('Autoplay started'))
|
|
||||||
.catch(err => {
|
|
||||||
this.log('Autoplay failed:', err)
|
|
||||||
if (status) status.textContent = 'Click to play video'
|
|
||||||
videoElement.addEventListener('click', () => {
|
|
||||||
videoElement.play()
|
|
||||||
.then(() => this.log('Play started by user'))
|
|
||||||
.catch(err => this.log('Play failed:', err))
|
|
||||||
}, { once: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
videoElement.addEventListener('loadedmetadata', () => {
|
|
||||||
this.log('Video metadata loaded')
|
|
||||||
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
|
|
||||||
this.log('Invalid duration, attempting to fix...')
|
|
||||||
videoElement.currentTime = 1e101
|
|
||||||
videoElement.currentTime = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
file.streamTo(videoElement)
|
|
||||||
this.log('Streaming started')
|
|
||||||
|
|
||||||
// Update stats every second
|
|
||||||
this.statsInterval = setInterval(() => {
|
|
||||||
if (!document.body.contains(videoElement)) {
|
|
||||||
clearInterval(this.statsInterval)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentage = torrent.progress * 100
|
|
||||||
if (progress) progress.style.width = `${percentage}%`
|
|
||||||
if (peers) peers.textContent = `Peers: ${torrent.numPeers}`
|
|
||||||
if (speed) speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s`
|
|
||||||
if (downloaded) downloaded.textContent =
|
|
||||||
`${this.formatBytes(torrent.downloaded)} / ${this.formatBytes(torrent.length)}`
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
status.textContent = torrent.progress === 1
|
|
||||||
? `${torrent.name}`
|
|
||||||
: `Loading ${torrent.name}...`
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
this.currentTorrent = torrent
|
|
||||||
resolve()
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Streaming error:', error)
|
|
||||||
if (status) status.textContent = 'Error starting video stream'
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
torrent.on('error', err => {
|
|
||||||
this.log('Torrent error:', err)
|
|
||||||
if (status) status.textContent = 'Error loading video'
|
|
||||||
clearInterval(this.statsInterval)
|
|
||||||
reject(err)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Failed to setup video streaming:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanup() {
|
|
||||||
try {
|
|
||||||
if (this.statsInterval) {
|
|
||||||
clearInterval(this.statsInterval)
|
|
||||||
}
|
|
||||||
if (this.currentTorrent) {
|
|
||||||
this.currentTorrent.destroy()
|
|
||||||
}
|
|
||||||
if (this.client) {
|
|
||||||
await this.client.destroy()
|
|
||||||
this.client = new WebTorrent() // Create a new client for future use
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Cleanup error:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const torrentClient = new TorrentClient()
|
|
@@ -0,0 +1,9 @@
|
|||||||
|
// js/utils/htmlUtils.js
|
||||||
|
export function escapeHTML(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
// js/utils/timeUtils.js
|
||||||
|
export function 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, secondsInUnit] of Object.entries(intervals)) {
|
||||||
|
const interval = Math.floor(seconds / secondsInUnit);
|
||||||
|
if (interval >= 1) {
|
||||||
|
return `${interval} ${unit}${interval === 1 ? "" : "s"} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user