mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-12 19:50:04 +05:00
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:
commit
2c7f1e9cf3
@ -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);
|
||||
}
|
||||
}, [
|
||||
|
||||
@ -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,
|
||||
|
||||
110
webui-src/app/debug/debug.js
Normal file
110
webui-src/app/debug/debug.js
Normal 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;
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.'));
|
||||
},
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
86
webui-src/app/scss/pages/_debug.scss
Normal file
86
webui-src/app/scss/pages/_debug.scss
Normal 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;
|
||||
}
|
||||
@ -10,3 +10,4 @@
|
||||
@forward "board";
|
||||
@forward "config";
|
||||
@forward "statistics";
|
||||
@forward "debug";
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user