diff --git a/js/channelProfile.js b/js/channelProfile.js index e590072a..fbe4667c 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4366,10 +4366,23 @@ function getCachedChannelVideoEvents(pubkey) { }; const rawMatches = collectMatches(nostrClient?.rawEvents, (callback) => { - if ( - nostrClient?.rawEvents && - typeof nostrClient.rawEvents.forEach === "function" - ) { + if (!nostrClient?.rawEvents) { + return; + } + + if (typeof nostrClient.rawEvents.getEventsByAuthor === "function") { + const candidates = nostrClient.rawEvents.getEventsByAuthor(normalized); + if (Array.isArray(candidates)) { + candidates.forEach((event) => { + if (event?.kind === 30078) { + callback(event); + } + }); + return; + } + } + + if (typeof nostrClient.rawEvents.forEach === "function") { nostrClient.rawEvents.forEach((event) => { if (event?.kind === 30078) { callback(event); diff --git a/js/nostr/client.js b/js/nostr/client.js index a50eaabc..bf146bd4 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -203,6 +203,7 @@ import { createNip07Adapter } from "./adapters/nip07Adapter.js"; import { createNsecAdapter } from "./adapters/nsecAdapter.js"; import { createNip46Adapter } from "./adapters/nip46Adapter.js"; import { queueSignEvent } from "./signRequestQueue.js"; +import { EventsMap } from "./eventsMap.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -1271,7 +1272,7 @@ export class NostrClient { * Kept for signature verification and republishing (e.g. NIP-94 mirror). * @public */ - this.rawEvents = new Map(); + this.rawEvents = new EventsMap(); /** * @type {Map} diff --git a/js/nostr/eventsMap.js b/js/nostr/eventsMap.js new file mode 100644 index 00000000..a3f7b59e --- /dev/null +++ b/js/nostr/eventsMap.js @@ -0,0 +1,90 @@ +/** + * A Map extension that maintains an author-based index for fast lookups. + * Used for `rawEvents` in NostrClient. + */ +export class EventsMap extends Map { + constructor(entries) { + super(entries); + this.authorIndex = new Map(); // pubkey -> Set + + // If entries provided, index them + if (entries) { + for (const [key, value] of this.entries()) { + this._addToIndex(value); + } + } + } + + _normalizePubkey(pubkey) { + if (typeof pubkey !== "string") return ""; + return pubkey.trim().toLowerCase(); + } + + _addToIndex(event) { + if (!event || typeof event !== "object") return; + const pubkey = this._normalizePubkey(event.pubkey); + if (!pubkey) return; + + let set = this.authorIndex.get(pubkey); + if (!set) { + set = new Set(); + this.authorIndex.set(pubkey, set); + } + set.add(event); + } + + _removeFromIndex(event) { + if (!event || typeof event !== "object") return; + const pubkey = this._normalizePubkey(event.pubkey); + if (!pubkey) return; + + const set = this.authorIndex.get(pubkey); + if (set) { + set.delete(event); + if (set.size === 0) { + this.authorIndex.delete(pubkey); + } + } + } + + set(key, value) { + const existing = this.get(key); + // If the value is identical, we still set it in the map, + // but the index doesn't strictly need updating. + // However, if the key was mapped to a DIFFERENT object with the SAME pubkey, + // we must update the Set (remove old object, add new object). + if (existing) { + if (existing === value) { + // No index change needed + return super.set(key, value); + } + this._removeFromIndex(existing); + } + + super.set(key, value); + this._addToIndex(value); + return this; + } + + delete(key) { + const value = this.get(key); + const result = super.delete(key); + if (result && value) { + this._removeFromIndex(value); + } + return result; + } + + clear() { + super.clear(); + this.authorIndex.clear(); + } + + getEventsByAuthor(pubkey) { + const normalized = this._normalizePubkey(pubkey); + if (!normalized) return []; + const set = this.authorIndex.get(normalized); + // Return array to allow iteration + return set ? Array.from(set) : []; + } +} diff --git a/scripts/benchmark-channel-scan.mjs b/scripts/benchmark-channel-scan.mjs new file mode 100644 index 00000000..09792ada --- /dev/null +++ b/scripts/benchmark-channel-scan.mjs @@ -0,0 +1,94 @@ +import { JSDOM } from 'jsdom'; +import { performance } from 'perf_hooks'; +import * as NostrTools from 'nostr-tools'; + +const dom = new JSDOM(`
`, { + url: "http://localhost/#view=channel-profile&npub=npub1test" +}); + +global.window = dom.window; +global.self = dom.window; +global.document = dom.window.document; +global.HTMLElement = dom.window.HTMLElement; +global.NodeList = dom.window.NodeList; +global.URLSearchParams = dom.window.URLSearchParams; +global.URL = dom.window.URL; +global.Blob = dom.window.Blob; +global.TextEncoder = dom.window.TextEncoder; +global.TextDecoder = dom.window.TextDecoder; +// global.crypto = dom.window.crypto; // Node has crypto + +global.window.NostrTools = NostrTools; + +// Mock requestIdleCallback +global.requestIdleCallback = (cb) => setTimeout(cb, 0); +global.cancelIdleCallback = (id) => clearTimeout(id); + +global.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {} +}; + +// We need to mock AccessControl because it's called in refreshActiveChannelVideoGrid +// accessControl.ensureReady() +// It imports accessControl from js/accessControl.js +// We can try to rely on its default behavior or mock the module if necessary. +// But importing real modules is easier if they don't have heavy side effects. + +// Import nostrClient +const { nostrClient } = await import('../js/nostrClientFacade.js'); +// Import channelProfile +const channelProfile = await import('../js/channelProfile.js'); + +async function runBenchmark() { + console.log("Setting up benchmark..."); + + nostrClient.pool = { + list: async () => [], + ensureRelay: async () => {}, + get: async () => null, + }; + nostrClient.relays = ["wss://relay.example.com"]; + + // Populate rawEvents with many events + const eventCount = 1000000; + const targetPubkey = "0000000000000000000000000000000000000000000000000000000000000001"; + + console.log(`Populating ${eventCount} events...`); + const rawEvents = nostrClient.rawEvents; + + for (let i = 0; i < eventCount; i++) { + const id = i.toString(16).padStart(64, '0'); + // Mix of target events and noise + const isTarget = i % 1000 === 0; + const pubkey = isTarget ? targetPubkey : id; // Unique pubkey for noise + const event = { + id, + pubkey, + kind: 30078, + created_at: Math.floor(Date.now() / 1000) - i, + content: "{}", + tags: [] + }; + rawEvents.set(id, event); + } + + console.log(`Populated. Raw events size: ${rawEvents.size}`); + + // Setup test state + channelProfile.__setChannelProfileTestState({ pubkey: targetPubkey }); + + // Benchmark + console.log("Starting benchmark..."); + const start = performance.now(); + + // We call refreshActiveChannelVideoGrid which triggers the scan + await channelProfile.refreshActiveChannelVideoGrid(); + + const end = performance.now(); + console.log(`refreshActiveChannelVideoGrid took ${(end - start).toFixed(2)}ms`); +} + +runBenchmark().catch(console.error); diff --git a/tests/nostr/eventsMap.test.mjs b/tests/nostr/eventsMap.test.mjs new file mode 100644 index 00000000..d14f6de8 --- /dev/null +++ b/tests/nostr/eventsMap.test.mjs @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { EventsMap } from '../../js/nostr/eventsMap.js'; + +test('EventsMap functionality', async (t) => { + await t.test('indexes events by author on set', () => { + const map = new EventsMap(); + const event1 = { id: '1', pubkey: 'abc', kind: 1 }; + const event2 = { id: '2', pubkey: 'abc', kind: 1 }; + const event3 = { id: '3', pubkey: 'def', kind: 1 }; + + map.set(event1.id, event1); + map.set(event2.id, event2); + map.set(event3.id, event3); + + const abcEvents = map.getEventsByAuthor('abc'); + assert.strictEqual(abcEvents.length, 2); + assert.ok(abcEvents.includes(event1)); + assert.ok(abcEvents.includes(event2)); + + const defEvents = map.getEventsByAuthor('def'); + assert.strictEqual(defEvents.length, 1); + assert.ok(defEvents.includes(event3)); + }); + + await t.test('updates index on delete', () => { + const map = new EventsMap(); + const event1 = { id: '1', pubkey: 'abc', kind: 1 }; + map.set(event1.id, event1); + + assert.strictEqual(map.getEventsByAuthor('abc').length, 1); + + map.delete(event1.id); + assert.strictEqual(map.getEventsByAuthor('abc').length, 0); + }); + + await t.test('updates index on overwrite with different object', () => { + const map = new EventsMap(); + const event1 = { id: '1', pubkey: 'abc', kind: 1 }; + const event1Update = { id: '1', pubkey: 'abc', kind: 1, content: 'update' }; + + map.set(event1.id, event1); + assert.ok(map.getEventsByAuthor('abc').includes(event1)); + + map.set(event1.id, event1Update); + const abcEvents = map.getEventsByAuthor('abc'); + assert.strictEqual(abcEvents.length, 1); + assert.ok(abcEvents.includes(event1Update)); + assert.ok(!abcEvents.includes(event1)); + }); + + await t.test('clears index on clear', () => { + const map = new EventsMap(); + const event1 = { id: '1', pubkey: 'abc', kind: 1 }; + map.set(event1.id, event1); + + map.clear(); + assert.strictEqual(map.getEventsByAuthor('abc').length, 0); + assert.strictEqual(map.size, 0); + }); + + await t.test('normalizes pubkeys', () => { + const map = new EventsMap(); + const event1 = { id: '1', pubkey: 'ABC', kind: 1 }; + map.set(event1.id, event1); + + assert.strictEqual(map.getEventsByAuthor('abc').length, 1); + assert.strictEqual(map.getEventsByAuthor('ABC').length, 1); + assert.strictEqual(map.getEventsByAuthor(' abc ').length, 1); + }); + + await t.test('handles non-event objects gracefully', () => { + const map = new EventsMap(); + map.set('1', null); + map.set('2', { foo: 'bar' }); // No pubkey + + assert.strictEqual(map.getEventsByAuthor('undefined').length, 0); + }); +});