mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
1246 lines
37 KiB
JavaScript
1246 lines
37 KiB
JavaScript
// js/subscriptions.js
|
|
import {
|
|
getActiveSigner,
|
|
nostrClient,
|
|
convertEventToVideo as sharedConvertEventToVideo,
|
|
requestDefaultExtensionPermissions,
|
|
DEFAULT_RELAY_URLS,
|
|
} from "./nostr.js";
|
|
import {
|
|
buildSubscriptionListEvent,
|
|
SUBSCRIPTION_LIST_IDENTIFIER
|
|
} from "./nostrEventSchemas.js";
|
|
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
|
import {
|
|
publishEventToRelays,
|
|
assertAnyRelayAccepted
|
|
} from "./nostrPublish.js";
|
|
import { getApplication } from "./applicationContext.js";
|
|
import { VideoListView } from "./ui/views/VideoListView.js";
|
|
import { ALLOW_NSFW_CONTENT } from "./config.js";
|
|
import { devLogger, userLogger } from "./utils/logger.js";
|
|
import moderationService from "./services/moderationService.js";
|
|
import nostrService from "./services/nostrService.js";
|
|
|
|
function normalizeHexPubkey(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed.toLowerCase() : "";
|
|
}
|
|
|
|
function extractEncryptionHints(event) {
|
|
if (!event || typeof event !== "object") {
|
|
return [];
|
|
}
|
|
|
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
|
const hints = [];
|
|
|
|
const pushUnique = (scheme) => {
|
|
if (!scheme || hints.includes(scheme)) {
|
|
return;
|
|
}
|
|
hints.push(scheme);
|
|
};
|
|
|
|
for (const tag of tags) {
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
continue;
|
|
}
|
|
const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
|
if (label !== "encrypted" && label !== "encryption") {
|
|
continue;
|
|
}
|
|
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
|
|
if (!rawValue) {
|
|
continue;
|
|
}
|
|
const parts = rawValue
|
|
.split(/[\s,]+/)
|
|
.map((part) => part.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
for (const part of parts) {
|
|
if (part.startsWith("nip44")) {
|
|
if (/^nip44(?:[_-]?v2|v2)/.test(part)) {
|
|
pushUnique("nip44_v2");
|
|
} else {
|
|
pushUnique("nip44");
|
|
}
|
|
} else if (part === "nip04" || part === "nip-04") {
|
|
pushUnique("nip04");
|
|
}
|
|
}
|
|
}
|
|
|
|
return hints;
|
|
}
|
|
|
|
function determineDecryptionOrder(event, availableSchemes) {
|
|
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
|
|
const availableSet = new Set(available);
|
|
const prioritized = [];
|
|
|
|
const hints = extractEncryptionHints(event);
|
|
const aliasMap = {
|
|
nip04: ["nip04"],
|
|
nip44: ["nip44", "nip44_v2"],
|
|
nip44_v2: ["nip44_v2", "nip44"],
|
|
};
|
|
|
|
for (const hint of hints) {
|
|
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
|
|
for (const candidate of candidates) {
|
|
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
|
|
prioritized.push(candidate);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const fallback of ["nip04", "nip44", "nip44_v2"]) {
|
|
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
|
|
prioritized.push(fallback);
|
|
}
|
|
}
|
|
|
|
return prioritized.length ? prioritized : available;
|
|
}
|
|
|
|
const getApp = () => getApplication();
|
|
|
|
/**
|
|
* Manages the user's subscription list (kind=30002) *privately*,
|
|
* using NIP-04 encryption for the content field.
|
|
* Also handles fetching and rendering subscribed channels' videos
|
|
* in the same card style as your home page.
|
|
*/
|
|
class SubscriptionsManager {
|
|
constructor() {
|
|
this.subscribedPubkeys = new Set();
|
|
this.subsEventId = null;
|
|
this.loaded = false;
|
|
this.subscriptionListView = null;
|
|
this.lastRunOptions = null;
|
|
this.lastResult = null;
|
|
this.lastContainerId = null;
|
|
this.unsubscribeFromNostrUpdates = null;
|
|
this.pendingRefreshPromise = null;
|
|
this.scheduledRefreshDetail = null;
|
|
this.isRunningFeed = false;
|
|
this.hasRenderedOnce = false;
|
|
this.ensureNostrServiceListener();
|
|
}
|
|
|
|
/**
|
|
* Decrypt the subscription list from kind=30002 (d="subscriptions").
|
|
*/
|
|
async loadSubscriptions(userPubkey) {
|
|
if (!userPubkey) {
|
|
userLogger.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
|
|
return;
|
|
}
|
|
try {
|
|
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
|
|
const sessionActorPubkey = normalizeHexPubkey(
|
|
nostrClient?.sessionActor?.pubkey,
|
|
);
|
|
|
|
const authorSet = new Set();
|
|
if (normalizedUserPubkey) {
|
|
authorSet.add(normalizedUserPubkey);
|
|
}
|
|
if (sessionActorPubkey) {
|
|
authorSet.add(sessionActorPubkey);
|
|
}
|
|
|
|
const authors = Array.from(authorSet);
|
|
if (!authors.length && userPubkey) {
|
|
authors.push(userPubkey);
|
|
}
|
|
|
|
const filter = {
|
|
kinds: [30002],
|
|
authors,
|
|
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
|
|
limit: 1
|
|
};
|
|
|
|
const relaySet = new Set();
|
|
const addRelayCandidates = (candidates) => {
|
|
if (!candidates) {
|
|
return;
|
|
}
|
|
const iterable = Array.isArray(candidates)
|
|
? candidates
|
|
: candidates instanceof Set
|
|
? Array.from(candidates)
|
|
: [];
|
|
for (const candidate of iterable) {
|
|
if (typeof candidate !== "string") {
|
|
continue;
|
|
}
|
|
const trimmed = candidate.trim();
|
|
if (trimmed) {
|
|
relaySet.add(trimmed);
|
|
}
|
|
}
|
|
};
|
|
|
|
addRelayCandidates(nostrClient.relays);
|
|
if (!relaySet.size) {
|
|
addRelayCandidates(nostrClient.readRelays);
|
|
}
|
|
if (!relaySet.size) {
|
|
addRelayCandidates(DEFAULT_RELAY_URLS);
|
|
}
|
|
|
|
const relayUrls = Array.from(relaySet);
|
|
if (!relayUrls.length) {
|
|
devLogger.warn(
|
|
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
|
|
);
|
|
}
|
|
|
|
const relayPromises = relayUrls.map((url) =>
|
|
nostrClient.pool.list([url], [filter]).catch((err) => {
|
|
userLogger.error(`[SubscriptionsManager] Relay error at ${url}`, err);
|
|
return [];
|
|
}),
|
|
);
|
|
|
|
const settledResults = await Promise.allSettled(relayPromises);
|
|
const events = [];
|
|
for (const outcome of settledResults) {
|
|
if (outcome.status === "fulfilled" && Array.isArray(outcome.value) && outcome.value.length) {
|
|
events.push(...outcome.value);
|
|
}
|
|
}
|
|
|
|
if (!events.length) {
|
|
this.subscribedPubkeys.clear();
|
|
this.subsEventId = null;
|
|
this.loaded = true;
|
|
return;
|
|
}
|
|
|
|
// Sort by created_at desc, pick newest
|
|
events.sort((a, b) => b.created_at - a.created_at);
|
|
const newest = events[0];
|
|
this.subsEventId = newest.id;
|
|
|
|
const signer = getActiveSigner();
|
|
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
|
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
|
const hints = extractEncryptionHints(newest);
|
|
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
|
|
const requiresNip04 =
|
|
!hints.length || hints.includes("nip04") || !requiresNip44;
|
|
|
|
let permissionResult = { ok: true };
|
|
const signerCoversRequiredSchemes =
|
|
(!requiresNip04 || signerHasNip04) && (!requiresNip44 || signerHasNip44);
|
|
|
|
if (!signerCoversRequiredSchemes) {
|
|
permissionResult = await requestDefaultExtensionPermissions();
|
|
}
|
|
|
|
if (!permissionResult.ok) {
|
|
userLogger.warn(
|
|
"[SubscriptionsManager] Extension permissions denied while loading subscriptions; treating list as empty.",
|
|
permissionResult.error,
|
|
);
|
|
this.subscribedPubkeys.clear();
|
|
this.subsEventId = null;
|
|
this.loaded = true;
|
|
return;
|
|
}
|
|
|
|
const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey);
|
|
if (!decryptResult.ok) {
|
|
userLogger.error(
|
|
"[SubscriptionsManager] Failed to decrypt subscription list:",
|
|
decryptResult.error,
|
|
);
|
|
this.subscribedPubkeys.clear();
|
|
this.subsEventId = null;
|
|
this.loaded = true;
|
|
return;
|
|
}
|
|
|
|
const decryptedStr = decryptResult.plaintext;
|
|
|
|
const parsed = JSON.parse(decryptedStr);
|
|
const subArray = Array.isArray(parsed.subPubkeys)
|
|
? parsed.subPubkeys
|
|
: [];
|
|
const normalized = subArray
|
|
.map((value) => normalizeHexPubkey(value))
|
|
.filter((value) => Boolean(value));
|
|
this.subscribedPubkeys = new Set(normalized);
|
|
|
|
this.loaded = true;
|
|
} catch (err) {
|
|
userLogger.error("[SubscriptionsManager] Failed to load subs:", err);
|
|
}
|
|
}
|
|
|
|
isSubscribed(channelHex) {
|
|
const normalized = normalizeHexPubkey(channelHex);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return this.subscribedPubkeys.has(normalized);
|
|
}
|
|
|
|
getSubscribedAuthors() {
|
|
return Array.from(this.subscribedPubkeys);
|
|
}
|
|
|
|
async decryptSubscriptionEvent(event, userPubkey) {
|
|
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
|
if (!ciphertext) {
|
|
const error = new Error("Subscription event is missing ciphertext content.");
|
|
error.code = "subscriptions-empty-ciphertext";
|
|
return { ok: false, error };
|
|
}
|
|
|
|
const decryptors = new Map();
|
|
const registerDecryptor = (scheme, handler) => {
|
|
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
|
|
return;
|
|
}
|
|
decryptors.set(scheme, handler);
|
|
};
|
|
|
|
const signer = getActiveSigner();
|
|
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
|
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
|
|
|
if (signerHasNip04) {
|
|
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
|
|
}
|
|
|
|
if (signerHasNip44) {
|
|
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
|
|
}
|
|
|
|
const nostrApi =
|
|
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
|
if (nostrApi) {
|
|
if (!signerHasNip04 && nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") {
|
|
registerDecryptor("nip04", (payload) =>
|
|
nostrApi.nip04.decrypt(userPubkey, payload)
|
|
);
|
|
}
|
|
|
|
const nip44 =
|
|
nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
|
|
if (nip44) {
|
|
if (!signerHasNip44 && typeof nip44.decrypt === "function") {
|
|
registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload));
|
|
}
|
|
|
|
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
|
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
|
registerDecryptor("nip44_v2", (payload) =>
|
|
nip44v2.decrypt(userPubkey, payload)
|
|
);
|
|
if (!decryptors.has("nip44")) {
|
|
registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!decryptors.size) {
|
|
const error = new Error(
|
|
"No active signer or extension decryptors are available for subscriptions."
|
|
);
|
|
error.code = "nostr-extension-missing";
|
|
return { ok: false, error };
|
|
}
|
|
|
|
const availableSchemes = Array.from(decryptors.keys());
|
|
const order = determineDecryptionOrder(event, availableSchemes);
|
|
const attemptErrors = [];
|
|
|
|
for (const scheme of order) {
|
|
const decryptFn = decryptors.get(scheme);
|
|
if (!decryptFn) {
|
|
continue;
|
|
}
|
|
try {
|
|
const plaintext = await decryptFn(ciphertext);
|
|
if (typeof plaintext !== "string") {
|
|
const error = new Error("Decryption returned a non-string payload.");
|
|
error.code = "subscriptions-invalid-plaintext";
|
|
attemptErrors.push({ scheme, error });
|
|
continue;
|
|
}
|
|
return { ok: true, plaintext, scheme };
|
|
} catch (error) {
|
|
attemptErrors.push({ scheme, error });
|
|
}
|
|
}
|
|
|
|
const error = new Error("Failed to decrypt subscription list with available schemes.");
|
|
error.code = "subscriptions-decrypt-failed";
|
|
if (attemptErrors.length) {
|
|
error.cause = attemptErrors;
|
|
}
|
|
return { ok: false, error, errors: attemptErrors };
|
|
}
|
|
|
|
async addChannel(channelHex, userPubkey) {
|
|
const normalizedChannel = normalizeHexPubkey(channelHex);
|
|
if (!userPubkey) {
|
|
throw new Error("No user pubkey => cannot addChannel.");
|
|
}
|
|
if (!normalizedChannel) {
|
|
devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex);
|
|
return;
|
|
}
|
|
if (this.subscribedPubkeys.has(normalizedChannel)) {
|
|
devLogger.log("Already subscribed to", channelHex);
|
|
return;
|
|
}
|
|
this.subscribedPubkeys.add(normalizedChannel);
|
|
await this.publishSubscriptionList(userPubkey);
|
|
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
|
|
userLogger.warn(
|
|
"[SubscriptionsManager] Failed to refresh after adding subscription:",
|
|
error
|
|
);
|
|
});
|
|
}
|
|
|
|
async removeChannel(channelHex, userPubkey) {
|
|
const normalizedChannel = normalizeHexPubkey(channelHex);
|
|
if (!userPubkey) {
|
|
throw new Error("No user pubkey => cannot removeChannel.");
|
|
}
|
|
if (!normalizedChannel) {
|
|
devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex);
|
|
return;
|
|
}
|
|
if (!this.subscribedPubkeys.has(normalizedChannel)) {
|
|
devLogger.log("Channel not found in subscription list:", channelHex);
|
|
return;
|
|
}
|
|
this.subscribedPubkeys.delete(normalizedChannel);
|
|
await this.publishSubscriptionList(userPubkey);
|
|
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
|
|
userLogger.warn(
|
|
"[SubscriptionsManager] Failed to refresh after removing subscription:",
|
|
error
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Encrypt (NIP-04) + publish the updated subscription set
|
|
* as kind=30002 with ["d", "subscriptions"] to be replaceable.
|
|
*/
|
|
async publishSubscriptionList(userPubkey) {
|
|
if (!userPubkey) {
|
|
throw new Error("No pubkey => cannot publish subscription list.");
|
|
}
|
|
|
|
const signer = getActiveSigner();
|
|
if (!signer) {
|
|
const error = new Error(
|
|
"An active signer is required to update subscriptions."
|
|
);
|
|
error.code = "signer-missing";
|
|
throw error;
|
|
}
|
|
|
|
if (signer.type === "extension") {
|
|
const permissionResult = await requestDefaultExtensionPermissions();
|
|
if (!permissionResult.ok) {
|
|
userLogger.warn(
|
|
"[SubscriptionsManager] Signer permissions denied while updating subscriptions.",
|
|
permissionResult.error,
|
|
);
|
|
const error = new Error(
|
|
"The active signer must allow encryption and signing before updating subscriptions.",
|
|
);
|
|
error.code = "extension-permission-denied";
|
|
error.cause = permissionResult.error;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (typeof signer.nip04Encrypt !== "function") {
|
|
const error = new Error("NIP-04 encryption is required to update subscriptions.");
|
|
error.code = "nip04-missing";
|
|
throw error;
|
|
}
|
|
|
|
if (typeof signer.signEvent !== "function") {
|
|
const error = new Error("Active signer missing signEvent support.");
|
|
error.code = "sign-event-missing";
|
|
throw error;
|
|
}
|
|
|
|
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
|
|
const plainStr = JSON.stringify(plainObj);
|
|
|
|
/*
|
|
* The subscription list is stored as a NIP-04 message to self, so both
|
|
* encryption and decryption intentionally use the user's own pubkey.
|
|
* Extensions are expected to support this encrypt-to-self flow; altering
|
|
* the target would break loadSubscriptions, which decrypts with the same
|
|
* pubkey. Any future sharing model (e.g., sharing with another user) will
|
|
* need a parallel read path and should not overwrite this behavior.
|
|
*/
|
|
let cipherText = "";
|
|
try {
|
|
cipherText = await signer.nip04Encrypt(userPubkey, plainStr);
|
|
} catch (err) {
|
|
userLogger.error("Encryption failed:", err);
|
|
throw err;
|
|
}
|
|
|
|
const evt = buildSubscriptionListEvent({
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: cipherText
|
|
});
|
|
|
|
let signedEvent;
|
|
try {
|
|
signedEvent = await signer.signEvent(evt);
|
|
} catch (signErr) {
|
|
userLogger.error("Failed to sign subscription list:", signErr);
|
|
throw signErr;
|
|
}
|
|
|
|
const sanitizeRelayList = (candidate) =>
|
|
Array.isArray(candidate)
|
|
? candidate
|
|
.map((url) => (typeof url === "string" ? url.trim() : ""))
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const writeRelays = sanitizeRelayList(nostrClient.writeRelays);
|
|
const fallbackRelays = writeRelays.length
|
|
? writeRelays
|
|
: sanitizeRelayList(nostrClient.relays);
|
|
const targetRelays = fallbackRelays.length
|
|
? fallbackRelays
|
|
: Array.from(DEFAULT_RELAY_URLS);
|
|
|
|
const publishResults = await publishEventToRelays(
|
|
nostrClient.pool,
|
|
targetRelays,
|
|
signedEvent
|
|
);
|
|
|
|
let publishSummary;
|
|
try {
|
|
publishSummary = assertAnyRelayAccepted(publishResults, {
|
|
context: "subscription list"
|
|
});
|
|
} catch (publishError) {
|
|
if (publishError?.relayFailures?.length) {
|
|
publishError.relayFailures.forEach(
|
|
({ url, error: relayError, reason }) => {
|
|
userLogger.error(
|
|
`[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`,
|
|
relayError || reason
|
|
);
|
|
}
|
|
);
|
|
}
|
|
throw publishError;
|
|
}
|
|
|
|
if (publishSummary.failed.length) {
|
|
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
|
const reason =
|
|
relayError instanceof Error
|
|
? relayError.message
|
|
: relayError
|
|
? String(relayError)
|
|
: "publish failed";
|
|
userLogger.warn(
|
|
`[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`,
|
|
relayError
|
|
);
|
|
});
|
|
}
|
|
|
|
this.subsEventId = signedEvent.id;
|
|
const acceptedUrls = publishSummary.accepted.map(({ url }) => url);
|
|
devLogger.log(
|
|
"Subscription list published, event id:",
|
|
signedEvent.id,
|
|
"accepted relays:",
|
|
acceptedUrls
|
|
);
|
|
}
|
|
|
|
/**
|
|
* If not loaded, load subs, then fetch + render videos
|
|
* in #subscriptionsVideoList with the same style as app.renderVideoList.
|
|
*/
|
|
async showSubscriptionVideos(
|
|
userPubkey,
|
|
containerId = "subscriptionsVideoList",
|
|
options = {}
|
|
) {
|
|
const limitCandidate = Number(options?.limit);
|
|
const limit =
|
|
Number.isFinite(limitCandidate) && limitCandidate > 0
|
|
? Math.floor(limitCandidate)
|
|
: null;
|
|
const reason =
|
|
typeof options?.reason === "string" && options.reason.trim()
|
|
? options.reason.trim()
|
|
: undefined;
|
|
|
|
this.lastContainerId = containerId;
|
|
|
|
const container = document.getElementById(containerId);
|
|
if (!userPubkey) {
|
|
if (container) {
|
|
container.innerHTML =
|
|
"<p class='text-muted-strong'>Please log in first.</p>";
|
|
}
|
|
this.lastRunOptions = null;
|
|
this.lastResult = null;
|
|
this.hasRenderedOnce = Boolean(container);
|
|
return null;
|
|
}
|
|
|
|
if (!this.loaded) {
|
|
try {
|
|
await this.loadSubscriptions(userPubkey);
|
|
} catch (error) {
|
|
userLogger.error(
|
|
"[SubscriptionsManager] Failed to load subscriptions while rendering feed:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
const channelHexes = this.getSubscribedAuthors();
|
|
if (!container) {
|
|
this.lastRunOptions = {
|
|
actorPubkey: userPubkey,
|
|
limit,
|
|
containerId
|
|
};
|
|
this.hasRenderedOnce = false;
|
|
return null;
|
|
}
|
|
|
|
if (!channelHexes.length) {
|
|
container.innerHTML =
|
|
"<p class='text-muted-strong'>No subscriptions found.</p>";
|
|
this.lastRunOptions = {
|
|
actorPubkey: userPubkey,
|
|
limit,
|
|
containerId
|
|
};
|
|
this.lastResult = { items: [], metadata: { reason: "no-subscriptions" } };
|
|
this.hasRenderedOnce = true;
|
|
return this.lastResult;
|
|
}
|
|
|
|
container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…");
|
|
|
|
this.lastRunOptions = {
|
|
actorPubkey: userPubkey,
|
|
limit,
|
|
containerId,
|
|
reason
|
|
};
|
|
|
|
this.ensureFeedRegistered();
|
|
this.ensureNostrServiceListener();
|
|
|
|
if (typeof nostrService?.awaitInitialLoad === "function") {
|
|
try {
|
|
await nostrService.awaitInitialLoad();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[SubscriptionsManager] Failed to await nostrService initial load:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
const engine = this.getFeedEngine();
|
|
if (!engine || typeof engine.run !== "function") {
|
|
container.innerHTML =
|
|
"<p class='text-muted-strong'>Subscriptions are unavailable right now.</p>";
|
|
this.hasRenderedOnce = true;
|
|
return null;
|
|
}
|
|
|
|
const app = getApp();
|
|
const runtime = this.buildFeedRuntime({
|
|
app,
|
|
authors: channelHexes,
|
|
limit,
|
|
});
|
|
const runOptions = {
|
|
actorPubkey: userPubkey,
|
|
limit,
|
|
runtime,
|
|
hooks: {
|
|
subscriptions: {
|
|
resolveAuthors: () => this.getSubscribedAuthors()
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
this.isRunningFeed = true;
|
|
const result = await engine.run("subscriptions", runOptions);
|
|
|
|
const videos = Array.isArray(result?.items)
|
|
? result.items.map((item) => item?.video).filter(Boolean)
|
|
: [];
|
|
|
|
const metadata = result && typeof result.metadata === "object"
|
|
? { ...result.metadata }
|
|
: {};
|
|
|
|
if (!metadata.feed) {
|
|
metadata.feed = "subscriptions";
|
|
}
|
|
if (limit) {
|
|
metadata.limit = limit;
|
|
}
|
|
if (reason) {
|
|
metadata.reason = reason;
|
|
}
|
|
|
|
const enrichedResult = { ...result, metadata };
|
|
|
|
if (app?.videosMap instanceof Map) {
|
|
videos.forEach((video) => {
|
|
if (video && typeof video.id === "string" && video.id) {
|
|
app.videosMap.set(video.id, video);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.lastResult = enrichedResult;
|
|
this.renderSameGridStyle(enrichedResult, containerId, {
|
|
limit,
|
|
reason,
|
|
emptyMessage:
|
|
"No playable subscription videos found yet. We'll keep watching for new posts.",
|
|
});
|
|
this.hasRenderedOnce = true;
|
|
return enrichedResult;
|
|
} catch (error) {
|
|
userLogger.error(
|
|
"[SubscriptionsManager] Failed to run subscriptions feed:",
|
|
error
|
|
);
|
|
if (container && this.lastResult) {
|
|
const fallbackReason = reason
|
|
? `${reason}:cached`
|
|
: "cached-result";
|
|
this.renderSameGridStyle(this.lastResult, containerId, {
|
|
limit,
|
|
reason: fallbackReason,
|
|
});
|
|
} else if (container) {
|
|
container.innerHTML =
|
|
"<p class='text-muted-strong'>Unable to load subscriptions right now.</p>";
|
|
}
|
|
this.hasRenderedOnce = Boolean(container);
|
|
return this.lastResult;
|
|
} finally {
|
|
this.isRunningFeed = false;
|
|
this.processScheduledRefresh();
|
|
}
|
|
}
|
|
|
|
ensureNostrServiceListener() {
|
|
if (this.unsubscribeFromNostrUpdates || typeof nostrService?.on !== "function") {
|
|
return;
|
|
}
|
|
|
|
this.unsubscribeFromNostrUpdates = nostrService.on(
|
|
"videos:updated",
|
|
(detail) => {
|
|
this.handleNostrVideosUpdated(detail);
|
|
}
|
|
);
|
|
}
|
|
|
|
handleNostrVideosUpdated(detail) {
|
|
if (!detail || !Array.isArray(detail.videos) || !detail.videos.length) {
|
|
return;
|
|
}
|
|
|
|
this.scheduledRefreshDetail = detail;
|
|
this.processScheduledRefresh();
|
|
}
|
|
|
|
processScheduledRefresh() {
|
|
if (!this.lastRunOptions || !this.hasRenderedOnce) {
|
|
return null;
|
|
}
|
|
|
|
if (!this.scheduledRefreshDetail) {
|
|
return null;
|
|
}
|
|
|
|
if (this.isRunningFeed || this.pendingRefreshPromise) {
|
|
return null;
|
|
}
|
|
|
|
const detail = this.scheduledRefreshDetail;
|
|
this.scheduledRefreshDetail = null;
|
|
|
|
const refreshReason =
|
|
typeof detail?.reason === "string" && detail.reason
|
|
? `nostr:${detail.reason}`
|
|
: "nostr:update";
|
|
|
|
this.pendingRefreshPromise = this.refreshActiveFeed({ reason: refreshReason })
|
|
.catch((error) => {
|
|
devLogger.warn(
|
|
"[SubscriptionsManager] Failed to refresh after nostrService update:",
|
|
error
|
|
);
|
|
})
|
|
.finally(() => {
|
|
this.pendingRefreshPromise = null;
|
|
if (this.scheduledRefreshDetail) {
|
|
this.processScheduledRefresh();
|
|
}
|
|
});
|
|
|
|
return this.pendingRefreshPromise;
|
|
}
|
|
|
|
ensureFeedRegistered() {
|
|
const app = getApp();
|
|
if (typeof app?.registerSubscriptionsFeed === "function") {
|
|
try {
|
|
app.registerSubscriptionsFeed();
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
"[SubscriptionsManager] Failed to register subscriptions feed:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
getFeedEngine() {
|
|
const app = getApp();
|
|
return app?.feedEngine || null;
|
|
}
|
|
|
|
buildFeedRuntime({ app, authors = [], limit = null } = {}) {
|
|
const normalizedAuthors = Array.isArray(authors)
|
|
? authors
|
|
.map((author) => normalizeHexPubkey(author))
|
|
.filter((author) => Boolean(author))
|
|
: [];
|
|
|
|
const blacklist =
|
|
app?.blacklistedEventIds instanceof Set
|
|
? new Set(app.blacklistedEventIds)
|
|
: new Set();
|
|
|
|
const isAuthorBlocked =
|
|
typeof app?.isAuthorBlocked === "function"
|
|
? (pubkey) => app.isAuthorBlocked(pubkey)
|
|
: () => false;
|
|
|
|
const limitCandidate = Number(limit);
|
|
const normalizedLimit =
|
|
Number.isFinite(limitCandidate) && limitCandidate > 0
|
|
? Math.floor(limitCandidate)
|
|
: null;
|
|
|
|
return {
|
|
subscriptionAuthors: normalizedAuthors,
|
|
authors: normalizedAuthors,
|
|
blacklistedEventIds: blacklist,
|
|
isAuthorBlocked,
|
|
limit: normalizedLimit
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Renders the feed in the same style as home.
|
|
* This includes gear menu, time-ago, lazy load, clickable authors, etc.
|
|
*/
|
|
renderSameGridStyle(result, containerId, options = {}) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const app = getApp();
|
|
const items = Array.isArray(result?.items) ? result.items : [];
|
|
const metadata =
|
|
result && typeof result.metadata === "object"
|
|
? { ...result.metadata }
|
|
: {};
|
|
|
|
if (!metadata.feed) {
|
|
metadata.feed = "subscriptions";
|
|
}
|
|
|
|
const limitCandidate = Number(options?.limit);
|
|
const limit =
|
|
Number.isFinite(limitCandidate) && limitCandidate > 0
|
|
? Math.floor(limitCandidate)
|
|
: null;
|
|
|
|
const limitedItems = limit ? items.slice(0, limit) : items;
|
|
const videos = limitedItems
|
|
.map((item) => (item && typeof item === "object" ? item.video : null))
|
|
.filter((video) => video && typeof video === "object");
|
|
|
|
if (!videos.length) {
|
|
const reasonDetail =
|
|
typeof metadata.reason === "string" && metadata.reason
|
|
? metadata.reason
|
|
: "empty";
|
|
this.renderEmptyState(container, {
|
|
message: options?.emptyMessage,
|
|
reason: reasonDetail,
|
|
metadata,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const listView = this.getListView(container, app);
|
|
if (!listView) {
|
|
container.innerHTML = `
|
|
<p class="flex justify-center items-center h-full w-full text-center text-muted-strong">
|
|
Unable to render subscriptions feed.
|
|
</p>`;
|
|
return;
|
|
}
|
|
|
|
listView.mount(container);
|
|
|
|
if (app?.videosMap instanceof Map) {
|
|
listView.state.videosMap = app.videosMap;
|
|
}
|
|
|
|
const enrichedMetadata = {
|
|
...metadata,
|
|
feed: "subscriptions"
|
|
};
|
|
if (limit) {
|
|
enrichedMetadata.limit = limit;
|
|
}
|
|
if (typeof options?.reason === "string" && options.reason) {
|
|
enrichedMetadata.reason = options.reason;
|
|
}
|
|
|
|
listView.state.feedMetadata = enrichedMetadata;
|
|
listView.render(videos, enrichedMetadata);
|
|
}
|
|
|
|
renderEmptyState(container, { message, reason, metadata } = {}) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const copy =
|
|
typeof message === "string" && message.trim()
|
|
? message.trim()
|
|
: "No playable subscription videos found yet. We'll keep watching for new posts.";
|
|
|
|
container.innerHTML = getSidebarLoadingMarkup(copy, { showSpinner: false });
|
|
|
|
if (this.subscriptionListView && this.subscriptionListView.state) {
|
|
const currentMetadata =
|
|
this.subscriptionListView.state.feedMetadata &&
|
|
typeof this.subscriptionListView.state.feedMetadata === "object"
|
|
? { ...this.subscriptionListView.state.feedMetadata }
|
|
: {};
|
|
|
|
if (metadata && typeof metadata === "object") {
|
|
Object.assign(currentMetadata, metadata);
|
|
}
|
|
|
|
if (reason && typeof reason === "string") {
|
|
currentMetadata.reason = reason;
|
|
} else if (!currentMetadata.reason) {
|
|
currentMetadata.reason = "empty";
|
|
}
|
|
|
|
this.subscriptionListView.state.feedMetadata = currentMetadata;
|
|
}
|
|
}
|
|
|
|
getListView(container, app) {
|
|
if (this.subscriptionListView) {
|
|
return this.subscriptionListView;
|
|
}
|
|
|
|
if (!container) {
|
|
return null;
|
|
}
|
|
|
|
const doc = container.ownerDocument || document;
|
|
const baseView = app?.videoListView || null;
|
|
|
|
const badgeHelpers = baseView?.badgeHelpers || {
|
|
attachHealthBadges: () => {},
|
|
attachUrlHealthBadges: () => {}
|
|
};
|
|
|
|
const formatTimeAgo = (timestamp) => {
|
|
if (typeof app?.formatTimeAgo === "function") {
|
|
return app.formatTimeAgo(timestamp);
|
|
}
|
|
if (typeof baseView?.formatters?.formatTimeAgo === "function") {
|
|
return baseView.formatters.formatTimeAgo(timestamp);
|
|
}
|
|
return timestamp;
|
|
};
|
|
|
|
const formatViewCountLabel = (total) => {
|
|
if (typeof baseView?.formatters?.formatViewCountLabel === "function") {
|
|
return baseView.formatters.formatViewCountLabel(total);
|
|
}
|
|
return typeof total === "number" ? total.toLocaleString() : `${total}`;
|
|
};
|
|
|
|
const assets = baseView?.assets || {
|
|
fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg",
|
|
unsupportedBtihMessage:
|
|
"This magnet link is missing a compatible BitTorrent v1 info hash."
|
|
};
|
|
|
|
const loadedThumbnails =
|
|
app?.loadedThumbnails instanceof Map
|
|
? app.loadedThumbnails
|
|
: baseView?.state?.loadedThumbnails instanceof Map
|
|
? baseView.state.loadedThumbnails
|
|
: new Map();
|
|
|
|
const videosMap =
|
|
app?.videosMap instanceof Map
|
|
? app.videosMap
|
|
: baseView?.state?.videosMap instanceof Map
|
|
? baseView.state.videosMap
|
|
: new Map();
|
|
|
|
const urlHealthCache =
|
|
app?.urlHealthSnapshots instanceof Map
|
|
? app.urlHealthSnapshots
|
|
: baseView?.state?.urlHealthByVideoId instanceof Map
|
|
? baseView.state.urlHealthByVideoId
|
|
: new Map();
|
|
|
|
const streamHealthCache =
|
|
app?.streamHealthSnapshots instanceof Map
|
|
? app.streamHealthSnapshots
|
|
: baseView?.state?.streamHealthByVideoId instanceof Map
|
|
? baseView.state.streamHealthByVideoId
|
|
: new Map();
|
|
|
|
const listViewConfig = {
|
|
document: doc,
|
|
container,
|
|
mediaLoader: app?.mediaLoader || baseView?.mediaLoader || null,
|
|
badgeHelpers,
|
|
formatters: {
|
|
formatTimeAgo,
|
|
formatViewCountLabel
|
|
},
|
|
helpers: {
|
|
escapeHtml: (value) => app?.escapeHTML?.(value) ?? value,
|
|
isMagnetSupported: (magnet) =>
|
|
app?.isMagnetUriSupported?.(magnet) ?? false,
|
|
toLocaleString: (value) =>
|
|
typeof value === "number" ? value.toLocaleString() : value
|
|
},
|
|
assets,
|
|
state: {
|
|
loadedThumbnails,
|
|
videosMap,
|
|
urlHealthByVideoId: urlHealthCache,
|
|
streamHealthByVideoId: streamHealthCache
|
|
},
|
|
utils: {
|
|
dedupeVideos: (videos) => (Array.isArray(videos) ? [...videos] : []),
|
|
getAllEvents: () => Array.from(nostrClient.allEvents.values()),
|
|
hasOlderVersion: (video, events) =>
|
|
app?.hasOlderVersion?.(video, events) ?? false,
|
|
derivePointerInfo: (video) =>
|
|
app?.deriveVideoPointerInfo?.(video) ?? null,
|
|
persistWatchHistoryMetadata: (video, pointerInfo) =>
|
|
app?.persistWatchHistoryMetadataForVideo?.(video, pointerInfo),
|
|
getShareUrlBase: () => app?.getShareUrlBase?.() ?? "",
|
|
buildShareUrlFromNevent: (nevent) =>
|
|
app?.buildShareUrlFromNevent?.(nevent) ?? "",
|
|
buildShareUrlFromEventId: (eventId) =>
|
|
app?.buildShareUrlFromEventId?.(eventId) ?? "",
|
|
canManageBlacklist: () =>
|
|
app?.canCurrentUserManageBlacklist?.() ?? false,
|
|
canEditVideo: (video) => video?.pubkey === app?.pubkey,
|
|
canDeleteVideo: (video) => video?.pubkey === app?.pubkey,
|
|
batchFetchProfiles: (authorSet) => app?.batchFetchProfiles?.(authorSet),
|
|
bindThumbnailFallbacks: (target) =>
|
|
app?.bindThumbnailFallbacks?.(target),
|
|
handleUrlHealthBadge: (payload) => app?.handleUrlHealthBadge?.(payload),
|
|
refreshDiscussionCounts: (videosList, { container: root } = {}) =>
|
|
app?.refreshVideoDiscussionCounts?.(videosList, {
|
|
videoListRoot: root || container || null
|
|
}),
|
|
ensureGlobalMoreMenuHandlers: () =>
|
|
app?.ensureGlobalMoreMenuHandlers?.(),
|
|
closeAllMenus: (options) => app?.closeAllMoreMenus?.(options)
|
|
},
|
|
renderers: {
|
|
getLoadingMarkup: (message) => getSidebarLoadingMarkup(message)
|
|
},
|
|
allowNsfw: ALLOW_NSFW_CONTENT === true
|
|
};
|
|
|
|
const listView = new VideoListView(listViewConfig);
|
|
|
|
listView.setPlaybackHandler((detail) => {
|
|
if (!detail) {
|
|
return;
|
|
}
|
|
if (detail.videoId) {
|
|
Promise.resolve(
|
|
app?.playVideoByEventId?.(detail.videoId, {
|
|
url: detail.url,
|
|
magnet: detail.magnet
|
|
})
|
|
).catch((error) => {
|
|
userLogger.error(
|
|
"[SubscriptionsManager] Failed to play by event id:",
|
|
error
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
Promise.resolve(
|
|
app?.playVideoWithFallback?.({ url: detail.url, magnet: detail.magnet })
|
|
).catch((error) => {
|
|
userLogger.error(
|
|
"[SubscriptionsManager] Failed to start playback:",
|
|
error
|
|
);
|
|
});
|
|
});
|
|
|
|
listView.setEditHandler(({ video, index }) => {
|
|
if (!video?.id) {
|
|
return;
|
|
}
|
|
app?.handleEditVideo?.({
|
|
eventId: video.id,
|
|
index: Number.isFinite(index) ? index : null
|
|
});
|
|
});
|
|
|
|
listView.setRevertHandler(({ video, index }) => {
|
|
if (!video?.id) {
|
|
return;
|
|
}
|
|
app?.handleRevertVideo?.({
|
|
eventId: video.id,
|
|
index: Number.isFinite(index) ? index : null
|
|
});
|
|
});
|
|
|
|
listView.setDeleteHandler(({ video, index }) => {
|
|
if (!video?.id) {
|
|
return;
|
|
}
|
|
app?.handleFullDeleteVideo?.({
|
|
eventId: video.id,
|
|
index: Number.isFinite(index) ? index : null
|
|
});
|
|
});
|
|
|
|
listView.setBlacklistHandler(({ video, dataset }) => {
|
|
const detail = {
|
|
...(dataset || {}),
|
|
author: dataset?.author || video?.pubkey || "",
|
|
context: dataset?.context || "subscriptions"
|
|
};
|
|
app?.handleMoreMenuAction?.("blacklist-author", detail);
|
|
});
|
|
|
|
listView.addEventListener("video:share", (event) => {
|
|
const detail = event?.detail || {};
|
|
const dataset = {
|
|
...(detail.dataset || {}),
|
|
eventId:
|
|
detail.eventId || detail.dataset?.eventId || detail.video?.id || "",
|
|
context: detail.dataset?.context || "subscriptions"
|
|
};
|
|
app?.handleMoreMenuAction?.(detail.action || "copy-link", dataset);
|
|
});
|
|
|
|
listView.addEventListener("video:context-action", (event) => {
|
|
const detail = event?.detail || {};
|
|
const dataset = {
|
|
...(detail.dataset || {}),
|
|
eventId: detail.dataset?.eventId || detail.video?.id || "",
|
|
context: detail.dataset?.context || "subscriptions"
|
|
};
|
|
app?.handleMoreMenuAction?.(detail.action, dataset);
|
|
});
|
|
|
|
this.subscriptionListView = listView;
|
|
return this.subscriptionListView;
|
|
}
|
|
|
|
async refreshActiveFeed(options = {}) {
|
|
if (!this.lastRunOptions) {
|
|
return null;
|
|
}
|
|
|
|
const { actorPubkey, containerId, limit } = this.lastRunOptions;
|
|
if (!actorPubkey || !containerId) {
|
|
return null;
|
|
}
|
|
|
|
const reason =
|
|
typeof options?.reason === "string" && options.reason.trim()
|
|
? options.reason.trim()
|
|
: this.lastRunOptions.reason;
|
|
|
|
if (typeof moderationService?.awaitUserBlockRefresh === "function") {
|
|
try {
|
|
await moderationService.awaitUserBlockRefresh();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[SubscriptionsManager] Failed to sync moderation before refreshing feed:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
return this.showSubscriptionVideos(actorPubkey, containerId, {
|
|
limit,
|
|
reason
|
|
});
|
|
}
|
|
|
|
convertEventToVideo(evt) {
|
|
return sharedConvertEventToVideo(evt);
|
|
}
|
|
}
|
|
|
|
export const subscriptions = new SubscriptionsManager();
|