RSNewWebUI/webui-src/app/network/network_data.js
jolavillette 54a486e44f webui: three edges of the round-2 fixes, from its first audit
rerunQueued was new module state outside the per-login reset: queued
under login A and left true, it swallowed login B's first force call --
the freshly added friend missing until the TTL lapsed. It resets with
the rest of the cache state now.

A refused initiate became a silent dead end once the zero pid was
recognised: nothing polled, nothing errored, "Please wait for secure
tunnel" forever. The refusal now ends the conversation visibly.

And the fixed 30 s vote refetch could land BEFORE the core's retally
(one board per ~15 s tick, asynchronous write), revert the optimistic
+1 on screen and stick -- cached content is never refreshed again. The
refetch now compares against the pre-vote counts and only replaces the
cached post once the tally has actually moved, with bounded retries;
on give-up the bump stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 11:24:10 +02:00

370 lines
12 KiB
JavaScript

const m = require('mithril');
const rs = require('rswebui');
async function refreshIds() {
let sslIds = [];
await rs.rsJsonApiRequest('/rsPeers/getFriendList', {}, (data) => (sslIds = data.sslIds));
return sslIds;
}
// The friend list is read one location at a time -- there is no bulk
// getPeerDetails -- and a node can have two thousand of them. Fired all at
// once they fill the browser's six sockets for minutes on a slow link, and
// every interactive request (opening a chat, the status poll) queues behind.
// So: a few at a time, the list filling as answers land, and the result kept
// for a while, since every page mount used to redo the whole sweep.
const SWEEP_CONCURRENCY = 3;
const GPG_DETAILS_TTL_MS = 5 * 60 * 1000;
let refreshInFlight = null;
let rerunQueued = false;
let refreshedAt = 0;
function runQueued(tasks, concurrency = SWEEP_CONCURRENCY) {
return new Promise((resolve) => {
let next = 0;
let finished = 0;
if (tasks.length === 0) {
resolve();
return;
}
const startNext = () => {
if (next >= tasks.length) return;
const task = tasks[next++];
Promise.resolve()
.then(task)
.catch(() => {})
.then(() => {
finished += 1;
if (finished >= tasks.length) resolve();
else startNext();
});
};
for (let i = 0; i < concurrency && i < tasks.length; i++) startNext();
});
}
async function loadOnlineIds() {
let ids = [];
await rs.rsJsonApiRequest('/rsPeers/getOnlineList', {}, (data) => {
if (data && data.sslIds) ids = data.sslIds;
});
return new Set(ids);
}
let cacheLogin = null;
let cachedDetails = {};
function currentCacheLogin() {
const login = rs.loginKey || {};
return JSON.stringify([login.url, login.username, login.isVerified, login.generation]);
}
function ensureCacheLogin() {
const key = currentCacheLogin();
if (cacheLogin !== key) {
cacheLogin = key;
cachedDetails = {};
refreshInFlight = null;
refreshedAt = 0;
// A rerun queued under the previous login must not swallow the next
// login's first force call.
rerunQueued = false;
}
return key;
}
const Data = {
get gpgDetails() {
ensureCacheLogin();
return cachedDetails;
},
set gpgDetails(details) {
ensureCacheLogin();
cachedDetails = details;
},
runQueued,
};
// A remembered friend is a placeholder shown while the core catches up with an
// addSslOnlyFriend / loadCertificateFromString that has just returned. That is
// a matter of seconds; anything older means the add did not stick, or the peer
// has since been removed, and the placeholder has to go rather than be
// re-injected into the friend list on every refresh, browser restarts included.
const PENDING_FRIEND_TTL_MS = 5 * 60 * 1000;
// Keyed per node: several RetroShare profiles can be reached from the same
// browser, and their friend lists have nothing to do with each other. Read
// lazily, since the login is not known when this module is first imported.
let pendingFriends = null;
let pendingFriendsKey = null;
function storageKey() {
const login = rs.loginKey || {};
return 'rs-webui-pending-friends:' + (login.url || '') + '|' + (login.username || '');
}
function loadPendingFriends() {
const key = storageKey();
if (pendingFriends !== null && pendingFriendsKey === key) return pendingFriends;
pendingFriendsKey = key;
try {
pendingFriends = JSON.parse(localStorage.getItem(key) || '{}');
} catch (_) {
pendingFriends = {};
}
return pendingFriends;
}
function savePendingFriends() {
try {
localStorage.setItem(pendingFriendsKey || storageKey(), JSON.stringify(pendingFriends || {}));
} catch (_) {
// The in-memory entry still works when private browsing blocks storage.
}
}
Data.forgetPendingFriend = function (gpgId) {
const pending = loadPendingFriends();
const key = String(gpgId || '').toLowerCase();
if (!key || !pending[key]) return;
delete pending[key];
delete Data.gpgDetails[key];
savePendingFriends();
};
function hasValidatedFingerprint(value) {
const fingerprint = String(value || '').replace(/\s/g, '');
return /[1-9a-f]/i.test(fingerprint);
}
Data.rememberPendingFriend = function (peerDetails) {
const pending = loadPendingFriends();
const data = peerDetails || {};
const gpgId = String(data.gpg_id || data.pgpId || '').toLowerCase();
const sslId = String(data.id || data.sslId || '');
if (!gpgId || !sslId) return;
const pendingValidation = !hasValidatedFingerprint(data.fpr || data.fingerprint);
pending[gpgId] = {
rememberedAt: Date.now(),
name: data.name || (pendingValidation
? `Profile ID ${gpgId.toUpperCase()} (Not yet validated)`
: `Profile ID ${gpgId.toUpperCase()}`),
fingerprint: data.fpr || '',
isSearched: false,
isOnline: false,
pendingValidation,
locations: [{
name: data.location || 'Unknown location',
id: sslId,
lastSeen: data.lastConnect || 0,
isOnline: false,
gpg_id: gpgId,
customState: '',
statusValue: 0,
statusTimestamp: 0,
avatar: '',
peerDetails: data,
}],
customState: '',
statusValue: 0,
statusTimestamp: 0,
avatar: '',
};
Data.gpgDetails[gpgId] = pending[gpgId];
savePendingFriends();
};
function normalizeStatusValue(value, fallback) {
if (value && typeof value === 'object') value = value.value ?? value.status ?? value.xint32;
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
const names = { OFFLINE: 0, AWAY: 1, BUSY: 2, ONLINE: 3, INACTIVE: 4 };
const match = Object.keys(names).find((name) => value.toUpperCase().includes(name));
if (match) return names[match];
}
return fallback;
}
Data.getStatusPresentation = function (statusValue, isOnline = false) {
const value = normalizeStatusValue(statusValue, isOnline ? 3 : 0);
return {
value,
label: ['Offline', 'Away', 'Busy', 'Online', 'Inactive'][value] || (isOnline ? 'Online' : 'Offline'),
color: ['#94a3b8', '#eab308', '#ef4444', '#10b981', '#f59e0b'][value] || '#94a3b8',
};
};
// `force` redoes the sweep whatever its age: after adding or removing a
// friend. Otherwise a fresh enough result is only touched up with the online
// list, one request, and concurrent callers share the sweep in flight.
Data.refreshGpgDetails = function (options = {}) {
const login = ensureCacheLogin();
const force = Boolean(options && options.force);
if (refreshInFlight) {
if (!force) return refreshInFlight;
// force is called right after adding or removing a friend, and the
// sweep in flight read its friend list BEFORE that change: joining it
// answers with the world as it was -- the added friend missing, the
// removed one back on screen. Chain ONE fresh sweep behind it; more
// force calls while it waits share that rerun.
if (rerunQueued) return refreshInFlight;
rerunQueued = true;
const rerun = refreshInFlight.catch(() => {}).then(() => {
rerunQueued = false;
if (refreshInFlight === rerun) refreshInFlight = null;
if (currentCacheLogin() !== login) return undefined;
if (refreshInFlight) return refreshInFlight;
return Data.refreshGpgDetails({ force: true });
});
refreshInFlight = rerun;
return rerun;
}
if (!force && refreshedAt && Date.now() - refreshedAt < GPG_DETAILS_TTL_MS) {
return refreshOnlineFlags(login);
}
refreshInFlight = sweepGpgDetails(login)
.then(() => { if (currentCacheLogin() === login) refreshedAt = Date.now(); })
.finally(() => { if (currentCacheLogin() === login) refreshInFlight = null; });
return refreshInFlight;
};
async function refreshOnlineFlags(login) {
const online = await loadOnlineIds();
if (currentCacheLogin() !== login) return;
Object.values(Data.gpgDetails || {}).forEach((friend) => {
let anyOnline = false;
(friend.locations || []).forEach((loc) => {
loc.isOnline = online.has(loc.id);
anyOnline = anyOnline || loc.isOnline;
});
friend.isOnline = anyOnline;
});
}
async function sweepGpgDetails(login) {
const details = {};
const sslIds = await refreshIds();
if (currentCacheLogin() !== login) return;
const online = await loadOnlineIds();
if (currentCacheLogin() !== login) return;
// A first load shows the list as it fills rather than nothing for the
// whole sweep; a refresh keeps the old list on screen until it is done.
const firstLoad = Object.keys(Data.gpgDetails || {}).length === 0;
if (firstLoad) Data.gpgDetails = details;
let sinceRedraw = 0;
const addLocation = (data, isOnline, customState, statusValue, statusTimestamp) => {
const gpgId = (data.gpg_id || '').toLowerCase();
const loc = {
name: data.location,
id: data.id,
lastSeen: data.lastConnect,
isOnline,
gpg_id: gpgId,
customState,
statusValue,
statusTimestamp,
avatar: '',
peerDetails: data,
};
if (details[gpgId] === undefined) {
details[gpgId] = {
name: data.name,
fingerprint: data.fpr || '',
isSearched: true,
isOnline,
locations: [loc],
customState,
statusValue,
statusTimestamp,
avatar: '',
};
} else {
details[gpgId].locations.push(loc);
if (!details[gpgId].fingerprint && data.fpr) {
details[gpgId].fingerprint = data.fpr;
}
if (!details[gpgId].customState || (isOnline && customState)) {
details[gpgId].customState = customState;
}
if (isOnline || !details[gpgId].isOnline) {
details[gpgId].statusValue = statusValue;
details[gpgId].statusTimestamp = statusTimestamp;
}
}
details[gpgId].isOnline = details[gpgId].isOnline || isOnline;
if (firstLoad && ++sinceRedraw >= 25) {
sinceRedraw = 0;
m.redraw();
}
};
// Status string and status value only mean something for a peer that is
// connected: two requests per online peer instead of two per location.
const tasks = sslIds.map((sslId) => async () => {
if (currentCacheLogin() !== login) return;
let data = null;
await rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (res) => {
if (res && res.det) data = res.det;
});
if (!data || currentCacheLogin() !== login) return;
const isOnline = online.has(sslId);
let customState = '';
let statusValue = isOnline ? 3 : 0;
let statusTimestamp = 0;
if (isOnline) {
await rs.rsJsonApiRequest('/rsChats/getCustomStateString', { peer_id: sslId }, (statusData) => {
if (statusData && statusData.retval) customState = statusData.retval;
});
if (currentCacheLogin() !== login) return;
await rs.rsJsonApiRequest('/rsStatus/getStatus', { id: sslId }, (statusData) => {
if (statusData && statusData.retval && statusData.statusInfo) {
statusValue = normalizeStatusValue(statusData.statusInfo.status, statusValue);
statusTimestamp = statusData.statusInfo.time_stamp || 0;
}
});
}
if (currentCacheLogin() !== login) return;
addLocation(data, isOnline, customState, statusValue, statusTimestamp);
});
await runQueued(tasks, SWEEP_CONCURRENCY);
if (currentCacheLogin() !== login) return;
const remembered = loadPendingFriends();
let rememberedChanged = false;
Object.entries(remembered).forEach(([gpgId, pending]) => {
if (details[gpgId]) {
const nativeFriend = details[gpgId];
const isValidated = hasValidatedFingerprint(nativeFriend.fingerprint || pending.fingerprint);
// Unvalidated short-invite peers are returned with an empty profile
// name and an all-zero fingerprint. Keep the name parsed from the
// RetroShare ID until the core has validated the PGP profile.
if (!nativeFriend.name) nativeFriend.name = pending.name;
if (isValidated) {
delete remembered[gpgId];
rememberedChanged = true;
} else {
nativeFriend.pendingValidation = true;
}
} else if (Date.now() - (pending.rememberedAt || 0) < PENDING_FRIEND_TTL_MS) {
// The core does not know this profile yet: keep showing the placeholder,
// but only for as long as it can plausibly still be catching up.
details[gpgId] = pending;
} else {
delete remembered[gpgId];
rememberedChanged = true;
}
});
if (rememberedChanged) savePendingFriends();
Data.gpgDetails = details;
}
module.exports = Data;