big update

This commit is contained in:
Keep Creating Online
2025-01-29 20:33:28 -05:00
parent d8f2020654
commit f5c835d31b
14 changed files with 4012 additions and 1180 deletions

View File

@@ -1 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 13.616v-3.232c-1.651-.587-2.694-.752-3.219-2.019v-.001c-.527-1.271.1-2.134.847-3.707l-2.285-2.285c-1.561.742-2.433 1.375-3.707.847h-.001c-1.269-.526-1.435-1.576-2.019-3.219h-3.232c-.582 1.635-.749 2.692-2.019 3.219h-.001c-1.271.528-2.132-.098-3.707-.847l-2.285 2.285c.745 1.568 1.375 2.434.847 3.707-.527 1.271-1.584 1.438-3.219 2.02v3.232c1.632.58 2.692.749 3.219 2.019.53 1.282-.114 2.166-.847 3.707l2.285 2.286c1.562-.743 2.434-1.375 3.707-.847h.001c1.27.526 1.436 1.579 2.019 3.219h3.232c.582-1.636.75-2.69 2.027-3.222h.001c1.262-.524 2.12.101 3.698.851l2.285-2.286c-.744-1.563-1.375-2.433-.848-3.706.527-1.271 1.588-1.44 3.221-2.021zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"/></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M24,13.616L24,10.384C22.349,9.797 21.306,9.632 20.781,8.365L20.781,8.364C20.254,7.093 20.881,6.23 21.628,4.657L19.343,2.372C17.782,3.114 16.91,3.747 15.636,3.219L15.635,3.219C14.366,2.693 14.2,1.643 13.616,0L10.384,0C9.802,1.635 9.635,2.692 8.365,3.219L8.364,3.219C7.093,3.747 6.232,3.121 4.657,2.372L2.372,4.657C3.117,6.225 3.747,7.091 3.219,8.364C2.692,9.635 1.635,9.802 0,10.384L0,13.616C1.632,14.196 2.692,14.365 3.219,15.635C3.749,16.917 3.105,17.801 2.372,19.342L4.657,21.628C6.219,20.885 7.091,20.253 8.364,20.781L8.365,20.781C9.635,21.307 9.801,22.36 10.384,24L13.616,24C14.198,22.364 14.366,21.31 15.643,20.778L15.644,20.778C16.906,20.254 17.764,20.879 19.342,21.629L21.627,19.343C20.883,17.78 20.252,16.91 20.779,15.637C21.306,14.366 22.367,14.197 24,13.616ZM12,16C9.791,16 8,14.209 8,12C8,9.791 9.791,8 12,8C14.209,8 16,9.791 16,12C16,14.209 14.209,16 12,16Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

Before

