Compare commits

..

7 Commits

Author SHA1 Message Date
Laan Tungir
6ad2d37a23 Redesign handleGetDiscoveredRelays: use Dexie cache for follows' kind 10002 events instead of ephemeral outbox tracker. Stable relay→follows mapping that only changes when contact list changes. Live connection status from pool. Cache result in worker to avoid re-querying Dexie on every expand. 2026-06-26 13:43:40 -04:00
Laan Tungir
28ce7bce1b Fix discovered relays table: filter out rows with no serving pubkeys, rename 'Sample Follows' to 'Follows', show all follows (not just 3), fix layout wrapping with overflow-x auto and max-height scroll 2026-06-26 13:29:49 -04:00
Laan Tungir
894a163111 Fix relay disappearing: make port.onmessage synchronous (all handlers fire-and-forget). Fix discovered relays serving data: replace broken LRU cache iteration with kind 3 cache query + per-pubkey trackerData.get(). Remove debug logging. 2026-06-26 10:52:01 -04:00
Laan Tungir
efe9d977cd Make warmOutbox handler non-blocking (fire-and-forget) in worker dispatch — trackUsers involves network I/O that was blocking the entire message dispatch loop, starving getRelayData/getRelayStats 2026-06-26 10:27:42 -04:00
Laan Tungir
bff0bca411 Remove redundant warmOutbox calls from ensureLiveFeedSubscription and newAuthors path — NDK.subscribe already calls trackUsers internally, so only the pre-bootstrap warmOutbox is needed. This reduces the outbox event burst that was starving the worker message queue. 2026-06-26 10:17:56 -04:00
Laan Tungir
fee3ffc764 Increase getRelayData/getRelayStats timeout from 5s to 15s — worker is busy processing outbox/NDK events from feed.html warmOutbox, causing message queue delays 2026-06-26 10:14:21 -04:00
Laan Tungir
d49a3e800f Add debug logging to getDiscoveredRelays handler to diagnose why Serving/Sample Follows columns are empty 2026-06-26 10:11:55 -04:00
5 changed files with 174 additions and 145 deletions

View File

@@ -556,9 +556,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
const now = Math.floor(Date.now() / 1000);
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
// Fire-and-forget: NDK.subscribe already calls trackUsers internally,
// but doing it explicitly avoids relying on internal timing.
void warmOutbox(authors).catch(() => {});
// NDK.subscribe already calls outboxTracker.trackUsers internally,
// so no explicit warmOutbox call is needed here.
subscribe(
{ kinds: [1], authors, since },
@@ -594,11 +593,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
}
if (newAuthors.length > 0) {
try {
await warmOutbox(newAuthors);
} catch (e) {
console.warn('[feed.html] warmOutbox for new authors failed (non-blocking):', e?.message || e);
}
// NDK's fetchEvents/subscribe already calls outboxTracker.trackUsers
// internally for author-based filters, so no explicit warmOutbox needed.
try {
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
renderFeed();

View File

@@ -1038,7 +1038,7 @@ export async function getRelayData() {
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
@@ -1046,7 +1046,7 @@ export async function getRelayData() {
console.warn('[init-ndk] Get relay data timeout, returning empty array');
resolve([]);
}
}, 5000);
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayData',
@@ -1071,7 +1071,7 @@ export async function getRelayStats() {
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
@@ -1079,7 +1079,7 @@ export async function getRelayStats() {
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
resolve({});
}
}, 5000);
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayStats',

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.45",
"VERSION_NUMBER": "0.7.45",
"BUILD_DATE": "2026-06-26T14:08:13.927Z"
"VERSION": "v0.7.52",
"VERSION_NUMBER": "0.7.52",
"BUILD_DATE": "2026-06-26T17:43:40.739Z"
}

View File

