Merge pull request #29 from jolavillette/fix/chat-fixes-for-121

Chat fixes from a phone: own messages, participants, distant chat, request storms, Debug page
This commit is contained in:
defnax 2026-08-30 14:46:34 +02:00 committed by GitHub
commit 2c7f1e9cf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 809 additions and 167 deletions

View File

@ -315,6 +315,22 @@ const ChatRoomHeader = () => {
)
]
: [
// Below 900px the participants column is not laid out; this
// opens it as a sheet. Desktop hides the button (see
// pages/_chat.scss), the column being always visible there.
m(
'button.participants-toggle',
{
title: 'Participants',
style: 'margin-right: 0.75rem;',
onclick: () => {
ChatHubState.showParticipants = !ChatHubState.showParticipants;
ChatHubState.activeMenu = null;
ChatHubState.hoveredUser = null;
}
},
[m('i.fas.fa-users'), ' ' + ChatLobbyModel.users.length]
),
m(
'button',
{
@ -395,7 +411,7 @@ const ChatConversationView = () => {
const isRoom = chatType === 3;
const isDistant = chatType === 2;
const canTalk = !isDistant || (ChatLobbyModel.distantChatStatus && ChatLobbyModel.distantChatStatus.status === 2);
return m('.chat-hub-conversation-layout', [
return m('.chat-hub-conversation-layout' + (ChatHubState.showParticipants ? '.show-participants' : ''), [
m('.chat-hub-conversation-main', [
m(
'.chat-hub-messages' + (isRoom ? '.compact-container' : ''),
@ -692,7 +708,18 @@ const ChatConversationView = () => {
m(HistoryBrowserModal, { isRoom: true }),
]),
m('.chat-hub-rightbar', [
m('.rightbar-title', 'Participants'),
m('.rightbar-title', [
'Participants',
m('button.rightbar-close', {
type: 'button',
title: 'Close',
'aria-label': 'Close participants',
onclick: () => {
ChatHubState.showParticipants = false;
ChatHubState.activeMenu = null;
},
}, m('i.fas.fa-times')),
]),
m('.rightbar-users-list', (() => {
const sortedUsers = [...ChatLobbyModel.users];
if (ChatHubState.userSortMethod === 'activity') {
@ -744,25 +771,7 @@ const ChatConversationView = () => {
statusTooltip = 'Away';
}
return m('.user', {
onmouseenter: (e) => {
if (ChatHubState.activeMenu) return;
const rect = e.currentTarget.getBoundingClientRect();
ChatHubState.hoveredUser = { gxsId, name, rect };
},
onmouseleave: () => {
ChatHubState.hoveredUser = null;
},
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
ChatHubState.hoveredUser = null;
ChatHubState.activeMenu = null;
m.redraw();
},
oncontextmenu: (e) => {
e.preventDefault();
e.stopPropagation();
const openUserMenu = (e) => {
ChatHubState.hoveredUser = null;
const rect = e.currentTarget.getBoundingClientRect();
@ -779,7 +788,36 @@ const ChatConversationView = () => {
ChatHubState.activeMenu = { gxsId, name, top };
m.redraw();
}
}
};
return m('.user', {
onmouseenter: (e) => {
if (ChatHubState.activeMenu) return;
const rect = e.currentTarget.getBoundingClientRect();
ChatHubState.hoveredUser = { gxsId, name, rect };
},
onmouseleave: () => {
ChatHubState.hoveredUser = null;
},
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
ChatHubState.hoveredUser = null;
// A phone has no right click: a tap on a participant is the
// only way to reach "Start private chat" and the rest of
// the menu. Same media query as the sheet in _chat.scss.
if (window.matchMedia('(max-width: 899px), (hover: none)').matches) {
openUserMenu(e);
return;
}
ChatHubState.activeMenu = null;
m.redraw();
},
oncontextmenu: (e) => {
e.preventDefault();
e.stopPropagation();
openUserMenu(e);
},
}, [
m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: gxsId, size: 32 }),
m('span.user-name', name),
@ -899,6 +937,7 @@ const ChatConversationView = () => {
!isOwn && m('.menu-item', {
onclick: () => {
ChatHubState.activeMenu = null;
ChatHubState.showParticipants = false;
people.setSelectedId(menu.gxsId, 'chat');
}
}, [
@ -908,6 +947,7 @@ const ChatConversationView = () => {
!isOwn && m('.menu-item', {
onclick: () => {
ChatHubState.activeMenu = null;
ChatHubState.showParticipants = false;
people.setSelectedId(menu.gxsId, 'details', true);
}
}, [

View File

@ -1078,6 +1078,7 @@ const ChatLobbyModel = {
this.stopParticipantPolling();
this.lastLobbyId = currentlobbyid;
ChatRoomsModel.unreadCount[currentlobbyid] = 0;
ChatHubState.showParticipants = false;
const finishLoad = (detail) => {
this.setupAction = this.setIdentity;
@ -1211,6 +1212,8 @@ const ChatHubState = {
hoveredUser: null,
mutedUsers: new Set(),
activeMenu: null,
// Phone only: the participants column is shown as a sheet over the messages.
showParticipants: false,
showAttachModal: false,
attachPath: '',
attachBrowseHint: false,

View File

@ -0,0 +1,110 @@
const m = require('mithril');
const rs = require('rswebui');
// A page for what is otherwise invisible from a phone: which build this is,
// what the core answers, and what the API is doing from this browser --
// requests in flight, the round trip of the last chat message, the slowest
// calls, the health of the event stream. Numbers, not a console.
const Debug = () => {
let timer = null;
let coreVersion = null;
let coreVersionAt = 0;
const ago = (t) => (t ? Math.round((Date.now() - t) / 1000) + ' s ago' : 'never');
const short = (p) => String(p || '').replace(/^\/rs/, '');
const loadCoreVersion = () => {
const startedAt = performance.now();
rs.rsJsonApiRequest('/rsJsonApi/version', {}, (data, success) => {
coreVersionAt = Math.round(performance.now() - startedAt);
coreVersion = success && data ? data : null;
m.redraw();
});
};
return {
oninit: () => {
loadCoreVersion();
// The counters move on their own; redraw once a second while here.
timer = setInterval(() => m.redraw(), 1000);
},
onremove: () => {
if (timer) clearInterval(timer);
},
view: (vnode) => {
const s = rs.apiStats;
const version = vnode.attrs.version || '';
const core = coreVersion
? `${coreVersion.major}.${coreVersion.minor}.${coreVersion.mini}${coreVersion.extra || ''} (${coreVersion.human || ''})`
: 'unknown';
return m('.debug-page', [
m('h2', 'Debug'),
m('.debug-section', [
m('h3', 'Build'),
m('.debug-grid', [
m('span', 'Web UI'), m('strong', version),
m('span', 'Core'), m('strong', core),
m('span', 'Core round trip'), m('strong', coreVersion ? coreVersionAt + ' ms' : '-'),
m('span', 'Page loaded'), m('strong', ago(s.startedAt)),
m('span', 'Viewport'), m('strong', `${window.innerWidth} x ${window.innerHeight} px`),
]),
m('.debug-actions', [
// The page keeps the code it loaded until reloaded, and a phone
// browser hides that action away: a new build shows only after this.
m('button', { onclick: () => window.location.reload(true) }, [m('i.fas.fa-sync-alt'), ' Reload the web UI']),
m('button', { onclick: loadCoreVersion }, [m('i.fas.fa-stopwatch'), ' Ping the core']),
]),
]),
m('.debug-section', [
m('h3', 'API from this browser'),
m('.debug-grid', [
m('span', 'Requests in flight'), m('strong', s.pending),
m('span', 'Requests since load'), m('strong', s.total),
m('span', 'Last sendChat'), m('strong', s.lastSend ? `${s.lastSend.ms} ms, ${ago(s.lastSend.at)}` : 'none yet'),
]),
m('h4', 'Slowest requests'),
s.slowest.length === 0
? m('p.debug-empty', 'Nothing yet.')
: m('table.debug-table', [
m('thead', m('tr', [m('th', 'Request'), m('th', 'Time'), m('th', 'When')])),
m('tbody', s.slowest.map((e) => m('tr', [
m('td', short(e.path)),
m('td', e.ms + ' ms'),
m('td', ago(e.at)),
]))),
]),
m('h4', 'Last requests'),
s.recent.length === 0
? m('p.debug-empty', 'Nothing yet.')
: m('table.debug-table', [
m('thead', m('tr', [m('th', 'Request'), m('th', 'Time'), m('th', 'When')])),
m('tbody', s.recent.map((e) => m('tr', [
m('td', short(e.path)),
m('td', e.ms + ' ms'),
m('td', ago(e.at)),
]))),
]),
m('.debug-actions', [
m('button', { onclick: () => rs.resetApiStats() }, [m('i.fas.fa-eraser'), ' Reset counters']),
]),
]),
m('.debug-section', [
m('h3', 'Event stream'),
m('.debug-grid', [
m('span', 'Received'), m('strong', rs.formatBytes(s.eventsBytes)),
m('span', 'Last event'), m('strong', ago(s.lastEventAt)),
m('span', 'Reconnections'), m('strong', s.eventsRestarts),
]),
m('p.debug-hint', 'The stream carries every event of the core over one long request; it holds one of the six connections a browser keeps to a host, the others queue the requests above.'),
]),
]);
},
};
};
module.exports = Debug;

View File

@ -192,7 +192,7 @@ async function refreshFriendLists(expectedGpgId) {
for (const delay of retryDelays) {
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
try {
await NetworkData.refreshGpgDetails();
await NetworkData.refreshGpgDetails({ force: true });
if (!expected || NetworkData.gpgDetails[expected]) break;
} catch (_) {
// RetroShare may still be storing the imported certificate/location.

View File

@ -1,5 +1,9 @@
const m = require('mithril');
// Bumped at every change of the web UI; shown in the rail, the phone header
// and the Debug page.
const WEBUI_VERSION = 'v170';
const login = require('login');
const rs = require('rswebui');
const home = require('home');
@ -13,6 +17,7 @@ const forums = require('forums/forums');
const boards = require('boards/boards');
const config = require('config/config_resolver');
const statistics = require('statistics/statistics');
const debug = require('debug/debug');
const statusbar = require('statusbar');
const networkState = require('network/network_state');
const peopleState = require('people/people_state');
@ -43,6 +48,7 @@ const navIcon = {
boards: 'i.fas.fa-globe.sidenav-icon',
config: 'i.fas.fa-cogs.sidenav-icon',
statistics: 'i.fas.fa-chart-pie.sidenav-icon',
debug: 'i.fas.fa-bug.sidenav-icon',
};
const navbar = () => {
@ -137,7 +143,7 @@ const navbar = () => {
? 'Connected to RetroShare Core'
: 'Connection Lost',
}),
m('span.webui-version', { style: { fontSize: '0.7em' } }, 'v159'),
m('span.webui-version', { style: { fontSize: '0.7em' } }, WEBUI_VERSION),
m('i.fas.fa-sync-alt.refresh-icon', {
style: { cursor: 'pointer', fontSize: '0.8em' },
onclick: () => window.location.reload(true),
@ -198,6 +204,7 @@ const mobileMoreLinks = {
boards: '/boards/MyBoards',
config: '/config/network',
statistics: '/statistics',
debug: '/debug',
};
const MobileStatus = () => {
@ -212,6 +219,7 @@ const MobileStatus = () => {
m('.mobile-app-header__brand', [
m('img', { src: 'images/retroshare.svg', alt: '' }),
m('strong', 'RetroShare'),
m('span.mobile-app-header__version', WEBUI_VERSION),
]),
m('button.mobile-status-trigger[type=button]', {
'aria-label': `Open connection status. ${summary.label}`,
@ -260,7 +268,15 @@ const MobileStatus = () => {
m('small', statusbar.formatBytes(state.totalOut)),
]),
]),
m('.mobile-status-sheet__version', 'WebUI v159'),
m('.mobile-status-sheet__version', [
'WebUI ' + WEBUI_VERSION,
// The page keeps the code it loaded until it is reloaded, and a
// phone browser hides that action away. A new build shows up
// here only after this.
m('button[type=button]', {
onclick: () => window.location.reload(true),
}, [m('i.fas.fa-sync-alt'), ' Reload']),
]),
])),
];
},
@ -352,6 +368,7 @@ const Layout = () => {
boards: '/boards/MyBoards',
statistics: '/statistics',
config: '/config/network',
debug: '/debug',
},
}),
m(
@ -445,6 +462,9 @@ m.route(document.getElementById('main'), '/', {
'/statistics': {
render: () => m(Layout, m(statistics)),
},
'/debug': {
render: () => m(Layout, m(debug, { version: WEBUI_VERSION })),
},
});
// v51 architectural fix: ensure event queue starts on direct route refresh

View File

@ -1,3 +1,4 @@
const m = require('mithril');
const rs = require('rswebui');
async function refreshIds() {
@ -6,15 +7,47 @@ async function refreshIds() {
return sslIds;
}
async function loadSslDetails() {
const sslDetails = [];
const sslIds = await refreshIds();
await Promise.all(
sslIds.map((sslId) =>
rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (data) => sslDetails.push(data.det))
)
);
return sslDetails;
// 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 refreshedAt = 0;
function runQueued(tasks, 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);
}
const Data = {
@ -133,95 +166,119 @@ Data.getStatusPresentation = function (statusValue, isOnline = false) {
};
};
Data.refreshGpgDetails = async function () {
const details = {};
const sslDetails = await loadSslDetails();
await Promise.all(
sslDetails.map((data) => {
let isOnline = false;
return rs
.rsJsonApiRequest(
'/rsPeers/isOnline',
{ sslId: data.id },
(stat) => (isOnline = stat.retval)
)
.then(() => {
let customState = '';
let statusValue = isOnline ? 3 : 0;
let statusTimestamp = 0;
return rs
.rsJsonApiRequest(
'/rsChats/getCustomStateString',
{ peer_id: data.id },
(statusData) => {
if (statusData && statusData.retval) {
customState = statusData.retval;
}
}
)
.catch(() => {})
.then(() => rs.rsJsonApiRequest(
'/rsStatus/getStatus',
{ id: data.id },
(statusData) => {
if (statusData && statusData.retval && statusData.statusInfo) {
statusValue = normalizeStatusValue(statusData.statusInfo.status, statusValue);
statusTimestamp = statusData.statusInfo.time_stamp || 0;
}
}
).catch(() => {}))
.then(() => {
const avatar = '';
return Promise.resolve()
.then(() => {
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,
};
// `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 force = Boolean(options && options.force);
if (refreshInFlight) return refreshInFlight;
if (!force && refreshedAt && Date.now() - refreshedAt < GPG_DETAILS_TTL_MS) {
return refreshOnlineFlags();
}
refreshInFlight = sweepGpgDetails()
.then(() => { refreshedAt = Date.now(); })
.finally(() => { refreshInFlight = null; });
return refreshInFlight;
};
if (details[gpgId] === undefined) {
details[gpgId] = {
name: data.name,
fingerprint: data.fpr || '',
isSearched: true,
isOnline,
locations: [loc],
customState,
statusValue,
statusTimestamp,
avatar: avatar || '',
};
} else {
details[gpgId].locations.push(loc);
if (!details[gpgId].fingerprint && data.fpr) {
details[gpgId].fingerprint = data.fpr;
}
if (avatar) {
details[gpgId].avatar = avatar;
}
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;
});
});
});
})
);
async function refreshOnlineFlags() {
const online = await loadOnlineIds();
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() {
const details = {};
const sslIds = await refreshIds();
const online = await loadOnlineIds();
// 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 () => {
let data = null;
await rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (res) => {
if (res && res.det) data = res.det;
});
if (!data) 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;
});
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;
}
});
}
addLocation(data, isOnline, customState, statusValue, statusTimestamp);
});
await runQueued(tasks, SWEEP_CONCURRENCY);
const remembered = loadPendingFriends();
let rememberedChanged = false;
@ -251,5 +308,5 @@ Data.refreshGpgDetails = async function () {
});
if (rememberedChanged) savePendingFriends();
Data.gpgDetails = details;
};
}
module.exports = Data;

View File

@ -78,7 +78,7 @@ const ConfirmRemove = () => {
pgpId: vnode.attrs.gpg_id,
});
State.selectedFriendGpgId = null;
await Data.refreshGpgDetails();
await Data.refreshGpgDetails({ force: true });
m.redraw();
widget.popupMessage(m('p', 'Friend removed successfully.'));
},

View File

@ -13,6 +13,7 @@ const {
startStatusPolling,
stopStatusPolling,
initializeDistantChat,
selectChatContact,
getDistantChatSession,
drainBufferedChatMessages,
markDistantChatRead,
@ -67,12 +68,13 @@ const PeopleLayout = () => {
});
window.addEventListener('click', dismissMenu);
if (State.chatPid && !State.chatDisconnected) {
// Only poll a tunnel that is the selected contact's own; anything
// else is left over from a previous selection.
const selectedSession = State.selectedId ? getDistantChatSession(State.selectedId) : null;
if (State.chatPid && !State.chatDisconnected && selectedSession && selectedSession.pid === State.chatPid) {
// Messages received while the tab was unmounted sit in the event
// queue buffer: pick them up before the first redraw.
if (State.selectedId) {
drainBufferedChatMessages(getDistantChatSession(State.selectedId));
}
drainBufferedChatMessages(selectedSession);
startStatusPolling();
}
},
@ -190,6 +192,7 @@ PeopleLayout.setSelectedId = (id, activeTab = 'details', showCompose = false) =>
State.activeFilter = filter;
State.selectedId = id;
selectChatContact(id);
State.activeTab = activeTab;
State.pendingChatOpen = activeTab === 'chat' ? id : null;
State.mobilePane = 'detail';

View File

@ -8,6 +8,7 @@ const {
initializeDistantChat,
sendDistantChatMessage,
leaveDistantChat,
loadOlderChatHistory,
setChatDraft,
switchChatIdentity,
} = require('people/people_state');
@ -121,7 +122,9 @@ const ChatTab = () => {
m('h4', 'Conversation Ended'),
m('p', State.chatCloseFoundNothing
? 'The tunnel was already gone: the core had no connection left to close. Click below to open a new one.'
: 'You have closed the distant chat tunnel. Click below to reconnect.'),
: State.chatEndedByPoll
? 'The tunnel went away: closed by your contact, or dropped by the core. Click below to open a new one.'
: 'You have closed the distant chat tunnel. Click below to reconnect.'),
m('button.blue', {
style: 'margin-top: 1rem; padding: 0.5rem 1.5rem; border-radius: 0.375rem; border: none; font-weight: 600; cursor: pointer;',
onclick: () => initializeDistantChat(),
@ -214,7 +217,23 @@ const ChatTab = () => {
]),
]),
m('.chat-messages', [
m('.chat-messages', {
// Near the top: ask for an older slice. It is inserted above what is
// on screen, so its height is given back to scrollTop and the
// reader does not move (same as the chat rooms).
onscroll: (e) => {
const element = e.target;
if (element.scrollTop > 120) return;
const previousHeight = element.scrollHeight;
const previousTop = element.scrollTop;
loadOlderChatHistory(() => {
requestAnimationFrame(() => {
const pane = document.querySelector('.chat-messages');
if (pane) pane.scrollTop = previousTop + (pane.scrollHeight - previousHeight);
});
});
},
}, [
State.chatMessages.length === 0
? m('.chat-warning', [
m('i.fas.fa-comments'),

View File

@ -31,6 +31,7 @@ const State = {
isHistoryLoading: false,
pendingChatOpen: null, // gxsId a chat was explicitly asked for from another page
chatCloseFoundNothing: false, // the core had no connection left to close
chatEndedByPoll: false, // the status poll saw the tunnel go, we did not close it
statusPollFailures: 0, // consecutive getDistantChatStatus answers of false
showEmojiPicker: false,
attachPath: '', // file being hashed for a retroshare:// link
@ -365,14 +366,20 @@ function getStatusTooltip(status) {
function pollDistantChatStatus() {
if (!State.chatPid) return;
const session = State.selectedId ? getDistantChatSession(State.selectedId) : null;
// Captured now: the answer lands seconds later on a slow link, and by then
// the user may be on another contact, or the page on another tunnel. An
// answer about a stale pid used to mark the new conversation as ended.
const pid = State.chatPid;
const askedFor = State.selectedId;
const session = askedFor ? getDistantChatSession(askedFor) : null;
rs.rsJsonApiRequest(
'/rsChats/getDistantChatStatus',
{
pid: State.chatPid,
pid,
},
(detail, success) => {
if (State.chatPid !== pid || State.selectedId !== askedFor) return;
// getDistantChatStatus answers false once the tunnel is gone from the
// core -- died of inaction, closed by the peer, closed by us. Ignoring
// that answer left the last known status on screen for good: a dead
@ -388,6 +395,7 @@ function pollDistantChatStatus() {
State.distantChatStatus = null;
State.chatDisconnected = true;
State.chatCloseFoundNothing = false;
State.chatEndedByPoll = true;
stopStatusPolling();
m.redraw();
}
@ -399,14 +407,19 @@ function pollDistantChatStatus() {
if (session) {
session.status = detail.info;
// A status line is a message like any other: when one really lands
// (the helper drops what is already there) the pane has to follow it,
// or "You can talk" sits below the fold and the tunnel looks stuck.
let statusLineAdded = false;
if (detail.info.status === 2) {
addSessionSystemMessage(session, 'Tunnel is secured. You can talk!');
statusLineAdded = addSessionSystemMessage(session, 'Tunnel is secured. You can talk!');
// The tunnel just went up: anything the peer sent while it was still
// pending is waiting in the event buffer.
drainBufferedChatMessages(session);
} else if (detail.info.status === 3) {
addSessionSystemMessage(session, 'Your partner closed the conversation.');
statusLineAdded = addSessionSystemMessage(session, 'Your partner closed the conversation.');
}
if (statusLineAdded && State.selectedId === askedFor) scrollChatToBottom();
}
m.redraw();
}
@ -458,7 +471,61 @@ function initializeDistantChat(force = false) {
return;
}
// Otherwise, start a new tunnel for this peer
// A live tunnel to this peer may exist without this page knowing: opened
// from the desktop window, or by the peer, possibly under another of our
// identities. Its id is sha1(sorted(own || peer)), so every candidate can
// be asked for by id. When one is up, chat as that identity: asking the
// core for any other pair digs a second tunnel, and the page then sat on
// "Connecting" beside a green tunnel in the desktop UI.
//
// Only a tunnel that can talk (status 2) counts. The core also keeps
// entries for tunnels that died -- a peer-opened one it cannot re-dig
// itself -- and settling on one of those left the page waiting for good.
// Either way the conversation is then opened through
// initiateDistantChatConnexion: for an existing pair the core just hands
// back the same tunnel id, and its notify pops the desktop window as it
// always did. Explicit identity switches (force) skip the probe.
if (!force) {
const askedFor = State.selectedId;
findLiveTunnelIdentity(askedFor, (ownId) => {
// The answers come back later; the user may have moved on.
if (State.selectedId !== askedFor) return;
if (ownId) State.selectedOwnGxsIdForChat = ownId;
openDistantChat(session);
});
return;
}
openDistantChat(session);
}
// Ask the core about every tunnel id we could share with this peer, one per
// own identity, and answer with the identity of the one that can talk.
function findLiveTunnelIdentity(peerGxsId, done) {
const candidates = (State.ownGxsIds || [])
.map((ownId) => ({ ownId, pid: peopleUtil.distantChatPid(ownId, peerGxsId) }))
.filter((c) => c.pid);
if (candidates.length === 0) {
done(null);
return;
}
const found = [];
let left = candidates.length;
candidates.forEach((c) => {
rs.rsJsonApiRequest('/rsChats/getDistantChatStatus', { pid: c.pid }, (detail, success) => {
if (success && detail && detail.retval && detail.info) {
found.push({ ...c, info: detail.info });
}
left -= 1;
if (left > 0) return;
const live = found.find((f) => f.info.status === 2);
done(live ? live.ownId : null);
});
});
}
function openDistantChat(session) {
session.pid = null;
session.status = null;
resetSessionMessages(session, [
@ -476,6 +543,7 @@ function initializeDistantChat(force = false) {
State.distantChatStatus = null;
State.chatDisconnected = false;
State.chatCloseFoundNothing = false;
State.chatEndedByPoll = false;
State.statusPollFailures = 0;
State.chatInputMsg = session.inputMsg || '';
m.redraw();
@ -503,25 +571,36 @@ function initializeDistantChat(force = false) {
}
function loadHistorySlice(session, chatPeerId, count) {
rs.rsJsonApiRequest('/rsHistory/getMessages', { chatPeerId, loadCount: count }, (data, success) => {
if (!success || !data || !data.msgs || !session) return;
if (addSessionMessages(session, data.msgs) && session.pid === State.chatPid) {
State.chatMessages = session.messages;
m.redraw();
}
});
}
function loadChatMessages() {
if (!State.chatPid) return;
// Captured now: the answer may come back after the user selected another
// peer, and it must then land in the session it was asked for.
const session = State.selectedId ? getDistantChatSession(State.selectedId) : null;
const chatPeerId = {
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 2, // TYPE_PRIVATE_DISTANT
peer_id: '00000000000000000000000000000000',
distant_chat_id: State.chatPid,
lobby_id: { xstr64: '0' },
};
// The current tunnel first, then whatever else the core holds with this
// contact (other identities, direct chat), so the pane shows the whole
// conversation and not only the file of the tunnel just opened.
const sources = historySourcesFor(State.selectedId);
const chatPeerId = distantChatIdFor(State.chatPid);
sources.forEach((other) => {
if (other.distant_chat_id !== State.chatPid) loadHistorySlice(session, other, HISTORY_PAGE);
});
rs.rsJsonApiRequest(
'/rsHistory/getMessages',
{
chatPeerId,
loadCount: 50,
loadCount: HISTORY_PAGE,
},
(data, success) => {
if (success && data.msgs) {
@ -641,6 +720,7 @@ function leaveDistantChat(closed) {
State.distantChatStatus = null;
State.chatDisconnected = true;
State.chatCloseFoundNothing = !closed;
State.chatEndedByPoll = false;
State.statusPollFailures = 0;
stopStatusPolling();
m.redraw();
@ -667,6 +747,23 @@ function findDistantChatSession(msgPid) {
return { session, targetGxsId };
}
// The page-wide chat fields (pid, status, messages) belong to one contact at
// a time. Selecting another one must not leave them pointing at the previous
// tunnel: the mount-time poll then asked about a pid the core may have
// dropped, and its "gone" answer ended the new conversation before it began.
function selectChatContact(gxsId) {
stopStatusPolling();
const session = gxsId ? getDistantChatSession(gxsId) : null;
State.chatPid = session ? session.pid : null;
State.chatMessages = session ? session.messages : [];
State.distantChatStatus = session ? session.status : null;
State.chatDisconnected = session ? Boolean(session.disconnected) : false;
State.chatCloseFoundNothing = false;
State.chatEndedByPoll = false;
State.statusPollFailures = 0;
State.chatInputMsg = session ? (session.inputMsg || '') : '';
}
function isDistantChatActive(gxsId) {
const session = gxsId && State.activeDistantChats[gxsId];
return Boolean(
@ -925,6 +1022,61 @@ function preloadAllChatHistory() {
});
}
// Everything the core may hold with this contact: one distant chat file per
// own identity we could have talked as (the tunnel id is derived from the
// pair), plus the direct chat file of every location of the friend behind
// the identity. The conversation pane and the history browser read the same.
function historySourcesFor(gxsId) {
const queries = [];
const pids = new Set();
(State.ownGxsIds || []).forEach((ownId) => {
const pid = peopleUtil.distantChatPid(ownId, gxsId);
if (pid) pids.add(pid);
});
pids.forEach((pid) => queries.push(distantChatIdFor(pid)));
locationIdsOf(gxsId).forEach((sslId) => queries.push(privateChatIdFor(sslId)));
return queries;
}
// Reading further back. p3HistoryMgr::getMessages takes a count and always
// answers with the newest ones -- no cursor -- so older text means asking
// every source for a bigger slice and letting addSessionMessages() drop what
// is already here. Same mechanism as the chat rooms (chat_state.js).
const HISTORY_PAGE = 50;
function loadOlderChatHistory(done) {
const gxsId = State.selectedId;
const session = gxsId ? getDistantChatSession(gxsId) : null;
if (!session || session.historyLoading || session.historyExhausted) return false;
const queries = historySourcesFor(gxsId);
if (queries.length === 0) return false;
session.historyLoading = true;
const wanted = (session.historyLoaded || HISTORY_PAGE) + HISTORY_PAGE * 2;
let left = queries.length;
let anyFull = false;
queries.forEach((chatPeerId) => {
rs.rsJsonApiRequest('/rsHistory/getMessages', { chatPeerId, loadCount: wanted }, (data, success) => {
if (success && data && data.msgs) {
if (data.msgs.length >= wanted) anyFull = true;
addSessionMessages(session, data.msgs);
}
left -= 1;
if (left > 0) return;
session.historyLoading = false;
session.historyLoaded = wanted;
// Every source answered with fewer than asked: nothing older is left.
if (!anyFull) session.historyExhausted = true;
if (session.pid === State.chatPid) State.chatMessages = session.messages;
m.redraw();
if (done) done();
});
});
return true;
}
function loadAllHistoryForSelectedPeer(callback) {
if (!State.selectedId) return;
@ -932,25 +1084,7 @@ function loadAllHistoryForSelectedPeer(callback) {
State.fullHistoryMessages = [];
m.redraw();
const pids = new Set();
// Distant chat history of the conversation currently open
if (State.chatPid) pids.add(State.chatPid);
// Distant chat history of the earlier conversations with this peer: one
// tunnel per own identity, and the tunnel id is the key -- the peer's GXS id
// never is, so asking for it could only ever answer an empty list.
(State.ownGxsIds || []).forEach((ownId) => {
const pid = peopleUtil.distantChatPid(ownId, State.selectedId);
if (pid) pids.add(pid);
});
const queries = Array.from(pids).map(distantChatIdFor);
// Direct chat history, one query per location of the friend behind this identity
locationIdsOf(State.selectedId).forEach((sslId) => {
queries.push(privateChatIdFor(sslId));
});
const queries = historySourcesFor(State.selectedId);
if (queries.length === 0) {
State.isHistoryLoading = false;
@ -1005,6 +1139,7 @@ module.exports = {
isSystemMsg,
preloadAllChatHistory,
loadAllHistoryForSelectedPeer,
loadOlderChatHistory,
fetchIdDetails,
loadGxsIdentities,
loadOwnGxsIds,
@ -1024,6 +1159,7 @@ module.exports = {
loadChatMessages,
sendDistantChatMessage,
leaveDistantChat,
selectChatContact,
setChatDraft,
switchChatIdentity,
refreshSelectedIdDetails,

View File

@ -96,6 +96,45 @@ function logout() {
m.route.set('/');
}
// What the API is doing, seen from this browser. Shown on the Debug page: a
// request that takes ten seconds shows here, and whether it was slow on its
// own or queued behind others (pending) is what tells the two apart.
const apiStats = {
pending: 0,
total: 0,
// Last /rsChats/sendChat: the one round trip the user feels directly.
lastSend: null,
// The five slowest requests since load, newest first on a tie.
slowest: [],
// The last twenty requests, newest first.
recent: [],
// Event stream: bytes received since (re)connection, last event time,
// number of reconnections.
eventsBytes: 0,
lastEventAt: 0,
eventsRestarts: 0,
startedAt: Date.now(),
};
function recordRequestTime(path, ms) {
apiStats.pending = Math.max(0, apiStats.pending - 1);
const entry = { path, ms: Math.round(ms), at: Date.now() };
if (path === '/rsChats/sendChat') apiStats.lastSend = entry;
apiStats.slowest.push(entry);
apiStats.slowest.sort((a, b) => b.ms - a.ms);
if (apiStats.slowest.length > 5) apiStats.slowest.length = 5;
apiStats.recent.unshift(entry);
if (apiStats.recent.length > 20) apiStats.recent.length = 20;
}
function resetApiStats() {
apiStats.total = 0;
apiStats.lastSend = null;
apiStats.slowest = [];
apiStats.recent = [];
apiStats.eventsRestarts = 0;
}
const connectionState = {
status: true,
// Status of the last HTTP response, or 0 when the request never reached the
@ -120,6 +159,9 @@ function rsJsonApiRequest(
headers['Authorization'] = 'Basic ' + btoa(loginKey.username + ':' + loginKey.passwd);
}
}
apiStats.pending += 1;
apiStats.total += 1;
const startedAt = performance.now();
// NOTE: After upgrading to mithrilv2, options.extract is no longer required
// since the status will become part of return value and then
// handleDeserialize can also be simply passed as options.deserialize
@ -145,6 +187,7 @@ function rsJsonApiRequest(
xhr: config,
})
.then((result) => {
recordRequestTime(path, performance.now() - startedAt);
if (result.status === 200) {
connectionState.status = true;
try {
@ -176,6 +219,7 @@ function rsJsonApiRequest(
return result;
})
.catch(function (e) {
recordRequestTime(path, performance.now() - startedAt);
// Reaching here after a valid 200 means the body could not be parsed,
// i.e. the response was cut short. The core answered and is still there;
// it is the answer that did not survive the trip.
@ -260,10 +304,27 @@ const eventQueue = {
}
},
handler: (event, owner) => {
if (event && event.mChatMessage && event.mChatMessage.chat_id) {
owner.chatMessages(event.mChatMessage.chat_id, owner, (r) => {
r.push(event.mChatMessage);
owner.notify(event.mChatMessage);
// Two event shapes carry a chat message on RsEventType::CHAT_SERVICE.
// A message from a peer is posted twice by the core: as an
// RsChatServiceEvent {mEventCode: CHAT_MESSAGE_RECEIVED, mMsg} and as
// an RsChatMessageEvent {mChatMessage}. A message we send ourselves
// -- from the desktop GUI, or from any other client of the same core
// -- is posted once, as the RsChatServiceEvent only
// (DistributedChatService::sendLobbyChat, p3ChatService::sendChat).
// Reading mChatMessage alone therefore showed every peer's line and
// none of our own typed elsewhere. Take our own messages from the
// RsChatServiceEvent as well, and only those: a peer's message must
// keep coming through once, because the room and direct chat unread
// counters are bumped before the receivers dedup by message key.
const chatMessage = event && (
(event.mChatMessage && event.mChatMessage.chat_id && event.mChatMessage)
|| (Number(event.mEventCode) === 1 && event.mMsg && event.mMsg.chat_id
&& event.mMsg.incoming === false && event.mMsg)
);
if (chatMessage) {
owner.chatMessages(chatMessage.chat_id, owner, (r) => {
r.push(chatMessage);
owner.notify(chatMessage);
});
} else if (event && (event.mCid || event.mEventCode !== undefined)) {
// Administrative chat event (e.g. lobby info change, peer join/leave)
@ -407,6 +468,8 @@ function startEventQueue(
xhr.onprogress = (ev) => {
const currIndex = xhr.responseText.length;
apiStats.eventsBytes = currIndex;
apiStats.lastEventAt = Date.now();
if (currIndex > lastIndex) {
const parts = xhr.responseText.substring(lastIndex, currIndex);
lastIndex = currIndex;
@ -450,6 +513,7 @@ function startEventQueue(
xhr.onload = () => { };
xhr.onerror = (err) => {
apiStats.eventsRestarts += 1;
console.error('[RS] Event Queue XHR error occurred:', err);
// Retry after 5 seconds to avoid silent event loss
setTimeout(() => {
@ -524,6 +588,8 @@ module.exports = {
rsJsonApiRequest,
idToHex: hexId,
connectionState,
apiStats,
resetApiStats,
setKeys,
setBackgroundTask,
logon,

View File

@ -283,3 +283,26 @@
display: flex;
}
}
/* =========================================================================
Pull-to-refresh
========================================================================= */
/* Reading older messages means scrolling to the top of a pane, and on a
phone that gesture, once the pane has nothing left to scroll, is also the
browser's pull-to-refresh: the whole page reloaded mid-conversation.
The panes stop the gesture at their edge, and the page itself never
turns it into a reload. */
@include touch {
html,
body {
overscroll-behavior-y: none;
}
.chat-messages,
.chat-hub-messages,
.tab-content,
.main-container {
overscroll-behavior-y: contain;
}
}

View File

@ -615,3 +615,32 @@
}
}
}
/* Version label in the phone header, and the reload button beside the
* version in the status sheet (main.js MobileStatus). */
.mobile-app-header__version {
margin-left: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
color: #64748b;
align-self: flex-end;
padding-bottom: 0.15rem;
}
.mobile-status-sheet__version {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
button {
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #334155;
border-radius: 0.375rem;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
}

View File

@ -1112,10 +1112,55 @@ textarea.chatMsg {
flex-shrink: 0;
}
/* Below 900px the participants column is not laid out: the room header's
* .participants-toggle opens it as a sheet over the messages instead, and
* a tap on a participant opens its menu (chat.js). Above, the column is
* always there, so the toggle and the sheet's close button are hidden. */
.chat-hub-rightbar .rightbar-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-hub-rightbar .rightbar-close {
display: none;
border: none;
background: transparent;
color: #64748b;
font-size: 1rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
}
@media (min-width: 900px) {
.chat-hub-header-bar .chat-header-actions .participants-toggle {
display: none;
}
}
@media (max-width: 899px) {
.chat-hub-conversation-layout {
position: relative;
}
.chat-hub-rightbar {
display: none;
}
.chat-hub-conversation-layout.show-participants .chat-hub-rightbar {
display: flex;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(80vw, 320px);
z-index: 60;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15);
}
.chat-hub-conversation-layout.show-participants .rightbar-close {
display: inline-flex;
}
}
.chat-hub-messages {
@ -2238,6 +2283,10 @@ label.mobile-chat-attachment__option:hover {
position: absolute !important;
right: 10px !important;
min-width: 220px !important;
/* Above its own .menu-backdrop (position: fixed, z-index 9998), which
* otherwise sits over the menu inside the phone participants sheet --
* the sheet's z-index makes a stacking context of its own. */
z-index: 9999 !important;
}
.chat-msg-context-menu {

View File

@ -0,0 +1,86 @@
// The debug page (debug/debug.js): build, API timings, event stream.
.debug-page {
padding: 1.25rem;
max-width: 720px;
h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
}
h3 {
margin: 0 0 0.5rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
h4 {
margin: 0.75rem 0 0.25rem;
font-size: 0.8rem;
color: #475569;
}
}
.debug-section {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.debug-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1rem;
font-size: 0.9rem;
color: #334155;
strong {
word-break: break-word;
}
}
.debug-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
button {
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #334155;
border-radius: 0.375rem;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
}
.debug-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
th, td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
td:first-child {
white-space: normal;
word-break: break-all;
}
}
.debug-empty, .debug-hint {
font-size: 0.8rem;
color: #64748b;
margin: 0.25rem 0 0;
}

View File

@ -10,3 +10,4 @@
@forward "board";
@forward "config";
@forward "statistics";
@forward "debug";

File diff suppressed because one or more lines are too long