Width:  |  Height:  |  Size: 811 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,64 @@
<!-- components/profile-modal.html -->
<div id="profileModal" class="fixed inset-0 bg-black/90 z-50 hidden">
<div
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
>
<div
class="modal-content bg-gray-900 w-full max-w-sm my-8 rounded-lg overflow-hidden relative flex flex-col"
>
<!-- Modal Header -->
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
>
<h2 class="text-xl font-bold text-white">Profile</h2>
<button
id="closeProfileModal"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-6 flex-1 text-gray-200 space-y-4">
<div class="flex items-center space-x-4">
<img
id="profileModalAvatar"
src="assets/jpg/default-profile.jpg"
alt="Profile"
class="w-16 h-16 object-cover rounded-full"
/>
<p id="profileModalName" class="text-lg font-semibold truncate">
Loading...
</p>
</div>
<p class="text-sm text-gray-400">
Some future profile details or settings could go here.
</p>
</div>
<!-- Modal Footer -->
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
<button
id="profileLogoutBtn"
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Logout
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,114 @@
<!-- components/upload-modal.html -->
<div id="uploadModal" class="fixed inset-0 bg-black/90 z-50 hidden">
<div
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
>
<div
class="modal-content bg-gray-900 w-full max-w-lg my-8 rounded-lg overflow-hidden relative"
>
<!-- Top Bar (similar to video-modal) -->
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300 p-4 flex items-center justify-between"
>
<h2 class="text-xl font-bold text-white">Share a Video</h2>
<button
id="closeUploadModal"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
>
<!-- X or arrow icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Form Content -->
<div class="p-6 space-y-4">
<form id="uploadForm" class="space-y-4">
<div>
<label
for="uploadTitle"
class="block text-sm font-medium text-gray-200"
>Title</label
>
<input
type="text"
id="uploadTitle"
required
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label
for="uploadMagnet"
class="block text-sm font-medium text-gray-200"
>Magnet Link</label
>
<input
type="text"
id="uploadMagnet"
required
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label
for="uploadThumbnail"
class="block text-sm font-medium text-gray-200"
>Thumbnail URL (optional)</label
>
<input
type="url"
id="uploadThumbnail"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label
for="uploadDescription"
class="block text-sm font-medium text-gray-200"
>Description (optional)</label
>
<textarea
id="uploadDescription"
rows="3"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
></textarea>
</div>
<div class="flex items-center space-x-2">
<input
type="checkbox"
id="uploadIsPrivate"
class="form-checkbox h-5 w-5 text-blue-500"
/>
<span class="text-sm font-medium text-gray-200"
>Private Listing (Encrypt Magnet)</span
>
</div>
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Publish
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -229,7 +229,11 @@ textarea:focus {
ring: 2px var(--color-primary);
}
/* Global button styles -- only apply to normal (non-icon) buttons */
/* -------------------------------------------
COMMENTED OUT the 'button:not(.icon-button)'
global rule that overrides your circles
--------------------------------------------
button:not(.icon-button) {
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
@@ -248,6 +252,7 @@ button:not(.icon-button):focus {
outline: none;
ring: 2px var(--color-primary);
}
*/
/* Utility Classes */
.line-clamp-2 {
@@ -425,18 +430,13 @@ footer a:hover {
backdrop-filter: blur(4px);
}
/* --- New Classes for Icon Buttons & Images --- */
/* Circular icon buttons */
/* Circular Icon Buttons */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
/* Fixed width/height for a perfect circle */
width: 2.5rem; /* 40px */
height: 2.5rem; /* 40px */
line-height: 0;
background-color: #3f3f46; /* Gray 700 */
color: #fff;
@@ -446,40 +446,28 @@ footer a:hover {
transition: background-color 0.2s, box-shadow 0.2s;
}
/* Hover state: slightly lighter gray */
.icon-button:hover {
background-color: #52525b; /* Gray 600 */
}
/* Focus/active states: red ring */
.icon-button:focus,
.icon-button:active {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6); /* Red ring #dc2626 */
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6);
}
/* Icon images (force white if originally black) */
.icon-image {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
/*
If your icon is black and you want to invert it to white, use this:
filter: brightness(0) invert(1);
If your icon is already white, keep it commented out or remove it.
*/
pointer-events: none;
}
/* Create a container that preserves a 16:9 aspect ratio via padding-top. */
/* Force 16:9 ratio using padding-top technique */
/* Ratio 16:9 Container */
.ratio-16-9 {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 => 9/16 = 0.5625 => 56.25% */
background-color: #1e293b; /* fallback background if image doesn't load */
padding-top: 56.25%;
background-color: #1e293b;
}
.ratio-16-9 > img {

1
src/css/tailwind.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,60 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitvid | Decentralized Video Sharing</title>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitvid | Decentralized Video Sharing</title>
<!-- Open Graph Meta Tags -->
<meta
property="og:title"
content="BitVid - Decentralized Video Sharing"
/>
<meta
property="og:description"
content="Share videos and follow creators freely, in a truly decentralized way."
/>
<meta
property="og:image"
content="https://bitvid.netlify.app/assets/jpg/bitvid.jpg"
/>
<meta property="og:url" content="https://bitvid.network" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
<meta
property="og:description"
content="Share videos and follow creators freely, in a truly decentralized way."
/>
<meta property="og:image" content="assets/jpg/bitvid.jpg" />
<meta property="og:url" content="https://bitvid.network" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<!-- App Icons -->
<link rel="icon" href="assets/favicon.ico" sizes="any" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="assets/png/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="assets/png/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<!-- App Icons -->
<link rel="icon" href="assets/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="assets/png/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="assets/png/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<!-- Styles -->
<link
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
rel="stylesheet"
/>
<link href="css/style.css" rel="stylesheet" />
</head>
<body>
<!-- Rest of your page content -->
</body>
</html>
<!-- Styles -->
<link href="css/tailwind.min.css" rel="stylesheet" />
<link href="css/style.css" rel="stylesheet" />
</head>
<body class="bg-gray-100">
<div
@@ -74,24 +57,51 @@
<!-- Login Section -->
<div id="loginSection" class="mb-8 flex items-center justify-between">
<div>
<div class="flex items-center space-x-4">
<!-- Login Button -->
<button
id="loginButton"
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-blue-500 text-white text-sm font-bold leading-none whitespace-nowrap appearance-none hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Login with Nostr
login
</button>
<p id="userStatus" class="mt-4 text-gray-500 hidden">
Logged in as: <span id="userPubKey"></span>
</p>
</div>
<div>
<!-- (Old) Logout Button is REMOVED or commented out -->
<!-- <button
id="logoutButton"
...
>logout</button>
-->
<!-- Upload Button (hidden by default) -->
<button
id="logoutButton"
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 hidden"
id="uploadButton"
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-500 text-white text-xl font-bold leading-none whitespace-nowrap appearance-none hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 hidden"
>
Logout
+
</button>
<!-- NEW: Profile Button (hidden by default) -->
<button
id="profileButton"
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-600 text-white text-sm leading-none hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 hidden"
>
<!-- We'll dynamically load the user's avatar into this <img> once logged in -->
<img
id="profileAvatar"
src="assets/jpg/default-profile.jpg"
alt="Profile"
class="object-cover rounded-full w-full h-full"
/>
</button>
</div>
<!-- (Optional) user status is hidden or removed -->
<div>
<!-- We can comment out or hide this in CSS if you do not want to show the hex key: -->
<!-- <p id="userStatus" class="mt-4 text-gray-500 hidden">
Logged in as: <span id="userPubKey"></span>
</p> -->
</div>
</div>
@@ -111,122 +121,15 @@
<!-- Success messages will appear here -->
</div>
<!-- Video Submission Form -->
<div
class="bg-white p-6 rounded-lg shadow-md mb-8 hidden"
id="videoFormContainer"
>
<h2 class="text-xl font-semibold mb-4">Share a Video</h2>
<form id="submitForm" class="space-y-4">
<div>
<label for="title" class="block text-sm font-medium text-gray-700"
>Title</label
>
<input
type="text"
id="title"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<!-- The main container for dynamic views -->
<main id="viewContainer" class="flex-grow mb-8">
<!-- We'll load our "most-recent-videos.html" or other views here -->
</main>
<div>
<label for="magnet" class="block text-sm font-medium text-gray-700"
>Magnet Link</label
>
<input
type="text"
id="magnet"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label
for="thumbnail"
class="block text-sm font-medium text-gray-700"
>Thumbnail URL (optional)</label
>
<input
type="url"
id="thumbnail"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<!-- Description Field -->
<div>
<label
for="description"
class="block text-sm font-medium text-gray-700"
>Description (optional)</label
>
<textarea
id="description"
rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
></textarea>
</div>
<!-- ADDED FOR PRIVATE LISTINGS -->
<div class="flex items-center space-x-2">
<input
type="checkbox"
id="isPrivate"
class="form-checkbox h-5 w-5"
/>
<span class="text-sm font-medium text-gray-700"
>Private Listing (Encrypt Magnet)</span
>
</div>
<!-- END ADDED FOR PRIVATE LISTINGS -->
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Share Video
</button>
</form>
</div>
<!-- Video Player Section -->
<div id="playerSection" class="mb-8 hidden">
<video id="video" controls class="w-full rounded-lg shadow-md"></video>
<!-- Status and Stats -->
<div class="mt-4">
<div id="status" class="text-gray-700 mb-2">
Initializing... Just give it a sec.
</div>
<div class="w-full bg-gray-300 rounded-full h-2 mb-2">
<div
class="bg-blue-500 h-2 rounded-full"
id="progress"
style="width: 0%"
></div>
</div>
<div class="flex justify-between text-sm text-gray-600">
<span id="peers">Peers: 0</span>
<span id="speed">0 KB/s</span>
<span id="downloaded">0 MB / 0 MB</span>
</div>
</div>
</div>
<!-- Video List -->
<div class="mb-8">
<div
id="videoList"
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
>
<!-- Videos will be dynamically inserted here -->
</div>
</div>
<!-- Imported Video Player Modal -->
<!-- Imported Video Player Modal (goes into modalContainer) -->
<div id="modalContainer"></div>
<!-- Tagline / Slogan -->
<div class="text-center mb-8">
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
seed. zap. subscribe.
@@ -440,17 +343,17 @@
</footer>
<!-- Scripts -->
<!-- Load WebTorrent via CDN -->
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/webtorrent/1.9.7/webtorrent.min.js"></script> -->
<!-- Load Nostr library -->
<script src="js/libs/nostr.bundle.js"></script>
<!-- Load JavaScript Modules -->
<script src="js/libs/nostr.bundle.js"></script>
<script type="module" src="js/config.js"></script>
<script type="module" src="js/lists.js"></script>
<script type="module" src="js/accessControl.js"></script>
<script type="module" src="js/webtorrent.js"></script>
<script type="module" src="js/nostr.js"></script>
<!-- Optional: a separate manager for view loading -->
<script type="module" src="js/viewManager.js"></script>
<!-- Main app script -->
<script type="module" src="js/app.js"></script>
</div>
</body>

1359
src/js/app copy.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

608
src/js/nostr copy 2.js Normal file
View File

@@ -0,0 +1,608 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.nostr.band",
];
// Just a helper to keep error spam in check
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
if (eventContent) {
console.log(`Event Content: ${eventContent}`);
}
errorLogCount++;
}
if (errorLogCount === MAX_ERROR_LOGS) {
console.error(
"Maximum error log limit reached. Further errors will be suppressed."
);
}
}
/**
* Example "encryption" that just reverses strings.
* In real usage, swap with actual crypto.
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
}
function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
/**
* Convert a raw Nostr event => your "video" object.
*/
function convertEventToVideo(event) {
const content = JSON.parse(event.content || "{}");
return {
id: event.id,
// We store a 'videoRootId' in content so we can group multiple edits
videoRootId: content.videoRootId || null,
version: content.version ?? 1,
isPrivate: content.isPrivate ?? false,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
mode: content.mode || "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
};
}
/**
* Key each "active" video by its root ID => so you only store
* the newest version for each root. But for older events w/o videoRootId,
* or w/o 'd' tag, we handle fallback logic below.
*/
function getActiveKey(video) {
// If it has a videoRootId, we use that
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
// This is a fallback approach so older events appear in the "active map".
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
// All events—old or new—so older share links still work
this.allEvents = new Map();
// "activeMap" holds only the newest version for each root ID (or fallback).
this.activeMap = new Map();
}
/**
* Connect to all configured relays
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected");
if (isDevMode) {
console.log(`Connected to ${successfulRelays.length} relay(s)`);
}
} catch (err) {
console.error("Nostr init failed:", err);
throw err;
}
}
async connectToRelays() {
return Promise.all(
this.relays.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, 5000);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
}
/**
* Attempt Nostr extension login or abort
*/
async login() {
try {
if (!window.nostr) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
}
// Access control check
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to whitelisted users only.");
}
}
this.pubkey = pubkey;
if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey);
}
return this.pubkey;
} catch (e) {
console.error("Login error:", e);
throw e;
}
}
logout() {
this.pubkey = null;
if (isDevMode) console.log("User logged out.");
}
decodeNsec(nsec) {
try {
const { data } = window.NostrTools.nip19.decode(nsec);
return data;
} catch (error) {
throw new Error("Invalid NSEC key.");
}
}
/**
* Publish a *new* video with a brand-new d tag & brand-new videoRootId
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
if (isDevMode) {
console.log("Publishing new video with data:", videoData);
}
let finalMagnet = videoData.magnet;
if (videoData.isPrivate) {
finalMagnet = fakeEncrypt(finalMagnet);
}
// new "videoRootId" ensures all future edits know they're from the same root
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const contentObject = {
videoRootId,
version: videoData.version ?? 1,
deleted: false,
isPrivate: videoData.isPrivate ?? false,
title: videoData.title || "",
magnet: finalMagnet,
thumbnail: videoData.thumbnail || "",
description: videoData.description || "",
mode: videoData.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", dTagValue],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Publish event with brand-new root:", videoRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) console.log("Signed event:", signedEvent);
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) console.log(`Video published to ${url}`);
} catch (err) {
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
}
})
);
return signedEvent;
} catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err);
throw err;
}
}
/**
* Edits a video by creating a *new event* with a brand-new d tag,
* but reuses the same videoRootId as the original.
* => old link remains pinned to the old event, new link is a fresh ID.
*/
async editVideo(originalVideo, updatedData, pubkey) {
if (!pubkey) throw new Error("Not logged in to edit.");
if (originalVideo.pubkey !== pubkey) {
throw new Error("You do not own this video (different pubkey).");
}
// Use the videoRootId from the originalVideo directly
const rootId = originalVideo.videoRootId || null;
// Determine if the original magnet was encrypted and decrypt if necessary
let oldPlainMagnet = originalVideo.magnet || "";
if (originalVideo.isPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
// Determine new privacy setting and magnet
const wantPrivate =
updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
let finalPlainMagnet = (updatedData.magnet || "").trim();
if (!finalPlainMagnet) {
finalPlainMagnet = oldPlainMagnet; // Fallback to original if not provided
}
let finalMagnet = finalPlainMagnet;
if (wantPrivate) {
finalMagnet = fakeEncrypt(finalPlainMagnet);
}
// Use existing rootId or generate a new one (if original lacked it)
const newRootId =
rootId || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
// Construct the new content object using originalVideo's properties where applicable
const contentObject = {
videoRootId: newRootId,
version: updatedData.version ?? originalVideo.version ?? 1,
deleted: false,
isPrivate: wantPrivate,
title: updatedData.title ?? originalVideo.title,
magnet: finalMagnet,
thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
description: updatedData.description ?? originalVideo.description,
mode: updatedData.mode ?? originalVideo.mode ?? "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", newD], // New dTag for the edit
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Creating edited event with root ID:", newRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
} catch (err) {
if (isDevMode) console.error(`Publish failed to ${url}`, err);
}
})
);
return signedEvent;
} catch (err) {
console.error("Edit failed:", err);
throw err;
}
}
/**
* "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
*/
async deleteVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to delete.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("Not your event (pubkey mismatch).");
}
// If front-end didn't pass the tags array, load the full event from local or from the relay:
let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id);
if (!fetched) {
throw new Error("Could not fetch the original event for deletion.");
}
// Rebuild baseEvent as a raw Nostr event that includes .tags and .content
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
// put the raw JSON content back into string form:
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
title: fetched.title,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
mode: fetched.mode,
}),
tags: fetched.tags,
};
}
// Now try to get the old d-tag
const dTag = baseEvent.tags.find((t) => t[0] === "d");
if (!dTag) {
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
}
const existingD = dTag[1];
// Parse out the old content so we can preserve version, isPrivate, etc.
const oldContent = JSON.parse(baseEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
// Mark it "deleted" and clear out magnet, thumbnail, etc.
const contentObject = {
version: oldVersion,
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "",
magnet: "",
thumbnail: "",
description: "Video was deleted by creator.",
mode: oldContent.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
// We reuse the same d => overshadow the original event
["d", existingD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Deleting video => mark 'deleted:true'.", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed deleted event:", signedEvent);
}
// Publish everywhere
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(`Delete event published to ${url}`);
}
} catch (err) {
if (isDevMode) {
console.error(`Failed to publish deleted event to ${url}:`, err);
}
}
})
);
return signedEvent;
} catch (err) {
if (isDevMode) {
console.error("Failed to sign deleted event:", err);
}
throw new Error("Failed to sign deleted event.");
}
}
/**
* Subscribes to *all* video events. We store them in this.allEvents so older
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
* version of each root (or fallback).
*/
subscribeVideos(onVideo) {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 500,
since: 0,
};
if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter);
}
const sub = this.pool.sub(this.relays, [filter]);
sub.on("event", (event) => {
try {
const video = convertEventToVideo(event);
this.allEvents.set(event.id, video);
// If its marked deleted, remove from active map if its the active version
if (video.deleted) {
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (existing && existing.id === video.id) {
this.activeMap.delete(activeKey);
}
return;
}
// Not deleted => see if its the newest
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive) {
// brand new => set it
this.activeMap.set(activeKey, video);
onVideo(video);
} else {
// compare timestamps
if (video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video);
}
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error processing event:", err);
}
}
});
sub.on("eose", () => {
if (isDevMode) {
console.log("[subscribeVideos] Reached EOSE for all relays");
}
});
return sub;
}
/**
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 300,
since: 0,
};
const localAll = new Map();
try {
await Promise.all(
this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]);
for (const evt of events) {
const vid = convertEventToVideo(evt);
localAll.set(evt.id, vid);
}
})
);
// Merge into this.allEvents
for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid);
}
// Rebuild activeMap
this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) {
if (video.deleted) continue;
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
// Return an array of newest for each root
const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
return activeVideos;
} catch (err) {
console.error("fetchVideos error:", err);
return [];
}
}
/**
* Attempt to fetch an event by ID from local cache, then from the relays
*/
async getEventById(eventId) {
const local = this.allEvents.get(eventId);
if (local) {
return local;
}
// direct fetch if missing
try {
for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
if (maybeEvt && maybeEvt.id === eventId) {
const video = convertEventToVideo(maybeEvt);
this.allEvents.set(eventId, video);
return video;
}
}
} catch (err) {
if (isDevMode) {
console.error("getEventById direct fetch error:", err);
}
}
return null; // not found
}
/**
* Return newest versions from activeMap if you want to skip older events
*/
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
}
export const nostrClient = new NostrClient();

