mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-14 02:55:47 +05:00
Releasing the in-flight key in a finally reintroduced the storm the key exists to stop: the view refires loadPostContent on every redraw while the body is null, and each completed request triggers a redraw -- an unfetchable post became a self-sustaining request loop at redraw rate, exactly what the deleted comment warned about. The key is now released immediately on success (the cached body stops the view), and after five minutes on failure -- storm-proof, and the post still gets retries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
9.7 KiB
JavaScript
304 lines
9.7 KiB
JavaScript
const m = require('mithril');
|
|
const rs = require('rswebui');
|
|
const widget = require('widgets');
|
|
|
|
const GROUP_SUBSCRIBE_ADMIN = 0x01; // means: you have the admin key for this group
|
|
const GROUP_SUBSCRIBE_PUBLISH = 0x02; // means: you have the publish key for thiss group. Typical use: publish key in forums are shared with specific friends.
|
|
const GROUP_SUBSCRIBE_SUBSCRIBED = 0x04; // means: you are subscribed to a group, which makes you a source for this group to your friend nodes.
|
|
const GROUP_SUBSCRIBE_NOT_SUBSCRIBED = 0x08;
|
|
const GROUP_MY_FORUM = GROUP_SUBSCRIBE_ADMIN + GROUP_SUBSCRIBE_SUBSCRIBED + GROUP_SUBSCRIBE_PUBLISH;
|
|
|
|
const THREAD_UNREAD = 0x00000003;
|
|
|
|
const Data = {
|
|
DisplayForums: {},
|
|
Threads: {},
|
|
ParentThreads: {},
|
|
ParentThreadMap: {},
|
|
loading: new Set(),
|
|
};
|
|
|
|
// 'forumId/msgId' of the post bodies currently being fetched, see
|
|
// loadPostContent(). Module level rather than in Data: it is plumbing, not
|
|
// forum content.
|
|
const bodyRequestsInFlight = new Set();
|
|
const FAILED_BODY_RETRY_MS = 5 * 60 * 1000;
|
|
|
|
function getTimestampValue(ts) {
|
|
if (!ts) return 0;
|
|
if (typeof ts === 'object') {
|
|
if (ts.xint64 !== undefined) return ts.xint64;
|
|
if (ts.xstr64 !== undefined) return Number(ts.xstr64);
|
|
return 0;
|
|
}
|
|
return ts;
|
|
}
|
|
|
|
function formatTimestamp(ts) {
|
|
const val = getTimestampValue(ts);
|
|
if (!val || val === 0) return '???';
|
|
try {
|
|
const localDate = new Date(val * 1000);
|
|
const offset = localDate.getTimezoneOffset() * 60000;
|
|
return new Date(localDate.getTime() - offset).toISOString().replace('T', ' ').slice(0, 16);
|
|
} catch (e) {
|
|
return 'Invalid Date';
|
|
}
|
|
}
|
|
|
|
async function updatedisplayforums(keyid) {
|
|
if (Data.loading.has(keyid)) return;
|
|
Data.loading.add(keyid);
|
|
|
|
try {
|
|
const res1 = await rs.rsJsonApiRequest('/rsgxsforums/getForumsInfo', {
|
|
forumIds: [keyid], // keyid: Forumid
|
|
});
|
|
if (res1 && res1.body && res1.body.retval && res1.body.forumsInfo && res1.body.forumsInfo.length > 0) {
|
|
const forumInfo = res1.body.forumsInfo[0];
|
|
Data.DisplayForums[keyid] = {
|
|
// struct for a forum
|
|
name: forumInfo.mMeta.mGroupName,
|
|
author: forumInfo.mMeta.mAuthorId,
|
|
isSearched: true,
|
|
description: forumInfo.mDescription,
|
|
isSubscribed:
|
|
forumInfo.mMeta.mSubscribeFlags === GROUP_SUBSCRIBE_SUBSCRIBED ||
|
|
forumInfo.mMeta.mSubscribeFlags === GROUP_MY_FORUM,
|
|
activity: forumInfo.mMeta.mLastPost,
|
|
created: forumInfo.mMeta.mPublishTs,
|
|
};
|
|
if (Data.Threads[keyid] === undefined) {
|
|
Data.Threads[keyid] = {};
|
|
}
|
|
|
|
const res2 = await rs.rsJsonApiRequest('/rsgxsforums/getForumPostsHierarchy', {
|
|
group: forumInfo,
|
|
});
|
|
|
|
if (res2 && res2.body && res2.body.vect) {
|
|
const vect = res2.body.vect;
|
|
// Index 0 is the root sentinel in GXS hierarchy
|
|
const rootSentinel = vect[0];
|
|
|
|
if (rootSentinel && rootSentinel.mChildren) {
|
|
Data.ParentThreads[keyid] = {};
|
|
rootSentinel.mChildren.forEach((topIndex) => {
|
|
const EntryToThread = (entryIndex) => {
|
|
const entry = vect[entryIndex];
|
|
const replies = {};
|
|
|
|
// Map ForumPostEntry to a structure compatible with the existing UI
|
|
const meta = {
|
|
mGroupId: keyid,
|
|
mMsgId: entry.mMsgId,
|
|
mOrigMsgId: entry.mMsgId,
|
|
mThreadId: entry.mMsgId,
|
|
mParentId:
|
|
entry.mParent !== 0
|
|
? vect[entry.mParent].mMsgId
|
|
: '00000000000000000000000000000000',
|
|
mAuthorId: entry.mAuthorId,
|
|
mMsgName: entry.mTitle,
|
|
mPublishTs: entry.mPublishTs,
|
|
mMostRecentTsInThread: getTimestampValue(entry.mPublishTs),
|
|
mMsgStatus: entry.mMsgStatus,
|
|
};
|
|
|
|
// Populate ParentThreadMap for compatibility
|
|
if (meta.mParentId !== '00000000000000000000000000000000') {
|
|
if (!Data.ParentThreadMap[meta.mParentId]) Data.ParentThreadMap[meta.mParentId] = {};
|
|
Data.ParentThreadMap[meta.mParentId][meta.mMsgId] = meta;
|
|
}
|
|
|
|
const threadStruct = {
|
|
thread: { mMeta: meta, mMsg: null },
|
|
replies,
|
|
showReplies: false,
|
|
};
|
|
|
|
// Add to flat map
|
|
Data.Threads[keyid][meta.mMsgId] = threadStruct;
|
|
|
|
if (entry.mChildren) {
|
|
entry.mChildren.forEach((childIndex) => {
|
|
const childThread = EntryToThread(childIndex);
|
|
replies[childThread.thread.mMeta.mMsgId] = childThread;
|
|
const childTs = childThread.thread.mMeta.mMostRecentTsInThread || 0;
|
|
if (childTs > meta.mMostRecentTsInThread) meta.mMostRecentTsInThread = childTs;
|
|
});
|
|
}
|
|
|
|
return threadStruct;
|
|
};
|
|
|
|
const topThread = EntryToThread(topIndex);
|
|
Data.ParentThreads[keyid][topThread.thread.mMeta.mMsgId] = topThread.thread.mMeta;
|
|
});
|
|
}
|
|
}
|
|
m.redraw();
|
|
}
|
|
} catch (e) {
|
|
console.error('[RS] Error updating forum display for:', keyid, e);
|
|
} finally {
|
|
Data.loading.delete(keyid);
|
|
m.redraw(); // Final redraw just in case
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the body (mMsg) of a single forum post on demand.
|
|
* Returns a Promise that resolves to the body string, or null on failure.
|
|
*/
|
|
async function loadPostContent(forumId, msgId) {
|
|
// If body is already loaded, return it immediately
|
|
if (
|
|
Data.Threads[forumId] &&
|
|
Data.Threads[forumId][msgId] &&
|
|
Data.Threads[forumId][msgId].thread.mMsg !== null
|
|
) {
|
|
return Data.Threads[forumId][msgId].thread.mMsg;
|
|
}
|
|
|
|
// This is called straight from the view (forum_view.js, the 'Loading
|
|
// content...' branch), and the body stays null for the whole round trip, so
|
|
// without this guard every redraw fires another getForumContent for the same
|
|
// post -- and a redraw happens on each of the other posts' answers. An open
|
|
// thread would multiply one request per post into one per post per redraw.
|
|
const inFlightKey = forumId + '/' + msgId;
|
|
if (bodyRequestsInFlight.has(inFlightKey)) return null;
|
|
bodyRequestsInFlight.add(inFlightKey);
|
|
|
|
try {
|
|
const res = await rs.rsJsonApiRequest('/rsgxsforums/getForumContent', {
|
|
forumId,
|
|
msgsIds: [msgId],
|
|
});
|
|
if (res && res.body && res.body.retval && res.body.msgs && res.body.msgs.length > 0) {
|
|
const body = res.body.msgs[0].mMsg;
|
|
// Cache the body in the existing thread entry
|
|
if (Data.Threads[forumId] && Data.Threads[forumId][msgId]) {
|
|
Data.Threads[forumId][msgId].thread.mMsg = body;
|
|
}
|
|
// The cached body is what stops the view from asking again, so the
|
|
// key is released only once it is in place.
|
|
bodyRequestsInFlight.delete(inFlightKey);
|
|
m.redraw();
|
|
return body;
|
|
}
|
|
} catch (e) {
|
|
console.error('[RS] Error loading post content:', forumId, msgId, e);
|
|
}
|
|
// Failure. The view fires this again on EVERY redraw while the body stays
|
|
// null, and each completed request triggers a redraw of its own -- so
|
|
// releasing the key here (a finally) makes an unfetchable post a
|
|
// self-sustaining request loop at redraw rate. Keep the key, release it
|
|
// after a while: one request per post per five minutes is storm-proof,
|
|
// and a body the core could not return still gets another chance.
|
|
setTimeout(() => bodyRequestsInFlight.delete(inFlightKey), FAILED_BODY_RETRY_MS);
|
|
return null;
|
|
}
|
|
|
|
const DisplayForumsFromList = () => {
|
|
return {
|
|
view: (v) =>
|
|
m(
|
|
'tr',
|
|
{
|
|
key: v.attrs.id,
|
|
class:
|
|
Data.DisplayForums[v.attrs.id] && Data.DisplayForums[v.attrs.id].isSearched
|
|
? ''
|
|
: 'hidden',
|
|
onclick: () => {
|
|
m.route.set('/forums/:tab/:mGroupId', {
|
|
tab: v.attrs.category,
|
|
mGroupId: v.attrs.id,
|
|
});
|
|
},
|
|
},
|
|
[m('td', Data.DisplayForums[v.attrs.id] ? Data.DisplayForums[v.attrs.id].name : '')]
|
|
),
|
|
};
|
|
};
|
|
|
|
const ForumSummary = () => {
|
|
let keyid = {};
|
|
return {
|
|
oninit: (v) => {
|
|
keyid = v.attrs.details.mGroupId;
|
|
updatedisplayforums(keyid);
|
|
},
|
|
view: (v) => { },
|
|
};
|
|
};
|
|
|
|
const ForumTable = () => {
|
|
return {
|
|
view: (v) => m('table.forums', [m('tr', [m('th', 'Forum Name')]), v.children]),
|
|
};
|
|
};
|
|
const ThreadsTable = () => {
|
|
return {
|
|
oninit: (v) => { },
|
|
view: (v) =>
|
|
m('table.threads', [
|
|
v.children,
|
|
]),
|
|
};
|
|
};
|
|
const ThreadsReplyTable = () => {
|
|
return {
|
|
oninit: (v) => { },
|
|
view: (v) =>
|
|
m('table.threadreply', [
|
|
v.children,
|
|
]),
|
|
};
|
|
};
|
|
|
|
const SearchBar = () => {
|
|
let searchString = '';
|
|
return {
|
|
view: (v) =>
|
|
m('input[type=text][id=searchforum][placeholder=Search Subject].searchbar', {
|
|
value: searchString,
|
|
oninput: (e) => {
|
|
searchString = e.target.value.toLowerCase();
|
|
for (const hash in Data.DisplayForums) {
|
|
if (Data.DisplayForums[hash].name.toLowerCase().indexOf(searchString) > -1) {
|
|
Data.DisplayForums[hash].isSearched = true;
|
|
} else {
|
|
Data.DisplayForums[hash].isSearched = false;
|
|
}
|
|
}
|
|
},
|
|
}),
|
|
};
|
|
};
|
|
function popupmessage(message, modalClass = '') {
|
|
widget.popupMessage(message, modalClass);
|
|
}
|
|
|
|
module.exports = {
|
|
Data,
|
|
SearchBar,
|
|
ForumSummary,
|
|
DisplayForumsFromList,
|
|
ForumTable,
|
|
ThreadsTable,
|
|
ThreadsReplyTable,
|
|
popupmessage,
|
|
updatedisplayforums,
|
|
loadPostContent,
|
|
getTimestampValue,
|
|
formatTimestamp,
|
|
GROUP_SUBSCRIBE_ADMIN,
|
|
GROUP_SUBSCRIBE_NOT_SUBSCRIBED,
|
|
GROUP_SUBSCRIBE_PUBLISH,
|
|
GROUP_SUBSCRIBE_SUBSCRIBED,
|
|
GROUP_MY_FORUM,
|
|
THREAD_UNREAD,
|
|
};
|