Files
bitvid/js/subscriptions.js
T

1535 lines
45 KiB
JavaScript

// js/subscriptions.js
import {
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import {
getActiveSigner,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr/index.js";
import {
listVideoViewEvents,
subscribeVideoViewEvents,
} from "./nostrViewEventsFacade.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import {
buildSubscriptionListEvent,
SUBSCRIPTION_LIST_IDENTIFIER,
getNostrEventSchema,
NOTE_TYPES,
} 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";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
function normalizeHexPubkey(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : "";
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
}
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
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) => normalizeEncryptionToken(part))
.filter(Boolean);
for (const part of parts) {
if (part === "nip44v2" || part === "nip44v02") {
pushUnique("nip44_v2");
continue;
}
if (part === "nip44") {
pushUnique("nip44");
continue;
}
if (part === "nip04" || part === "nip4") {
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 ["nip44_v2", "nip44", "nip04"]) {
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
prioritized.push(fallback);
}
}
return prioritized.length ? prioritized : available;
}
function serializeSubscriptionTagMatrix(values) {
const tags = [];
const seen = new Set();
if (!values) {
return JSON.stringify(tags);
}
const iterable = Array.isArray(values)
? values
: values instanceof Set
? Array.from(values)
: [];
for (const candidate of iterable) {
const normalized = normalizeHexPubkey(candidate);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
tags.push(["p", normalized]);
}
return JSON.stringify(tags);
}
function parseSubscriptionPlaintext(plaintext) {
if (typeof plaintext !== "string" || !plaintext) {
return [];
}
let parsed;
try {
parsed = JSON.parse(plaintext);
} catch (error) {
devLogger.warn(
"[SubscriptionsManager] Failed to parse subscription ciphertext as JSON; treating as empty.",
error,
);
return [];
}
if (Array.isArray(parsed)) {
const collected = [];
const seen = new Set();
for (const entry of parsed) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
const marker = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
if (marker !== "p") {
continue;
}
const normalized = normalizeHexPubkey(entry[1]);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
collected.push(normalized);
}
return collected;
}
if (parsed && typeof parsed === "object") {
const tagArray = Array.isArray(parsed.tags) ? parsed.tags : [];
if (tagArray.length) {
try {
return parseSubscriptionPlaintext(JSON.stringify(tagArray));
} catch {
// fall through to legacy handling
}
}
const legacy = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : [];
return legacy.map((value) => normalizeHexPubkey(value)).filter(Boolean);
}
return [];
}
const getApp = () => getApplication();
const listVideoViewEventsApi = (pointer, options) =>
listVideoViewEvents(nostrClient, pointer, options);
const subscribeVideoViewEventsApi = (pointer, options) =>
subscribeVideoViewEvents(nostrClient, pointer, options);
/**
* Manages the user's subscription list (kind=30000 follow set) *privately*,
* using encrypted NIP-51 tag arrays (NIP-04/NIP-44) 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.currentUserPubkey = null;
this.loaded = false;
this.loadingPromise = null;
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=30000 (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: [SUBSCRIPTION_SET_KIND],
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 normalized = parseSubscriptionPlaintext(decryptedStr);
this.subscribedPubkeys = new Set(normalized);
this.currentUserPubkey = normalizedUserPubkey;
this.loaded = true;
} catch (err) {
userLogger.error("[SubscriptionsManager] Failed to load subs:", err);
}
}
reset() {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.currentUserPubkey = null;
this.loaded = false;
this.lastRunOptions = null;
this.lastResult = null;
this.hasRenderedOnce = false;
}
async ensureLoaded(actorHex) {
const normalizedActor = normalizeHexPubkey(actorHex) || actorHex;
if (!normalizedActor) {
return;
}
if (this.loaded && this.currentUserPubkey === normalizedActor) {
return;
}
if (this.loadingPromise) {
try {
await this.loadingPromise;
} catch (error) {
throw error;
}
return;
}
const loader = this.loadSubscriptions(normalizedActor);
this.loadingPromise = loader;
try {
// Race against a timeout so the UI doesn't hang indefinitely if relays stall.
const timeoutMs = 6000;
await Promise.race([
loader,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Timeout loading subscriptions")),
timeoutMs
)
),
]);
} catch (error) {
userLogger.warn("[SubscriptionsManager] ensureLoaded timed out or failed:", error);
} finally {
this.loadingPromise = null;
}
}
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.");
}
await this.ensureLoaded(userPubkey);
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.");
}
await this.ensureLoaded(userPubkey);
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=30000 with ["d", "subscriptions"] to be replaceable.
*/
async publishSubscriptionList(userPubkey) {
if (!userPubkey) {
throw new Error("No pubkey => cannot publish subscription list.");
}
let signer = getActiveSigner();
if (!signer) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
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.signEvent !== "function") {
const error = new Error("Active signer missing signEvent support.");
error.code = "sign-event-missing";
throw error;
}
const plainStr = serializeSubscriptionTagMatrix(this.subscribedPubkeys);
const encryptors = [];
const registerEncryptor = (scheme, handler) => {
if (!scheme || typeof handler !== "function") {
return;
}
encryptors.push({ scheme, handler });
};
if (typeof signer.nip44Encrypt === "function") {
registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(userPubkey, value));
registerEncryptor("nip44", (value) => signer.nip44Encrypt(userPubkey, value));
}
if (typeof signer.nip04Encrypt === "function") {
registerEncryptor("nip04", (value) => signer.nip04Encrypt(userPubkey, value));
}
if (!encryptors.length) {
const error = new Error(
"An encryption-capable signer is required to update subscriptions.",
);
error.code = "subscriptions-missing-encryptor";
throw error;
}
/*
* The subscription list is stored as an encrypted 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 = "";
let encryptionScheme = "";
const seenSchemes = new Set();
const encryptionErrors = [];
for (const candidate of encryptors) {
if (seenSchemes.has(candidate.scheme)) {
continue;
}
seenSchemes.add(candidate.scheme);
try {
const encrypted = await candidate.handler(plainStr);
if (typeof encrypted === "string" && encrypted) {
cipherText = encrypted;
encryptionScheme = candidate.scheme;
break;
}
} catch (error) {
encryptionErrors.push({ scheme: candidate.scheme, error });
}
}
if (!cipherText) {
const error = new Error("Failed to encrypt subscription list payload.");
error.code = "subscriptions-encrypt-failed";
error.cause = encryptionErrors;
throw error;
}
const encryptionTagValue =
encryptionScheme === "nip44_v2"
? "nip44_v2"
: encryptionScheme === "nip44"
? "nip44"
: encryptionScheme === "nip04"
? "nip04"
: undefined;
const evt = buildSubscriptionListEvent({
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
content: cipherText,
encryption: encryptionTagValue,
});
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;
}
try {
await this.ensureLoaded(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;
const preferenceSource =
typeof app?.getHashtagPreferences === "function"
? app.getHashtagPreferences()
: {};
const moderationThresholds =
typeof app?.getActiveModerationThresholds === "function"
? app.getActiveModerationThresholds()
: null;
return {
subscriptionAuthors: normalizedAuthors,
authors: normalizedAuthors,
blacklistedEventIds: blacklist,
isAuthorBlocked,
limit: normalizedLimit,
tagPreferences: {
interests: Array.isArray(preferenceSource?.interests)
? [...preferenceSource.interests]
: [],
disinterests: Array.isArray(preferenceSource?.disinterests)
? [...preferenceSource.disinterests]
: [],
},
moderationThresholds: moderationThresholds
? { ...moderationThresholds }
: undefined,
};
}
/**
* 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);
if (typeof listView.setPopularTagsContainer === "function") {
listView.setPopularTagsContainer(null);
}
const buildModerationPayload = (detail = {}) => {
const event = detail?.event || null;
const trigger =
detail?.trigger ||
(event && (event.currentTarget || event.target)) ||
null;
const card = detail?.card || null;
const video = detail?.video || null;
const datasetContext = (() => {
const detailContext =
typeof detail?.context === "string" ? detail.context.trim() : "";
if (detailContext) {
return detailContext;
}
const datasetSource =
(detail?.dataset && typeof detail.dataset === "object"
? detail.dataset
: null) ||
(trigger && trigger.dataset) ||
(card && card.root && card.root.dataset) ||
null;
if (
datasetSource &&
typeof datasetSource.context === "string" &&
datasetSource.context.trim()
) {
return datasetSource.context.trim();
}
return "";
})();
return {
...detail,
video,
card,
trigger,
context: datasetContext || "subscriptions",
};
};
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,
video
});
});
listView.setRevertHandler(({ video, index }) => {
if (!video?.id) {
return;
}
app?.handleRevertVideo?.({
eventId: video.id,
index: Number.isFinite(index) ? index : null,
video
});
});
listView.setDeleteHandler(({ video, index }) => {
if (!video?.id) {
return;
}
app?.handleFullDeleteVideo?.({
eventId: video.id,
index: Number.isFinite(index) ? index : null,
video
});
});
listView.setBlacklistHandler(({ video, dataset }) => {
const detail = {
...(dataset || {}),
author: dataset?.author || video?.pubkey || "",
context: dataset?.context || "subscriptions"
};
app?.handleMoreMenuAction?.("blacklist-author", detail);
});
listView.setModerationOverrideHandler((detail = {}) => {
if (typeof app?.handleModerationOverride !== "function") {
return false;
}
return app.handleModerationOverride(buildModerationPayload(detail));
});
listView.setModerationBlockHandler((detail = {}) => {
if (typeof app?.handleModerationBlock !== "function") {
return false;
}
return app.handleModerationBlock(buildModerationPayload(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);
}
}
SubscriptionsManager.listVideoViewEvents = listVideoViewEventsApi;
SubscriptionsManager.subscribeVideoViewEvents = subscribeVideoViewEventsApi;
export const subscriptions = new SubscriptionsManager();