684
src/js/nostr copy.js Normal file
View File

@@ -0,0 +1,684 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.nostr.band",
];
// Rate limiting for error logs
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100; // Adjust as needed
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
if (eventContent) {
console.log(`Event Content: ${eventContent}`);
}
errorLogCount++;
}
if (errorLogCount === MAX_ERROR_LOGS) {
console.error(
"Maximum error log limit reached. Further errors will be suppressed."
);
}
}
/**
* A very naive "encryption" function that just reverses the string.
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
}
function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
/**
* Convert a raw Nostr event into your "video" object.
*/
function convertEventToVideo(event) {
const content = JSON.parse(event.content || "{}");
return {
id: event.id,
version: content.version ?? 1,
isPrivate: content.isPrivate ?? false,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
mode: content.mode || "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
};
}
/**
* Return a combined key for (pubkey, dTagValue).
* If there's no `d` tag, we fallback to a special key so
* those older events still appear in the grid.
*/
function getPubkeyDKey(evt) {
const dTag = evt.tags.find((t) => t[0] === "d");
if (dTag) {
return `${evt.pubkey}:${dTag[1]}`;
} else {
// NEW: older events didn't have a d-tag, so use an alternative key
// Example: "npubXYZ:no-d:id-of-event"
return `${evt.pubkey}:no-d:${evt.id}`;
}
}
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
// All events, old or new, keyed by event.id
this.allEvents = new Map();
// Only the "active" (non-deleted) newest version per (pubkey + dTag OR fallback)
this.activeMap = new Map();
}
/**
* Initializes the Nostr client by connecting to relays.
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected");
if (isDevMode) {
console.log(`Connected to ${successfulRelays.length} relay(s)`);
}
} catch (err) {
console.error("Nostr init failed:", err);
throw err;
}
}
// Connects to each relay, ensuring they're alive
async connectToRelays() {
return Promise.all(
this.relays.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, 5000);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
}
/**
* Logs in the user using a Nostr extension or by entering an NSEC key.
*/
async login() {
try {
if (!window.nostr) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (like Alby or nos2x)."
);
}
const pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
}
// Check access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error(
"Your account has been blocked from accessing this platform."
);
} else {
throw new Error(
"Access is currently restricted to whitelisted users only."
);
}
}
this.pubkey = pubkey;
if (isDevMode) {
console.log(
"Successfully logged in with extension. Public key:",
this.pubkey
);
}
return this.pubkey;
} catch (e) {
console.error("Login error:", e);
throw e;
}
}
logout() {
this.pubkey = null;
if (isDevMode) console.log("User logged out.");
}
decodeNsec(nsec) {
try {
const { data } = window.NostrTools.nip19.decode(nsec);
return data;
} catch (error) {
throw new Error("Invalid NSEC key.");
}
}
/**
* Publishes a new video event to all relays (creates a brand-new note).
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (isDevMode) {
console.log("Publishing video with data:", videoData);
}
// If user sets "isPrivate = true", encrypt the magnet
let finalMagnet = videoData.magnet;
if (videoData.isPrivate === true) {
finalMagnet = fakeEncrypt(finalMagnet);
}
const version = videoData.version ?? 1;
const uniqueD = `${Date.now()}-${Math.random()
.toString(36)
.substring(2, 10)}`;
const contentObject = {
version,
deleted: false,
isPrivate: videoData.isPrivate || false,
title: videoData.title,
magnet: finalMagnet,
thumbnail: videoData.thumbnail,
description: videoData.description,
mode: videoData.mode,
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", uniqueD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Event content after stringify:", event.content);
console.log("Using d tag:", uniqueD);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(`Event published to ${url}`);
}
} catch (err) {
if (isDevMode) {
console.error(`Failed to publish to ${url}:`, err.message);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign event:", error.message);
}
throw new Error("Failed to sign event.");
}
}
/**
* Edits an existing video event by reusing the same "d" tag.
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
*/
async editVideo(originalEvent, updatedVideoData, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("You do not own this event (different pubkey).");
}
if (isDevMode) {
console.log("Editing video event:", originalEvent);
console.log("New video data:", updatedVideoData);
}
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
if (!dTag) {
throw new Error('No "d" tag => cannot edit as addressable kind=30078.');
}
const existingD = dTag[1];
const oldContent = JSON.parse(originalEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
const oldDeleted = oldContent.deleted === true;
const newVersion = updatedVideoData.version ?? oldVersion;
const oldWasPrivate = oldContent.isPrivate === true;
let oldPlainMagnet = oldContent.magnet || "";
if (oldWasPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
const newIsPrivate =
typeof updatedVideoData.isPrivate === "boolean"
? updatedVideoData.isPrivate
: oldContent.isPrivate ?? false;
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
let finalMagnet = finalPlainMagnet;
if (newIsPrivate) {
finalMagnet = fakeEncrypt(finalPlainMagnet);
}
const contentObject = {
version: newVersion,
deleted: oldDeleted,
isPrivate: newIsPrivate,
title: updatedVideoData.title,
magnet: finalMagnet,
thumbnail: updatedVideoData.thumbnail,
description: updatedVideoData.description,
mode: updatedVideoData.mode,
};
if (isDevMode) {
console.log("Building updated content object:", contentObject);
}
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Reusing d tag:", existingD);
console.log("Updated event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(
`Edited event published to ${url} (d="${existingD}")`
);
}
} catch (err) {
if (isDevMode) {
console.error(
`Failed to publish edited event to ${url}:`,
err.message
);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign edited event:", error.message);
}
throw new Error("Failed to sign edited event.");
}
}
/**
* Soft-delete or hide an existing video by marking content as "deleted: true"
*/
async deleteVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("You do not own this event (different pubkey).");
}
if (isDevMode) {
console.log("Deleting video event:", originalEvent);
}
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
if (!dTag) {
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
}
const existingD = dTag[1];
const oldContent = JSON.parse(originalEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
const contentObject = {
version: oldVersion,
deleted: true,
title: oldContent.title || "",
magnet: "",
thumbnail: "",
description: "This video has been deleted.",
mode: oldContent.mode || "live",
isPrivate: oldContent.isPrivate || false,
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Reusing d tag for delete:", existingD);
console.log("Deleted event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed deleted event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(
`Deleted event published to ${url} (d="${existingD}")`
);
}
} catch (err) {
if (isDevMode) {
console.error(`Failed to publish deleted event to ${url}:`, err);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign deleted event:", error);
}
throw new Error("Failed to sign deleted event.");
}
}
/**
* Subscribes to all video events from all relays.
* We store them in `allEvents` (so old IDs are still available),
* and we also maintain `activeMap` for the newest versions of each pubkey-dKey.
*
* @param {Function} onVideo - Callback for each newly recognized "active" video
*/
subscribeVideos(onVideo) {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 500,
since: 0, // we want from the beginning
};
if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter);
}
const sub = this.pool.sub(this.relays, [filter]);
sub.on("event", (event) => {
try {
// Convert event => video object
const video = convertEventToVideo(event);
// Always store it in allEvents
this.allEvents.set(event.id, video);
// If deleted, remove from activeMap if it's the active version
if (video.deleted) {
const key = getPubkeyDKey(event); // might be no-d if no 'd' tag
const activeVid = this.activeMap.get(key);
if (activeVid && activeVid.id === event.id) {
this.activeMap.delete(key);
}
return;
}
// It's not deleted => see if we should set it as active
const key = getPubkeyDKey(event); // might be "npubXYZ:no-d:ID"
const existingActive = this.activeMap.get(key);
if (!existingActive) {
// brand new => store it
this.activeMap.set(key, video);
onVideo(video);
} else {
// We have an active version; check timestamps
if (video.created_at > existingActive.created_at) {
// It's newer => overwrite
this.activeMap.set(key, video);
onVideo(video);
} else {
// It's an older event => ignore from "active" perspective
// but still in allEvents for old links
}
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error parsing event:", err);
}
}
});
sub.on("eose", () => {
if (isDevMode) {
console.log("[subscribeVideos] Reached EOSE for all relays");
}
// Optionally notify that the initial load is done
});
return sub;
}
/**
* Bulk fetch of videos from all relays. Then we build the `activeMap`
* so your grid can show all old & new events (even if no 'd' tag).
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
// Increase limit if you want more than 300
limit: 300,
since: 0,
};
const localAll = new Map();
try {
await Promise.all(
this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]);
for (const evt of events) {
const video = convertEventToVideo(evt);
localAll.set(evt.id, video);
}
})
);
// Merge localAll into our global allEvents
for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid);
}
// Re-build activeMap
this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) {
if (video.deleted) continue; // skip
const key = getPubkeyDKey({
id,
tags: video.tags,
pubkey: video.pubkey,
});
const existing = this.activeMap.get(key);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(key, video);
}
}
// Return sorted "active" array for your grid
const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
return activeVideos;
} catch (err) {
console.error("fetchVideos error:", err);
return [];
}
}
/**
* Get an event by ID from our local cache (allEvents) if present.
* If missing, do a direct pool.get() for that ID. This ensures older
* "archived" events might still be loaded from the relays.
*/
async getEventById(eventId) {
const local = this.allEvents.get(eventId);
if (local) {
return local;
}
// NEW: do a direct fetch if not found
try {
for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
if (maybeEvt && maybeEvt.id === eventId) {
const video = convertEventToVideo(maybeEvt);
// store in allEvents
this.allEvents.set(eventId, video);
return video;
}
}
} catch (err) {
if (isDevMode) {
console.error("getEventById direct fetch error:", err);
}
}
// not found
return null;
}
/**
* Return the "active" videos, i.e. latest for each (pubkey+d or fallback).
*/
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
isValidVideo(content) {
try {
const isValid =
content &&
typeof content === "object" &&
typeof content.title === "string" &&
content.title.length > 0 &&
typeof content.magnet === "string" &&
content.magnet.length > 0 &&
typeof content.mode === "string" &&
["dev", "live"].includes(content.mode) &&
(typeof content.thumbnail === "string" ||
typeof content.thumbnail === "undefined") &&
(typeof content.description === "string" ||
typeof content.description === "undefined");
if (isDevMode && !isValid) {
console.log("Invalid video content:", content);
}
return isValid;
} catch (error) {
if (isDevMode) {
console.error("Error validating video:", error);
}
return false;
}
}
}
export const nostrClient = new NostrClient();

