Files
bitvid/js/subscriptions.js
T

2351 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// js/subscriptions.js
import {
getActiveSigner,
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { normalizeNostrPubkey, sanitizeRelayList } from "./nostr/nip46Client.js";
import {
listVideoViewEvents,
subscribeVideoViewEvents,
} from "./nostrViewEventsFacade.js";
import { DEFAULT_RELAY_URLS, capReadRelays } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
import {
buildSubscriptionListEvent,
SUBSCRIPTION_LIST_IDENTIFIER,
getNostrEventSchema,
NOTE_TYPES,
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import {
publishEventToRelays,
assertAnyRelayAcceptedOrUnconfirmed,
} 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 {
TinyEventEmitter,
isTransientDecryptError,
computeRetryDelay,
normalizeHexPubkey,
normalizeEncryptionToken,
serializeSubscriptionTagMatrix,
parseSubscriptionPlaintext,
parseCachedSubscriptionSnapshot,
} from "./subscriptionsHelpers.js";
import {
noteSignerDecryptTimeout,
noteSignerDecryptSuccess,
} from "./utils/signerHealthNotice.js";
import moderationService from "./services/moderationService.js";
import nostrService from "./services/nostrService.js";
import { profileCache } from "./state/profileCache.js";
import {
DEFAULT_NIP07_ENCRYPTION_METHODS,
runNip07WithRetry,
NIP07_PRIORITY,
} from "./nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
import { SHORT_TIMEOUT_MS } from "./constants.js";
import {
getLastSuccessfulScheme,
setLastSuccessfulScheme,
} from "./nostr/decryptionSchemeCache.js";
import {
computeIncrementalSinceWithOverlap,
selectNewestListEvent,
} from "./nostr/listEventOrdering.js";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
// PERF: Reduced from 10s to 6s — the signer is guaranteed ready before list
// loading starts (authSessionCoordinator waits for the permission pre-grant),
// so decryption should succeed within 2-3s. 6s is generous enough for slow
// relays while still failing fast enough for the scheme fallback to try the
// next decryption method without stalling the login path.
// Generous decrypt budget: real extensions can take 15-25s to answer a nip-07
// decrypt under cold-login load even though the crypto is instant. A tighter
// budget times out mid-flight and retries forever (KNOWN_BUGS #0).
const DECRYPT_TIMEOUT_MS = 30000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// should recover near-instantly. The shorter delay prevents unnecessary wait
// time during the critical login path when the first decrypt attempt fails.
const DECRYPT_RETRY_DELAY_MS = 1500;
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, cachedScheme = null) {
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
const availableSet = new Set(available);
const prioritized = [];
if (cachedScheme && availableSet.has(cachedScheme)) {
prioritized.push(cachedScheme);
}
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;
}
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 (prefer NIP-44 v2) for the content field.
* Published events should include an explicit ["encrypted", "<scheme>"] tag.
* 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.subsEventCreatedAt = null;
this.subsEventPubkey = null;
// Last live-subscription event id we triggered a refresh for (loop guard).
this._lastHandledListEventId = null;
// Most recently fetched list ciphertext, reused by decrypt retries so they
// don't re-query relays.
this._pendingDecryptEvent = null;
this.currentUserPubkey = null;
this.uiReady = false;
this.dataReady = false;
this.loaded = false;
this.loadedFromCache = false;
this.lastSuccessfulSyncAt = null;
this.lastAttemptAt = null;
this.loadingPromise = null;
this.backgroundLoading = false;
this.subscriptionListView = null;
this.lastRunOptions = null;
this.lastResult = null;
this.lastContainerId = null;
this.lastLoadError = null;
this.unsubscribeFromNostrUpdates = null;
this.pendingRefreshPromise = null;
this.scheduledRefreshDetail = null;
this.isRunningFeed = false;
this.hasRenderedOnce = false;
this.emitter = new TinyEventEmitter();
this.decryptRetryTimeoutId = null;
this.decryptRetryAttempt = 0;
this.ensureNostrServiceListener();
this.subscriptionKey = null;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.reset();
// Optionally reload immediately if we have a pubkey?
// Usually UI triggers reload via showSubscriptionVideos
} else if (event === "runtimeCleared" && detail.pubkey === this.currentUserPubkey) {
this.reset();
if (this.currentUserPubkey) {
this.loadSubscriptions(this.currentUserPubkey, {
allowPermissionPrompt: false,
});
}
}
});
}
/**
* Decrypt the subscription list from kind=30000 (d="subscriptions").
*/
async loadSubscriptions(userPubkey, options = {}) {
if (!userPubkey) {
userLogger.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
return;
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
this.lastAttemptAt = Date.now();
// 1. Attempt to load from cache first
const cached = profileCache.getProfileData(normalizedUserPubkey, "subscriptions");
const cachedSnapshot = parseCachedSubscriptionSnapshot(cached);
if (cachedSnapshot.hasSnapshot) {
devLogger.log("[SubscriptionsManager] Loaded subscriptions from cache.");
this.subscribedPubkeys = new Set(cachedSnapshot.subscribedPubkeys);
this.subsEventId = cachedSnapshot.eventId;
this.subsEventCreatedAt = cachedSnapshot.createdAt;
this.subsEventPubkey = cachedSnapshot.eventPubkey;
this.currentUserPubkey = normalizedUserPubkey;
this.uiReady = true;
this.dataReady = true;
this.loaded = true;
this.loadedFromCache = true;
// Trigger background update
if (!allowPermissionPrompt && !this.backgroundLoading) {
this.backgroundLoading = true;
this.emitter.emit("change", {
action: "background-loading",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
background: true,
});
} else if (allowPermissionPrompt) {
this.backgroundLoading = false;
}
this.updateFromRelays(userPubkey, { allowPermissionPrompt }).catch((err) => {
devLogger.warn("[SubscriptionsManager] Background update failed:", err);
});
return;
}
// 2. If no cache, must wait for relays
await this.updateFromRelays(userPubkey, { allowPermissionPrompt });
}
saveToCache(userPubkey) {
// We assume userPubkey matches active profile, enforced by profileCache
profileCache.set("subscriptions", {
subscribedPubkeys: Array.from(this.subscribedPubkeys),
eventId: this.subsEventId,
createdAt: this.subsEventCreatedAt,
eventPubkey: this.subsEventPubkey,
});
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
scheduleDecryptRetry(userPubkey, error, options = {}) {
const normalized = normalizeHexPubkey(userPubkey) || userPubkey;
if (!normalized) {
return;
}
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
}
const attempt = this.decryptRetryAttempt;
const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt);
this.decryptRetryAttempt = attempt + 1;
this.decryptRetryTimeoutId = setTimeout(() => {
this.decryptRetryTimeoutId = null;
if (this.currentUserPubkey && this.currentUserPubkey !== normalized) {
return;
}
// Reuse the already-fetched ciphertext so the retry only re-runs the
// DECRYPT (no redundant relay fetch). Fall back to a full fetch only if we
// somehow have no pending event cached.
const retryOptions = this._pendingDecryptEvent
? { ...options, cachedDecryptEvent: this._pendingDecryptEvent }
: options;
this.updateFromRelays(normalized, retryOptions).catch((retryError) => {
userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError);
});
}, retryDelayMs);
devLogger.log(
`[SubscriptionsManager] Decryption timed out; scheduling retry in ${retryDelayMs / 1000}s.`,
error,
);
}
async updateFromRelays(userPubkey, options = {}) {
if (!userPubkey) return;
try {
this.lastAttemptAt = Date.now();
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const signerReadinessGate =
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
? options.signerReadinessGate
: null;
const shouldSuppressPermissionEscalation =
signerReadinessGate &&
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
const wasBackgroundLoading = this.backgroundLoading;
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
const wasLoadedForUser =
this.loaded && this.currentUserPubkey === normalizedUserPubkey;
this.lastLoadError = null;
const readRelays = relayManager.getReadRelayUrls();
const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
if (!relayUrls.length) {
devLogger.warn(
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
);
this.uiReady = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
if (!nostrClient?.pool && typeof nostrClient?.ensurePool === "function") {
try {
await nostrClient.ensurePool();
} catch (error) {
devLogger.warn(
"[SubscriptionsManager] Failed to initialize relay pool for subscription",
error,
);
}
}
// DECRYPT-RETRY FAST PATH: a retry re-uses the ciphertext we already
// fetched rather than re-querying relays. The relay data hasn't changed
// between attempts — only signer availability has — so re-fetching on
// every retry just floods the cold-login burst with redundant kind-30000
// REQs (KNOWN_BUGS #0). Only the initial load (and a genuinely new live
// event) hits the network.
let hadCachedSnapshot = false;
let mergedEvents = [];
const retryEvent =
options?.cachedDecryptEvent && options.cachedDecryptEvent.id
? options.cachedDecryptEvent
: null;
if (retryEvent) {
mergedEvents = [retryEvent];
} else {
this.ensureSubscriptionListSubscription(normalizedUserPubkey, relayUrls);
// Use incremental fetch helper
const cachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
);
hadCachedSnapshot = cachedSnapshot.hasSnapshot;
// Legacy snapshots did not record the source author. Fetch their full
// authoritative state once so a cache created by an older buggy build
// cannot keep suppressing the real list with an invalid timestamp.
const shouldForceFullFetch =
!cachedSnapshot.hasSnapshot ||
cachedSnapshot.eventPubkey !== normalizedUserPubkey;
const incrementalSince = shouldForceFullFetch
? 0
: computeIncrementalSinceWithOverlap(cachedSnapshot.createdAt, 1);
// Subscription lists are private, account-scoped state. In particular,
// a BitLogin session actor can differ from the active profile pubkey.
// Never mix its events into this read: choosing the newest event across
// both accounts could overwrite the active account's subscriptions.
const events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: incrementalSince,
timeoutMs: 12000,
});
if (events.length) {
const deduped = new Map();
for (const event of events) {
if (
!event ||
typeof event !== "object" ||
!event.id ||
(event.pubkey &&
normalizeHexPubkey(event.pubkey) !== normalizedUserPubkey)
) {
continue;
}
const existing = deduped.get(event.id);
if (!existing || (event.created_at ?? 0) > (existing.created_at ?? 0)) {
deduped.set(event.id, event);
}
}
mergedEvents.push(...deduped.values());
}
}
if (!mergedEvents.length) {
if (!this.loaded) {
// If we have nothing loaded and found nothing, maybe user has no list.
// But we shouldn't wipe blindly if incremental fetch just returned no *new* events.
// Wait, fetchListIncrementally returns *events found*.
// If it performed incremental fetch and found nothing, it returns [].
// If it performed full fetch and found nothing, it returns [].
// We need to differentiate "no updates" vs "empty list".
// If we have cached data, we keep it.
// If we have NO cached data (this.loaded = false), and we get [], we assume empty list.
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
this.uiReady = true;
this.dataReady = true;
this.loaded = true;
this.loadedFromCache = false;
this.lastSuccessfulSyncAt = Date.now();
} else {
// We have data loaded, and relays returned nothing.
// This means no updates. We keep what we have.
devLogger.log("[SubscriptionsManager] No updates from relays.");
this.uiReady = true;
this.dataReady = true;
this.loaded = true;
this.loadedFromCache = false;
this.lastSuccessfulSyncAt = Date.now();
}
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
const newest = selectNewestListEvent(mergedEvents);
if (!newest) {
return;
}
// Cache the ciphertext so a decrypt retry can re-attempt WITHOUT another
// relay round-trip (see the retry fast-path above).
this._pendingDecryptEvent = newest;
// Any event returned here is effectively "new information" (or re-fetched full state).
// fetchListIncrementally already handles the "newer than last sync" logic per relay,
// and we persist event metadata for additional cache context.
this.subsEventId = newest.id;
this.subsEventCreatedAt = Number.isFinite(newest.created_at)
? newest.created_at
: null;
this.subsEventPubkey = normalizedUserPubkey;
let decryptResult;
try {
const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey, {
allowPermissionPrompt,
signerReadinessGate,
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
const timeoutError = new Error(
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "subscriptions-decrypt-timeout";
reject(timeoutError);
},
DECRYPT_TIMEOUT_MS,
),
);
decryptResult = await Promise.race([decryptPromise, timeoutPromise]);
} catch (error) {
decryptResult = { ok: false, error };
}
if (!decryptResult.ok) {
this.lastLoadError = decryptResult.error || null;
this.uiReady = true;
if (isTransientDecryptError(decryptResult.error)) {
// Channel/timeout failures recover shortly — retry instead of
// abandoning the subscription list until a page refresh. A present
// signer that won't answer feeds the shared unresponsive notice.
if (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
noteSignerDecryptTimeout("subscriptions");
}
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
if (
!allowPermissionPrompt &&
decryptResult.error?.code === "subscriptions-permission-required"
) {
if (shouldSuppressPermissionEscalation) {
userLogger.warn(
"[SubscriptionsManager] Skipping permission escalation due to shared signer gate outcome.",
signerReadinessGate,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
// Retry with permission prompt enabled so the signer / window.nostr
// path is available once the extension has finished initializing.
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt: true,
signerReadinessGate,
});
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
userLogger.error(
"[SubscriptionsManager] Failed to decrypt subscription list:",
decryptResult.error,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
if (wasLoadedForUser || hadCachedSnapshot) {
userLogger.warn(
"[SubscriptionsManager] Preserving cached subscriptions despite decryption failure.",
);
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
if (!this.loaded) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.dataReady = false;
this.loaded = true;
}
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
this.decryptRetryAttempt = 0;
noteSignerDecryptSuccess();
const decryptedStr = decryptResult.plaintext;
const normalized = parseSubscriptionPlaintext(decryptedStr);
const newSet = new Set(normalized);
const previousSet = this.subscribedPubkeys;
// Update state
this.subscribedPubkeys = newSet;
this.currentUserPubkey = normalizedUserPubkey;
this.uiReady = true;
this.dataReady = true;
this.loaded = true;
this.loadedFromCache = false;
this.lastSuccessfulSyncAt = Date.now();
if (wasBackgroundLoading) {
this.backgroundLoading = false;
}
// Update persistent cache
this.saveToCache(normalizedUserPubkey);
// Check if we need to refresh the feed
let changed = newSet.size !== previousSet.size;
if (!changed) {
for (const key of newSet) {
if (!previousSet.has(key)) {
changed = true;
break;
}
}
}
if (changed) {
this.emitter.emit("change", {
action: "sync",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
devLogger.log("[SubscriptionsManager] Subscription list updated from relays, refreshing feed.");
this.refreshActiveFeed({ reason: "subscription-update-background" }).catch((error) => {
userLogger.warn(
"[SubscriptionsManager] Failed to refresh after background subscription update:",
error
);
});
} else if (wasBackgroundLoading) {
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
} catch (err) {
this.lastLoadError = err || null;
this.uiReady = true;
userLogger.error("[SubscriptionsManager] Failed to update subs from relays:", err);
}
}
reset() {
this.stopSubscriptionListSubscription();
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
this._lastHandledListEventId = null;
this._pendingDecryptEvent = null;
this.currentUserPubkey = null;
this.uiReady = false;
this.dataReady = false;
this.loaded = false;
this.loadedFromCache = false;
this.lastSuccessfulSyncAt = null;
this.lastAttemptAt = null;
this.backgroundLoading = false;
this.lastRunOptions = null;
this.lastResult = null;
this.hasRenderedOnce = false;
this.lastLoadError = null;
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
this.decryptRetryTimeoutId = null;
}
this.decryptRetryAttempt = 0;
this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] });
}
stopSubscriptionListSubscription() {
if (this.subscriptionHandle) {
try {
this.subscriptionHandle.close();
} catch (error) {
devLogger.warn("[SubscriptionsManager] Failed to close subscription handle", error);
}
this.subscriptionHandle = null;
}
if (this.subscriptionKey) {
relaySubscriptionService.stopSubscription(this.subscriptionKey, "reset");
this.subscriptionKey = null;
}
}
ensureSubscriptionListSubscription(userPubkey, relays) {
const normalized = normalizeHexPubkey(userPubkey);
if (!normalized || !nostrClient?.pool) {
return null;
}
// Bound the live follows-list subscription to the §17 read cap (≤8, user
// relays first + reserved defaults) so it doesn't fan out across the user's
// full NIP-65 set during the cold-login REQ storm.
const cappedRelays = capReadRelays(relays);
const key = `subscriptions:${normalized}`;
this.subscriptionKey = key;
const filters = [
{
kinds: [SUBSCRIPTION_SET_KIND],
authors: [normalized],
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
},
];
// Route through the L1 SubscriptionManager when available: centralized
// dedup + reconnect re-issue (so the follows list self-heals after a relay
// drop). Falls back to relaySubscriptionService in manager-less envs.
const manager =
typeof nostrClient.getSubscriptionManager === "function"
? nostrClient.getSubscriptionManager()
: null;
if (manager) {
this.subscriptionHandle = manager.subscribe({
key,
relays: cappedRelays,
filters,
label: "subscription-list",
onEvent: (event) => this.handleSubscriptionListEvent(event),
});
return this.subscriptionHandle;
}
return relaySubscriptionService.ensureSubscription({
key,
pool: nostrClient.pool,
relays: cappedRelays,
filters,
label: "subscription-list",
onEvent: (event) => this.handleSubscriptionListEvent(event),
});
}
handleSubscriptionListEvent(event) {
const normalized = normalizeHexPubkey(event?.pubkey);
if (!normalized || normalized !== this.currentUserPubkey) {
return;
}
// Emit-on-change guard: the live subscription re-delivers our OWN list event
// every time updateFromRelays re-fetches it, so refreshing on every event
// forms a self-sustaining loop (fetch → relay echoes the event → handler →
// fetch …) that floods the login burst with redundant kind-30000 REQs
// (KNOWN_BUGS #0). Skip when the event is one we've already acted on (by id)
// or is not newer than what we've applied. The id check matters DURING the
// cold-login window where subsEventCreatedAt isn't set yet (decrypt still in
// flight) — without it the loop iterates several times before settling.
const incomingCreatedAt = Number.isFinite(event?.created_at)
? event.created_at
: 0;
const knownCreatedAt = Number.isFinite(this.subsEventCreatedAt)
? this.subsEventCreatedAt
: 0;
if (event?.id && event.id === this.subsEventId) {
return; // exact event we already hold
}
if (event?.id && event.id === this._lastHandledListEventId) {
return; // already triggered a refresh for this exact event
}
if (incomingCreatedAt <= knownCreatedAt && knownCreatedAt > 0) {
return; // not newer than what we've already applied
}
if (event?.id) {
this._lastHandledListEventId = event.id;
}
this.updateFromRelays(normalized, { allowPermissionPrompt: false })
.catch((error) => {
devLogger.warn(
"[SubscriptionsManager] Failed to refresh after subscription list event",
error,
);
});
}
async ensureLoaded(actorHex, options = {}) {
devLogger.log("[SubscriptionsManager] ensureLoaded start", actorHex);
const normalizedActor = normalizeHexPubkey(actorHex) || actorHex;
if (!normalizedActor) {
return { ok: false, error: new Error("Missing actor pubkey."), state: this.getLoadState() };
}
if (this.dataReady && this.currentUserPubkey === normalizedActor) {
devLogger.log("[SubscriptionsManager] ensureLoaded already loaded");
return { ok: true, error: null, state: this.getLoadState() };
}
if (this.loadingPromise) {
try {
devLogger.log("[SubscriptionsManager] ensureLoaded awaiting existing promise");
await this.loadingPromise;
} catch (error) {
return { ok: false, error, state: this.getLoadState() };
}
return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState() };
}
const loader = this.loadSubscriptions(normalizedActor, options);
this.loadingPromise = loader;
// Clean up loadingPromise when the actual loader completes rather than
// when the timeout fires. This prevents a second ensureLoaded() call
// from starting a duplicate load while the first is still in-flight.
const cleanup = () => {
if (this.loadingPromise === loader) {
this.loadingPromise = null;
}
};
loader.then(cleanup, cleanup);
try {
// Race against a timeout so the UI doesn't hang indefinitely if relays stall.
// The timeout accommodates relay fetch (~12s) + decryption (~6s timeout per
// scheme × up to 3 schemes = ~18s) = ~30s. Reduced from 45s to prevent
// blocking the login path for nearly a minute.
const timeoutMs = 25000;
let timeoutId;
await Promise.race([
loader,
new Promise((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error("Timeout loading subscriptions")),
timeoutMs
);
}),
]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
devLogger.log("[SubscriptionsManager] ensureLoaded success");
return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState() };
} catch (error) {
userLogger.warn("[SubscriptionsManager] ensureLoaded timed out or failed:", error);
this.lastLoadError = this.lastLoadError || error;
this.uiReady = true;
return { ok: false, error: this.lastLoadError, state: this.getLoadState() };
}
}
getLoadState() {
return {
uiReady: this.uiReady === true,
dataReady: this.dataReady === true,
lastLoadError: this.lastLoadError || null,
loadedFromCache: this.loadedFromCache === true,
lastSuccessfulSyncAt: Number.isFinite(this.lastSuccessfulSyncAt)
? Number(this.lastSuccessfulSyncAt)
: null,
lastAttemptAt: Number.isFinite(this.lastAttemptAt)
? Number(this.lastAttemptAt)
: null,
};
}
isSubscribed(channelHex) {
const normalized = normalizeHexPubkey(channelHex);
if (!normalized) {
return false;
}
return this.subscribedPubkeys.has(normalized);
}
async toggleChannel(channelHex, userPubkey) {
if (!userPubkey) {
throw new Error("No user pubkey => cannot toggleChannel.");
}
await this.ensureLoaded(userPubkey);
const isSubscribed = this.isSubscribed(channelHex) === true;
try {
if (isSubscribed) {
await this.removeChannel(channelHex, userPubkey);
} else {
await this.addChannel(channelHex, userPubkey);
}
return { ok: true, subscribed: !isSubscribed };
} catch (error) {
userLogger.error("[SubscriptionsManager] Failed to toggle subscription:", error);
throw error;
}
}
getSubscribedAuthors() {
return Array.from(this.subscribedPubkeys);
}
async decryptSubscriptionEvent(event, userPubkey, options = {}) {
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 allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const signerReadinessGate =
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
? options.signerReadinessGate
: null;
const suppressPermissionPrompt =
signerReadinessGate &&
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
const hints = extractEncryptionHints(event);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44;
const signerHasRequiredDecryptors = (candidate) => {
const hasNip04 = typeof candidate?.nip04Decrypt === "function";
const hasNip44 = typeof candidate?.nip44Decrypt === "function";
return (!requiresNip44 || hasNip44) && (!requiresNip04 || hasNip04);
};
let signer = getActiveSigner();
// FIX: Always attempt to resolve the signer if one isn't available,
// regardless of allowPermissionPrompt. The previous guard meant that
// background refreshes (allowPermissionPrompt=false) could never decrypt
// because they skipped signer resolution entirely. ensureActiveSignerForPubkey
// does not itself prompt the user — it only waits for an already-injected
// extension or returns the existing signer.
if (
(!signer || !signerHasRequiredDecryptors(signer)) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function"
) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
// FIX: Check method existence only, not capabilities. NIP-07 adapters
// always expose decrypt methods that check extension state at call time.
// The capabilities getter dynamically queries window.nostr which may not
// have injected nip04/nip44 modules yet (lazy injection), causing false
// negatives that block decryption even when methods will work at call time.
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
// FIX: Always check window.nostr for decrypt methods as a fallback,
// not only when allowPermissionPrompt is true. The extension may have
// already granted permissions from a previous session, and checking
// its methods doesn't trigger any user-visible prompts.
const nostrApi =
typeof window !== "undefined" ? window?.nostr : null;
const windowHasNip04 = typeof nostrApi?.nip04?.decrypt === "function";
const windowHasNip44 =
(nostrApi?.nip44 && typeof nostrApi.nip44.decrypt === "function") ||
(nostrApi?.nip44?.v2 && typeof nostrApi.nip44.v2.decrypt === "function");
if (
(!signerHasNip44 && !windowHasNip44 && requiresNip44) ||
(!signerHasNip04 && !windowHasNip04 && requiresNip04)
) {
if (!allowPermissionPrompt) {
const error = new Error(
"Decrypt permissions are required to read subscriptions."
);
error.code = "subscriptions-permission-required";
return { ok: false, error };
}
if (suppressPermissionPrompt) {
const error = new Error(
"Shared signer gate indicates extension permissions are unavailable.",
);
error.code = "subscriptions-permission-required";
return { ok: false, error };
}
try {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
);
if (!permissionResult?.ok) {
const error =
permissionResult?.error instanceof Error
? permissionResult.error
: new Error(
"Extension permissions denied while decrypting subscriptions."
);
error.code = "nostr-permission-denied";
return { ok: false, error };
}
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.code = "nostr-permission-error";
return { ok: false, error: wrapped };
}
}
const decryptors = new Map();
const registerDecryptor = (scheme, handler) => {
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
return;
}
decryptors.set(scheme, handler);
};
// Per-call nip-07 decrypt budget. A short value kills in-progress decrypts
// on slow extensions (15-25s under cold-login load), so the list never
// decrypts and retries forever. Give the interactive call real room; the
// background refresh stays tighter for fast scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt
? 25000
: 10000;
const signerDecryptOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
retryMultiplier: 1,
};
if (signerHasNip44) {
registerDecryptor("nip44", (payload) =>
signer.nip44Decrypt(userPubkey, payload, signerDecryptOptions),
);
registerDecryptor("nip44_v2", (payload) =>
signer.nip44Decrypt(userPubkey, payload, signerDecryptOptions),
);
}
if (signerHasNip04) {
registerDecryptor("nip04", (payload) =>
signer.nip04Decrypt(userPubkey, payload, signerDecryptOptions),
);
}
if (nostrApi) {
const decrypterOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
retryMultiplier: 1,
};
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor("nip04", (payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(userPubkey, payload),
{
label: "nip04.decrypt",
...decrypterOptions,
},
),
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
? nostrApi.nip44
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor("nip44", (payload) =>
runNip07WithRetry(
() => nip44.decrypt(userPubkey, payload),
{
label: "nip44.decrypt",
...decrypterOptions,
},
),
);
}
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor("nip44_v2", (payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
...decrypterOptions,
},
),
);
if (!decryptors.has("nip44")) {
registerDecryptor("nip44", (payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
...decrypterOptions,
},
),
);
}
}
}
}
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());
// Use the shared cross-service cache so all list services benefit from
// the first successful decrypt during login.
const cachedScheme = getLastSuccessfulScheme(userPubkey);
const order = determineDecryptionOrder(
event,
availableSchemes,
cachedScheme,
);
// PERF: Fast path — if the shared cache already knows the correct scheme
// (e.g. blocks decrypted first), try ONLY that scheme before falling back
// to the full parallel probe. This avoids queuing redundant extension calls.
if (cachedScheme && decryptors.has(cachedScheme)) {
try {
const plaintext = await decryptors.get(cachedScheme)(ciphertext);
if (typeof plaintext === "string") {
setLastSuccessfulScheme(userPubkey, cachedScheme);
return { ok: true, plaintext, scheme: cachedScheme };
}
} catch (_fastPathError) {
// Cached scheme failed — fall through to parallel probe.
}
}
// PERF: Try all decryption schemes in parallel via Promise.any().
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
// per scheme that previously made worst-case decryption take 45s+.
// Deduplicate schemes in the same family (nip44/nip44_v2 both call
// the same underlying signer.nip44Decrypt) to avoid redundant calls.
const getSchemeFamily = (s) =>
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
const seenFamilies = new Set();
const attemptSchemes = [];
// Route by ciphertext format: only the family matching the payload can
// decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other
// family is wasted work — and under a NIP-46 remote signer it's a wasted
// PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls
// decryption. Restrict the probe to the matching family.
const formatFamily =
typeof ciphertext === "string" && ciphertext.includes("?iv=")
? "nip04"
: "nip44";
const matchingOrder = order.filter(
(scheme) => getSchemeFamily(scheme) === formatFamily,
);
const probeOrder = matchingOrder.length ? matchingOrder : order;
const attempts = probeOrder
.map((scheme) => {
const family = getSchemeFamily(scheme);
if (seenFamilies.has(family)) return null;
seenFamilies.add(family);
const decryptFn = decryptors.get(scheme);
if (!decryptFn) return null;
attemptSchemes.push(scheme);
// FIX: Wrap in Promise.resolve().then() to catch synchronous throws
// from decryptor functions (e.g. NIP-07 adapter nip44Decrypt throws
// immediately if the extension hasn't injected its nip44 module yet).
// Without this, a sync throw breaks the .map() chain and prevents
// Promise.any() from trying the remaining schemes.
return Promise.resolve()
.then(() => decryptFn(ciphertext))
.then((plaintext) => {
if (typeof plaintext !== "string") {
throw new Error("Decryption returned a non-string payload.");
}
return { plaintext, scheme };
});
})
.filter(Boolean);
if (attempts.length) {
try {
const result = await Promise.any(attempts);
setLastSuccessfulScheme(userPubkey, result.scheme);
return { ok: true, plaintext: result.plaintext, scheme: result.scheme };
} catch (aggregateError) {
const attemptErrors = (aggregateError.errors || []).map((err, i) => ({
scheme: attemptSchemes[i] || "unknown",
error: err,
}));
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 };
}
}
const error = new Error("Failed to decrypt subscription list with available schemes.");
error.code = "subscriptions-decrypt-failed";
return { ok: false, error, errors: [] };
}
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);
this.saveToCache(userPubkey);
this.emitter.emit("change", {
action: "add",
channel: normalizedChannel,
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
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);
this.saveToCache(userPubkey);
this.emitter.emit("change", {
action: "remove",
channel: normalizedChannel,
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
await this.publishSubscriptionList(userPubkey);
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
userLogger.warn(
"[SubscriptionsManager] Failed to refresh after removing subscription:",
error
);
});
}
/**
* Encrypt (prefer NIP-44 v2, fallback to NIP-04) + publish the updated subscription set
* as kind=30000 with ["d", "subscriptions"] to be replaceable.
* The published event includes an ["encrypted", "<scheme>"] tag.
*/
async publishSubscriptionList(userPubkey, { isBackup = false } = {}) {
if (!userPubkey) {
throw new Error("No pubkey => cannot publish subscription list.");
}
let signer = getActiveSigner();
if (!signer) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const canSign = typeof signer?.canSign === "function"
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign) {
const error = new Error(
"An active signer is required to update subscriptions."
);
error.code = "signer-missing";
throw error;
}
if (signer.type === "extension" || signer.type === "nip07") {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
);
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,
});
if (isBackup) {
const backupId = `subscriptions-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
// Override d tag
const dTagIndex = evt.tags.findIndex(t => t[0] === 'd');
if (dTagIndex >= 0) {
evt.tags[dTagIndex][1] = backupId;
} else {
evt.tags.push(['d', backupId]);
}
}
let signedEvent;
try {
signedEvent = await signer.signEvent(evt);
} catch (signErr) {
userLogger.error("Failed to sign subscription list:", signErr);
throw signErr;
}
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
);
// Only an explicit relay rejection (or a pre-publish signer/encryption error,
// thrown earlier) is a real failure. A result where every relay merely timed
// out means the event was sent to an idempotent, replaceable list and almost
// certainly persisted — treated as a soft success (unconfirmed) so the
// optimistic UI is NOT reverted and the user doesn't see a spurious error.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "subscription list",
});
const softSuccess = publishSummary.unconfirmed === true;
if (softSuccess) {
userLogger.warn(
"[SubscriptionsManager] Subscription list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
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
);
});
}
// Record the published event id for both a confirmed and an
// optimistic/unconfirmed publish — the event carries this id regardless.
this.subsEventId = signedEvent.id;
this.subsEventCreatedAt = signedEvent.created_at;
this.saveToCache(userPubkey);
const acceptedUrls = publishSummary
? publishSummary.accepted.map(({ url }) => url)
: [];
devLogger.log(
softSuccess
? "Subscription list published (optimistic/unconfirmed), event id:"
: "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) {
this._renderStatusMessage(container, "Please log in first.");
}
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) {
this._renderStatusMessage(container, "No subscriptions found.");
this.lastRunOptions = {
actorPubkey: userPubkey,
limit,
containerId
};
this.lastResult = { items: [], metadata: { reason: "no-subscriptions" } };
this.hasRenderedOnce = true;
return this.lastResult;
}
if (!this.hasRenderedOnce) {
this._renderLoading(container, "Fetching subscriptions…");
}
this.lastRunOptions = {
actorPubkey: userPubkey,
limit,
containerId,
reason
};
this.ensureFeedRegistered();
this.ensureNostrServiceListener();
if (typeof nostrService?.awaitInitialLoad === "function") {
try {
devLogger.log("[SubscriptionsManager] awaiting nostrService initial load...");
await nostrService.awaitInitialLoad();
devLogger.log("[SubscriptionsManager] nostrService initial load done.");
} catch (error) {
devLogger.warn(
"[SubscriptionsManager] Failed to await nostrService initial load:",
error
);
}
}
const engine = this.getFeedEngine();
if (!engine || typeof engine.run !== "function") {
this._renderStatusMessage(container, "Subscriptions are unavailable right now.");
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;
devLogger.log("[SubscriptionsManager] Calling engine.run('subscriptions')...");
const result = await engine.run("subscriptions", runOptions);
devLogger.log("[SubscriptionsManager] engine.run complete. Items:", result?.items?.length);
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) {
this._renderStatusMessage(container, "Unable to load subscriptions right now.");
}
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) {
this._renderStatusMessage(
container,
"Unable to render subscriptions feed.",
"flex justify-center items-center h-full w-full text-center"
);
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.";
this._renderLoading(container, copy, 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
});
}
async createBackup(userPubkey) {
const backupEvent = await this.publishSubscriptionList(userPubkey, {
isBackup: true,
});
return backupEvent;
}
async fetchHistory(userPubkey) {
if (!userPubkey) return [];
const normalizedUserPubkey = normalizeHexPubkey(userPubkey);
// Fetch all kind 30000 events for this user to find backups and old versions
const events = await nostrClient.fetchEvents({
kinds: [SUBSCRIPTION_SET_KIND],
authors: [normalizedUserPubkey],
limit: 50, // Reasonable limit for history
});
return events
.filter((e) => {
const dTag = e.tags.find((t) => t[0] === "d")?.[1];
return (
dTag === SUBSCRIPTION_LIST_IDENTIFIER ||
(typeof dTag === "string" && dTag.startsWith("subscriptions-backup-"))
);
})
.sort((a, b) => b.created_at - a.created_at);
}
async restoreBackup(event, userPubkey) {
if (!event || !userPubkey) return;
const decryptResult = await this.decryptSubscriptionEvent(event, userPubkey);
if (!decryptResult.ok) {
throw decryptResult.error || new Error("Failed to decrypt backup");
}
const decryptedStr = decryptResult.plaintext;
const normalized = parseSubscriptionPlaintext(decryptedStr);
// Update local state
this.subscribedPubkeys = new Set(normalized);
this.saveToCache(userPubkey);
// Publish as current list
await this.publishSubscriptionList(userPubkey);
this.emitter.emit("change", {
action: "restore",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
await this.refreshActiveFeed({ reason: "restore-backup" });
}
async rebroadcastBackup(event, userPubkey) {
if (!event || !userPubkey) return;
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) throw new Error("Invalid backup event: missing d tag");
let signer = getActiveSigner();
if (!signer) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const newEvent = {
kind: event.kind,
created_at: Math.floor(Date.now() / 1000),
tags: event.tags,
content: event.content,
pubkey: userPubkey
};
const signedEvent = await signer.signEvent(newEvent);
await publishEventToRelays(
nostrClient.pool,
sanitizeRelayList(nostrClient.writeRelays),
signedEvent
);
return signedEvent;
}
convertEventToVideo(evt) {
return sharedConvertEventToVideo(evt);
}
_renderStatusMessage(container, message, extraClasses = "") {
if (!container) return;
container.replaceChildren();
const p = document.createElement("p");
p.className = `text-muted-strong ${extraClasses}`.trim();
p.textContent = message;
container.appendChild(p);
}
_renderLoading(container, message, showSpinner = true) {
if (!container) return;
container.replaceChildren();
const wrapper = document.createElement("div");
wrapper.className = "sidebar-loading-wrapper";
wrapper.setAttribute("role", "status");
wrapper.setAttribute("aria-live", "polite");
const indicator = document.createElement("div");
indicator.className = "sidebar-loading-indicator";
if (showSpinner) {
const spinner = document.createElement("span");
spinner.className = "status-spinner status-spinner--inline";
spinner.setAttribute("aria-hidden", "true");
indicator.appendChild(spinner);
}
const text = document.createElement("span");
text.className = "sidebar-loading-text";
text.textContent = message;
indicator.appendChild(text);
wrapper.appendChild(indicator);
container.appendChild(wrapper);
}
}
SubscriptionsManager.listVideoViewEvents = listVideoViewEventsApi;
SubscriptionsManager.subscribeVideoViewEvents = subscribeVideoViewEventsApi;
export const subscriptions = new SubscriptionsManager();