This commit is contained in:
Keep Creating Online
2025-01-29 21:57:09 -05:00
parent f5c835d31b
commit f47212ebb2
5 changed files with 30 additions and 2696 deletions

View File

@@ -1,7 +1,7 @@
:root {
--color-bg: #0f172a;
--color-card: #1e293b;
--color-primary: #f43f5e;
--color-primary: #fe0032;
--color-secondary: #ff93a5;
--color-text: #f8fafc;
--color-muted: #94a3b8;

View File

@@ -45,65 +45,53 @@
class="container mx-auto px-4 py-8 min-h-screen flex flex-col"
>
<!-- Header -->
<header class="mb-8">
<div class="flex items-start">
<img
src="assets/svg/bitvid-logo-light-mode.svg"
alt="BitVid Logo"
class="h-16"
/>
</div>
</header>
<header class="mb-8 flex items-center">
<!-- Logo on the left -->
<img
src="assets/svg/bitvid-logo-light-mode.svg"
alt="BitVid Logo"
class="h-16"
/>
<!-- Login Section -->
<div id="loginSection" class="mb-8 flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- Buttons on the far right -->
<div class="ml-auto flex items-center space-x-4">
<!-- Login Button -->
<button
id="loginButton"
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"
style="background-color: #fe0032"
class="inline-flex items-center justify-center w-12 h-12 rounded-full text-white text-sm font-bold leading-none whitespace-nowrap appearance-none hover:bg-[#e6002c] focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2"
>
login
</button>
<!-- (Old) Logout Button is REMOVED or commented out -->
<!-- <button
id="logoutButton"
...
>logout</button>
-->
<!-- Upload Button (hidden by default) -->
<!-- Upload (Add Video) Button -->
<button
id="uploadButton"
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-500 text-white text-xl font-bold leading-none whitespace-nowrap appearance-none hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 hidden"
style="background-color: #fe0032"
class="inline-flex items-center justify-center w-12 h-12 rounded-full text-white text-xl font-bold leading-none whitespace-nowrap appearance-none hover:bg-[#e6002c] focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 hidden"
>
+
</button>
<!-- NEW: Profile Button (hidden by default) -->
<!-- Profile Button -->
<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"
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-black text-white text-sm leading-none hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black 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"
/>
<!-- Ensures a border around the smaller avatar -->
<div class="w-10 h-10 rounded-full overflow-hidden">
<img
id="profileAvatar"
src="assets/jpg/default-profile.jpg"
alt="Profile"
class="w-full h-full object-cover"
/>
</div>
</button>
</div>
</header>
<!-- (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>
<!-- (Removed old loginSection) -->
<!-- Error Container -->
<div
@@ -121,9 +109,9 @@
<!-- Success messages will appear here -->
</div>
<!-- The main container for dynamic views -->
<!-- Main container for dynamic views -->
<main id="viewContainer" class="flex-grow mb-8">
<!-- We'll load our "most-recent-videos.html" or other views here -->
<!-- We'll load "most-recent-videos.html" or other views here -->
</main>
<!-- Imported Video Player Modal (goes into modalContainer) -->
@@ -140,7 +128,6 @@
<div id="disclaimerModal" class="hidden">
<div class="modal-content">
<div class="modal-scroll">
<!-- Logo -->
<div class="flex justify-center mb-8">
<img
src="assets/svg/bitvid-logo-dark-mode.svg"
@@ -179,7 +166,6 @@
</p>
</div>
<!-- Main Content -->
<div class="space-y-6 text-gray-300">
<p>
bitvid is a decentralized video platform where content is shared
@@ -233,7 +219,6 @@
</div>
</div>
<!-- Action Button in fixed container -->
<div class="button-container">
<button
id="acceptDisclaimer"

File diff suppressed because it is too large Load Diff

View File

@@ -1,608 +0,0 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"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();

View File

@@ -1,684 +0,0 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"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();