View File

@@ -11,10 +11,9 @@ const RELAY_URLS = [
"wss://relay.nostr.band",
];
// Rate limiting for error logs
// Just a helper to keep error spam in check
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100; // Adjust as needed
const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
@@ -31,8 +30,8 @@ function logErrorOnce(message, eventContent = null) {
}
/**
* A very naive "encryption" function that just reverses the string.
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
* Example "encryption" that just reverses strings.
* In real usage, swap with actual crypto.
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
@@ -41,18 +40,63 @@ function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
/**
* Convert a raw Nostr event => your "video" object.
*/
function convertEventToVideo(event) {
const content = JSON.parse(event.content || "{}");
return {
id: event.id,
// We store a 'videoRootId' in content so we can group multiple edits
videoRootId: content.videoRootId || null,
version: content.version ?? 1,
isPrivate: content.isPrivate ?? false,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
mode: content.mode || "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
};
}
/**
* Key each "active" video by its root ID => so you only store
* the newest version for each root. But for older events w/o videoRootId,
* or w/o 'd' tag, we handle fallback logic below.
*/
function getActiveKey(video) {
// If it has a videoRootId, we use that
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
// This is a fallback approach so older events appear in the "active map".
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
// We keep a Map of subscribed videos for quick lookups by event.id
this.subscribedVideos = new Map();
// All events—old or new—so older share links still work
this.allEvents = new Map();
// "activeMap" holds only the newest version for each root ID (or fallback).
this.activeMap = new Map();
}
/**
* Initializes the Nostr client by connecting to relays.
* Connect to all configured relays
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
@@ -63,18 +107,16 @@ class NostrClient {
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected");
if (isDevMode)
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(
@@ -100,49 +142,40 @@ class NostrClient {
}
/**
* Logs in the user using a Nostr extension or by entering an NSEC key.
* Attempt Nostr extension login or abort
*/
async login() {
try {
if (!window.nostr) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (like Alby or nos2x)."
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
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
// Access control check
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error(
"Your account has been blocked from accessing this platform."
);
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error(
"Access is currently restricted to whitelisted users only."
);
throw new Error("Access restricted to whitelisted users only.");
}
}
this.pubkey = pubkey;
if (isDevMode)
console.log(
"Successfully logged in with extension. Public key:",
this.pubkey
);
if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey);
}
return this.pubkey;
} catch (e) {
console.error("Login error:", e);
@@ -150,17 +183,11 @@ class NostrClient {
}
}
/**
* 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);
@@ -171,271 +198,253 @@ class NostrClient {
}
/**
* Publishes a new video event to all relays (creates a brand-new note).
* Publish a *new* video with a brand-new d tag & brand-new videoRootId
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (!pubkey) throw new Error("Not logged in to publish video.");
if (isDevMode) {
console.log("Publishing video with data:", videoData);
console.log("Publishing new video with data:", videoData);
}
// If user sets "isPrivate = true", encrypt the magnet
let finalMagnet = videoData.magnet;
if (videoData.isPrivate === true) {
if (videoData.isPrivate) {
finalMagnet = fakeEncrypt(finalMagnet);
}
// Default version is 1 if not specified
const version = videoData.version ?? 1;
// new "videoRootId" ensures all future edits know they're from the same root
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const uniqueD = `${Date.now()}-${Math.random()
.toString(36)
.substring(2, 10)}`;
// Always mark "deleted" false for new posts
const contentObject = {
version,
videoRootId,
version: videoData.version ?? 1,
deleted: false,
isPrivate: videoData.isPrivate || false,
title: videoData.title,
isPrivate: videoData.isPrivate ?? false,
title: videoData.title || "",
magnet: finalMagnet,
thumbnail: videoData.thumbnail,
description: videoData.description,
mode: videoData.mode,
thumbnail: videoData.thumbnail || "",
description: videoData.description || "",
mode: videoData.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 100),
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", uniqueD],
["d", dTagValue],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Event content after stringify:", event.content);
console.log("Using d tag:", uniqueD);
console.log("Publish event with brand-new root:", videoRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed event:", signedEvent);
}
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}`);
}
if (isDevMode) console.log(`Video published to ${url}`);
} catch (err) {
if (isDevMode) {
console.error(`Failed to publish to ${url}:`, err.message);
}
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign event:", error.message);
}
throw new Error("Failed to sign event.");
} catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err);
throw err;
}
}
/**
* Edits an existing video event by reusing the same "d" tag.
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
* Edits a video by creating a *new event* with a brand-new d tag,
* but reuses the same videoRootId as the original.
* => old link remains pinned to the old event, new link is a fresh ID.
*/
async editVideo(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).");
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).");
}
if (isDevMode) {
console.log("Editing video event:", originalEvent);
console.log("New video data:", updatedVideoData);
}
// Use the videoRootId directly from the converted video
const rootId = originalVideo.videoRootId || null;
// 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) {
// Decrypt the old magnet if it was private
let oldPlainMagnet = originalVideo.magnet || "";
if (originalVideo.isPrivate && 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;
// Determine new privacy setting
const wantPrivate =
updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
// 3) The user might type a new magnet or keep oldPlainMagnet
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
// Fallback to old magnet if none provided
let finalPlainMagnet = (updatedData.magnet || "").trim();
if (!finalPlainMagnet) {
finalPlainMagnet = oldPlainMagnet;
}
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
// Re-encrypt if user wants private
let finalMagnet = finalPlainMagnet;
if (newIsPrivate) {
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)}`;
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
// Build updated content
const contentObject = {
version: newVersion,
deleted: oldDeleted,
isPrivate: newIsPrivate,
title: updatedVideoData.title,
videoRootId: newRootId,
version: updatedData.version ?? originalVideo.version ?? 1,
deleted: false,
isPrivate: wantPrivate,
title: updatedData.title ?? originalVideo.title,
magnet: finalMagnet,
thumbnail: updatedVideoData.thumbnail,
description: updatedVideoData.description,
mode: updatedVideoData.mode,
thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
description: updatedData.description ?? originalVideo.description,
mode: updatedData.mode ?? originalVideo.mode ?? "live",
};
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],
["d", newD], // new share link
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Reusing d tag:", existingD);
console.log("Updated event content:", event.content);
console.log("Creating edited event with root ID:", newRootId);
console.log("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
);
console.error(`Publish failed to ${url}`, err);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign edited event:", error.message);
}
throw new Error("Failed to sign edited event.");
} catch (err) {
console.error("Edit failed:", err);
throw err;
}
}
/**
* Soft-delete or hide an existing video by marking content as "deleted: true"
* and republishing with the same (kind=30078, pubkey, d) address.
* "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
*/
async deleteVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
throw new Error("Not logged in to delete.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("You do not own this event (different pubkey).");
throw new Error("Not your event (pubkey mismatch).");
}
if (isDevMode) {
console.log("Deleting video event:", originalEvent);
// If front-end didn't pass the tags array, load the full event from local or from the relay:
let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id);
if (!fetched) {
throw new Error("Could not fetch the original event for deletion.");
}
// Rebuild baseEvent as a raw Nostr event that includes .tags and .content
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
// put the raw JSON content back into string form:
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
title: fetched.title,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
mode: fetched.mode,
}),
tags: fetched.tags,
};
}
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
// Now try to get the old d-tag
const dTag = baseEvent.tags.find((t) => t[0] === "d");
if (!dTag) {
throw new Error(
'This event has no "d" tag, cannot delete as addressable kind=30078.'
);
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
}
const existingD = dTag[1];
const oldContent = JSON.parse(originalEvent.content || "{}");
// After you've parsed oldContent:
const oldContent = JSON.parse(baseEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
// Mark it "deleted" and clear out magnet, thumbnail, etc.
// ADD this block to handle the old root or fallback:
let finalRootId = oldContent.videoRootId || null;
if (!finalRootId) {
// If its 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}`;
}
}
// Now build the content object, including videoRootId:
const contentObject = {
videoRootId: finalRootId, // <-- CRUCIAL so the delete event shares the same root key
version: oldVersion,
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "",
magnet: "",
thumbnail: "",
description: "This video has been deleted.",
description: "Video was deleted by creator.",
mode: oldContent.mode || "live",
isPrivate: oldContent.isPrivate || false,
};
// Reuse the same d-tag for an addressable edit
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
// We reuse the same d => overshadow the original event
["d", existingD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Reusing d tag for delete:", existingD);
console.log("Deleted event content:", event.content);
console.log("Deleting video => mark 'deleted:true'.", event.content);
}
try {
@@ -444,14 +453,13 @@ class NostrClient {
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(
`Deleted event published to ${url} (d="${existingD}")`
);
console.log(`Delete event published to ${url}`);
}
} catch (err) {
if (isDevMode) {
@@ -460,80 +468,67 @@ class NostrClient {
}
})
);
return signedEvent;
} catch (error) {
} catch (err) {
if (isDevMode) {
console.error("Failed to sign deleted event:", error);
console.error("Failed to sign deleted event:", err);
}
throw new Error("Failed to sign deleted event.");
}
}
/**
* Subscribes to video events from all configured relays, storing them in a Map.
*
* @param {Function} onVideo - Callback fired for each new/updated video
* Subscribes to *all* video events. We store them in this.allEvents so older
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
* version of each root (or fallback).
*/
subscribeVideos(onVideo) {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 500, // Adjust as needed
limit: 500,
since: 0,
};
if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter);
}
// Create subscription across all relays
const sub = this.pool.sub(this.relays, [filter]);
sub.on("event", (event) => {
try {
const content = JSON.parse(event.content);
const video = convertEventToVideo(event);
this.allEvents.set(event.id, video);
// If its marked deleted, remove from active map if its the active version
// NEW CODE
if (video.deleted) {
const activeKey = getActiveKey(video);
// Don't compare IDs—just remove that key from the active map
this.activeMap.delete(activeKey);
// (Optional) If you want a debug log:
// console.log(`[DELETE] Removed activeKey=${activeKey}`);
// If marked deleted
if (content.deleted === true) {
// Remove it from our Map if we had it
if (this.subscribedVideos.has(event.id)) {
this.subscribedVideos.delete(event.id);
// Optionally notify the callback so UI can remove it
// onVideo(null, { deletedId: event.id });
}
return;
}
// Construct a video object
const video = {
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,
};
// Check if we already have it in our Map
if (!this.subscribedVideos.has(event.id)) {
// It's new, so store it
this.subscribedVideos.set(event.id, video);
// Then notify the callback that a new video arrived
// Not deleted => see if its the newest
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive) {
// brand new => set it
this.activeMap.set(activeKey, video);
onVideo(video);
} else {
// Optional: if you want to detect edits, compare the new vs. old and update
// this.subscribedVideos.set(event.id, video);
// onVideo(video) to re-render, etc.
// compare timestamps
if (video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video);
}
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error parsing event:", err);
console.error("[subscribeVideos] Error processing event:", err);
}
}
});
@@ -542,61 +537,60 @@ class NostrClient {
if (isDevMode) {
console.log("[subscribeVideos] Reached EOSE for all relays");
}
// Optionally: onVideo(null, { eose: true }) to signal initial load done
});
return sub; // so you can unsub later if needed
return sub;
}
/**
* A one-time, bulk fetch of videos from all configured relays.
* (Limit has been reduced to 300 for better performance.)
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 300, // Reduced from 1000 for quicker fetches
limit: 300,
since: 0,
};
const videoEvents = new Map();
const localAll = new Map();
try {
// Query each relay in parallel
// 1) Fetch all events from each relay
await Promise.all(
this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]);
for (const evt of events) {
try {
const content = JSON.parse(evt.content);
if (content.deleted) {
videoEvents.delete(evt.id);
} else {
videoEvents.set(evt.id, {
id: evt.id,
pubkey: evt.pubkey,
created_at: evt.created_at,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
mode: content.mode || "live",
isPrivate: content.isPrivate || false,
tags: evt.tags,
});
}
} catch (e) {
console.error("Error parsing event content:", e);
}
const vid = convertEventToVideo(evt);
localAll.set(evt.id, vid);
}
})
);
// Turn the Map into a sorted array
const allVideos = Array.from(videoEvents.values()).sort(
// 2) Merge into this.allEvents
for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid);
}
// 3) Rebuild activeMap
this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) {
// Skip if the video is marked deleted
if (video.deleted) continue;
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
// If there's no existing entry or this is newer, set/replace
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
// 4) Return newest version for each root in descending order
const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
return allVideos;
return activeVideos;
} catch (err) {
console.error("fetchVideos error:", err);
return [];
@@ -604,46 +598,38 @@ class NostrClient {
}
/**
* Validates video content structure.
* Attempt to fetch an event by ID from local cache, then from the relays
*/
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;
async getEventById(eventId) {
const local = this.allEvents.get(eventId);
if (local) {
return local;
}
// direct fetch if missing
try {
for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
if (maybeEvt && maybeEvt.id === eventId) {
const video = convertEventToVideo(maybeEvt);
this.allEvents.set(eventId, video);
return video;
}
}
} catch (err) {
if (isDevMode) {
console.error("getEventById direct fetch error:", err);
}
}
return null; // not found
}
/**
* Return newest versions from activeMap if you want to skip older events
*/
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
}

