Compare commits

..

11 Commits

Author SHA1 Message Date
Laan Tungir
0e8229e13e Update inspiration FAQ to reference waxwing's Primal post and the Meteor paper 2026-06-28 20:30:26 -04:00
Laan Tungir
230bd70e07 Change all muted/dimmed text on stego page to regular primary color 2026-06-28 20:27:13 -04:00
Laan Tungir
c36cb4a0fe Replace simple example FAQ with detailed toy-model walkthrough from llm_steganography_explained.md 2026-06-28 20:25:51 -04:00
Laan Tungir
d6b167a50b Expand How It Works into FAQ section with collapsible questions 2026-06-28 20:23:01 -04:00
Laan Tungir
27f4cd6857 Set llm-steganography page to no-login (auth mode none) by default 2026-06-28 20:18:18 -04:00
Laan Tungir
28c8109f8a Add LLM Steganography demo page with GPT-2-based half-splitting entropy coding 2026-06-28 20:17:04 -04:00
Laan Tungir
00e93b2b2f This is a meaningful git commit message 2026-06-27 08:17:42 -04:00
Laan Tungir
2334f742cf Add kind 0 cache query as fallback for profile names in discovered relays — Dexie profiles table may not have all follows, so also query kind 0 events from NDK cache. Add debug log for name resolution count. 2026-06-26 21:51:10 -04:00
Laan Tungir
8c456ec522 Fix discovered relays cache invalidation: add version suffix to cache key so servingNames data is rebuilt after code change 2026-06-26 21:33:34 -04:00
Laan Tungir
e734f33542 Display usernames instead of pubkeys in discovered relays Follows column — query Dexie profiles table for displayName/name, fall back to truncated pubkey if no profile cached 2026-06-26 20:20:37 -04:00
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
5 changed files with 2394 additions and 54 deletions

345
www/js/stego.mjs Normal file
View File

