mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
936 lines
26 KiB
JavaScript
936 lines
26 KiB
JavaScript
// js/nostr.js
|
|
|
|
import { isDevMode } from "./config.js";
|
|
import { accessControl } from "./accessControl.js";
|
|
import {
|
|
deriveTitleFromEvent,
|
|
parseVideoEventPayload,
|
|
findLegacyMagnetInEvent,
|
|
} from "./videoEventUtils.js";
|
|
|
|
/**
|
|
* The usual relays
|
|
*/
|
|
const RELAY_URLS = [
|
|
"wss://relay.damus.io",
|
|
"wss://nos.lol",
|
|
"wss://relay.snort.social",
|
|
"wss://relay.primal.net",
|
|
"wss://relay.nostr.band",
|
|
];
|
|
|
|
// To limit error spam
|
|
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, replace with actual crypto.
|
|
*/
|
|
function fakeEncrypt(magnet) {
|
|
return magnet.split("").reverse().join("");
|
|
}
|
|
function fakeDecrypt(encrypted) {
|
|
return encrypted.split("").reverse().join("");
|
|
}
|
|
|
|
const EXTENSION_MIME_MAP = {
|
|
mp4: "video/mp4",
|
|
m4v: "video/x-m4v",
|
|
webm: "video/webm",
|
|
mkv: "video/x-matroska",
|
|
mov: "video/quicktime",
|
|
avi: "video/x-msvideo",
|
|
ogv: "video/ogg",
|
|
ogg: "video/ogg",
|
|
m3u8: "application/x-mpegURL",
|
|
mpd: "application/dash+xml",
|
|
ts: "video/mp2t",
|
|
mpg: "video/mpeg",
|
|
mpeg: "video/mpeg",
|
|
flv: "video/x-flv",
|
|
"3gp": "video/3gpp",
|
|
};
|
|
|
|
function inferMimeTypeFromUrl(url) {
|
|
if (!url || typeof url !== "string") {
|
|
return "";
|
|
}
|
|
|
|
let pathname = "";
|
|
try {
|
|
const parsed = new URL(url);
|
|
pathname = parsed.pathname || "";
|
|
} catch (err) {
|
|
const sanitized = url.split("?")[0].split("#")[0];
|
|
pathname = sanitized || "";
|
|
}
|
|
|
|
const lastSegment = pathname.split("/").pop() || "";
|
|
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
|
|
if (!match) {
|
|
return "";
|
|
}
|
|
|
|
const extension = match[1].toLowerCase();
|
|
return EXTENSION_MIME_MAP[extension] || "";
|
|
}
|
|
|
|
/**
|
|
* Convert a raw Nostr event => your "video" object.
|
|
* Accepts legacy (<2) payloads when they expose a usable magnet/info hash.
|
|
*/
|
|
function convertEventToVideo(event) {
|
|
const {
|
|
parsedContent,
|
|
parseError,
|
|
title,
|
|
url,
|
|
magnet,
|
|
infoHash,
|
|
version,
|
|
} = parseVideoEventPayload(event);
|
|
|
|
const trimmedUrl = typeof url === "string" ? url.trim() : "";
|
|
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
|
const legacyMagnet = findLegacyMagnetInEvent(event);
|
|
const trimmedLegacyMagnet =
|
|
typeof legacyMagnet === "string" ? legacyMagnet.trim() : "";
|
|
const trimmedInfoHash = typeof infoHash === "string" ? infoHash.trim() : "";
|
|
const playbackMagnet =
|
|
trimmedMagnet || trimmedLegacyMagnet || trimmedInfoHash;
|
|
|
|
const numericVersion = Number.isFinite(version) ? version : 0;
|
|
const hasPlayableSource = Boolean(trimmedUrl) || Boolean(playbackMagnet);
|
|
|
|
if (!hasPlayableSource) {
|
|
return {
|
|
id: event.id,
|
|
invalid: true,
|
|
reason: "missing playable source",
|
|
};
|
|
}
|
|
|
|
const derivedTitle = deriveTitleFromEvent({
|
|
parsedContent,
|
|
tags: event.tags,
|
|
primaryTitle: title,
|
|
});
|
|
|
|
let resolvedTitle = derivedTitle;
|
|
if (!resolvedTitle && numericVersion < 2 && playbackMagnet) {
|
|
resolvedTitle = trimmedInfoHash
|
|
? `Legacy Video ${trimmedInfoHash.slice(0, 8)}`
|
|
: "Legacy BitTorrent Video";
|
|
}
|
|
|
|
if (!resolvedTitle) {
|
|
const reason = parseError
|
|
? "missing title (json parse error)"
|
|
: "missing title";
|
|
return { id: event.id, invalid: true, reason };
|
|
}
|
|
|
|
return {
|
|
id: event.id,
|
|
videoRootId: parsedContent.videoRootId || event.id,
|
|
version: numericVersion,
|
|
isPrivate: parsedContent.isPrivate ?? false,
|
|
title: resolvedTitle,
|
|
url: trimmedUrl,
|
|
magnet: playbackMagnet,
|
|
rawMagnet: trimmedMagnet || trimmedLegacyMagnet,
|
|
infoHash: trimmedInfoHash,
|
|
thumbnail: parsedContent.thumbnail ?? "",
|
|
description: parsedContent.description ?? "",
|
|
mode: parsedContent.mode ?? "live",
|
|
deleted: parsedContent.deleted === true,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
tags: event.tags,
|
|
invalid: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* If the video has videoRootId => use that as the “group key”.
|
|
* Otherwise fallback to (pubkey + dTag), or if no dTag => “LEGACY:id”
|
|
*/
|
|
function getActiveKey(video) {
|
|
if (video.videoRootId) {
|
|
return `ROOT:${video.videoRootId}`;
|
|
}
|
|
const dTag = video.tags?.find((t) => t[0] === "d");
|
|
if (dTag) {
|
|
return `${video.pubkey}:${dTag[1]}`;
|
|
}
|
|
return `LEGACY:${video.id}`;
|
|
}
|
|
|
|
export { convertEventToVideo };
|
|
|
|
class NostrClient {
|
|
constructor() {
|
|
this.pool = null;
|
|
this.pubkey = null;
|
|
this.relays = RELAY_URLS;
|
|
|
|
// Store all events so older links still work
|
|
this.allEvents = new Map();
|
|
|
|
// “activeMap” holds only the newest version for each root
|
|
this.activeMap = new Map();
|
|
}
|
|
|
|
/**
|
|
* Connect to the 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 login with a Nostr extension
|
|
*/
|
|
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
|
|
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 (err) {
|
|
console.error("Login error:", err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
logout() {
|
|
this.pubkey = null;
|
|
if (isDevMode) console.log("User logged out.");
|
|
}
|
|
|
|
/**
|
|
* Publish a new video
|
|
* CHANGED: Force version=2 for all new notes
|
|
*/
|
|
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);
|
|
}
|
|
|
|
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
|
|
let finalMagnet = rawMagnet.trim();
|
|
if (videoData.isPrivate && finalMagnet) {
|
|
finalMagnet = fakeEncrypt(finalMagnet);
|
|
}
|
|
const finalUrl =
|
|
typeof videoData.url === "string" ? videoData.url.trim() : "";
|
|
const finalThumbnail =
|
|
typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : "";
|
|
const finalDescription =
|
|
typeof videoData.description === "string"
|
|
? videoData.description.trim()
|
|
: "";
|
|
const finalTitle =
|
|
typeof videoData.title === "string" ? videoData.title.trim() : "";
|
|
const providedMimeType =
|
|
typeof videoData.mimeType === "string"
|
|
? videoData.mimeType.trim()
|
|
: "";
|
|
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
|
|
// brand-new root & d
|
|
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
const contentObject = {
|
|
videoRootId,
|
|
version: 2, // forcibly set version=2
|
|
deleted: false,
|
|
isPrivate: videoData.isPrivate ?? false,
|
|
title: finalTitle,
|
|
url: finalUrl,
|
|
magnet: finalMagnet,
|
|
thumbnail: finalThumbnail,
|
|
description: finalDescription,
|
|
mode: videoData.mode || "live",
|
|
};
|
|
|
|
const event = {
|
|
kind: 30078,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
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);
|
|
}
|
|
})
|
|
);
|
|
|
|
if (finalUrl) {
|
|
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
|
|
const mimeType =
|
|
providedMimeType || inferredMimeType || "application/octet-stream";
|
|
|
|
const mirrorTags = [
|
|
["url", finalUrl],
|
|
["m", mimeType],
|
|
];
|
|
|
|
if (finalThumbnail) {
|
|
mirrorTags.push(["thumb", finalThumbnail]);
|
|
}
|
|
|
|
const altText = finalDescription || finalTitle || "";
|
|
if (altText) {
|
|
mirrorTags.push(["alt", altText]);
|
|
}
|
|
|
|
if (!contentObject.isPrivate && finalMagnet) {
|
|
mirrorTags.push(["magnet", finalMagnet]);
|
|
}
|
|
|
|
const mirrorEvent = {
|
|
kind: 1063,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
tags: mirrorTags,
|
|
content: altText,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("Prepared NIP-94 mirror event:", mirrorEvent);
|
|
}
|
|
|
|
try {
|
|
const signedMirrorEvent = await window.nostr.signEvent(mirrorEvent);
|
|
if (isDevMode) {
|
|
console.log("Signed NIP-94 mirror event:", signedMirrorEvent);
|
|
}
|
|
|
|
await Promise.all(
|
|
this.relays.map(async (url) => {
|
|
try {
|
|
await this.pool.publish([url], signedMirrorEvent);
|
|
if (isDevMode) {
|
|
console.log(`NIP-94 mirror published to ${url}`);
|
|
}
|
|
} catch (mirrorErr) {
|
|
if (isDevMode) {
|
|
console.error(
|
|
`Failed to publish NIP-94 mirror to ${url}`,
|
|
mirrorErr
|
|
);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
if (isDevMode) {
|
|
console.log(
|
|
"NIP-94 mirror dispatched for hosted URL:",
|
|
finalUrl
|
|
);
|
|
}
|
|
} catch (mirrorError) {
|
|
if (isDevMode) {
|
|
console.error(
|
|
"Failed to sign/publish NIP-94 mirror event:",
|
|
mirrorError
|
|
);
|
|
}
|
|
}
|
|
} else if (isDevMode) {
|
|
console.log("Skipping NIP-94 mirror: no hosted URL provided.");
|
|
}
|
|
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.
|
|
*
|
|
* This version forces version=2 for the original note and uses
|
|
* lowercase comparison for public keys.
|
|
*/
|
|
async editVideo(originalEventStub, updatedData, userPubkey) {
|
|
if (!userPubkey) {
|
|
throw new Error("Not logged in to edit.");
|
|
}
|
|
|
|
// Convert the provided pubkey to lowercase
|
|
const userPubkeyLower = userPubkey.toLowerCase();
|
|
|
|
// Use getEventById to fetch the full original event details
|
|
const baseEvent = await this.getEventById(originalEventStub.id);
|
|
if (!baseEvent) {
|
|
throw new Error("Could not retrieve the original event to edit.");
|
|
}
|
|
|
|
// Check that the original event is version 2 or higher
|
|
if (baseEvent.version < 2) {
|
|
throw new Error(
|
|
"This video is not in the supported version for editing."
|
|
);
|
|
}
|
|
|
|
// Ownership check (compare lowercase hex public keys)
|
|
if (
|
|
!baseEvent.pubkey ||
|
|
baseEvent.pubkey.toLowerCase() !== userPubkeyLower
|
|
) {
|
|
throw new Error("You do not own this video (pubkey mismatch).");
|
|
}
|
|
|
|
// Decrypt the old magnet if the note is private
|
|
let oldPlainMagnet = baseEvent.magnet || "";
|
|
if (baseEvent.isPrivate && oldPlainMagnet) {
|
|
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
|
}
|
|
|
|
const oldUrl = baseEvent.url || "";
|
|
|
|
// Determine if the updated note should be private
|
|
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
|
|
|
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
|
const newMagnetValue =
|
|
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
|
|
let finalPlainMagnet = newMagnetValue || oldPlainMagnet;
|
|
let finalMagnet =
|
|
wantPrivate && finalPlainMagnet
|
|
? fakeEncrypt(finalPlainMagnet)
|
|
: finalPlainMagnet;
|
|
|
|
const newUrlValue =
|
|
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
|
|
const finalUrl = newUrlValue || oldUrl;
|
|
|
|
// Use the existing videoRootId (or fall back to the base event's ID)
|
|
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
|
|
|
// Generate a new d-tag so that the edit gets its own share link
|
|
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
|
|
|
|
// Build the updated content object
|
|
const contentObject = {
|
|
videoRootId: oldRootId,
|
|
version: updatedData.version ?? baseEvent.version ?? 2,
|
|
deleted: false,
|
|
isPrivate: wantPrivate,
|
|
title: updatedData.title ?? baseEvent.title,
|
|
url: finalUrl,
|
|
magnet: finalMagnet,
|
|
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
|
description: updatedData.description ?? baseEvent.description,
|
|
mode: updatedData.mode ?? baseEvent.mode ?? "live",
|
|
};
|
|
|
|
const event = {
|
|
kind: 30078,
|
|
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
|
|
pubkey: userPubkeyLower,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["t", "video"],
|
|
["d", newD], // new share link tag
|
|
],
|
|
content: JSON.stringify(contentObject),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("Creating edited event with root ID:", oldRootId);
|
|
console.log("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 video published to ${url}`);
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error(`Publish failed to ${url}`, err);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
return signedEvent;
|
|
} catch (err) {
|
|
console.error("Edit failed:", err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* revertVideo => old style
|
|
*/
|
|
async revertVideo(originalEvent, pubkey) {
|
|
if (!pubkey) {
|
|
throw new Error("Not logged in to revert.");
|
|
}
|
|
if (originalEvent.pubkey !== pubkey) {
|
|
throw new Error("Not your event (pubkey mismatch).");
|
|
}
|
|
|
|
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 reverting.");
|
|
}
|
|
baseEvent = {
|
|
id: fetched.id,
|
|
pubkey: fetched.pubkey,
|
|
content: JSON.stringify({
|
|
version: fetched.version,
|
|
deleted: fetched.deleted,
|
|
isPrivate: fetched.isPrivate,
|
|
title: fetched.title,
|
|
url: fetched.url,
|
|
magnet: fetched.magnet,
|
|
thumbnail: fetched.thumbnail,
|
|
description: fetched.description,
|
|
mode: fetched.mode,
|
|
}),
|
|
tags: fetched.tags,
|
|
};
|
|
}
|
|
|
|
const dTag = baseEvent.tags.find((t) => t[0] === "d");
|
|
if (!dTag) {
|
|
throw new Error(
|
|
'No "d" tag => cannot revert addressable kind=30078 event.'
|
|
);
|
|
}
|
|
const existingD = dTag[1];
|
|
|
|
const oldContent = JSON.parse(baseEvent.content || "{}");
|
|
const oldVersion = oldContent.version ?? 1;
|
|
|
|
let finalRootId = oldContent.videoRootId || null;
|
|
if (!finalRootId) {
|
|
finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
|
|
}
|
|
|
|
const contentObject = {
|
|
videoRootId: finalRootId,
|
|
version: oldVersion,
|
|
deleted: true,
|
|
isPrivate: oldContent.isPrivate ?? false,
|
|
title: oldContent.title || "",
|
|
url: "",
|
|
magnet: "",
|
|
thumbnail: "",
|
|
description: "This version was reverted by the creator.",
|
|
mode: oldContent.mode || "live",
|
|
};
|
|
|
|
const event = {
|
|
kind: 30078,
|
|
pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["t", "video"],
|
|
["d", existingD],
|
|
],
|
|
content: JSON.stringify(contentObject),
|
|
};
|
|
|
|
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(`Failed to revert on ${url}`, err);
|
|
}
|
|
})
|
|
);
|
|
|
|
return signedEvent;
|
|
}
|
|
|
|
/**
|
|
* "Deleting" => Mark all content with the same videoRootId as {deleted:true}
|
|
* and blank out magnet/desc.
|
|
*
|
|
* This version now asks for confirmation before proceeding.
|
|
*/
|
|
async deleteAllVersions(videoRootId, pubkey) {
|
|
if (!pubkey) {
|
|
throw new Error("Not logged in to delete all versions.");
|
|
}
|
|
|
|
// Ask for confirmation before proceeding
|
|
if (
|
|
!window.confirm(
|
|
"Are you sure you want to delete all versions of this video? This action cannot be undone."
|
|
)
|
|
) {
|
|
console.log("Deletion cancelled by user.");
|
|
return null; // Cancel deletion if user clicks "Cancel"
|
|
}
|
|
|
|
// 1) Find all events in our local allEvents that share the same root.
|
|
const matchingEvents = [];
|
|
for (const [id, vid] of this.allEvents.entries()) {
|
|
if (
|
|
vid.videoRootId === videoRootId &&
|
|
vid.pubkey === pubkey &&
|
|
!vid.deleted
|
|
) {
|
|
matchingEvents.push(vid);
|
|
}
|
|
}
|
|
if (!matchingEvents.length) {
|
|
throw new Error("No existing events found for that root.");
|
|
}
|
|
|
|
// 2) For each event, create a "revert" event to mark it as deleted.
|
|
// This will prompt the user (via the extension) to sign the deletion.
|
|
for (const vid of matchingEvents) {
|
|
await this.revertVideo(
|
|
{
|
|
id: vid.id,
|
|
pubkey: vid.pubkey,
|
|
content: JSON.stringify({
|
|
version: vid.version,
|
|
deleted: vid.deleted,
|
|
isPrivate: vid.isPrivate,
|
|
title: vid.title,
|
|
url: vid.url,
|
|
magnet: vid.magnet,
|
|
thumbnail: vid.thumbnail,
|
|
description: vid.description,
|
|
mode: vid.mode,
|
|
}),
|
|
tags: vid.tags,
|
|
},
|
|
pubkey
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Saves all known events to localStorage (or a different storage if you prefer).
|
|
*/
|
|
saveLocalData() {
|
|
// Convert our allEvents map into a plain object for JSON storage
|
|
const allEventsObject = {};
|
|
for (const [id, vid] of this.allEvents.entries()) {
|
|
allEventsObject[id] = vid;
|
|
}
|
|
localStorage.setItem("bitvidEvents", JSON.stringify(allEventsObject));
|
|
}
|
|
|
|
/**
|
|
* Subscribe to *all* videos (old and new) with a single subscription,
|
|
* buffering incoming events to avoid excessive DOM updates.
|
|
*/
|
|
subscribeVideos(onVideo) {
|
|
const filter = {
|
|
kinds: [30078],
|
|
"#t": ["video"],
|
|
// Adjust limit/time as desired
|
|
limit: 500,
|
|
since: 0,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
|
}
|
|
|
|
const sub = this.pool.sub(this.relays, [filter]);
|
|
const invalidDuringSub = [];
|
|
|
|
// We'll collect events here instead of processing them instantly
|
|
let eventBuffer = [];
|
|
|
|
// 1) On each incoming event, just push to the buffer
|
|
sub.on("event", (event) => {
|
|
eventBuffer.push(event);
|
|
});
|
|
|
|
// 2) Process buffered events on a setInterval (e.g., every second)
|
|
const processInterval = setInterval(() => {
|
|
if (eventBuffer.length > 0) {
|
|
// Copy and clear the buffer
|
|
const toProcess = eventBuffer.slice();
|
|
eventBuffer = [];
|
|
|
|
// Now handle each event
|
|
for (const evt of toProcess) {
|
|
try {
|
|
const video = convertEventToVideo(evt);
|
|
|
|
if (video.invalid) {
|
|
invalidDuringSub.push({ id: video.id, reason: video.reason });
|
|
continue;
|
|
}
|
|
|
|
// Store in allEvents
|
|
this.allEvents.set(evt.id, video);
|
|
|
|
// If it's a "deleted" note, remove from activeMap
|
|
if (video.deleted) {
|
|
const activeKey = getActiveKey(video);
|
|
this.activeMap.delete(activeKey);
|
|
continue;
|
|
}
|
|
|
|
// Otherwise, if it's newer than what we have, update activeMap
|
|
const activeKey = getActiveKey(video);
|
|
const prevActive = this.activeMap.get(activeKey);
|
|
if (!prevActive || video.created_at > prevActive.created_at) {
|
|
this.activeMap.set(activeKey, video);
|
|
onVideo(video); // Trigger the callback that re-renders
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error("[subscribeVideos] Error processing event:", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optionally, save data to local storage after processing the batch
|
|
this.saveLocalData();
|
|
}
|
|
}, 1000);
|
|
|
|
// You can still use sub.on("eose") if needed
|
|
sub.on("eose", () => {
|
|
if (isDevMode && invalidDuringSub.length > 0) {
|
|
console.warn(
|
|
`[subscribeVideos] found ${invalidDuringSub.length} invalid video notes (with reasons):`,
|
|
invalidDuringSub
|
|
);
|
|
}
|
|
if (isDevMode) {
|
|
console.log(
|
|
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
|
|
);
|
|
}
|
|
});
|
|
|
|
// Return the subscription object if you need to unsub manually later
|
|
return sub;
|
|
}
|
|
|
|
/**
|
|
* fetchVideos => old approach
|
|
*/
|
|
async fetchVideos() {
|
|
const filter = {
|
|
kinds: [30078],
|
|
"#t": ["video"],
|
|
limit: 300,
|
|
since: 0,
|
|
};
|
|
|
|
const localAll = new Map();
|
|
// NEW: track invalid
|
|
const invalidNotes = [];
|
|
|
|
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);
|
|
if (vid.invalid) {
|
|
// Accumulate if invalid
|
|
invalidNotes.push({ id: vid.id, reason: vid.reason });
|
|
} else {
|
|
// Only add if good
|
|
localAll.set(evt.id, vid);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
// Merge into 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);
|
|
}
|
|
}
|
|
|
|
// OPTIONAL: Log invalid stats
|
|
if (invalidNotes.length > 0 && isDevMode) {
|
|
console.warn(
|
|
`Skipped ${invalidNotes.length} invalid video notes:\n`,
|
|
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
|
|
);
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getEventById => old approach
|
|
*/
|
|
async getEventById(eventId) {
|
|
const local = this.allEvents.get(eventId);
|
|
if (local) {
|
|
return local;
|
|
}
|
|
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;
|
|
}
|
|
|
|
getActiveVideos() {
|
|
return Array.from(this.activeMap.values()).sort(
|
|
(a, b) => b.created_at - a.created_at
|
|
);
|
|
}
|
|
}
|
|
|
|
export const nostrClient = new NostrClient();
|