17
src/js/viewManager.js Normal file
View File

@@ -0,0 +1,17 @@
// js/viewManager.js
// Load a partial view by URL into the #viewContainer
export async function loadView(viewUrl) {
try {
const res = await fetch(viewUrl);
if (!res.ok) {
throw new Error(`Failed to load view: ${res.status}`);
}
const html = await res.text();
document.getElementById("viewContainer").innerHTML = html;
} catch (err) {
console.error("View loading error:", err);
document.getElementById("viewContainer").innerHTML =
"<p class='text-center text-red-500'>Failed to load content.</p>";
}
}

View File

@@ -1,16 +1,14 @@
// <!-- keep this <ai_context> section if it already exists at the top of your file -->
// js/webtorrent.js
import WebTorrent from "./webtorrent.min.js";
export class TorrentClient {
constructor() {
// Create WebTorrent client
this.client = new WebTorrent();
this.currentTorrent = null;
this.TIMEOUT_DURATION = 60000; // 60 seconds
this.statsInterval = null;
// We remove the “statsInterval” since were not using it here anymore
// this.statsInterval = null;
}
log(msg) {
@@ -62,9 +60,6 @@ export class TorrentClient {
});
}
/**
* Registers the service worker, waiting until it's fully active before proceeding.
*/
async setupServiceWorker() {
try {
const isBraveBrowser = await this.isBrave();
@@ -72,21 +67,18 @@ export class TorrentClient {
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 Brave, we optionally clear all service workers so we can re-register cleanly
// Optional Brave config
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");
}
@@ -134,11 +126,9 @@ export class TorrentClient {
});
}
// Wait for service worker to become active
await this.waitForServiceWorkerActivation(registration);
this.log("Service worker activated");
// Make sure its truly active
const readyRegistration = await Promise.race([
navigator.serviceWorker.ready,
new Promise((_, reject) =>
@@ -170,7 +160,8 @@ export class TorrentClient {
}
/**
* Streams the given magnet URI to the specified <video> element.
* Streams the magnet to the <video> element.
* No stats intervals here—just returns the torrent object.
*/
async streamVideo(magnetURI, videoElement) {
try {
@@ -180,314 +171,127 @@ export class TorrentClient {
throw new Error("Service worker setup failed");
}
// 2) Create WebTorrent server AFTER service worker is ready
// 2) Create WebTorrent server
this.client.createServer({ controller: registration });
this.log("WebTorrent server created");
const isFirefoxBrowser = this.isFirefox();
if (isFirefoxBrowser) {
// ----------------------
// FIREFOX CODE PATH
// (sequential, concurrency limit, smaller chunk)
// ----------------------
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
if (isFirefoxBrowser) {
this.log("Starting torrent download (Firefox path)");
this.client.add(
magnetURI,
{
strategy: "sequential",
maxWebConns: 4, // reduce concurrency
},
{ strategy: "sequential", maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
}
);
});
} else {
// ----------------------
// CHROME / OTHER BROWSERS CODE PATH
// (your original "faster" approach)
// ----------------------
return new Promise((resolve, reject) => {
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
});
});
}
}
});
} catch (error) {
this.log("Failed to setup video streaming:", error);
throw error;
}
}
/**
* The "faster" original approach for Chrome/other browsers.
*/
// Minimal handleChromeTorrent — no internal setInterval
handleChromeTorrent(torrent, videoElement, resolve, reject) {
this.log("Torrent added (Chrome path): " + 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}...`;
}
// Find playable file (same as old code)
const file = torrent.files.find(
(f) =>
f.name.endsWith(".mp4") ||
f.name.endsWith(".webm") ||
f.name.endsWith(".mkv")
const file = torrent.files.find((f) =>
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
);
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";
return reject(error);
return reject(new Error("No compatible video file found in torrent"));
}
// Mute for autoplay
// Mute & crossOrigin
videoElement.muted = true;
videoElement.crossOrigin = "anonymous";
// Error handling same as old code
// Catch video errors
videoElement.addEventListener("error", (e) => {
const errObj = e.target.error;
this.log("Video error:", errObj);
if (errObj) {
this.log("Error code:", errObj.code);
this.log("Error message:", errObj.message);
}
if (status) {
status.textContent =
"Error playing video. Try refreshing the page.";
}
this.log("Video error:", e.target.error);
});
// Attempt autoplay
videoElement.addEventListener("canplay", () => {
const playPromise = videoElement.play();
if (playPromise !== undefined) {
playPromise
.then(() => this.log("Autoplay started (Chrome path)"))
.catch((err) => {
this.log("Autoplay failed:", err);
if (status) status.textContent = "Click to play video";
videoElement.addEventListener(
"click",
() => {
videoElement
.play()
.catch((err2) => this.log("Play failed:", err2));
},
{ once: true }
);
});
}
videoElement.play().catch((err) => {
this.log("Autoplay failed:", err);
});
});
videoElement.addEventListener("loadedmetadata", () => {
this.log("Video metadata loaded (Chrome path)");
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
this.log("Invalid duration, attempting to fix...");
videoElement.currentTime = 1e101;
videoElement.currentTime = 0;
}
});
// Now stream to the video element
// Actually stream
try {
file.streamTo(videoElement); // no chunk constraints
this.log("Streaming started (Chrome path)");
// Update stats
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);
file.streamTo(videoElement);
this.currentTorrent = torrent;
resolve();
resolve(torrent);
} catch (err) {
this.log("Streaming error (Chrome path):", err);
if (status) status.textContent = "Error starting video stream";
reject(err);
}
// Torrent error event
// Also handle torrent error events
torrent.on("error", (err) => {
this.log("Torrent error (Chrome path):", err);
if (status) status.textContent = "Error loading video";
clearInterval(this.statsInterval);
reject(err);
});
}
/**
* The new approach for Firefox: sequential, concurrency limit, smaller chunk size.
*/
// Minimal handleFirefoxTorrent
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
this.log("Torrent added (Firefox path): " + 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}...`;
}
// Find playable file
const file = torrent.files.find(
(f) =>
f.name.endsWith(".mp4") ||
f.name.endsWith(".webm") ||
f.name.endsWith(".mkv")
const file = torrent.files.find((f) =>
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
);
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";
return reject(error);
return reject(new Error("No compatible video file found in torrent"));
}
videoElement.muted = true;
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
const errObj = e.target.error;
this.log("Video error (Firefox path):", errObj);
if (errObj) {
this.log("Error code:", errObj.code);
this.log("Error message:", errObj.message);
}
if (status) {
status.textContent =
"Error playing video. Try refreshing the page.";
}
this.log("Video error (Firefox path):", e.target.error);
});
videoElement.addEventListener("canplay", () => {
const playPromise = videoElement.play();
if (playPromise !== undefined) {
playPromise
.then(() => this.log("Autoplay started (Firefox path)"))
.catch((err) => {
this.log("Autoplay failed:", err);
if (status) status.textContent = "Click to play video";
videoElement.addEventListener(
"click",
() => {
videoElement
.play()
.catch((err2) => this.log("Play failed:", err2));
},
{ once: true }
);
});
}
videoElement.play().catch((err) => {
this.log("Autoplay failed:", err);
});
});
videoElement.addEventListener("loadedmetadata", () => {
this.log("Video metadata loaded (Firefox path)");
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
this.log("Invalid duration, attempting to fix...");
videoElement.currentTime = 1e101;
videoElement.currentTime = 0;
}
});
// We set a smaller chunk size for Firefox
try {
file.streamTo(videoElement, { highWaterMark: 32 * 1024 }); // 32 KB chunk
this.log("Streaming started (Firefox path)");
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);
file.streamTo(videoElement, { highWaterMark: 32 * 1024 });
this.currentTorrent = torrent;
resolve();
resolve(torrent);
} catch (err) {
this.log("Streaming error (Firefox path):", err);
if (status) status.textContent = "Error starting video stream";
reject(err);
}
// Listen for torrent errors
torrent.on("error", (err) => {
this.log("Torrent error (Firefox path):", err);
if (status) status.textContent = "Error loading video";
clearInterval(this.statsInterval);
reject(err);
});
}
/**
* Clean up after playback or page unload.
* Clean up
*/
async cleanup() {
try {
if (this.statsInterval) {
clearInterval(this.statsInterval);
}
// No local interval to clear here
if (this.currentTorrent) {
this.currentTorrent.destroy();
}
if (this.client) {
await this.client.destroy();
// Recreate fresh client for next time
this.client = new WebTorrent();
}
} catch (error) {

View File

@@ -0,0 +1,13 @@
<!-- views/most-recent-videos.html -->
<section>
<!-- You can have a heading if you want -->
<h2 class="text-xl mb-4 text-gray-700">Most Recent Videos</h2>
<!-- Video List -->
<div
id="videoList"
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
>
<!-- Videos will be dynamically inserted here -->
</div>
</section>