@@ -0,0 +1,345 @@
/**
* LLM-based steganography — JavaScript port of stego.py.
*
* Half-splitting entropy coding on top of GPT-2's next-token distribution.
* Both encoder and decoder run the same JS code with the same PRNG seed,
* so they stay synchronized regardless of cross-language PRNG differences.
*/
import { Tensor } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
// ---------------------------------------------------------------------------
// PRNG — mulberry32 (deterministic given seed)
// ---------------------------------------------------------------------------
export function mulberry32(seed) {
let a = seed >>> 0;
return function random() {
a = (a + 0x6D2B79F5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------------------------------------------------------------------------
// Bit <-> string conversion (UTF-8)
// ---------------------------------------------------------------------------
export function strToBits(s) {
const data = new TextEncoder().encode(s);
const bits = [];
for (const byte of data) {
for (let i = 7; i >= 0; i--) {
bits.push((byte >> i) & 1);
}
}
return bits;
}
export function bitsToStr(bits) {
// Pad to a multiple of 8 (should already be).
const padded = bits.slice();
while (padded.length % 8 !== 0) padded.push(0);
const bytes = new Uint8Array(Math.floor(padded.length / 8));
for (let i = 0; i < padded.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) {
byte = (byte << 1) | padded[i + j];
}
bytes[i / 8] = byte;
}
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
// ---------------------------------------------------------------------------
// Softmax over a TypedArray / Array of logits (numerically stable)
// ---------------------------------------------------------------------------
function softmax(logits) {
let max = -Infinity;
for (let i = 0; i < logits.length; i++) {
if (logits[i] > max) max = logits[i];
}
const probs = new Float32Array(logits.length);
let sum = 0;
for (let i = 0; i < logits.length; i++) {
const e = Math.exp(logits[i] - max);
probs[i] = e;
sum += e;
}
if (sum <= 0) sum = 1;
for (let i = 0; i < logits.length; i++) {
probs[i] /= sum;
}
return probs;
}
// ---------------------------------------------------------------------------
// Next-token probability distribution from the model.
// Returns a Float32Array of length vocab_size.
// ---------------------------------------------------------------------------
async function nextTokenProbs(model, tokenizer, tokenIds) {
// Build the model inputs directly from the token-id array. We must NOT
// pass the id array through tokenizer(): in Transformers.js v3 the
// tokenizer call expects a string and will fail with
// "e.split is not a function" when given numbers. Instead we construct
// int64 tensors ourselves and feed them straight to the model.
const seqLength = tokenIds.length;
const idData = BigInt64Array.from(tokenIds, (x) => BigInt(x));
const maskData = BigInt64Array.from({ length: seqLength }, () => 1n);
const inputs = {
input_ids: new Tensor("int64", idData, [1, seqLength]),
attention_mask: new Tensor("int64", maskData, [1, seqLength]),
};
const output = await model(inputs);
const logits = output.logits;
// logits.dims = [batch, seq_len, vocab_size]
const dims = logits.dims;
const seqLen = dims[1];
const vocabSize = dims[2];
const data = logits.data;
// Extract the last token's logits: row = (batch=0, last token, :)
const offset = (seqLen - 1) * vocabSize;
const lastLogits = new Float32Array(vocabSize);
for (let i = 0; i < vocabSize; i++) {
lastLogits[i] = data[offset + i];
}
return softmax(lastLogits);
}
// ---------------------------------------------------------------------------
// Split the sorted vocabulary into two halves at cumulative prob 0.50.
// Returns { firstIds, firstProbs, secondIds, secondProbs }.
// ---------------------------------------------------------------------------
export function splitHalves(probs) {
const vocabSize = probs.length;
// Build an index array covering EVERY token id [0 .. vocabSize-1] and
// sort it by probability descending. Sorting indices (rather than
// dropping any) guarantees that all token ids are retained and that the
// two slices below form a complete, disjoint partition of the vocabulary.
// Ties are broken by ascending token id so the ordering is fully
// deterministic — important under int8 quantization where many logits can
// be exactly equal (an unstable sort could otherwise diverge between the
// encoder and decoder passes).
const sortedIds = new Array(vocabSize);
for (let i = 0; i < vocabSize; i++) sortedIds[i] = i;
sortedIds.sort((a, b) => {
const diff = probs[b] - probs[a];
if (diff !== 0) return diff;
return a - b; // stable tie-break by token id
});
// Find the split point: the first index at which the cumulative
// probability mass reaches/exceeds 0.50. Everything up to and including
// that token forms the first half; the remainder forms the second half.
let cum = 0.0;
let splitIdx = vocabSize; // default: all mass in first half (edge case)
for (let i = 0; i < vocabSize; i++) {
cum += probs[sortedIds[i]];
if (cum >= 0.50) {
splitIdx = i + 1; // include this token in the first half
break;
}
}
// Clamp so BOTH halves are guaranteed non-empty. The split index must be
// at least 1 (first half non-empty) and at most vocabSize-1 (second half
// non-empty). This handles degenerate distributions where a single token
// already holds >= 0.50 of the mass, or where the mass never reaches 0.50.
if (splitIdx < 1) splitIdx = 1;
if (splitIdx > vocabSize - 1) splitIdx = vocabSize - 1;
// slice(0, splitIdx) + slice(splitIdx) ALWAYS covers the entire sorted
// array, so every token id appears in exactly one half — none missing,
// none duplicated. Ids are plain Numbers (0..vocabSize-1).
const firstIds = sortedIds.slice(0, splitIdx);
const secondIds = sortedIds.slice(splitIdx);
const firstProbs = firstIds.map(id => probs[id]);
const secondProbs = secondIds.map(id => probs[id]);
return { firstIds, firstProbs, secondIds, secondProbs };
}
// ---------------------------------------------------------------------------
// Sample a token id from the given half using renormalized probabilities.
// Advances the PRNG exactly once (one call to rng()).
// ---------------------------------------------------------------------------
export function sampleWithinHalf(ids, probs, rng) {
let total = 0;
for (let i = 0; i < probs.length; i++) total += probs[i];
if (total <= 0) total = 1;
const r = rng();
let cum = 0.0;
let chosen = ids[ids.length - 1];
for (let i = 0; i < ids.length; i++) {
cum += probs[i] / total;
if (r < cum) {
chosen = ids[i];
break;
}
}
return chosen;
}
// ---------------------------------------------------------------------------
// Encoder
// ---------------------------------------------------------------------------
export async function encode(model, tokenizer, secretMessage, context, key, padTokens = 3, onToken = null) {
const rng = mulberry32(key >>> 0);
const bits = strToBits(secretMessage);
const numBits = bits.length;
const totalBits = numBits;
const totalChars = secretMessage.length;
// Tokenize the context. Transformers.js tokenizer() returns an object
// with input_ids; we extract the raw id array.
const tokenIds = tokenizeToIds(tokenizer, context);
for (let bitIndex = 0; bitIndex < bits.length; bitIndex++) {
const bit = bits[bitIndex];
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
let chosen;
if (bit === 0) {
chosen = sampleWithinHalf(firstIds, firstProbs, rng);
} else {
chosen = sampleWithinHalf(secondIds, secondProbs, rng);
}
tokenIds.push(chosen);
// Stream the partial cover text back to the caller and yield to the
// browser so the UI can repaint between (slow) forward passes.
if (onToken) {
const textSoFar = tokenizer.decode(tokenIds, { skip_special_tokens: true });
const charIndex = Math.floor(bitIndex / 8) + 1;
const currentChar = secretMessage[charIndex - 1] ?? "";
onToken(textSoFar, bitIndex + 1, totalBits, currentChar, charIndex, totalChars);
}
await new Promise(resolve => setTimeout(resolve, 0));
}
// Padding tokens: sample from the full distribution using the same PRNG.
for (let p = 0; p < padTokens; p++) {
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
// Sort descending.
const pairs = [];
for (let i = 0; i < probs.length; i++) pairs.push([probs[i], i]);
pairs.sort((a, b) => b[0] - a[0]);
const sp = pairs.map(x => x[0]);
const si = pairs.map(x => x[1]);
let total = 0;
for (let i = 0; i < sp.length; i++) total += sp[i];
if (total <= 0) total = 1;
const r = rng();
let cum = 0.0;
let chosen = si[0];
for (let i = 0; i < si.length; i++) {
cum += sp[i] / total;
if (r < cum) {
chosen = si[i];
break;
}
}
tokenIds.push(chosen);
}
const coverText = tokenizer.decode(tokenIds, { skip_special_tokens: true });
return { coverText, numBits, tokenCount: tokenIds.length };
}
// ---------------------------------------------------------------------------
// Decoder
// ---------------------------------------------------------------------------
export async function decode(model, tokenizer, coverText, context, key, numBits, padTokens = 3, onProgress = null) {
const rng = mulberry32(key >>> 0);
const totalBits = numBits;
const contextIds = tokenizeToIds(tokenizer, context);
const coverIds = tokenizeToIds(tokenizer, coverText);
// Generated tokens are everything after the context prefix.
const genIds = coverIds.slice(contextIds.length);
const tokenIds = contextIds.slice();
const bits = [];
for (const genToken of genIds) {
if (bits.length >= numBits) break;
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
// splitHalves yields plain Number ids and partitions the entire
// vocabulary, so a normalized (Number) genToken is guaranteed to be in
// exactly one half. tokenizeToIds normalizes ids to Number, but coerce
// defensively here too in case a BigInt slips through.
const token = typeof genToken === "bigint" ? Number(genToken) : genToken;
const firstSet = new Set(firstIds);
const secondSet = new Set(secondIds);
let recoveredBit;
if (firstSet.has(token)) {
recoveredBit = 0;
bits.push(0);
// Advance PRNG identically to the encoder.
sampleWithinHalf(firstIds, firstProbs, rng);
} else if (secondSet.has(token)) {
recoveredBit = 1;
bits.push(1);
sampleWithinHalf(secondIds, secondProbs, rng);
} else {
throw new Error(`Generated token ${token} not found in either half.`);
}
tokenIds.push(token);
// Report progress and yield so the UI can repaint between passes.
if (onProgress) {
const currentToken = tokenizer.decode([token], { skip_special_tokens: true });
const completedBytes = Math.floor(bits.length / 8);
const recoveredChars = bitsToStr(bits.slice(0, completedBytes * 8));
onProgress(
bits.length,
totalBits,
recoveredChars,
currentToken,
recoveredBit,
);
}
await new Promise(resolve => setTimeout(resolve, 0));
}
return bitsToStr(bits.slice(0, numBits));
}
// ---------------------------------------------------------------------------
// Helper: tokenize a string to a plain array of token ids.
// Handles the various shapes Transformers.js may return.
// ---------------------------------------------------------------------------
function tokenizeToIds(tokenizer, text) {
const inputs = tokenizer(text, { add_special_tokens: false });
let ids;
if (inputs && inputs.input_ids) {
ids = inputs.input_ids;
} else if (Array.isArray(inputs)) {
ids = inputs;
} else {
ids = inputs;
}
// ids may be a Tensor or a nested array like [[1,2,3]].
let arr;
if (ids && typeof ids.data !== "undefined" && ids.dims) {
arr = Array.from(ids.data);
} else if (Array.isArray(ids)) {
// Flatten one level if nested: [[...]] -> [...]
arr = (ids.length > 0 && Array.isArray(ids[0])) ? ids[0].slice() : ids.slice();
} else {
// Fallback: try to iterate.
arr = Array.from(ids);
}
// CRITICAL: token tensors are int64, so ids.data is a BigInt64Array and
// Array.from() yields BigInts (e.g. 351n). splitHalves produces plain
// Number ids, and `new Set([...Numbers]).has(351n)` is false — which is
// exactly what caused "Generated token 351 not found in either half".
// Normalize every id to a plain Number so membership checks line up.
return arr.map(v => (typeof v === "bigint" ? Number(v) : Number(v)));
}

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.51",
"VERSION_NUMBER": "0.7.51",
"BUILD_DATE": "2026-06-26T17:29:49.389Z"
"VERSION": "v0.7.62",
"VERSION_NUMBER": "0.7.62",
"BUILD_DATE": "2026-06-29T00:30:26.801Z"
}

1868
www/llm-steganography.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6443,73 +6443,200 @@ 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}).
function shortHexPubkey(hex) {
if (!hex || hex.length < 16) return hex || '-';
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
}
// Cache for discovered relays — rebuilt only when the contact list changes.
// The version suffix forces a rebuild when the handler code changes (e.g.
// adding servingNames).
let discoveredRelaysCache = null;
let discoveredRelaysCacheKey = '';
const DISCOVERED_RELAYS_CACHE_VERSION = 'v3-names-k0';
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.
// The typescript-lru-cache iteration API is broken (it yields millions
// of phantom keys from only a handful of real entries), so instead of
// iterating the LRU cache we query the NDK cache for the user's kind 3
// contact list and then look up each followed pubkey by key.
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
try {
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) : [];
// 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 = `${DISCOVERED_RELAYS_CACHE_VERSION}_${latestKind3?.id || 'none'}_${latestKind3?.created_at || 0}`;
for (const pubkey of followedPubkeys) {
try {
const outboxItem = ndk?.outboxTracker?.data?.get(pubkey);
if (!outboxItem?.writeRelays) continue;
for (const relayUrl of outboxItem.writeRelays) {
const normalized = normalizeRelayUrl(relayUrl);
if (!normalized) continue;
if (!relayServesPubkeys.has(normalized)) {
relayServesPubkeys.set(normalized, new Set());
// 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
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 {
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);
}
}
}
}
}
} catch (_) {}
}
// Look up profile names for all serving pubkeys from the Dexie
// profiles table so we can display usernames instead of pubkeys.
// Look up profile names for all serving pubkeys.
const pubkeyToName = new Map();
const allServingPubkeys = new Set();
for (const servingSet of relayServesPubkeys.values()) {
for (const pk of servingSet) allServingPubkeys.add(pk);
}
// Strategy 1: Try Dexie profiles table.
try {
const adapter = ndk?.cacheAdapter?.adapter;
if (adapter?.db?.profiles) {
for (const pk of allServingPubkeys) {
try {
const row = await adapter.db.profiles.get(pk);
if (row) {
const name = row.displayName || row.name || '';
if (name) pubkeyToName.set(pk, name);
}
} catch (_) {}
}
relayServesPubkeys.get(normalized).add(pubkey);
}
} catch (_) {}
// Strategy 2: If Dexie profiles didn't have names for everyone,
// query kind 0 events from the NDK cache for the missing pubkeys.
const missingPubkeys = Array.from(allServingPubkeys).filter(pk => !pubkeyToName.has(pk));
if (missingPubkeys.length > 0) {
try {
// Query in batches of 400.
for (let i = 0; i < missingPubkeys.length; i += 400) {
const batch = missingPubkeys.slice(i, i + 400);
const k0Events = await ndk.fetchEvents(
{ kinds: [0], authors: batch },
{ cacheUsage: 'ONLY_CACHE' }
);
if (k0Events && k0Events.size > 0) {
// For each author, keep only the latest kind 0.
const latestByAuthor = new Map();
for (const evt of k0Events) {
const existing = latestByAuthor.get(evt.pubkey);
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
latestByAuthor.set(evt.pubkey, evt);
}
}
for (const [pubkey, evt] of latestByAuthor) {
try {
const profile = typeof evt.content === 'string'
? JSON.parse(evt.content) : evt.content;
const name = profile?.display_name || profile?.name || '';
if (name) pubkeyToName.set(pubkey, name);
} catch (_) {}
}
}
}
} catch (_) {}
}
console.log('[Worker] getDiscoveredRelays: profile names resolved for', pubkeyToName.size, 'of', allServingPubkeys.size, 'pubkeys');
// 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);
const pubkeys = Array.from(servingSet);
// Map pubkeys to names, falling back to truncated pubkey.
const names = pubkeys.map(pk =>
pubkeyToName.get(pk) || shortHexPubkey(pk)
);
relays.push({
url: relayUrl,
status: poolRelay?.status || 0,
connected: poolRelay ? poolRelay.status >= 5 : false,
servingPubkeys: pubkeys,
servingNames: names
});
}
// 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
};
});
}

View File

@@ -1383,9 +1383,9 @@ const versionInfo = await getVersion();
for (const relay of relaysWithFollows) {
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
const followsList = Array.isArray(relay.servingPubkeys)
? relay.servingPubkeys.map(shortNpub).join(', ')
: '-';
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
? relay.servingNames.join(', ')
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.map(shortNpub).join(', ') : '-');
const rowClass = relay.connected ? 'tblRowConnected' : '';
html += `<tr class="${rowClass}">`;