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); 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) { button:not(.icon-button) {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background-color: var(--color-primary); background-color: var(--color-primary);
@@ -248,6 +252,7 @@ button:not(.icon-button):focus {
outline: none; outline: none;
ring: 2px var(--color-primary); ring: 2px var(--color-primary);
} }
*/
/* Utility Classes */ /* Utility Classes */
.line-clamp-2 { .line-clamp-2 {
@@ -425,18 +430,13 @@ footer a:hover {
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
/* --- New Classes for Icon Buttons & Images --- */ /* Circular Icon Buttons */
/* Circular icon buttons */
.icon-button { .icon-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* Fixed width/height for a perfect circle */
width: 2.5rem; /* 40px */ width: 2.5rem; /* 40px */
height: 2.5rem; /* 40px */ height: 2.5rem; /* 40px */
line-height: 0; line-height: 0;
background-color: #3f3f46; /* Gray 700 */ background-color: #3f3f46; /* Gray 700 */
color: #fff; color: #fff;
@@ -446,40 +446,28 @@ footer a:hover {
transition: background-color 0.2s, box-shadow 0.2s; transition: background-color 0.2s, box-shadow 0.2s;
} }
/* Hover state: slightly lighter gray */
.icon-button:hover { .icon-button:hover {
background-color: #52525b; /* Gray 600 */ background-color: #52525b; /* Gray 600 */
} }
/* Focus/active states: red ring */
.icon-button:focus, .icon-button:focus,
.icon-button:active { .icon-button:active {
outline: none; 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 { .icon-image {
width: 1.25rem; /* 20px */ width: 1.25rem; /* 20px */
height: 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; pointer-events: none;
} }
/* Create a container that preserves a 16:9 aspect ratio via padding-top. */ /* Ratio 16:9 Container */
/* Force 16:9 ratio using padding-top technique */
.ratio-16-9 { .ratio-16-9 {
position: relative; position: relative;
width: 100%; width: 100%;
padding-top: 56.25%; /* 16:9 => 9/16 = 0.5625 => 56.25% */ padding-top: 56.25%;
background-color: #1e293b; /* fallback background if image doesn't load */ background-color: #1e293b;
} }
.ratio-16-9 > img { .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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitvid | Decentralized Video Sharing</title> <title>bitvid | Decentralized Video Sharing</title>
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta <meta property="og:title" content="BitVid - Decentralized Video Sharing" />
property="og:title" <meta
content="BitVid - Decentralized Video Sharing" property="og:description"
/> content="Share videos and follow creators freely, in a truly decentralized way."
<meta />
property="og:description" <meta property="og:image" content="assets/jpg/bitvid.jpg" />
content="Share videos and follow creators freely, in a truly decentralized way." <meta property="og:url" content="https://bitvid.network" />
/> <meta property="og:type" content="website" />
<meta <meta property="og:locale" content="en_US" />
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" />
<!-- App Icons --> <!-- App Icons -->
<link rel="icon" href="assets/favicon.ico" sizes="any" /> <link rel="icon" href="assets/favicon.ico" sizes="any" />
<link <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
rel="apple-touch-icon" <link
sizes="180x180" rel="icon"
href="/apple-touch-icon.png" type="image/png"
/> sizes="32x32"
<link href="assets/png/favicon-32x32.png"
rel="icon" />
type="image/png" <link
sizes="32x32" rel="icon"
href="assets/png/favicon-32x32.png" type="image/png"
/> sizes="16x16"
<link href="assets/png/favicon-16x16.png"
rel="icon" />
type="image/png" <link rel="manifest" href="/site.webmanifest" />
sizes="16x16" <meta name="theme-color" content="#0f172a" />
href="assets/png/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<!-- Styles --> <!-- Styles -->
<link <link href="css/tailwind.min.css" rel="stylesheet" />
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link href="css/style.css" rel="stylesheet" />
rel="stylesheet" </head>
/>
<link href="css/style.css" rel="stylesheet" />
</head>
<body>
<!-- Rest of your page content -->
</body>
</html>
<body class="bg-gray-100"> <body class="bg-gray-100">
<div <div
@@ -74,24 +57,51 @@
<!-- Login Section --> <!-- Login Section -->
<div id="loginSection" class="mb-8 flex items-center justify-between"> <div id="loginSection" class="mb-8 flex items-center justify-between">
<div> <div class="flex items-center space-x-4">
<!-- Login Button -->
<button <button
id="loginButton" 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> </button>
<p id="userStatus" class="mt-4 text-gray-500 hidden">
Logged in as: <span id="userPubKey"></span> <!-- (Old) Logout Button is REMOVED or commented out -->
</p> <!-- <button
</div> id="logoutButton"
<div> ...
>logout</button>
-->
<!-- Upload Button (hidden by default) -->
<button <button
id="logoutButton" id="uploadButton"
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" 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> </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>
</div> </div>
@@ -111,122 +121,15 @@
<!-- Success messages will appear here --> <!-- Success messages will appear here -->
</div> </div>
<!-- Video Submission Form --> <!-- The main container for dynamic views -->
<div <main id="viewContainer" class="flex-grow mb-8">
class="bg-white p-6 rounded-lg shadow-md mb-8 hidden" <!-- We'll load our "most-recent-videos.html" or other views here -->
id="videoFormContainer" </main>
>
<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>
<div> <!-- Imported Video Player Modal (goes into modalContainer) -->
<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 -->
<div id="modalContainer"></div> <div id="modalContainer"></div>
<!-- Tagline / Slogan -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<h2 class="text-2xl font-bold text-gray-500 tracking-wide"> <h2 class="text-2xl font-bold text-gray-500 tracking-wide">
seed. zap. subscribe. seed. zap. subscribe.
@@ -440,17 +343,17 @@
</footer> </footer>
<!-- Scripts --> <!-- 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 src="js/libs/nostr.bundle.js"></script>
<script type="module" src="js/config.js"></script> <script type="module" src="js/config.js"></script>
<script type="module" src="js/lists.js"></script> <script type="module" src="js/lists.js"></script>
<script type="module" src="js/accessControl.js"></script> <script type="module" src="js/accessControl.js"></script>
<script type="module" src="js/webtorrent.js"></script> <script type="module" src="js/webtorrent.js"></script>
<script type="module" src="js/nostr.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> <script type="module" src="js/app.js"></script>
</div> </div>
</body> </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", "wss://relay.nostr.band",
]; ];
// Rate limiting for error logs // Just a helper to keep error spam in check
let errorLogCount = 0; let errorLogCount = 0;
const MAX_ERROR_LOGS = 100; // Adjust as needed const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) { function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) { if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message); console.error(message);
@@ -31,8 +30,8 @@ function logErrorOnce(message, eventContent = null) {
} }
/** /**
* A very naive "encryption" function that just reverses the string. * Example "encryption" that just reverses strings.
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.). * In real usage, swap with actual crypto.
*/ */
function fakeEncrypt(magnet) { function fakeEncrypt(magnet) {
return magnet.split("").reverse().join(""); return magnet.split("").reverse().join("");
@@ -41,18 +40,63 @@ function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join(""); 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 { class NostrClient {
constructor() { constructor() {
this.pool = null; this.pool = null;
this.pubkey = null; this.pubkey = null;
this.relays = RELAY_URLS; this.relays = RELAY_URLS;
// We keep a Map of subscribed videos for quick lookups by event.id // All events—old or new—so older share links still work
this.subscribedVideos = new Map(); 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() { async init() {
if (isDevMode) console.log("Connecting to relays..."); if (isDevMode) console.log("Connecting to relays...");
@@ -63,18 +107,16 @@ class NostrClient {
const successfulRelays = results const successfulRelays = results
.filter((r) => r.success) .filter((r) => r.success)
.map((r) => r.url); .map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected"); if (successfulRelays.length === 0) throw new Error("No relays connected");
if (isDevMode) {
if (isDevMode)
console.log(`Connected to ${successfulRelays.length} relay(s)`); console.log(`Connected to ${successfulRelays.length} relay(s)`);
}
} catch (err) { } catch (err) {
console.error("Nostr init failed:", err); console.error("Nostr init failed:", err);
throw err; throw err;
} }
} }
// Helper method to handle relay connections
async connectToRelays() { async connectToRelays() {
return Promise.all( return Promise.all(
this.relays.map( 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() { async login() {
try { try {
if (!window.nostr) { if (!window.nostr) {
console.log("No Nostr extension found"); console.log("No Nostr extension found");
throw new Error( 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 pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey); const npub = window.NostrTools.nip19.npubEncode(pubkey);
// Debug logs
if (isDevMode) { if (isDevMode) {
console.log("Got pubkey:", pubkey); console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub); console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist()); console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist()); 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.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) { if (accessControl.isBlacklisted(npub)) {
throw new Error( throw new Error("Your account has been blocked on this platform.");
"Your account has been blocked from accessing this platform."
);
} else { } else {
throw new Error( throw new Error("Access restricted to whitelisted users only.");
"Access is currently restricted to whitelisted users only."
);
} }
} }
this.pubkey = pubkey; this.pubkey = pubkey;
if (isDevMode) if (isDevMode) {
console.log( console.log("Logged in with extension. Pubkey:", this.pubkey);
"Successfully logged in with extension. Public key:", }
this.pubkey
);
return this.pubkey; return this.pubkey;
} catch (e) { } catch (e) {
console.error("Login error:", e); console.error("Login error:", e);
@@ -150,17 +183,11 @@ class NostrClient {
} }
} }
/**
* Logs out the user.
*/
logout() { logout() {
this.pubkey = null; this.pubkey = null;
if (isDevMode) console.log("User logged out."); if (isDevMode) console.log("User logged out.");
} }
/**
* Decodes an NSEC key.
*/
decodeNsec(nsec) { decodeNsec(nsec) {
try { try {
const { data } = window.NostrTools.nip19.decode(nsec); 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) { async publishVideo(videoData, pubkey) {
if (!pubkey) { if (!pubkey) throw new Error("Not logged in to publish video.");
throw new Error("User is not logged in.");
}
if (isDevMode) { 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; let finalMagnet = videoData.magnet;
if (videoData.isPrivate === true) { if (videoData.isPrivate) {
finalMagnet = fakeEncrypt(finalMagnet); finalMagnet = fakeEncrypt(finalMagnet);
} }
// Default version is 1 if not specified // new "videoRootId" ensures all future edits know they're from the same root
const version = videoData.version ?? 1; 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 = { const contentObject = {
version, videoRootId,
version: videoData.version ?? 1,
deleted: false, deleted: false,
isPrivate: videoData.isPrivate || false, isPrivate: videoData.isPrivate ?? false,
title: videoData.title, title: videoData.title || "",
magnet: finalMagnet, magnet: finalMagnet,
thumbnail: videoData.thumbnail, thumbnail: videoData.thumbnail || "",
description: videoData.description, description: videoData.description || "",
mode: videoData.mode, mode: videoData.mode || "live",
}; };
const event = { const event = {
kind: 30078, kind: 30078,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 100), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["t", "video"], ["t", "video"],
["d", uniqueD], ["d", dTagValue],
], ],
content: JSON.stringify(contentObject), content: JSON.stringify(contentObject),
}; };
if (isDevMode) { if (isDevMode) {
console.log("Event content after stringify:", event.content); console.log("Publish event with brand-new root:", videoRootId);
console.log("Using d tag:", uniqueD); console.log("Event content:", event.content);
} }
try { try {
const signedEvent = await window.nostr.signEvent(event); const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) { if (isDevMode) console.log("Signed event:", signedEvent);
console.log("Signed event:", signedEvent);
}
await Promise.all( await Promise.all(
this.relays.map(async (url) => { this.relays.map(async (url) => {
try { try {
await this.pool.publish([url], signedEvent); await this.pool.publish([url], signedEvent);
if (isDevMode) { if (isDevMode) console.log(`Video published to ${url}`);
console.log(`Event published to ${url}`);
}
} catch (err) { } catch (err) {
if (isDevMode) { if (isDevMode) console.error(`Failed to publish: ${url}`, err);
console.error(`Failed to publish to ${url}:`, err.message);
}
} }
}) })
); );
return signedEvent; return signedEvent;
} catch (error) { } catch (err) {
if (isDevMode) { if (isDevMode) console.error("Failed to sign/publish:", err);
console.error("Failed to sign event:", error.message); throw err;
}
throw new Error("Failed to sign event.");
} }
} }
/** /**
* Edits an existing video event by reusing the same "d" tag. * Edits a video by creating a *new event* with a brand-new d tag,
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet. * 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) { async editVideo(originalVideo, updatedData, pubkey) {
if (!pubkey) { if (!pubkey) throw new Error("Not logged in to edit.");
throw new Error("User is not logged in."); if (originalVideo.pubkey !== pubkey) {
} throw new Error("You do not own this video (different pubkey).");
if (originalEvent.pubkey !== pubkey) {
throw new Error("You do not own this event (different pubkey).");
} }
if (isDevMode) { // Use the videoRootId directly from the converted video
console.log("Editing video event:", originalEvent); const rootId = originalVideo.videoRootId || null;
console.log("New video data:", updatedVideoData);
}
// Grab the d tag from the original event // Decrypt the old magnet if it was private
const dTag = originalEvent.tags.find((tag) => tag[0] === "d"); let oldPlainMagnet = originalVideo.magnet || "";
if (!dTag) { if (originalVideo.isPrivate && oldPlainMagnet) {
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); oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
} }
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate // Determine new privacy setting
const newIsPrivate = const wantPrivate =
typeof updatedVideoData.isPrivate === "boolean" updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
? updatedVideoData.isPrivate
: oldContent.isPrivate ?? false;
// 3) The user might type a new magnet or keep oldPlainMagnet // Fallback to old magnet if none provided
const userTypedMagnet = (updatedVideoData.magnet || "").trim(); let finalPlainMagnet = (updatedData.magnet || "").trim();
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet; if (!finalPlainMagnet) {
finalPlainMagnet = oldPlainMagnet;
}
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext // Re-encrypt if user wants private
let finalMagnet = finalPlainMagnet; let finalMagnet = finalPlainMagnet;
if (newIsPrivate) { if (wantPrivate) {
finalMagnet = fakeEncrypt(finalPlainMagnet); 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 // Build updated content
const contentObject = { const contentObject = {
version: newVersion, videoRootId: newRootId,
deleted: oldDeleted, version: updatedData.version ?? originalVideo.version ?? 1,
isPrivate: newIsPrivate, deleted: false,
title: updatedVideoData.title, isPrivate: wantPrivate,
title: updatedData.title ?? originalVideo.title,
magnet: finalMagnet, magnet: finalMagnet,
thumbnail: updatedVideoData.thumbnail, thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
description: updatedVideoData.description, description: updatedData.description ?? originalVideo.description,
mode: updatedVideoData.mode, mode: updatedData.mode ?? originalVideo.mode ?? "live",
}; };
if (isDevMode) {
console.log("Building updated content object:", contentObject);
}
const event = { const event = {
kind: 30078, kind: 30078,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["t", "video"], ["t", "video"],
["d", existingD], ["d", newD], // new share link
], ],
content: JSON.stringify(contentObject), content: JSON.stringify(contentObject),
}; };
if (isDevMode) { if (isDevMode) {
console.log("Reusing d tag:", existingD); console.log("Creating edited event with root ID:", newRootId);
console.log("Updated event content:", event.content); console.log("Event content:", event.content);
} }
try { try {
const signedEvent = await window.nostr.signEvent(event); const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
// Publish to all relays
await Promise.all( await Promise.all(
this.relays.map(async (url) => { this.relays.map(async (url) => {
try { try {
await this.pool.publish([url], signedEvent); await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(
`Edited event published to ${url} (d="${existingD}")`
);
}
} catch (err) { } catch (err) {
if (isDevMode) { if (isDevMode) {
console.error( console.error(`Publish failed to ${url}`, err);
`Failed to publish edited event to ${url}:`,
err.message
);
} }
} }
}) })
); );
return signedEvent; return signedEvent;
} catch (error) { } catch (err) {
if (isDevMode) { console.error("Edit failed:", err);
console.error("Failed to sign edited event:", error.message); throw err;
}
throw new Error("Failed to sign edited event.");
} }
} }
/** /**
* Soft-delete or hide an existing video by marking content as "deleted: true" * "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
* and republishing with the same (kind=30078, pubkey, d) address.
*/ */
async deleteVideo(originalEvent, pubkey) { async deleteVideo(originalEvent, pubkey) {
if (!pubkey) { if (!pubkey) {
throw new Error("User is not logged in."); throw new Error("Not logged in to delete.");
} }
if (originalEvent.pubkey !== pubkey) { 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) { // If front-end didn't pass the tags array, load the full event from local or from the relay:
console.log("Deleting video event:", originalEvent); 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) { if (!dTag) {
throw new Error( throw new Error('No "d" tag => cannot delete addressable kind=30078.');
'This event has no "d" tag, cannot delete as addressable kind=30078.'
);
} }
const existingD = dTag[1]; 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; 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 = { const contentObject = {
videoRootId: finalRootId, // <-- CRUCIAL so the delete event shares the same root key
version: oldVersion, version: oldVersion,
deleted: true, deleted: true,
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "", title: oldContent.title || "",
magnet: "", magnet: "",
thumbnail: "", thumbnail: "",
description: "This video has been deleted.", description: "Video was deleted by creator.",
mode: oldContent.mode || "live", mode: oldContent.mode || "live",
isPrivate: oldContent.isPrivate || false,
}; };
// Reuse the same d-tag for an addressable edit
const event = { const event = {
kind: 30078, kind: 30078,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["t", "video"], ["t", "video"],
// We reuse the same d => overshadow the original event
["d", existingD], ["d", existingD],
], ],
content: JSON.stringify(contentObject), content: JSON.stringify(contentObject),
}; };
if (isDevMode) { if (isDevMode) {
console.log("Reusing d tag for delete:", existingD); console.log("Deleting video => mark 'deleted:true'.", event.content);
console.log("Deleted event content:", event.content);
} }
try { try {
@@ -444,14 +453,13 @@ class NostrClient {
console.log("Signed deleted event:", signedEvent); console.log("Signed deleted event:", signedEvent);
} }
// Publish everywhere
await Promise.all( await Promise.all(
this.relays.map(async (url) => { this.relays.map(async (url) => {
try { try {
await this.pool.publish([url], signedEvent); await this.pool.publish([url], signedEvent);
if (isDevMode) { if (isDevMode) {
console.log( console.log(`Delete event published to ${url}`);
`Deleted event published to ${url} (d="${existingD}")`
);
} }
} catch (err) { } catch (err) {
if (isDevMode) { if (isDevMode) {
@@ -460,80 +468,67 @@ class NostrClient {
} }
}) })
); );
return signedEvent; return signedEvent;
} catch (error) { } catch (err) {
if (isDevMode) { 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."); throw new Error("Failed to sign deleted event.");
} }
} }
/** /**
* Subscribes to video events from all configured relays, storing them in a Map. * 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
* @param {Function} onVideo - Callback fired for each new/updated video * version of each root (or fallback).
*/ */
subscribeVideos(onVideo) { subscribeVideos(onVideo) {
const filter = { const filter = {
kinds: [30078], kinds: [30078],
"#t": ["video"], "#t": ["video"],
limit: 500, // Adjust as needed limit: 500,
since: 0, since: 0,
}; };
if (isDevMode) { if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter); console.log("[subscribeVideos] Subscribing with filter:", filter);
} }
// Create subscription across all relays
const sub = this.pool.sub(this.relays, [filter]); const sub = this.pool.sub(this.relays, [filter]);
sub.on("event", (event) => { sub.on("event", (event) => {
try { 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; return;
} }
// Construct a video object // Not deleted => see if its the newest
const video = { const activeKey = getActiveKey(video);
id: event.id, const prevActive = this.activeMap.get(activeKey);
version: content.version ?? 1, if (!prevActive) {
isPrivate: content.isPrivate ?? false, // brand new => set it
title: content.title || "", this.activeMap.set(activeKey, video);
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
onVideo(video); onVideo(video);
} else { } else {
// Optional: if you want to detect edits, compare the new vs. old and update // compare timestamps
// this.subscribedVideos.set(event.id, video); if (video.created_at > prevActive.created_at) {
// onVideo(video) to re-render, etc. this.activeMap.set(activeKey, video);
onVideo(video);
}
} }
} catch (err) { } catch (err) {
if (isDevMode) { if (isDevMode) {
console.error("[subscribeVideos] Error parsing event:", err); console.error("[subscribeVideos] Error processing event:", err);
} }
} }
}); });
@@ -542,61 +537,60 @@ class NostrClient {
if (isDevMode) { if (isDevMode) {
console.log("[subscribeVideos] Reached EOSE for all relays"); 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. * Bulk fetch from all relays, store in allEvents, rebuild activeMap
* (Limit has been reduced to 300 for better performance.)
*/ */
async fetchVideos() { async fetchVideos() {
const filter = { const filter = {
kinds: [30078], kinds: [30078],
"#t": ["video"], "#t": ["video"],
limit: 300, // Reduced from 1000 for quicker fetches limit: 300,
since: 0, since: 0,
}; };
const videoEvents = new Map();
const localAll = new Map();
try { try {
// Query each relay in parallel // 1) Fetch all events from each relay
await Promise.all( await Promise.all(
this.relays.map(async (url) => { this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]); const events = await this.pool.list([url], [filter]);
for (const evt of events) { for (const evt of events) {
try { const vid = convertEventToVideo(evt);
const content = JSON.parse(evt.content); localAll.set(evt.id, vid);
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);
}
} }
}) })
); );
// Turn the Map into a sorted array // 2) Merge into this.allEvents
const allVideos = Array.from(videoEvents.values()).sort( 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 (a, b) => b.created_at - a.created_at
); );
return allVideos; return activeVideos;
} catch (err) { } catch (err) {
console.error("fetchVideos error:", err); console.error("fetchVideos error:", err);
return []; 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) { async getEventById(eventId) {
try { const local = this.allEvents.get(eventId);
const isValid = if (local) {
content && return local;
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;
} }
// 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 // js/webtorrent.js
import WebTorrent from "./webtorrent.min.js"; import WebTorrent from "./webtorrent.min.js";
export class TorrentClient { export class TorrentClient {
constructor() { constructor() {
// Create WebTorrent client
this.client = new WebTorrent(); this.client = new WebTorrent();
this.currentTorrent = null; this.currentTorrent = null;
this.TIMEOUT_DURATION = 60000; // 60 seconds 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) { log(msg) {
@@ -62,9 +60,6 @@ export class TorrentClient {
}); });
} }
/**
* Registers the service worker, waiting until it's fully active before proceeding.
*/
async setupServiceWorker() { async setupServiceWorker() {
try { try {
const isBraveBrowser = await this.isBrave(); const isBraveBrowser = await this.isBrave();
@@ -72,21 +67,18 @@ export class TorrentClient {
if (!window.isSecureContext) { if (!window.isSecureContext) {
throw new Error("HTTPS or localhost required"); throw new Error("HTTPS or localhost required");
} }
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) { if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
throw new Error("Service Worker not supported or disabled"); 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) { if (isBraveBrowser) {
this.log("Checking Brave configuration..."); this.log("Checking Brave configuration...");
if (!navigator.serviceWorker) { if (!navigator.serviceWorker) {
throw new Error( throw new Error(
"Please enable Service Workers in Brave Shield settings" "Please enable Service Workers in Brave Shield settings"
); );
} }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Please enable WebRTC in Brave Shield settings"); 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); await this.waitForServiceWorkerActivation(registration);
this.log("Service worker activated"); this.log("Service worker activated");
// Make sure its truly active
const readyRegistration = await Promise.race([ const readyRegistration = await Promise.race([
navigator.serviceWorker.ready, navigator.serviceWorker.ready,
new Promise((_, reject) => 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) { async streamVideo(magnetURI, videoElement) {
try { try {
@@ -180,314 +171,127 @@ export class TorrentClient {
throw new Error("Service worker setup failed"); 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.client.createServer({ controller: registration });
this.log("WebTorrent server created"); this.log("WebTorrent server created");
const isFirefoxBrowser = this.isFirefox(); const isFirefoxBrowser = this.isFirefox();
if (isFirefoxBrowser) { return new Promise((resolve, reject) => {
// ---------------------- if (isFirefoxBrowser) {
// FIREFOX CODE PATH
// (sequential, concurrency limit, smaller chunk)
// ----------------------
return new Promise((resolve, reject) => {
this.log("Starting torrent download (Firefox path)"); this.log("Starting torrent download (Firefox path)");
this.client.add( this.client.add(
magnetURI, magnetURI,
{ { strategy: "sequential", maxWebConns: 4 },
strategy: "sequential",
maxWebConns: 4, // reduce concurrency
},
(torrent) => { (torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject); this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
} }
); );
}); } else {
} else {
// ----------------------
// CHROME / OTHER BROWSERS CODE PATH
// (your original "faster" approach)
// ----------------------
return new Promise((resolve, reject) => {
this.log("Starting torrent download (Chrome path)"); this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, (torrent) => { this.client.add(magnetURI, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleChromeTorrent(torrent, videoElement, resolve, reject); this.handleChromeTorrent(torrent, videoElement, resolve, reject);
}); });
}); }
} });
} catch (error) { } catch (error) {
this.log("Failed to setup video streaming:", error); this.log("Failed to setup video streaming:", error);
throw error; throw error;
} }
} }
/** // Minimal handleChromeTorrent — no internal setInterval
* The "faster" original approach for Chrome/other browsers.
*/
handleChromeTorrent(torrent, videoElement, resolve, reject) { handleChromeTorrent(torrent, videoElement, resolve, reject) {
this.log("Torrent added (Chrome path): " + torrent.name); const file = torrent.files.find((f) =>
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
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")
); );
if (!file) { if (!file) {
const error = new Error("No compatible video file found in torrent"); return reject(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);
} }
// Mute for autoplay // Mute & crossOrigin
videoElement.muted = true; videoElement.muted = true;
videoElement.crossOrigin = "anonymous"; videoElement.crossOrigin = "anonymous";
// Error handling same as old code // Catch video errors
videoElement.addEventListener("error", (e) => { videoElement.addEventListener("error", (e) => {
const errObj = e.target.error; this.log("Video error:", 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.";
}
}); });
// Attempt autoplay // Attempt autoplay
videoElement.addEventListener("canplay", () => { videoElement.addEventListener("canplay", () => {
const playPromise = videoElement.play(); videoElement.play().catch((err) => {
if (playPromise !== undefined) { this.log("Autoplay failed:", err);
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.addEventListener("loadedmetadata", () => { // Actually stream
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
try { try {
file.streamTo(videoElement); // no chunk constraints file.streamTo(videoElement);
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);
this.currentTorrent = torrent; this.currentTorrent = torrent;
resolve(); resolve(torrent);
} catch (err) { } catch (err) {
this.log("Streaming error (Chrome path):", err); this.log("Streaming error (Chrome path):", err);
if (status) status.textContent = "Error starting video stream";
reject(err); reject(err);
} }
// Torrent error event // Also handle torrent error events
torrent.on("error", (err) => { torrent.on("error", (err) => {
this.log("Torrent error (Chrome path):", err); this.log("Torrent error (Chrome path):", err);
if (status) status.textContent = "Error loading video";
clearInterval(this.statsInterval);
reject(err); reject(err);
}); });
} }
/** // Minimal handleFirefoxTorrent
* The new approach for Firefox: sequential, concurrency limit, smaller chunk size.
*/
handleFirefoxTorrent(torrent, videoElement, resolve, reject) { handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
this.log("Torrent added (Firefox path): " + torrent.name); const file = torrent.files.find((f) =>
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
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")
); );
if (!file) { if (!file) {
const error = new Error("No compatible video file found in torrent"); return reject(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);
} }
videoElement.muted = true; videoElement.muted = true;
videoElement.crossOrigin = "anonymous"; videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => { videoElement.addEventListener("error", (e) => {
const errObj = e.target.error; this.log("Video error (Firefox path):", 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.";
}
}); });
videoElement.addEventListener("canplay", () => { videoElement.addEventListener("canplay", () => {
const playPromise = videoElement.play(); videoElement.play().catch((err) => {
if (playPromise !== undefined) { this.log("Autoplay failed:", err);
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.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 { try {
file.streamTo(videoElement, { highWaterMark: 32 * 1024 }); // 32 KB chunk file.streamTo(videoElement, { highWaterMark: 32 * 1024 });
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);
this.currentTorrent = torrent; this.currentTorrent = torrent;
resolve(); resolve(torrent);
} catch (err) { } catch (err) {
this.log("Streaming error (Firefox path):", err); this.log("Streaming error (Firefox path):", err);
if (status) status.textContent = "Error starting video stream";
reject(err); reject(err);
} }
// Listen for torrent errors
torrent.on("error", (err) => { torrent.on("error", (err) => {
this.log("Torrent error (Firefox path):", err); this.log("Torrent error (Firefox path):", err);
if (status) status.textContent = "Error loading video";
clearInterval(this.statsInterval);
reject(err); reject(err);
}); });
} }
/** /**
* Clean up after playback or page unload. * Clean up
*/ */
async cleanup() { async cleanup() {
try { try {
if (this.statsInterval) { // No local interval to clear here
clearInterval(this.statsInterval);
}
if (this.currentTorrent) { if (this.currentTorrent) {
this.currentTorrent.destroy(); this.currentTorrent.destroy();
} }
if (this.client) { if (this.client) {
await this.client.destroy(); await this.client.destroy();
// Recreate fresh client for next time
this.client = new WebTorrent(); this.client = new WebTorrent();
} }
} catch (error) { } 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>