diff --git a/webui-src/app/people/people.js b/webui-src/app/people/people.js index f859ac9..249ecf4 100644 --- a/webui-src/app/people/people.js +++ b/webui-src/app/people/people.js @@ -9,9 +9,11 @@ const { loadOwnGxsIds, preloadAllChatHistory, syncFilter, + startStatusPolling, stopStatusPolling, initializeDistantChat, } = require('people/people_state'); + const PeopleSidebar = require('people/people_sidebar'); const DetailsTab = require('people/people_details_tab'); const ChatTab = require('people/people_chat_tab'); @@ -36,9 +38,41 @@ const PeopleLayout = () => { // Register for chatEvents to receive live incoming messages rs.events[15].notify = (chatMessage) => { const msgCid = chatMessage.chat_id; - if (msgCid && msgCid.type === 2 && State.chatPid) { + if (msgCid && msgCid.type === 2) { const msgPid = rs.idToHex(msgCid.distant_chat_id); - if (msgPid === State.chatPid) { + + // Find active session matching this distant chat PID + let session = null; + let targetGxsId = null; + Object.keys(State.activeDistantChats || {}).forEach((id) => { + if (State.activeDistantChats[id] && State.activeDistantChats[id].pid === msgPid) { + session = State.activeDistantChats[id]; + targetGxsId = id; + } + }); + + if (session) { + const isNearDuplicate = session.messages.some( + (m) => (m.msg || m.message) === chatMessage.msg && Math.abs(m.sendTime - chatMessage.sendTime) < 5 + ); + if (!isNearDuplicate) { + session.messages.push(chatMessage); + session.messages.sort((a, b) => a.sendTime - b.sendTime); + if (targetGxsId) { + State.chatHistoryMap[targetGxsId] = { + lastMsg: chatMessage.msg || chatMessage.message || '', + lastTime: chatMessage.sendTime || Math.floor(Date.now() / 1000), + }; + } + m.redraw(); + if (State.selectedId === targetGxsId) { + setTimeout(() => { + const element = document.querySelector('.chat-messages'); + if (element) element.scrollTop = element.scrollHeight; + }, 100); + } + } + } else if (State.chatPid && msgPid === State.chatPid) { const isNearDuplicate = State.chatMessages.some( (m) => (m.msg || m.message) === chatMessage.msg && Math.abs(m.sendTime - chatMessage.sendTime) < 5 ); @@ -54,6 +88,10 @@ const PeopleLayout = () => { } } }; + + if (State.chatPid && !State.chatDisconnected) { + startStatusPolling(); + } }, onremove: () => { if (rs.events[15]) { @@ -62,6 +100,7 @@ const PeopleLayout = () => { stopStatusPolling(); window.removeEventListener('click', dismissMenu); }, + onupdate: (vnode) => { syncFilter(vnode.attrs.tab); }, diff --git a/webui-src/app/people/people_chat_tab.js b/webui-src/app/people/people_chat_tab.js index edff5cf..5221fa7 100644 --- a/webui-src/app/people/people_chat_tab.js +++ b/webui-src/app/people/people_chat_tab.js @@ -141,7 +141,7 @@ const ChatTab = () => { value: ownId, onchange: (e) => { State.selectedOwnGxsIdForChat = e.target.value; - initializeDistantChat(); + initializeDistantChat(true); }, }, State.ownGxsIds.map((id) => m('option', { value: id }, rs.userList.username(id)))), ]); @@ -170,6 +170,9 @@ const ChatTab = () => { }, (data, success) => { if (success) { + if (State.selectedId && State.activeDistantChats[State.selectedId]) { + delete State.activeDistantChats[State.selectedId]; + } State.chatPid = null; State.chatMessages = []; State.distantChatStatus = null; @@ -182,6 +185,7 @@ const ChatTab = () => { } }, }, [ + m('i.fas.fa-sign-out-alt'), 'Leave Chat', ]), diff --git a/webui-src/app/people/people_sidebar.js b/webui-src/app/people/people_sidebar.js index 75874cc..4b0109e 100644 --- a/webui-src/app/people/people_sidebar.js +++ b/webui-src/app/people/people_sidebar.js @@ -35,6 +35,17 @@ const PeopleSidebar = () => { // 1. Determine list based on mainTab ('people' vs 'chats') let displayItems = []; + // 0. Compute active chats count (conversations with real message history) + const allUserGroupIds = new Set((rs.userList.users || []).map((u) => u.mGroupId)); + Object.keys(State.chatHistoryMap || {}).forEach((id) => allUserGroupIds.add(id)); + let activeChatsCount = 0; + allUserGroupIds.forEach((gxsId) => { + const hist = State.chatHistoryMap && State.chatHistoryMap[gxsId]; + if (hist && hist.lastMsg && !isSystemMsg(hist.lastMsg)) { + activeChatsCount++; + } + }); + if (State.mainTab === 'people') { let baseList = []; if (State.activeFilter === 'own') { @@ -57,10 +68,7 @@ const PeopleSidebar = () => { }); } else { // Chats Tab: ONLY contacts and identities that have real chat history (ignoring system tunnel status logs) - const userGroupIds = new Set((rs.userList.users || []).map((u) => u.mGroupId)); - Object.keys(State.chatHistoryMap || {}).forEach((id) => userGroupIds.add(id)); - - displayItems = Array.from(userGroupIds) + displayItems = Array.from(allUserGroupIds) .map((gxsId) => { const entry = rs.userList.userMap[gxsId]; const name = entry && entry.name ? entry.name : (rs.userList.username(gxsId) || 'Unknown'); @@ -127,10 +135,15 @@ const PeopleSidebar = () => { m.redraw(); }, }, - [m('i.fas.fa-comments'), ' Chats'] + [ + m('i.fas.fa-comments'), + ' Chats', + activeChatsCount > 0 && m('span.segment-badge', activeChatsCount), + ] ), ]), + // 3. Sub-Filter Row (People Tab) State.mainTab === 'people' && m('.sub-filter-row', [ diff --git a/webui-src/app/people/people_state.js b/webui-src/app/people/people_state.js index 3867d1e..b4fbc3a 100644 --- a/webui-src/app/people/people_state.js +++ b/webui-src/app/people/people_state.js @@ -21,6 +21,7 @@ const State = { distantChatStatus: null, statusPollInterval: null, chatDisconnected: false, + activeDistantChats: {}, // gxsId -> { pid, status, messages, inputMsg, disconnected } activeMenu: null, showHistoryModal: false, historySearchQuery: '', @@ -28,6 +29,21 @@ const State = { isHistoryLoading: false, }; +function getDistantChatSession(gxsId) { + if (!gxsId) return null; + if (!State.activeDistantChats[gxsId]) { + State.activeDistantChats[gxsId] = { + pid: null, + status: null, + messages: [], + inputMsg: '', + disconnected: false, + }; + } + return State.activeDistantChats[gxsId]; +} + + function fetchIdDetails(gxsId) { if (!gxsId) return; if (State.gxsIdToDetailsMap[gxsId] === undefined) { @@ -175,11 +191,6 @@ function syncFilter(tab) { if (State.activeFilter !== newFilter) { State.activeFilter = newFilter; - State.selectedId = null; - State.chatPid = null; - State.chatMessages = []; - State.chatInputMsg = ''; - State.activeTab = 'details'; } } @@ -203,6 +214,8 @@ function getStatusTooltip(status) { function pollDistantChatStatus() { if (!State.chatPid) return; + const session = State.selectedId ? getDistantChatSession(State.selectedId) : null; + rs.rsJsonApiRequest( '/rsChats/getDistantChatStatus', { @@ -211,6 +224,7 @@ function pollDistantChatStatus() { (detail, success) => { if (success && detail.retval) { State.distantChatStatus = detail.info; + if (session) session.status = detail.info; if (detail.info.status === 2) { const text = 'Tunnel is secured. You can talk!'; @@ -258,7 +272,6 @@ function stopStatusPolling() { clearInterval(State.statusPollInterval); State.statusPollInterval = null; } - State.distantChatStatus = null; } function isSystemMsg(msgText) { @@ -273,11 +286,28 @@ function isSystemMsg(msgText) { ); } -function initializeDistantChat() { +function initializeDistantChat(force = false) { if (!State.selectedId || !State.selectedOwnGxsIdForChat) return; - State.chatPid = null; - State.chatMessages = [ + const session = getDistantChatSession(State.selectedId); + + // If chat session is already established/initiating for this peer and not forced/disconnected: + if (!force && session.pid && !session.disconnected) { + State.chatPid = session.pid; + State.chatMessages = session.messages; + State.distantChatStatus = session.status; + State.chatDisconnected = session.disconnected; + + loadChatMessages(); + pollDistantChatStatus(); + startStatusPolling(); + return; + } + + // Otherwise, start a new tunnel for this peer + session.pid = null; + session.status = null; + session.messages = [ { incoming: true, isSystem: true, @@ -285,6 +315,11 @@ function initializeDistantChat() { sendTime: Math.floor(Date.now() / 1000), } ]; + session.disconnected = false; + + State.chatPid = null; + State.chatMessages = session.messages; + State.distantChatStatus = null; State.chatDisconnected = false; m.redraw(); @@ -297,7 +332,9 @@ function initializeDistantChat() { }, (res) => { if (res && res.pid) { - State.chatPid = rs.idToHex(res.pid); + const hexPid = rs.idToHex(res.pid); + session.pid = hexPid; + State.chatPid = hexPid; State.distantChatStatus = null; loadChatMessages(); pollDistantChatStatus(); @@ -307,6 +344,7 @@ function initializeDistantChat() { ); } + function loadChatMessages() { if (!State.chatPid) return; @@ -564,6 +602,7 @@ function loadAllHistoryForSelectedPeer(callback) { module.exports = { State, + getDistantChatSession, isSystemMsg, preloadAllChatHistory, loadAllHistoryForSelectedPeer, @@ -586,3 +625,4 @@ module.exports = { loadChatMessages, sendDistantChatMessage, }; + diff --git a/webui-src/app/scss/pages/_people.scss b/webui-src/app/scss/pages/_people.scss index 8788b15..36f0810 100644 --- a/webui-src/app/scss/pages/_people.scss +++ b/webui-src/app/scss/pages/_people.scss @@ -58,3 +58,140 @@ img.avatar { color: green; cursor: pointer; } + +.people-sidebar-header { + display: flex; + flex-direction: column; + padding: 0.75rem 1rem 0.5rem 1rem; + gap: 0.75rem; + border-bottom: 1px solid #e2e8f0; + background-color: #ffffff; + + .searchbar-wrapper { + position: relative; + display: flex; + align-items: center; + + i.fa-search { + position: absolute; + left: 0.85rem; + color: #94a3b8; + font-size: 0.9rem; + } + + input.searchbar-input { + width: 100%; + padding: 0.5rem 0.75rem 0.5rem 2.25rem; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 0.9rem; + background-color: #f8fafc; + color: #1e293b; + outline: none; + transition: all 0.2s ease; + + &:focus { + border-color: #3b82f6; + background-color: #ffffff; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + } + } + + .segmented-control { + display: flex; + background-color: #f1f5f9; + padding: 3px; + border-radius: 0.5rem; + gap: 4px; + + button.segment-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + font-weight: 600; + color: #64748b; + background: transparent; + border: none; + border-radius: 0.375rem; + cursor: pointer; + box-shadow: none; + transition: all 0.2s ease; + + &:hover { + color: #1e293b; + } + + &.active { + background-color: #ffffff; + color: #0f172a; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + + .segment-badge { + background-color: #019dff; + color: #ffffff; + } + } + + .segment-badge { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #cbd5e1; + color: #334155; + font-size: 0.75rem; + font-weight: 700; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.35rem; + border-radius: 9999px; + line-height: 1; + transition: all 0.2s ease; + } + } + } + + .sub-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + min-height: 32px; + + select.filter-select { + padding: 0.35rem 0.6rem; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + font-size: 0.85rem; + font-weight: 600; + color: #475569; + background-color: #ffffff; + cursor: pointer; + outline: none; + } + + .btn-add-id { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 0.375rem; + background-color: #3b82f6; + color: #ffffff; + border: none; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; + + &:hover { + background-color: #2563eb; + } + } + } +} + diff --git a/webui-src/styles.css b/webui-src/styles.css index e3858ff..55041c2 100644 --- a/webui-src/styles.css +++ b/webui-src/styles.css @@ -689,6 +689,28 @@ h1{font-size:3rem}h2{font-size:2.25rem}h3{font-size:1.875rem}h4{font-size:1.5rem box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); } +.people-sidebar-header .segmented-control button.segment-tab .segment-badge { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #cbd5e1; + color: #334155; + font-size: 0.75rem; + font-weight: 700; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.35rem; + border-radius: 9999px; + line-height: 1; + transition: all 0.2s ease; +} + +.people-sidebar-header .segmented-control button.segment-tab.active .segment-badge { + background-color: #019dff; + color: #ffffff; +} + + /* Sub-header Filter Row */ .people-sidebar-header .sub-filter-row { display: flex;