@@ -6443,98 +6443,125 @@ async function handleSetRelayEventLogging(requestId, enabled, port) {
// from followed authors who write to relays the user does not subscribe to.
// For each discovered relay we also report which followed pubkeys it serves,
// by inverting ndk.outboxTracker.data (pubkey -> OutboxItem{writeRelays}).
// Cache for discovered relays — rebuilt only when the contact list changes.
let discoveredRelaysCache = null;
let discoveredRelaysCacheKey = '';
async function handleGetDiscoveredRelays(requestId, port) {
try {
if (!ndk?.pool?.relays) {
port.postMessage({
type: 'getDiscoveredRelaysResult',
requestId,
relays: []
});
if (!ndk) {
port.postMessage({ type: 'getDiscoveredRelaysResult', requestId, relays: [] });
return;
}
// Build the set of the user's own relay URLs (normalized) from
// relayTypes (kind 10002). These are the "configured" relays; anything
// else in the pool is a discovered/temporary outbox relay.
// relayTypes (kind 10002). These are the "configured" relays.
const ownRelays = new Set();
for (const url of relayTypes.keys()) {
const normalized = normalizeRelayUrl(url);
if (normalized) ownRelays.add(normalized);
}
// Build relayUrl -> [pubkeys] map by inverting the outbox tracker.
// ndk.outboxTracker.data is an LRUCache (typescript-lru-cache).
// We try a few iteration strategies to be resilient to API differences.
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
try {
const trackerData = ndk?.outboxTracker?.data;
if (trackerData) {
const collectForPubkey = (pubkey) => {
try {
const outboxItem = trackerData.get(pubkey);
const writeRelays = outboxItem?.writeRelays;
if (!writeRelays) return;
for (const r of writeRelays) {
const rUrl = r?.url ? normalizeRelayUrl(r.url) : (typeof r === 'string' ? normalizeRelayUrl(r) : null);
if (!rUrl) continue;
if (!relayServesPubkeys.has(rUrl)) {
relayServesPubkeys.set(rUrl, new Set());
}
relayServesPubkeys.get(rUrl).add(pubkey);
}
} catch (_) {
// ignore per-pubkey errors
}
};
// Get the user's kind 3 contact list from the Dexie cache to
// determine the followed pubkeys. Use the contact list's created_at
// as a cache key so we only rebuild when it changes.
const kind3Events = await ndk.fetchEvents(
{ kinds: [3], authors: [currentPubkey], limit: 1 },
{ cacheUsage: 'ONLY_CACHE' }
);
const latestKind3 = kind3Events && kind3Events.size > 0
? Array.from(kind3Events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
: null;
const followedPubkeys = latestKind3 ? extractFollowedPubkeysFromKind3(latestKind3) : [];
const cacheKey = `${latestKind3?.id || 'none'}_${latestKind3?.created_at || 0}`;
// Strategy 1: standard Map-like entries()
let iterated = false;
try {
if (typeof trackerData.entries === 'function') {
for (const [pubkey] of trackerData.entries()) {
collectForPubkey(pubkey);
}
iterated = true;
}
} catch (_) {}
// Rebuild the relay→[pubkeys] mapping from follows' kind 10002 events
// in the Dexie cache if the contact list has changed.
if (discoveredRelaysCacheKey !== cacheKey) {
discoveredRelaysCacheKey = cacheKey;
discoveredRelaysCache = null; // invalidate
// Strategy 2: keys() + get()
if (!iterated) {
if (followedPubkeys.length > 0) {
// Query all follows' kind 10002 events from the Dexie cache.
// This is the persistent data source — no 2-minute TTL like
// the outbox tracker.
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
// Process in batches of 400 to avoid filter size limits.
for (let i = 0; i < followedPubkeys.length; i += 400) {
const batch = followedPubkeys.slice(i, i + 400);
try {
if (typeof trackerData.keys === 'function') {
for (const pubkey of trackerData.keys()) {
collectForPubkey(pubkey);
const k10002Events = await ndk.fetchEvents(
{ kinds: [10002], authors: batch },
{ cacheUsage: 'ONLY_CACHE' }
);
if (k10002Events && k10002Events.size > 0) {
// For each author, keep only the latest kind 10002.
const latestByAuthor = new Map();
for (const evt of k10002Events) {
const existing = latestByAuthor.get(evt.pubkey);
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
latestByAuthor.set(evt.pubkey, evt);
}
}
// Parse 'r' tags from each author's latest kind 10002.
for (const [pubkey, evt] of latestByAuthor) {
if (!Array.isArray(evt.tags)) continue;
for (const tag of evt.tags) {
if (tag[0] === 'r' && tag[1]) {
const relayType = tag[2] || 'both';
// Include 'write' and 'both' relays — these are
// where the follow posts content.
if (relayType === 'write' || relayType === 'both') {
const normalized = normalizeRelayUrl(tag[1]);
if (!normalized) continue;
if (!relayServesPubkeys.has(normalized)) {
relayServesPubkeys.set(normalized, new Set());
}
relayServesPubkeys.get(normalized).add(pubkey);
}
}
}
}
iterated = true;
}
} catch (_) {}
}
// Strategy 3: iterate as Map directly
if (!iterated && trackerData instanceof Map) {
for (const pubkey of trackerData.keys()) {
collectForPubkey(pubkey);
}
// Build the discovered relays list: relays that are NOT in the
// user's own kind 10002 but ARE in follows' kind 10002.
const relays = [];
for (const [relayUrl, servingSet] of relayServesPubkeys) {
if (ownRelays.has(relayUrl)) continue; // skip user's own relays
// Check live connection status from the pool.
const poolRelay = ndk.pool?.relays?.get(relayUrl);
relays.push({
url: relayUrl,
status: poolRelay?.status || 0,
connected: poolRelay ? poolRelay.status >= 5 : false,
servingPubkeys: Array.from(servingSet)
});
}
// Sort by serving count (most follows first), then by URL.
relays.sort((a, b) => {
const countDiff = b.servingPubkeys.length - a.servingPubkeys.length;
if (countDiff !== 0) return countDiff;
return a.url.localeCompare(b.url);
});
discoveredRelaysCache = relays;
}
} catch (err) {
console.warn('[Worker] getDiscoveredRelays: outbox tracker inversion failed:', err?.message || err);
}
const relays = [];
for (const relay of ndk.pool.relays.values()) {
const normalized = normalizeRelayUrl(relay?.url);
if (!normalized) continue;
// Skip relays that are part of the user's own kind 10002 list.
if (ownRelays.has(normalized)) continue;
const servingSet = relayServesPubkeys.get(normalized);
relays.push({
url: relay.url,
status: relay.status,
connected: relay.status >= 5,
servingPubkeys: servingSet ? Array.from(servingSet) : []
// If we have cached data, update the live connection status from the
// pool without rebuilding the entire mapping.
let relays = discoveredRelaysCache || [];
if (relays.length > 0 && ndk.pool?.relays) {
relays = relays.map(r => {
const poolRelay = ndk.pool.relays.get(r.url);
return {
...r,
status: poolRelay?.status || r.status || 0,
connected: poolRelay ? poolRelay.status >= 5 : r.connected
};
});
}
@@ -6822,7 +6849,7 @@ self.onconnect = (event) => {
connectedPorts.push(port);
port.onmessage = async (e) => {
port.onmessage = (e) => {
const {
type,
pubkey,
@@ -6864,11 +6891,11 @@ self.onconnect = (event) => {
switch(type) {
case 'init':
await handleInit(pubkey, port);
void handleInit(pubkey, port);
break;
case 'subscribe':
await handleSubscribe(subId, filters, opts, port);
void handleSubscribe(subId, filters, opts, port);
break;
case 'unsubscribe':
@@ -6876,35 +6903,38 @@ self.onconnect = (event) => {
break;
case 'publish':
await handlePublish(requestId, event, port);
void handlePublish(requestId, event, port);
break;
case 'publishRaw':
await handlePublishRaw(requestId, event, port);
void handlePublishRaw(requestId, event, port);
break;
case 'publishRawToRelay':
await handlePublishRawToRelay(requestId, event, relayUrl, port);
void handlePublishRawToRelay(requestId, event, relayUrl, port);
break;
case 'sendNip17Message':
await handleSendNip17Message(requestId, recipientPubkey, content, port);
void handleSendNip17Message(requestId, recipientPubkey, content, port);
break;
case 'fetchEvents':
await handleFetchEvents(requestId, filters, relayUrl, port);
void handleFetchEvents(requestId, filters, relayUrl, port);
break;
case 'ndkFetchEvents':
await handleNdkFetchEvents(requestId, filters, port);
void handleNdkFetchEvents(requestId, filters, port);
break;
case 'warmOutbox':
await handleWarmOutbox(requestId, pubkeys, port);
// Don't await — trackUsers involves network I/O to outbox pool
// relays and would block the entire message dispatch loop,
// starving getRelayData/getRelayStats messages.
void handleWarmOutbox(requestId, pubkeys, port);
break;
case 'setRelayEventLogging':
await handleSetRelayEventLogging(requestId, enabled, port);
void handleSetRelayEventLogging(requestId, enabled, port);
break;
case 'getDiscoveredRelays':
@@ -6940,109 +6970,101 @@ self.onconnect = (event) => {
break;
case 'queryCache':
await handleQueryCache(requestId, filters, port);
void handleQueryCache(requestId, filters, port);
break;
case 'purgePlaylistCache':
await handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
void handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
break;
case 'syncMuteList': {
try {
const result = await loadWorkerMuteList(currentPubkey, { reason: 'sync-request' });
port.postMessage({
type: 'response',
requestId,
data: { success: true, result }
case 'syncMuteList':
void loadWorkerMuteList(currentPubkey, { reason: 'sync-request' })
.then((result) => {
port.postMessage({ type: 'response', requestId, data: { success: true, result } });
})
.catch((error) => {
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
});
} catch (error) {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error)
});
}
break;
}
case 'fetchCachedProfile':
await handleFetchCachedProfile(requestId, e.data.pubkey, port);
void handleFetchCachedProfile(requestId, e.data.pubkey, port);
break;
case 'storeProfile':
await handleStoreProfile(e.data.pubkey, e.data.profile);
void handleStoreProfile(e.data.pubkey, e.data.profile);
break;
case 'getAllCachedProfiles':
await handleGetAllCachedProfiles(requestId, port);
void handleGetAllCachedProfiles(requestId, port);
break;
case 'getUserSettings':
await handleGetUserSettings(requestId, port);
void handleGetUserSettings(requestId, port);
break;
case 'patchUserSettings':
await handlePatchUserSettings(requestId, patch, options, port);
void handlePatchUserSettings(requestId, patch, options, port);
break;
case 'setUserSettings':
await handleSetUserSettings(requestId, settings, options, port);
void handleSetUserSettings(requestId, settings, options, port);
break;
case 'resetUserSettings':
await handleResetUserSettings(requestId, options, port);
void handleResetUserSettings(requestId, options, port);
break;
case 'encryptContent':
await handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
void handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
break;
case 'decryptContent':
await handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
void handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
break;
case 'walletPublishMintList':
await handleWalletPublishMintList(requestId, relays, receiveMints, port);
void handleWalletPublishMintList(requestId, relays, receiveMints, port);
break;
case 'walletFetchMintList':
await handleWalletFetchMintList(requestId, targetPubkey, port);
void handleWalletFetchMintList(requestId, targetPubkey, port);
break;
case 'walletInit':
await handleWalletInit(requestId, port);
void handleWalletInit(requestId, port);
break;
case 'walletStart':
await handleWalletStart(requestId, pubkey, port);
void handleWalletStart(requestId, pubkey, port);
break;
case 'walletCreate':
await handleWalletCreate(requestId, mints, relays, port);
void handleWalletCreate(requestId, mints, relays, port);
break;
case 'walletHasWallet':
await handleWalletHasWallet(requestId, port);
void handleWalletHasWallet(requestId, port);
break;
case 'walletGetBalance':
await handleWalletGetBalance(requestId, port);
void handleWalletGetBalance(requestId, port);
break;
case 'walletGetMints':
await handleWalletGetMints(requestId, port);
void handleWalletGetMints(requestId, port);
break;
case 'walletReceiveToken':
await handleWalletReceiveToken(requestId, token, description, port);
void handleWalletReceiveToken(requestId, token, description, port);
break;
case 'walletSendToken':
await handleWalletSendToken(requestId, amount, memo, mint, port);
void handleWalletSendToken(requestId, amount, memo, mint, port);
break;
case 'walletSendNutzap':
await handleWalletSendNutzap(
void handleWalletSendNutzap(
requestId,
amount,
memo,
@@ -7057,23 +7079,23 @@ self.onconnect = (event) => {
break;
case 'walletCreateDeposit':
await handleWalletCreateDeposit(requestId, amount, mint, port);
void handleWalletCreateDeposit(requestId, amount, mint, port);
break;
case 'walletPayInvoice':
await handleWalletPayInvoice(requestId, invoice, mint, port);
void handleWalletPayInvoice(requestId, invoice, mint, port);
break;
case 'walletAddMint':
await handleWalletAddMint(requestId, mintUrl, port);
void handleWalletAddMint(requestId, mintUrl, port);
break;
case 'walletRemoveMint':
await handleWalletRemoveMint(requestId, mintUrl, port);
void handleWalletRemoveMint(requestId, mintUrl, port);
break;
case 'walletGetTransactions':
await handleWalletGetTransactions(requestId, port);
void handleWalletGetTransactions(requestId, port);
break;
case 'walletShutdown':
@@ -7081,11 +7103,11 @@ self.onconnect = (event) => {
break;
case 'walletCheckProofs':
await handleWalletCheckProofs(requestId, port);
void handleWalletCheckProofs(requestId, port);
break;
case 'walletRepublish':
await handleWalletRepublish(requestId, port);
void handleWalletRepublish(requestId, port);
break;
default:

View File

@@ -257,9 +257,9 @@
================================================================ -->
<div id="divBody" class="clsBodyFull">
<div id="divRelays">Loading relays...</div>
<div id="divDiscoveredRelaysWrap" style="width:95%;margin:10px auto 0 auto;">
<div id="divDiscoveredRelaysWrap" style="width:100%;margin:10px 0 0 0;overflow-x:auto;">
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
<div id="divDiscoveredRelays" style="display:none;"></div>
<div id="divDiscoveredRelays" style="display:none;max-height:300px;overflow-y:auto;"></div>
</div>
<div id="divRelayEventsWrap">
<div id="divRelayEvents" class="relay-events-stream"></div>
@@ -1361,19 +1361,30 @@ const versionInfo = await getVersion();
return;
}
// Filter out relays with no serving pubkeys — if no follows use this
// relay, there's no reason to show it in the discovered relays list.
const relaysWithFollows = relays.filter(r =>
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
);
if (relaysWithFollows.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
html += '<thead><tr>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Sample Follows</th>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Follows</th>';
html += '</tr></thead><tbody>';
for (const relay of relays) {
for (const relay of relaysWithFollows) {
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
const sampleFollows = Array.isArray(relay.servingPubkeys)
? relay.servingPubkeys.slice(0, 3).map(shortNpub).join(', ')
const followsList = Array.isArray(relay.servingPubkeys)
? relay.servingPubkeys.map(shortNpub).join(', ')
: '-';
const rowClass = relay.connected ? 'tblRowConnected' : '';
@@ -1381,7 +1392,7 @@ const versionInfo = await getVersion();
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;">${sampleFollows}</td>`;
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;word-break:break-all;">${followsList}</td>`;
html += '</tr>';
}