mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
2407 lines
76 KiB
JavaScript
2407 lines
76 KiB
JavaScript
// 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 } 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,
|
||
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";
|
||
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 {
|
||
getLastSuccessfulScheme,
|
||
setLastSuccessfulScheme,
|
||
} from "./nostr/decryptionSchemeCache.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.
|
||
const DECRYPT_TIMEOUT_MS = 6000;
|
||
// 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 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, 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;
|
||
}
|
||
|
||
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 [];
|
||
}
|
||
|
||
function parseCachedSubscriptionSnapshot(cached) {
|
||
if (Array.isArray(cached)) {
|
||
const normalized = cached.map((value) => normalizeHexPubkey(value)).filter(Boolean);
|
||
return {
|
||
subscribedPubkeys: normalized,
|
||
eventId: null,
|
||
createdAt: null,
|
||
hasSnapshot: true,
|
||
};
|
||
}
|
||
|
||
if (cached && typeof cached === "object") {
|
||
const listCandidate = Array.isArray(cached.subscribedPubkeys)
|
||
? cached.subscribedPubkeys
|
||
: Array.isArray(cached.subPubkeys)
|
||
? cached.subPubkeys
|
||
: [];
|
||
const normalized = listCandidate
|
||
.map((value) => normalizeHexPubkey(value))
|
||
.filter(Boolean);
|
||
const eventId = typeof cached.eventId === "string" ? cached.eventId : null;
|
||
const createdAtRaw = cached.createdAt;
|
||
const createdAtCandidate =
|
||
typeof createdAtRaw === "number"
|
||
? createdAtRaw
|
||
: typeof createdAtRaw === "string" && createdAtRaw.trim()
|
||
? Number(createdAtRaw)
|
||
: Number.NaN;
|
||
const createdAt = Number.isFinite(createdAtCandidate)
|
||
? Math.floor(createdAtCandidate)
|
||
: null;
|
||
const hasSnapshot =
|
||
Array.isArray(cached.subscribedPubkeys) ||
|
||
Array.isArray(cached.subPubkeys) ||
|
||
Boolean(eventId) ||
|
||
Number.isFinite(createdAtCandidate);
|
||
return {
|
||
subscribedPubkeys: normalized,
|
||
eventId,
|
||
createdAt,
|
||
hasSnapshot,
|
||
};
|
||
}
|
||
|
||
return {
|
||
subscribedPubkeys: [],
|
||
eventId: null,
|
||
createdAt: null,
|
||
hasSnapshot: false,
|
||
};
|
||
}
|
||
|
||
const getApp = () => getApplication();
|
||
|
||
const listVideoViewEventsApi = (pointer, options) =>
|
||
listVideoViewEvents(nostrClient, pointer, options);
|
||
const subscribeVideoViewEventsApi = (pointer, options) =>
|
||
subscribeVideoViewEvents(nostrClient, pointer, options);
|
||
|
||
class TinyEventEmitter {
|
||
constructor() {
|
||
this.listeners = new Map();
|
||
}
|
||
|
||
on(eventName, handler) {
|
||
if (typeof eventName !== "string" || typeof handler !== "function") {
|
||
return () => {};
|
||
}
|
||
|
||
if (!this.listeners.has(eventName)) {
|
||
this.listeners.set(eventName, new Set());
|
||
}
|
||
|
||
const handlers = this.listeners.get(eventName);
|
||
handlers.add(handler);
|
||
|
||
return () => {
|
||
handlers.delete(handler);
|
||
if (!handlers.size) {
|
||
this.listeners.delete(eventName);
|
||
}
|
||
};
|
||
}
|
||
|
||
emit(eventName, detail) {
|
||
const handlers = this.listeners.get(eventName);
|
||
if (!handlers || !handlers.size) {
|
||
return;
|
||
}
|
||
|
||
for (const handler of Array.from(handlers)) {
|
||
try {
|
||
handler(detail);
|
||
} catch (error) {
|
||
userLogger.warn(
|
||
`[SubscriptionsManager] listener for "${eventName}" threw an error`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.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.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.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,
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
this.decryptRetryTimeoutId = setTimeout(() => {
|
||
this.decryptRetryTimeoutId = null;
|
||
if (this.currentUserPubkey && this.currentUserPubkey !== normalized) {
|
||
return;
|
||
}
|
||
this.updateFromRelays(normalized, options).catch((retryError) => {
|
||
userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError);
|
||
});
|
||
}, DECRYPT_RETRY_DELAY_MS);
|
||
devLogger.log(
|
||
`[SubscriptionsManager] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 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,
|
||
);
|
||
}
|
||
}
|
||
this.ensureSubscriptionListSubscription(normalizedUserPubkey, relayUrls);
|
||
|
||
// Use incremental fetch helper
|
||
const cachedSnapshot = parseCachedSubscriptionSnapshot(
|
||
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
|
||
);
|
||
const hadCachedSnapshot = cachedSnapshot.hasSnapshot;
|
||
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
|
||
|
||
// Fetch user and session actor subscription lists in parallel to reduce
|
||
// total relay wait time during login.
|
||
const fetchPromises = [
|
||
nostrClient.fetchListIncrementally({
|
||
kind: SUBSCRIPTION_SET_KIND,
|
||
pubkey: normalizedUserPubkey,
|
||
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
|
||
relayUrls,
|
||
since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0),
|
||
timeoutMs: 12000,
|
||
}),
|
||
];
|
||
|
||
const normalizedSessionActorPubkey = normalizeNostrPubkey(
|
||
nostrClient?.sessionActor?.pubkey,
|
||
);
|
||
const shouldFetchSessionActor =
|
||
normalizedSessionActorPubkey &&
|
||
normalizedSessionActorPubkey !== normalizedUserPubkey;
|
||
if (shouldFetchSessionActor) {
|
||
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
|
||
profileCache.getProfileData(
|
||
normalizedSessionActorPubkey,
|
||
"subscriptions",
|
||
),
|
||
);
|
||
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
|
||
fetchPromises.push(
|
||
nostrClient.fetchListIncrementally({
|
||
kind: SUBSCRIPTION_SET_KIND,
|
||
pubkey: normalizedSessionActorPubkey,
|
||
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
|
||
relayUrls,
|
||
since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0),
|
||
timeoutMs: 12000,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const fetchResults = await Promise.all(fetchPromises);
|
||
let events = fetchResults[0] || [];
|
||
if (shouldFetchSessionActor && fetchResults[1]?.length) {
|
||
events = events.concat(fetchResults[1]);
|
||
}
|
||
|
||
const mergedEvents = [];
|
||
if (events.length) {
|
||
const deduped = new Map();
|
||
for (const event of events) {
|
||
if (!event || typeof event !== "object" || !event.id) {
|
||
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.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;
|
||
}
|
||
|
||
// Sort by created_at desc, pick newest
|
||
mergedEvents.sort((a, b) => b.created_at - a.created_at);
|
||
const newest = mergedEvents[0];
|
||
|
||
// 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;
|
||
|
||
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 (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
|
||
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;
|
||
}
|
||
|
||
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.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.emitter.emit("change", { action: "reset", subscribedPubkeys: [] });
|
||
}
|
||
|
||
stopSubscriptionListSubscription() {
|
||
if (this.subscriptionKey) {
|
||
relaySubscriptionService.stopSubscription(this.subscriptionKey, "reset");
|
||
this.subscriptionKey = null;
|
||
}
|
||
}
|
||
|
||
ensureSubscriptionListSubscription(userPubkey, relays) {
|
||
const normalized = normalizeHexPubkey(userPubkey);
|
||
if (!normalized || !nostrClient?.pool) {
|
||
return null;
|
||
}
|
||
|
||
const key = `subscriptions:${normalized}`;
|
||
this.subscriptionKey = key;
|
||
|
||
const filters = [
|
||
{
|
||
kinds: [SUBSCRIPTION_SET_KIND],
|
||
authors: [normalized],
|
||
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
|
||
},
|
||
];
|
||
|
||
return relaySubscriptionService.ensureSubscription({
|
||
key,
|
||
pool: nostrClient.pool,
|
||
relays,
|
||
filters,
|
||
label: "subscription-list",
|
||
onEvent: (event) => this.handleSubscriptionListEvent(event),
|
||
});
|
||
}
|
||
|
||
handleSubscriptionListEvent(event) {
|
||
const normalized = normalizeHexPubkey(event?.pubkey);
|
||
if (!normalized || normalized !== this.currentUserPubkey) {
|
||
return;
|
||
}
|
||
|
||
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);
|
||
};
|
||
|
||
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
|
||
// gate now guarantees the extension is ready before decryption starts, so
|
||
// decrypt calls should complete within 1-2s. 5s accommodates slow
|
||
// extensions while still failing fast enough for scheme fallback.
|
||
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 5000;
|
||
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 = [];
|
||
const attempts = order
|
||
.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
|
||
);
|
||
|
||
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;
|
||
this.subsEventCreatedAt = signedEvent.created_at;
|
||
this.saveToCache(userPubkey);
|
||
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) {
|
||
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();
|