mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +00:00
Merge pull request #746 from PR0M3TH3AN/codex/optimize-subscription-feed-fetching
Optimize subscription feed retrieval and rendering
This commit is contained in:
6 files changed
+700
-34
No files matched your search
@@ -16,7 +16,7 @@ function normalizeAuthor(value) {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed;
|
||||
return trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeActorCandidate(...values) {
|
||||
@@ -92,12 +92,19 @@ export function createSubscriptionAuthorsSource({ service } = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limitCandidate = Number(context?.runtime?.limit);
|
||||
const limit =
|
||||
Number.isFinite(limitCandidate) && limitCandidate > 0
|
||||
? Math.floor(limitCandidate)
|
||||
: null;
|
||||
|
||||
const options = {
|
||||
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
||||
isAuthorBlocked:
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false,
|
||||
limit,
|
||||
};
|
||||
|
||||
const authorList = Array.from(authors);
|
||||
@@ -129,7 +136,15 @@ export function createSubscriptionAuthorsSource({ service } = {}) {
|
||||
return author && authors.has(author);
|
||||
});
|
||||
|
||||
return filtered.map((video) => ({
|
||||
const sorted = filtered
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) => (Number(b?.created_at) || 0) - (Number(a?.created_at) || 0)
|
||||
);
|
||||
|
||||
const limited = limit ? sorted.slice(0, limit) : sorted;
|
||||
|
||||
return limited.map((video) => ({
|
||||
video,
|
||||
metadata: {
|
||||
source: "nostr:subscriptions",
|
||||
|
||||
@@ -202,6 +202,8 @@ export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([
|
||||
"nip04.decrypt",
|
||||
"nip44.encrypt",
|
||||
"nip44.decrypt",
|
||||
"nip44.v2.encrypt",
|
||||
"nip44.v2.decrypt",
|
||||
]);
|
||||
|
||||
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
|
||||
|
||||
@@ -297,6 +297,24 @@ export class NostrService {
|
||||
index.get(author).push(video);
|
||||
}
|
||||
|
||||
for (const [author, videos] of index.entries()) {
|
||||
if (!Array.isArray(videos) || videos.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
videos.sort((a, b) => {
|
||||
const aCreatedCandidate = Number(a?.created_at);
|
||||
const bCreatedCandidate = Number(b?.created_at);
|
||||
const aCreated = Number.isFinite(aCreatedCandidate)
|
||||
? aCreatedCandidate
|
||||
: 0;
|
||||
const bCreated = Number.isFinite(bCreatedCandidate)
|
||||
? bCreatedCandidate
|
||||
: 0;
|
||||
return bCreated - aCreated;
|
||||
});
|
||||
index.set(author, videos);
|
||||
}
|
||||
|
||||
this.videosByAuthorIndex = index;
|
||||
this.authorIndexDirty = false;
|
||||
return this.videosByAuthorIndex;
|
||||
@@ -400,13 +418,25 @@ export class NostrService {
|
||||
const index = this.ensureVideosByAuthorIndex();
|
||||
const collected = [];
|
||||
const seen = new Set();
|
||||
const limitCandidate = Number(options?.limit);
|
||||
const limit =
|
||||
Number.isFinite(limitCandidate) && limitCandidate > 0
|
||||
? Math.floor(limitCandidate)
|
||||
: null;
|
||||
const perAuthorLimit = limit
|
||||
? Math.max(limit * 2, limit + 5)
|
||||
: null;
|
||||
|
||||
for (const author of normalizedAuthors) {
|
||||
const entries = index.get(author);
|
||||
if (!Array.isArray(entries)) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const video of entries) {
|
||||
const sliceCount = perAuthorLimit
|
||||
? Math.min(perAuthorLimit, entries.length)
|
||||
: entries.length;
|
||||
for (let idx = 0; idx < sliceCount; idx += 1) {
|
||||
const video = entries[idx];
|
||||
if (!video || typeof video !== "object") {
|
||||
continue;
|
||||
}
|
||||
@@ -421,13 +451,19 @@ export class NostrService {
|
||||
|
||||
const filtered = this.filterVideos(collected, options);
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const aCreatedCandidate = Number(a?.created_at);
|
||||
const bCreatedCandidate = Number(b?.created_at);
|
||||
const aCreated = Number.isFinite(aCreatedCandidate) ? aCreatedCandidate : 0;
|
||||
const bCreated = Number.isFinite(bCreatedCandidate) ? bCreatedCandidate : 0;
|
||||
return bCreated - aCreated;
|
||||
});
|
||||
|
||||
if (limit) {
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
async loadVideos({
|
||||
|
||||
+304
-29
@@ -20,6 +20,92 @@ import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import moderationService from "./services/moderationService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
|
||||
function normalizeHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function extractEncryptionHints(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
const hints = [];
|
||||
|
||||
const pushUnique = (scheme) => {
|
||||
if (!scheme || hints.includes(scheme)) {
|
||||
return;
|
||||
}
|
||||
hints.push(scheme);
|
||||
};
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
||||
if (label !== "encrypted" && label !== "encryption") {
|
||||
continue;
|
||||
}
|
||||
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
const parts = rawValue
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("nip44")) {
|
||||
if (/^nip44(?:[_-]?v2|v2)/.test(part)) {
|
||||
pushUnique("nip44_v2");
|
||||
} else {
|
||||
pushUnique("nip44");
|
||||
}
|
||||
} else if (part === "nip04" || part === "nip-04") {
|
||||
pushUnique("nip04");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
function determineDecryptionOrder(event, availableSchemes) {
|
||||
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
|
||||
const availableSet = new Set(available);
|
||||
const prioritized = [];
|
||||
|
||||
const hints = extractEncryptionHints(event);
|
||||
const aliasMap = {
|
||||
nip04: ["nip04"],
|
||||
nip44: ["nip44", "nip44_v2"],
|
||||
nip44_v2: ["nip44_v2", "nip44"],
|
||||
};
|
||||
|
||||
for (const hint of hints) {
|
||||
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
|
||||
for (const candidate of candidates) {
|
||||
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
|
||||
prioritized.push(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fallback of ["nip04", "nip44", "nip44_v2"]) {
|
||||
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
|
||||
prioritized.push(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return prioritized.length ? prioritized : available;
|
||||
}
|
||||
|
||||
const getApp = () => getApplication();
|
||||
|
||||
/**
|
||||
@@ -104,25 +190,28 @@ class SubscriptionsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
let decryptedStr = "";
|
||||
try {
|
||||
decryptedStr = await window.nostr.nip04.decrypt(
|
||||
userPubkey,
|
||||
newest.content
|
||||
const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey);
|
||||
if (!decryptResult.ok) {
|
||||
userLogger.error(
|
||||
"[SubscriptionsManager] Failed to decrypt subscription list:",
|
||||
decryptResult.error,
|
||||
);
|
||||
} catch (errDecrypt) {
|
||||
userLogger.error("[SubscriptionsManager] Decryption failed:", errDecrypt);
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedStr = decryptResult.plaintext;
|
||||
|
||||
const parsed = JSON.parse(decryptedStr);
|
||||
const subArray = Array.isArray(parsed.subPubkeys)
|
||||
? parsed.subPubkeys
|
||||
: [];
|
||||
this.subscribedPubkeys = new Set(subArray);
|
||||
const normalized = subArray
|
||||
.map((value) => normalizeHexPubkey(value))
|
||||
.filter((value) => Boolean(value));
|
||||
this.subscribedPubkeys = new Set(normalized);
|
||||
|
||||
this.loaded = true;
|
||||
} catch (err) {
|
||||
@@ -131,22 +220,111 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
isSubscribed(channelHex) {
|
||||
return this.subscribedPubkeys.has(channelHex);
|
||||
const normalized = normalizeHexPubkey(channelHex);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return this.subscribedPubkeys.has(normalized);
|
||||
}
|
||||
|
||||
getSubscribedAuthors() {
|
||||
return Array.from(this.subscribedPubkeys);
|
||||
}
|
||||
|
||||
async decryptSubscriptionEvent(event, userPubkey) {
|
||||
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
||||
if (!ciphertext) {
|
||||
const error = new Error("Subscription event is missing ciphertext content.");
|
||||
error.code = "subscriptions-empty-ciphertext";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (!nostrApi) {
|
||||
const error = new Error("Nostr extension is unavailable for decrypting subscriptions.");
|
||||
error.code = "nostr-extension-missing";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const decryptors = new Map();
|
||||
const registerDecryptor = (scheme, handler) => {
|
||||
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
|
||||
return;
|
||||
}
|
||||
decryptors.set(scheme, handler);
|
||||
};
|
||||
|
||||
if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") {
|
||||
registerDecryptor("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
|
||||
if (nip44) {
|
||||
if (typeof nip44.decrypt === "function") {
|
||||
registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
||||
registerDecryptor("nip44_v2", (payload) => nip44v2.decrypt(userPubkey, payload));
|
||||
if (!decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decryptors.size) {
|
||||
const error = new Error("No compatible decryption helpers are available.");
|
||||
error.code = "subscriptions-no-decryptors";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const availableSchemes = Array.from(decryptors.keys());
|
||||
const order = determineDecryptionOrder(event, availableSchemes);
|
||||
const attemptErrors = [];
|
||||
|
||||
for (const scheme of order) {
|
||||
const decryptFn = decryptors.get(scheme);
|
||||
if (!decryptFn) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const plaintext = await decryptFn(ciphertext);
|
||||
if (typeof plaintext !== "string") {
|
||||
const error = new Error("Decryption returned a non-string payload.");
|
||||
error.code = "subscriptions-invalid-plaintext";
|
||||
attemptErrors.push({ scheme, error });
|
||||
continue;
|
||||
}
|
||||
return { ok: true, plaintext, scheme };
|
||||
} catch (error) {
|
||||
attemptErrors.push({ scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error("Failed to decrypt subscription list with available schemes.");
|
||||
error.code = "subscriptions-decrypt-failed";
|
||||
if (attemptErrors.length) {
|
||||
error.cause = attemptErrors;
|
||||
}
|
||||
return { ok: false, error, errors: attemptErrors };
|
||||
}
|
||||
|
||||
async addChannel(channelHex, userPubkey) {
|
||||
const normalizedChannel = normalizeHexPubkey(channelHex);
|
||||
if (!userPubkey) {
|
||||
throw new Error("No user pubkey => cannot addChannel.");
|
||||
}
|
||||
if (this.subscribedPubkeys.has(channelHex)) {
|
||||
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(channelHex);
|
||||
this.subscribedPubkeys.add(normalizedChannel);
|
||||
await this.publishSubscriptionList(userPubkey);
|
||||
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
|
||||
userLogger.warn(
|
||||
@@ -157,14 +335,19 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
async removeChannel(channelHex, userPubkey) {
|
||||
const normalizedChannel = normalizeHexPubkey(channelHex);
|
||||
if (!userPubkey) {
|
||||
throw new Error("No user pubkey => cannot removeChannel.");
|
||||
}
|
||||
if (!this.subscribedPubkeys.has(channelHex)) {
|
||||
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(channelHex);
|
||||
this.subscribedPubkeys.delete(normalizedChannel);
|
||||
await this.publishSubscriptionList(userPubkey);
|
||||
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
|
||||
userLogger.warn(
|
||||
@@ -314,7 +497,14 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
if (!this.loaded) {
|
||||
await this.loadSubscriptions(userPubkey);
|
||||
try {
|
||||
await this.loadSubscriptions(userPubkey);
|
||||
} catch (error) {
|
||||
userLogger.error(
|
||||
"[SubscriptionsManager] Failed to load subscriptions while rendering feed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const channelHexes = this.getSubscribedAuthors();
|
||||
@@ -373,7 +563,11 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
const app = getApp();
|
||||
const runtime = this.buildFeedRuntime({ app, authors: channelHexes });
|
||||
const runtime = this.buildFeedRuntime({
|
||||
app,
|
||||
authors: channelHexes,
|
||||
limit,
|
||||
});
|
||||
const runOptions = {
|
||||
actorPubkey: userPubkey,
|
||||
limit,
|
||||
@@ -393,6 +587,22 @@ class SubscriptionsManager {
|
||||
? 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) {
|
||||
@@ -401,20 +611,34 @@ class SubscriptionsManager {
|
||||
});
|
||||
}
|
||||
|
||||
this.lastResult = result;
|
||||
this.renderSameGridStyle(result, containerId, { limit, reason });
|
||||
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 result;
|
||||
return enrichedResult;
|
||||
} catch (error) {
|
||||
userLogger.error(
|
||||
"[SubscriptionsManager] Failed to run subscriptions feed:",
|
||||
error
|
||||
);
|
||||
container.innerHTML =
|
||||
"<p class='text-muted-strong'>Unable to load subscriptions right now.</p>";
|
||||
this.lastResult = null;
|
||||
if (container && this.lastResult) {
|
||||
const fallbackReason = reason
|
||||
? `${reason}:cached`
|
||||
: "cached-result";
|
||||
this.renderSameGridStyle(this.lastResult, containerId, {
|
||||
limit,
|
||||
reason: fallbackReason,
|
||||
});
|
||||
} else if (container) {
|
||||
container.innerHTML =
|
||||
"<p class='text-muted-strong'>Unable to load subscriptions right now.</p>";
|
||||
}
|
||||
this.hasRenderedOnce = Boolean(container);
|
||||
return null;
|
||||
return this.lastResult;
|
||||
} finally {
|
||||
this.isRunningFeed = false;
|
||||
this.processScheduledRefresh();
|
||||
@@ -500,9 +724,11 @@ class SubscriptionsManager {
|
||||
return app?.feedEngine || null;
|
||||
}
|
||||
|
||||
buildFeedRuntime({ app, authors = [] } = {}) {
|
||||
buildFeedRuntime({ app, authors = [], limit = null } = {}) {
|
||||
const normalizedAuthors = Array.isArray(authors)
|
||||
? authors.filter((author) => typeof author === "string" && author)
|
||||
? authors
|
||||
.map((author) => normalizeHexPubkey(author))
|
||||
.filter((author) => Boolean(author))
|
||||
: [];
|
||||
|
||||
const blacklist =
|
||||
@@ -515,11 +741,18 @@ class SubscriptionsManager {
|
||||
? (pubkey) => app.isAuthorBlocked(pubkey)
|
||||
: () => false;
|
||||
|
||||
const limitCandidate = Number(limit);
|
||||
const normalizedLimit =
|
||||
Number.isFinite(limitCandidate) && limitCandidate > 0
|
||||
? Math.floor(limitCandidate)
|
||||
: null;
|
||||
|
||||
return {
|
||||
subscriptionAuthors: normalizedAuthors,
|
||||
authors: normalizedAuthors,
|
||||
blacklistedEventIds: blacklist,
|
||||
isAuthorBlocked
|
||||
isAuthorBlocked,
|
||||
limit: normalizedLimit
|
||||
};
|
||||
}
|
||||
|
||||
@@ -540,6 +773,10 @@ class SubscriptionsManager {
|
||||
? { ...result.metadata }
|
||||
: {};
|
||||
|
||||
if (!metadata.feed) {
|
||||
metadata.feed = "subscriptions";
|
||||
}
|
||||
|
||||
const limitCandidate = Number(options?.limit);
|
||||
const limit =
|
||||
Number.isFinite(limitCandidate) && limitCandidate > 0
|
||||
@@ -552,10 +789,15 @@ class SubscriptionsManager {
|
||||
.filter((video) => video && typeof video === "object");
|
||||
|
||||
if (!videos.length) {
|
||||
container.innerHTML = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-muted-strong">
|
||||
No videos available yet.
|
||||
</p>`;
|
||||
const reasonDetail =
|
||||
typeof metadata.reason === "string" && metadata.reason
|
||||
? metadata.reason
|
||||
: "empty";
|
||||
this.renderEmptyState(container, {
|
||||
message: options?.emptyMessage,
|
||||
reason: reasonDetail,
|
||||
metadata,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -589,6 +831,39 @@ class SubscriptionsManager {
|
||||
listView.render(videos, enrichedMetadata);
|
||||
}
|
||||
|
||||
renderEmptyState(container, { message, reason, metadata } = {}) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const copy =
|
||||
typeof message === "string" && message.trim()
|
||||
? message.trim()
|
||||
: "No playable subscription videos found yet. We'll keep watching for new posts.";
|
||||
|
||||
container.innerHTML = getSidebarLoadingMarkup(copy, { showSpinner: false });
|
||||
|
||||
if (this.subscriptionListView && this.subscriptionListView.state) {
|
||||
const currentMetadata =
|
||||
this.subscriptionListView.state.feedMetadata &&
|
||||
typeof this.subscriptionListView.state.feedMetadata === "object"
|
||||
? { ...this.subscriptionListView.state.feedMetadata }
|
||||
: {};
|
||||
|
||||
if (metadata && typeof metadata === "object") {
|
||||
Object.assign(currentMetadata, metadata);
|
||||
}
|
||||
|
||||
if (reason && typeof reason === "string") {
|
||||
currentMetadata.reason = reason;
|
||||
} else if (!currentMetadata.reason) {
|
||||
currentMetadata.reason = "empty";
|
||||
}
|
||||
|
||||
this.subscriptionListView.state.feedMetadata = currentMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
getListView(container, app) {
|
||||
if (this.subscriptionListView) {
|
||||
return this.subscriptionListView;
|
||||
|
||||
@@ -199,8 +199,117 @@ async function testNoAuthorsSkipsLookup() {
|
||||
assert.deepEqual(result.videos, [], "no authors should yield an empty feed");
|
||||
}
|
||||
|
||||
async function testLimitApplied() {
|
||||
const engine = createFeedEngine();
|
||||
const feedName = "subscriptions-limit";
|
||||
|
||||
const videos = [
|
||||
{ id: "video-1", pubkey: "pub1", created_at: 100 },
|
||||
{ id: "video-2", pubkey: "pub1", created_at: 200 },
|
||||
{ id: "video-3", pubkey: "pub1", created_at: 300 },
|
||||
{ id: "video-4", pubkey: "pub1", created_at: 400 },
|
||||
{ id: "video-5", pubkey: "pub1", created_at: 500 },
|
||||
];
|
||||
|
||||
const calls = [];
|
||||
const service = {
|
||||
async getActiveVideosByAuthors(authors, options) {
|
||||
calls.push({ authors, options });
|
||||
return videos;
|
||||
},
|
||||
async getFilteredActiveVideos() {
|
||||
throw new Error("limit run should not use fallback lookup");
|
||||
},
|
||||
};
|
||||
|
||||
engine.registerFeed(feedName, {
|
||||
source: createSubscriptionAuthorsSource({ service }),
|
||||
stages: [
|
||||
createBlacklistFilterStage({ shouldIncludeVideo: () => true }),
|
||||
createDedupeByRootStage(),
|
||||
],
|
||||
sorter: createChronologicalSorter(),
|
||||
});
|
||||
|
||||
const result = await engine.runFeed(feedName, {
|
||||
runtime: {
|
||||
subscriptionAuthors: ["pub1"],
|
||||
authors: ["pub1"],
|
||||
blacklistedEventIds: new Set(),
|
||||
isAuthorBlocked: () => false,
|
||||
limit: 3,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1, "limit run should request targeted lookup once");
|
||||
assert.equal(
|
||||
calls[0]?.options?.limit,
|
||||
3,
|
||||
"limit option should be forwarded to the service lookup",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
result.videos.map((video) => video.id),
|
||||
["video-5", "video-4", "video-3"],
|
||||
"feed should only include the newest videos within the limit",
|
||||
);
|
||||
}
|
||||
|
||||
async function testAuthorNormalization() {
|
||||
const engine = createFeedEngine();
|
||||
const feedName = "subscriptions-author-normalization";
|
||||
|
||||
const videos = [
|
||||
{ id: "video-mixed", pubkey: "pubkey-lower", created_at: 123 },
|
||||
];
|
||||
|
||||
const calls = [];
|
||||
const service = {
|
||||
async getActiveVideosByAuthors(authors, options) {
|
||||
calls.push({ authors, options });
|
||||
return videos;
|
||||
},
|
||||
async getFilteredActiveVideos() {
|
||||
throw new Error("case normalization run should not fall back to filtered lookup");
|
||||
},
|
||||
};
|
||||
|
||||
engine.registerFeed(feedName, {
|
||||
source: createSubscriptionAuthorsSource({ service }),
|
||||
stages: [
|
||||
createBlacklistFilterStage({ shouldIncludeVideo: () => true }),
|
||||
createDedupeByRootStage(),
|
||||
],
|
||||
sorter: createChronologicalSorter(),
|
||||
});
|
||||
|
||||
const result = await engine.runFeed(feedName, {
|
||||
runtime: {
|
||||
subscriptionAuthors: [" PUBKEY-LOWER "],
|
||||
authors: ["PUBKEY-LOWER"],
|
||||
blacklistedEventIds: new Set(),
|
||||
isAuthorBlocked: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1, "case normalization run should call targeted lookup once");
|
||||
assert.deepEqual(
|
||||
calls[0].authors,
|
||||
["pubkey-lower"],
|
||||
"authors passed to the service should be lowercased",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
result.videos.map((video) => video.id),
|
||||
["video-mixed"],
|
||||
"videos should be retained when the service pubkey casing differs",
|
||||
);
|
||||
}
|
||||
|
||||
await testBlockedCreatorsFiltered();
|
||||
await testDuplicateRootsFiltered();
|
||||
await testNoAuthorsSkipsLookup();
|
||||
await testLimitApplied();
|
||||
await testAuthorNormalization();
|
||||
|
||||
console.log("subscriptions-feed tests passed");
|
||||
@@ -125,6 +125,177 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSubscriptions falls back to nip44 when hinted", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
const manager = new SubscriptionsManager();
|
||||
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
||||
|
||||
const hadWindow = typeof globalThis.window !== "undefined";
|
||||
if (!hadWindow) {
|
||||
globalThis.window = {};
|
||||
}
|
||||
const originalWindowNostr = globalThis.window.nostr;
|
||||
|
||||
const relayUrls = ["wss://relay-nip44.example"];
|
||||
nostrClient.relays = relayUrls;
|
||||
|
||||
const event = {
|
||||
id: "event-nip44",
|
||||
created_at: 400,
|
||||
content: "cipher-nip44",
|
||||
tags: [["encrypted", "nip44_v2"]],
|
||||
};
|
||||
|
||||
nostrClient.pool = {
|
||||
list() {
|
||||
return Promise.resolve([event]);
|
||||
},
|
||||
};
|
||||
|
||||
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
|
||||
const decryptCalls = { nip04: 0, nip44: 0 };
|
||||
globalThis.window.nostr = {
|
||||
nip04: {
|
||||
async decrypt() {
|
||||
decryptCalls.nip04 += 1;
|
||||
throw new Error("nip04 unavailable");
|
||||
},
|
||||
},
|
||||
nip44: {
|
||||
async decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44 += 1;
|
||||
return JSON.stringify({ subPubkeys: ["pub-nip44"], hint: ciphertext });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(manager.subscribedPubkeys),
|
||||
["pub-nip44"],
|
||||
"nip44 decrypted subscriptions should populate the set",
|
||||
);
|
||||
assert.equal(manager.subsEventId, "event-nip44");
|
||||
assert.equal(manager.loaded, true);
|
||||
assert.equal(
|
||||
decryptCalls.nip44,
|
||||
1,
|
||||
"nip44 decrypt should be invoked once when hinted",
|
||||
);
|
||||
assert.equal(
|
||||
decryptCalls.nip04,
|
||||
0,
|
||||
"nip04 decrypt should be skipped when nip44 hint is present",
|
||||
);
|
||||
} finally {
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete globalThis.window.nostr;
|
||||
} else {
|
||||
globalThis.window.nostr = originalWindowNostr;
|
||||
}
|
||||
if (!hadWindow) {
|
||||
delete globalThis.window;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSubscriptions handles nip44.v2 decryptors", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
const manager = new SubscriptionsManager();
|
||||
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
||||
|
||||
const hadWindow = typeof globalThis.window !== "undefined";
|
||||
if (!hadWindow) {
|
||||
globalThis.window = {};
|
||||
}
|
||||
const originalWindowNostr = globalThis.window.nostr;
|
||||
|
||||
const relayUrls = ["wss://relay-nip44-v2.example"];
|
||||
nostrClient.relays = relayUrls;
|
||||
|
||||
const event = {
|
||||
id: "event-nip44-v2",
|
||||
created_at: 500,
|
||||
content: "cipher-nip44-v2",
|
||||
tags: [["encrypted", "nip44_v2"]],
|
||||
};
|
||||
|
||||
nostrClient.pool = {
|
||||
list() {
|
||||
return Promise.resolve([event]);
|
||||
},
|
||||
};
|
||||
|
||||
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
|
||||
const decryptCalls = { nip04: 0, nip44: 0, nip44_v2: 0 };
|
||||
globalThis.window.nostr = {
|
||||
nip44: {
|
||||
v2: {
|
||||
async decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44_v2 += 1;
|
||||
return JSON.stringify({ subPubkeys: ["pub-nip44-v2"], hint: ciphertext });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(manager.subscribedPubkeys),
|
||||
["pub-nip44-v2"],
|
||||
"nip44.v2 decrypted subscriptions should populate the set",
|
||||
);
|
||||
assert.equal(manager.subsEventId, "event-nip44-v2");
|
||||
assert.equal(manager.loaded, true);
|
||||
assert.equal(
|
||||
decryptCalls.nip44_v2,
|
||||
1,
|
||||
"nip44.v2 decrypt should be invoked once when provided",
|
||||
);
|
||||
assert.equal(
|
||||
decryptCalls.nip44,
|
||||
0,
|
||||
"no legacy nip44 decryptors should be invoked when only v2 exists",
|
||||
);
|
||||
assert.equal(
|
||||
decryptCalls.nip04,
|
||||
0,
|
||||
"nip04 decrypt should not be attempted when unavailable",
|
||||
);
|
||||
} finally {
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete globalThis.window.nostr;
|
||||
} else {
|
||||
globalThis.window.nostr = originalWindowNostr;
|
||||
}
|
||||
if (!hadWindow) {
|
||||
delete globalThis.window;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"showSubscriptionVideos waits for nostrService warm-up and refreshes after updates",
|
||||
async () => {
|
||||
@@ -203,6 +374,11 @@ test(
|
||||
|
||||
assert.equal(runCalls.length, 1, "engine.run should execute after warm-up");
|
||||
assert.equal(renderCalls.length, 1, "initial render should occur once");
|
||||
assert.equal(
|
||||
renderCalls[0].options.emptyMessage.includes("No playable subscription videos"),
|
||||
true,
|
||||
"empty state should provide a descriptive message",
|
||||
);
|
||||
|
||||
nostrService.emit("videos:updated", {
|
||||
reason: "subscription",
|
||||
@@ -226,6 +402,11 @@ test(
|
||||
"video-1",
|
||||
"updated render should receive fresh videos",
|
||||
);
|
||||
assert.equal(
|
||||
renderCalls[1].options.emptyMessage.includes("No playable subscription videos"),
|
||||
true,
|
||||
"subsequent renders should retain the empty state message for metadata",
|
||||
);
|
||||
|
||||
if (typeof manager.unsubscribeFromNostrUpdates === "function") {
|
||||
manager.unsubscribeFromNostrUpdates();
|
||||
@@ -257,3 +438,51 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test("renderSameGridStyle shows empty state message", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
const dom = new JSDOM(
|
||||
"<!doctype html><div id=\"subscriptionsVideoList\"></div>",
|
||||
{ url: "https://example.test/" },
|
||||
);
|
||||
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
|
||||
const manager = new SubscriptionsManager();
|
||||
|
||||
manager.renderSameGridStyle(
|
||||
{ items: [] },
|
||||
"subscriptionsVideoList",
|
||||
{ emptyMessage: "Custom empty state copy." },
|
||||
);
|
||||
|
||||
const container = dom.window.document.getElementById("subscriptionsVideoList");
|
||||
assert.match(
|
||||
container.textContent,
|
||||
/Custom empty state copy\./,
|
||||
"empty state should render the provided copy",
|
||||
);
|
||||
assert.ok(
|
||||
!container.textContent.includes("Fetching subscriptions"),
|
||||
"loading spinner should be removed when rendering the empty state",
|
||||
);
|
||||
|
||||
dom.window.close();
|
||||
|
||||
if (typeof originalWindow === "undefined") {
|
||||
delete globalThis.window;
|
||||
} else {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
|
||||
if (typeof originalDocument === "undefined") {
|
||||
delete globalThis.document;
|
||||
} else {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user