From c1eb3a767cdae30fc98299f31ede97851cd52b45 Mon Sep 17 00:00:00 2001
From: defnax <9952056+defnax@users.noreply.github.com>
Date: Sun, 5 Jul 2026 18:49:22 +0200
Subject: [PATCH 01/40] Improved Network page
---
webui-src/app/mail/mail_compose.js | 85 ++-
webui-src/app/network/network.js | 711 +++++++++++++++++++++----
webui-src/app/network/network_data.js | 57 +-
webui-src/app/scss/pages/_network.scss | 574 ++++++++++++++++++--
webui-src/styles.css | 547 ++++++++++++++++++-
5 files changed, 1789 insertions(+), 185 deletions(-)
diff --git a/webui-src/app/mail/mail_compose.js b/webui-src/app/mail/mail_compose.js
index faff4f9..3fb7e16 100644
--- a/webui-src/app/mail/mail_compose.js
+++ b/webui-src/app/mail/mail_compose.js
@@ -27,37 +27,75 @@ const Layout = () => {
},
},
};
- async function loadMailUserDetails(msgType, senderId, recipientList) {
+ async function loadMailUserDetails(msgType, senderId, recipientList, isDirectMail) {
Data.allUsers = await peopleUtil.sortUsers(rs.userList.users);
if (msgType === 'reply') {
Data.allUsers.forEach(async (user) => {
if (user.mGroupId === (await senderId)) Data.recipients.to.sendList.push(user);
});
}
- await peopleUtil.ownIds(async (data) => {
- Data.ownId = await data;
- for (let i = 0; i < Data.ownId.length; i++) {
- if (Number(Data.ownId[i]) === 0) {
- Data.ownId.splice(i, 1); // workaround for id '0'
- }
- }
- if (msgType === 'reply') {
- Data.identity = Data.ownId.filter((id) =>
- Object.prototype.hasOwnProperty.call(recipientList, id)
- )[0];
- }
+
+ // Wrap ownIds in a Promise
+ const gxsIds = await new Promise((resolve) => {
+ peopleUtil.ownIds((ids) => {
+ resolve(ids || []);
+ });
});
+
+ Data.ownId = gxsIds.filter((id) => id && id !== '0000000000000000' && Number(id) !== 0);
+
+ // Fetch own Node GPG ID
+ const netStatus = await new Promise((resolve) => {
+ rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (res) => {
+ resolve(res || null);
+ });
+ });
+
+ if (netStatus && netStatus.status) {
+ const ownNodeId = netStatus.status.ownId;
+ if (ownNodeId && !Data.ownId.includes(ownNodeId)) {
+ rs.userList.userMap[ownNodeId] = {
+ name: (netStatus.status.ownName || 'Node') + ' (Node GPG Key)',
+ isContact: false,
+ };
+ Data.ownId.push(ownNodeId);
+ }
+ if (msgType === 'compose' && isDirectMail) {
+ Data.identity = ownNodeId;
+ }
+ }
+
+ if (msgType === 'reply') {
+ Data.identity = Data.ownId.filter((id) =>
+ Object.prototype.hasOwnProperty.call(recipientList, id)
+ )[0];
+ }
}
async function loadDetails(attrs) {
- const { msgType, senderId, recipientList } = await attrs;
- await loadMailUserDetails(msgType, senderId, recipientList);
+ const { msgType, senderId, recipientList, isDirectMail } = await attrs;
+ await loadMailUserDetails(msgType, senderId, recipientList, isDirectMail);
Object.keys(Data.recipients).forEach((item) => {
Data.recipients[item].inputList = Data.allUsers;
});
if (msgType === 'compose') {
- Data.identity = Data.ownId[0];
+ if (!isDirectMail) {
+ Data.identity = Data.ownId[0];
+ }
+ if (attrs.toId) {
+ const matchingUser = Data.allUsers.find((user) => user.mGroupId === attrs.toId);
+ if (matchingUser) {
+ Data.recipients.to.sendList.push(matchingUser);
+ } else {
+ // If toId is a GPG ID (not in GXS list), add it manually as a GPG recipient
+ const friendName = attrs.friendName || 'Unknown Friend';
+ Data.recipients.to.sendList.push({
+ mGroupId: attrs.toId,
+ mGroupName: friendName + ' (Node GPG Key)',
+ });
+ }
+ }
}
if (msgType === 'reply') {
@@ -73,13 +111,13 @@ const Layout = () => {
-----Original Message-----
From:
- ${rs.userList.userMap[senderId]}
+ ${rs.userList.username(senderId)}
To:
${Object.keys(recipientList).map(
(recip) => `
- ${rs.userList.userMap[recipientList[recip]._addr_string] || 'Unknown'},
+ ${rs.userList.username(recipientList[recip]._addr_string) || 'Unknown'},
`
)}
@@ -94,7 +132,7 @@ const Layout = () => {
On ${timeStamp.toLocaleDateString()} ${time},
- ${rs.userList.userMap[senderId]}
+ ${rs.userList.username(senderId)}
wrote:
`;
@@ -172,13 +210,16 @@ const Layout = () => {
Data.identity = Data.ownId[e.target.selectedIndex];
},
},
- Data.ownId &&
+ Data.ownId &&
Data.ownId.map((id) =>
m(
'option',
- { value: id },
+ {
+ value: id,
+ selected: id === Data.identity,
+ },
rs.userList.userMap[id]
- ? rs.userList.userMap[id].toLocaleString() + ' (' + id.slice(0, 12) + '...)'
+ ? (rs.userList.userMap[id].name || id) + ' (' + id.slice(0, 12) + '...)'
: 'No Signature'
)
)
diff --git a/webui-src/app/network/network.js b/webui-src/app/network/network.js
index 2b3d494..1d9be87 100644
--- a/webui-src/app/network/network.js
+++ b/webui-src/app/network/network.js
@@ -2,7 +2,183 @@ const m = require('mithril');
const rs = require('rswebui');
const widget = require('widgets');
const Data = require('network/network_data');
+const peopleUtil = require('people/people_util');
+const compose = require('mail/mail_compose');
+// State variables for Network Page
+const State = {
+ ownProfile: {
+ name: 'Loading...',
+ ssl_id: '',
+ gpg_id: '',
+ customState: '',
+ },
+ ownGxsIds: [],
+ selectedOwnGxsId: '',
+ selectedOwnGxsDetails: null,
+ selectedFriendGpgId: null,
+ activeTab: 'details', // 'details' | 'chat'
+ searchString: '',
+ gpgToGxsIdMap: {},
+ gxsIdToDetailsMap: {},
+ currentChatPeerId: null,
+ chatMessages: [],
+ chatInputMsg: '',
+ showMailCompose: false,
+};
+
+// Fetch own node name using the same API as config_node.js
+function loadOwnProfile() {
+ // Use rsConfig/getConfigNetStatus - the same proven endpoint used in config_node.js
+ rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (data) => {
+ if (data && data.status) {
+ State.ownProfile.name = data.status.ownName || 'Unknown';
+ State.ownProfile.ssl_id = data.status.ownId || '';
+
+ // Fetch own custom status message using our own Location SSL ID
+ if (State.ownProfile.ssl_id) {
+ rs.rsJsonApiRequest('/rsChats/getCustomStateString', { peer_id: State.ownProfile.ssl_id }, (statusData) => {
+ if (statusData && statusData.retval) {
+ State.ownProfile.customState = statusData.retval;
+ m.redraw();
+ }
+ });
+
+ // Also fetch our own node GPG ID via getPeerDetails using our own SSL ID
+ rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId: State.ownProfile.ssl_id }, (detData) => {
+ if (detData && detData.det && detData.det.gpg_id) {
+ State.ownProfile.gpg_id = detData.det.gpg_id;
+ m.redraw();
+ }
+ });
+ }
+ m.redraw();
+ }
+ });
+
+ // Load own GXS identities using the existing utility
+ peopleUtil.ownIds((ids) => {
+ if (ids) {
+ State.ownGxsIds = ids.filter(
+ (id) => id && id !== '0000000000000000' && Number(id) !== 0
+ );
+ if (State.ownGxsIds.length > 0 && !State.selectedOwnGxsId) {
+ State.selectedOwnGxsId = State.ownGxsIds[0];
+ loadSelectedOwnGxsDetails();
+ }
+ m.redraw();
+ }
+ });
+}
+
+function loadSelectedOwnGxsDetails() {
+ if (!State.selectedOwnGxsId) return;
+ rs.rsJsonApiRequest(
+ '/rsIdentity/getIdDetails',
+ { id: State.selectedOwnGxsId },
+ (data) => {
+ if (data && data.details) {
+ State.selectedOwnGxsDetails = data.details;
+ m.redraw();
+ }
+ }
+ );
+}
+
+// Build map GPG ID -> GXS ID for all known identities
+function loadGxsIdentities() {
+ rs.rsJsonApiRequest('/rsIdentity/getIdentitiesSummaries', {}, (data) => {
+ if (data && data.ids) {
+ data.ids.forEach((user) => {
+ const gxsId = user.mGroupId;
+ rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (detData) => {
+ if (detData && detData.details) {
+ State.gxsIdToDetailsMap[gxsId] = detData.details;
+ const pgpId = detData.details.mPgpId;
+ if (pgpId && pgpId !== '0000000000000000') {
+ State.gpgToGxsIdMap[pgpId.toLowerCase()] = gxsId;
+ }
+ m.redraw();
+ }
+ });
+ });
+ }
+ });
+}
+
+// Start a direct chat with a friend using their SSL peer ID (type 1)
+function startDirectChat(sslId) {
+ State.currentChatPeerId = sslId;
+ State.chatMessages = [];
+ loadDirectChatMessages();
+}
+
+// Get the first online SSL ID for a friend, or fallback to first location
+function getOnlineSslId(gpgId) {
+ const friend = Data.gpgDetails[gpgId];
+ if (!friend || !friend.locations || friend.locations.length === 0) return null;
+ const onlineLoc = friend.locations.find((loc) => loc.isOnline);
+ return onlineLoc ? onlineLoc.id : friend.locations[0].id;
+}
+
+// Load message history for direct chat (type 1 is not in the event handler,
+// so we manage messages locally)
+function loadDirectChatMessages() {
+ // Messages are received via the event system and stored locally
+ // Register for incoming chat messages
+ rs.events[15].notify = (chatMessage) => {
+ if (
+ chatMessage.chat_id &&
+ (chatMessage.chat_id.type === 1 || chatMessage.chat_id.type === 2) &&
+ rs.idToHex(chatMessage.chat_id) === State.currentChatPeerId
+ ) {
+ State.chatMessages.push(chatMessage);
+ m.redraw();
+ scrollChatToBottom();
+ }
+ };
+}
+
+// Send direct chat message (type 1 / peer_id)
+function sendDirectChatMessage() {
+ if (!State.chatInputMsg.trim() || !State.currentChatPeerId) return;
+
+ const msg = State.chatInputMsg;
+ State.chatInputMsg = '';
+
+ rs.rsJsonApiRequest(
+ '/rsChats/sendChat',
+ {
+ id: { type: 1, peer_id: State.currentChatPeerId },
+ msg: msg,
+ },
+ (data, success) => {
+ if (success) {
+ // Add own message to local log
+ State.chatMessages.push({
+ chat_id: { type: 1, peer_id: State.currentChatPeerId },
+ msg,
+ sendTime: Date.now() / 1000,
+ incoming: false,
+ own: true,
+ });
+ m.redraw();
+ scrollChatToBottom();
+ } else {
+ console.error('[RS] Failed to send direct chat message');
+ }
+ }
+ );
+}
+
+function scrollChatToBottom() {
+ setTimeout(() => {
+ const el = document.getElementById('chat-messages-container');
+ if (el) el.scrollTop = el.scrollHeight;
+ }, 100);
+}
+
+// Popup confirmation to remove friend SSL connection
const ConfirmRemove = () => {
return {
view: (vnode) => [
@@ -16,7 +192,9 @@ const ConfirmRemove = () => {
rs.rsJsonApiRequest('/rsPeers/removeFriend', {
pgpId: vnode.attrs.gpg_id,
});
- m.redraw();
+ State.selectedFriendGpgId = null;
+ Data.refreshGpgDetails().then(() => m.redraw());
+ widget.popupMessage(m('p', 'Friend removed successfully.'));
},
},
'Confirm'
@@ -25,125 +203,446 @@ const ConfirmRemove = () => {
};
};
-const Locations = () => {
- return {
- view: (v) => [
- m('h4', 'Locations'),
- v.attrs.locations.map((loc) =>
- m('.location', [
- m('i.fas.fa-user-tag', { style: 'margin-top:3px' }),
- m('span', { style: 'margin-top:1px' }, loc.name),
- m('p', 'ID :'),
- m('p', loc.id),
- m('p', 'Last contacted :'),
- m('p', new Date(loc.lastSeen * 1000).toDateString()),
- m('p', 'Online :'),
- m('i.fas', {
- class: loc.isOnline ? 'fa-check-circle' : 'fa-times-circle',
- }),
- m(
- 'button.red',
- {
- onclick: () =>
- widget.popupMessage(
- m(ConfirmRemove, {
- gpg: loc.gpg_id,
- })
- ),
- },
- 'Remove node'
- ),
- ])
- ),
- ],
- };
-};
+// Helper: get avatar safely for UserAvatar (must pass undefined, not null)
+function getSafeAvatar(details) {
+ if (
+ details &&
+ details.mAvatar &&
+ details.mAvatar.mData &&
+ details.mAvatar.mData.base64 !== ''
+ ) {
+ return details.mAvatar;
+ }
+ return undefined;
+}
-const Friend = () => {
+const OwnProfileCard = () => {
return {
- isExpanded: false,
+ view: () => {
+ const ownGxsId = State.ownProfile.gpg_id ? State.gpgToGxsIdMap[State.ownProfile.gpg_id.toLowerCase()] : null;
+ const ownDetails = ownGxsId ? State.gxsIdToDetailsMap[ownGxsId] : null;
+ const avatar = getSafeAvatar(ownDetails);
+ const firstLetter = (State.ownProfile.name || 'U').slice(0, 1).toUpperCase();
- view: (vnode) =>
- m(
- '.friend',
- {
- key: vnode.attrs.id,
- class: Data.gpgDetails[vnode.attrs.id].isSearched ? '' : 'hidden',
- },
- [
- m('i.fas.fa-angle-right', {
- class: 'fa-rotate-' + (vnode.state.isExpanded ? '90' : '0'),
- style: 'margin-top:12px',
- onclick: () => (vnode.state.isExpanded = !vnode.state.isExpanded),
- }),
- m('.brief-info', { class: Data.gpgDetails[vnode.attrs.id].isOnline ? 'online' : '' }, [
- m('i.fas.fa-2x.fa-user-circle'),
- m('span', Data.gpgDetails[vnode.attrs.id].name),
+ return m('.own-profile-card', [
+ m('.profile-header', [
+ m(peopleUtil.UserAvatar, { avatar, firstLetter }),
+ m('.profile-info', [
+ m('.profile-name', State.ownProfile.name || 'Loading...'),
+ m('.profile-status', 'Online'),
+ State.ownProfile.customState &&
+ m(
+ '.profile-custom-status',
+ {
+ style: 'font-size: 0.8rem; color: #94a3b8; font-style: italic; margin-top: 2px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 150px;',
+ title: State.ownProfile.customState,
+ },
+ State.ownProfile.customState
+ ),
]),
- m(
- '.details',
- {
- style: 'display:' + (vnode.state.isExpanded ? 'block' : 'none'),
- },
- [
- m(Locations, {
- locations: Data.gpgDetails[vnode.attrs.id].locations,
- }),
- ]
- ),
- ]
- ),
- };
-};
-
-const SearchBar = () => {
- let searchString = '';
- return {
- view: () =>
- m('input.searchbar', {
- type: 'text',
- placeholder: 'search',
- value: searchString,
- oninput: (e) => {
- searchString = e.target.value.toLowerCase();
- for (const id in Data.gpgDetails) {
- if (Data.gpgDetails[id].name.toLowerCase().indexOf(searchString) > -1) {
- Data.gpgDetails[id].isSearched = true;
- } else {
- Data.gpgDetails[id].isSearched = false;
- }
- }
- },
- }),
+ ]),
+ ]);
+ },
};
};
const FriendsList = () => {
return {
- oninit: () => {
- Data.refreshGpgDetails();
- },
- view: () =>
- m('.widget', [
- m('.widget__heading', [m('h3', 'Friend nodes'), m(SearchBar)]),
- m('.widget__body', [
- Object.entries(Data.gpgDetails)
- .sort((a, b) => {
- return a[1].isOnline === b[1].isOnline ? 0 : a[1].isOnline ? -1 : 1;
- })
- .map((item) => {
- const id = item[0];
- return m(Friend, { id });
- }),
+ view: () => {
+ const search = State.searchString.toLowerCase();
+ const filteredFriends = Object.entries(Data.gpgDetails).filter(
+ ([gpgId, friend]) => (friend.name || '').toLowerCase().includes(search)
+ );
+
+ return m('.friends-list-container', [
+ m('.searchbar-container', [
+ m('input.searchbar', {
+ type: 'text',
+ placeholder: 'Search friends...',
+ value: State.searchString,
+ oninput: (e) => {
+ State.searchString = e.target.value;
+ },
+ }),
]),
- ]),
+ m('.friends-scroll', [
+ filteredFriends.length === 0
+ ? m('p', { style: 'padding: 1rem; color: #94a3b8; text-align: center;' }, 'No friends found')
+ : filteredFriends
+ .sort((a, b) => (a[1].isOnline === b[1].isOnline ? 0 : a[1].isOnline ? -1 : 1))
+ .map(([gpgId, friend]) => {
+ const friendGxsId = State.gpgToGxsIdMap[gpgId.toLowerCase()];
+ const friendDetails = friendGxsId ? State.gxsIdToDetailsMap[friendGxsId] : null;
+ const avatar = getSafeAvatar(friendDetails);
+ const firstLetter = (friend.name || '?').slice(0, 1).toUpperCase();
+ const isSelected = State.selectedFriendGpgId === gpgId;
+
+ return m(
+ `.friend-list-item${isSelected ? '.selected' : ''}`,
+ {
+ key: gpgId,
+ onclick: () => {
+ State.selectedFriendGpgId = gpgId;
+ State.currentChatPeerId = null;
+ State.chatMessages = [];
+ if (State.activeTab === 'chat') {
+ const sslId = getOnlineSslId(gpgId);
+ if (sslId) startDirectChat(sslId);
+ }
+ },
+ },
+ [
+ m('.friend-avatar', m(peopleUtil.UserAvatar, { avatar, firstLetter })),
+ m('.friend-meta', [
+ m('.friend-name', friend.name),
+ m(
+ `.friend-status${friend.isOnline ? '.online' : ''}`,
+ friend.isOnline ? 'Online' : 'Offline'
+ ),
+ friend.customState &&
+ m(
+ '.friend-custom-status',
+ {
+ style: 'font-size: 0.85rem; color: #64748b; margin-top: 2px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 160px;',
+ title: friend.customState,
+ },
+ friend.customState
+ ),
+ ]),
+ ]
+ );
+ }),
+ ]),
+ ]);
+ },
};
};
-const Layout = () => {
+// Right Pane Tabs and Tab Views
+const DetailsTab = () => {
return {
- view: () => m('.node-panel', m(FriendsList)),
+ view: () => {
+ const gpgId = State.selectedFriendGpgId;
+ const friend = Data.gpgDetails[gpgId];
+ if (!friend) return null;
+
+ const friendGxsId = State.gpgToGxsIdMap[gpgId.toLowerCase()];
+
+ return m('.network-detail-view', [
+ m('.detail-header', [
+ m('.detail-title', [
+ m('h2', friend.name),
+ m('.detail-subtitle', [
+ m('i.fas.fa-fingerprint'),
+ m('span', 'GPG ID: ' + gpgId),
+ ]),
+ ]),
+ m('.detail-actions', [
+ m(
+ 'button',
+ {
+ onclick: () => {
+ const sslId = getOnlineSslId(gpgId);
+ if (sslId) {
+ State.activeTab = 'chat';
+ startDirectChat(sslId);
+ }
+ },
+ },
+ [m('i.fas.fa-comments'), ' Start Chat']
+ ),
+ m(
+ 'button',
+ {
+ onclick: () => {
+ State.showMailCompose = true;
+ },
+ },
+ [m('i.fas.fa-envelope'), ' Send Mail']
+ ),
+ ]),
+ ]),
+
+ m('.detail-section', [
+ m('h3', 'Profile Info'),
+ m('.info-grid', [
+ m('.info-label', 'Status'),
+ m(
+ '.info-value',
+ { style: friend.isOnline ? 'color: #10b981; font-weight: 600;' : '' },
+ friend.isOnline ? 'Online' : 'Offline'
+ ),
+ m('.info-label', 'Custom Status'),
+ m(
+ '.info-value',
+ { style: 'font-style: italic; color: #64748b;' },
+ friend.customState || 'None'
+ ),
+ friendGxsId ? [
+ m('.info-label', 'GXS Identity'),
+ m('.info-value', friendGxsId),
+ ] : null,
+ m('.info-label', 'Node GPG Key'),
+ m('.info-value', gpgId),
+ ]),
+ ]),
+
+ m('.detail-section', [
+ m('h3', 'Locations (' + friend.locations.length + ')'),
+ m(
+ '.locations-grid',
+ friend.locations
+ .slice()
+ .sort((a, b) => (a.isOnline === b.isOnline ? 0 : a.isOnline ? -1 : 1))
+ .map((loc) =>
+ m('.location-card', { key: loc.id }, [
+ m('.loc-header', [
+ m('.loc-name', loc.name),
+ m(
+ '.loc-status' + (loc.isOnline ? '.online' : '.offline'),
+ loc.isOnline ? 'Online' : 'Offline'
+ ),
+ ]),
+ m('.loc-body', [
+ m('.loc-label', 'SSL ID'),
+ m('.loc-val', loc.id),
+ m('.loc-label', 'Last Seen'),
+ m('.loc-val', new Date(loc.lastSeen * 1000).toLocaleString()),
+ ]),
+ m('.loc-footer', [
+ m(
+ 'button.red',
+ {
+ onclick: () =>
+ widget.popupMessage(
+ m(ConfirmRemove, {
+ gpg_id: loc.gpg_id,
+ })
+ ),
+ },
+ 'Remove Location'
+ ),
+ ]),
+ ])
+ )
+ ),
+ ]),
+ ]);
+ },
};
};
-module.exports = Layout;
+const ChatTab = () => {
+ return {
+ view: () => {
+ const gpgId = State.selectedFriendGpgId;
+ const friend = Data.gpgDetails[gpgId];
+ if (!friend) return null;
+
+ const sslId = getOnlineSslId(gpgId);
+
+ if (!sslId) {
+ return m('.network-chat-view', [
+ m('.chat-warning', [
+ m('i.fas.fa-exclamation-triangle'),
+ m('h4', 'No Location Found'),
+ m('p', 'This friend has no known locations to start a direct chat with.'),
+ ]),
+ ]);
+ }
+
+ if (!State.currentChatPeerId) {
+ return m('.network-chat-view', [
+ m('.chat-warning', [
+ m('i.fas.fa-comments'),
+ m('h4', 'Direct Chat'),
+ m('p', 'Click below to start a direct chat with ' + friend.name + '.'),
+ m(
+ 'button',
+ {
+ onclick: () => startDirectChat(sslId),
+ },
+ 'Start Chat'
+ ),
+ ]),
+ ]);
+ }
+
+ return m('.network-chat-view', [
+ (() => {
+ const activeLoc = friend.locations.find((loc) => loc.id === State.currentChatPeerId);
+ const locName = activeLoc ? activeLoc.name : 'Unknown Location';
+ const locOnline = activeLoc ? activeLoc.isOnline : false;
+ return m('.chat-header-bar', {
+ style: {
+ padding: '0.75rem 1rem',
+ backgroundColor: '#ffffff',
+ borderBottom: '1px solid #cbd5e1',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ }
+ }, [
+ m('.chat-header-info', [
+ m('.chat-header-name', { style: { fontWeight: '700', color: '#1e293b' } }, friend.name),
+ m('.chat-header-location', { style: { fontSize: '0.8rem', color: '#64748b', display: 'flex', alignItems: 'center', marginTop: '0.25rem' } }, [
+ m('span', 'Location: ' + locName),
+ m('span.status-dot', {
+ style: {
+ display: 'inline-block',
+ width: '8px',
+ height: '8px',
+ borderRadius: '50%',
+ backgroundColor: locOnline ? '#10b981' : '#ef4444',
+ marginLeft: '6px',
+ marginRight: '4px'
+ }
+ }),
+ m('span', { style: { color: locOnline ? '#10b981' : '#ef4444', fontWeight: '500' } }, locOnline ? 'Online' : 'Offline')
+ ])
+ ])
+ ]);
+ })(),
+ m(
+ '.chat-messages[id=chat-messages-container]',
+ State.chatMessages.map((msg) => {
+ const isOwn = msg.own === true;
+ const senderName = isOwn
+ ? (State.ownProfile.name || 'Me')
+ : friend.name;
+ const time = new Date(msg.sendTime * 1000).toLocaleTimeString();
+ const text = (msg.msg || '')
+ .replaceAll('
', '\n')
+ .replace(new RegExp('|<[^>]*>', 'gm'), '');
return m(
- '.message',
+ '.message' + (msg.incoming ? '.incoming' : '.outgoing'),
m('span.datetime', datetime),
m('span.username', username),
m('span.messagetext', text)
@@ -395,20 +395,23 @@ const ChatLobbyModel = {
}
};
- // Lookup for chat-user names (Only for lobbies for now)
// Lookup for chat-user names
if (detail.gxs_ids) {
- let names = [];
+ let list = [];
if (Array.isArray(detail.gxs_ids)) {
- names = detail.gxs_ids.reduce((a, u) => a.concat(rs.userList.username(u.key)), []);
+ list = detail.gxs_ids.map((u) => {
+ const key = u.key;
+ return { key, name: rs.userList.username(key) };
+ });
} else if (typeof detail.gxs_ids === 'object') {
- names = Object.keys(detail.gxs_ids).map(key => rs.userList.username(key));
+ list = Object.keys(detail.gxs_ids).map((key) => {
+ return { key, name: rs.userList.username(key) };
+ });
}
- names.sort((a, b) => a.localeCompare(b));
- this.users = [];
- names.forEach((name) => (this.users = this.users.concat([m('.user', name)])));
+ list.sort((a, b) => a.name.localeCompare(b.name));
+ this.users = list;
} else {
- this.users = [m('.user', detail.lobby_name)];
+ this.users = [{ key: detail.gxs_id || '', name: detail.lobby_name }];
}
m.redraw();
};
@@ -484,6 +487,38 @@ const ChatLobbyModel = {
},
};
+// ************************* Chat Hub State ****************************
+
+function getSafeAvatar(details) {
+ if (
+ details &&
+ details.mAvatar &&
+ details.mAvatar.mData &&
+ details.mAvatar.mData.base64 !== ''
+ ) {
+ return details.mAvatar;
+ }
+ return undefined;
+}
+
+const ChatHubState = {
+ selectedRoomId: null,
+ selectedRoom: null,
+ selectedRoomType: null,
+ searchString: '',
+ ownProfile: { name: 'Loading...' },
+ gxsDetails: {},
+};
+
+function loadOwnChatProfile() {
+ rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (data) => {
+ if (data && data.status) {
+ ChatHubState.ownProfile.name = data.status.ownName || 'Unknown';
+ m.redraw();
+ }
+ });
+}
+
// ************************* views ****************************
const Lobby = () => {
@@ -523,20 +558,6 @@ const LobbyList = {
},
};
-const SubscribedLeftLobbies = {
- view() {
- return [
- m('h5.lefttitle', 'subscribed:'),
- m(LobbyList, {
- rooms: sortLobbies(Object.values(ChatRoomsModel.subscribedRooms)),
- tagname: '.leftlobby.subscribed',
- lobbytagname: 'leftname',
- onclick: ChatLobbyModel.switchToEvent,
- }),
- ];
- },
-};
-
const SubscribedLobbies = {
view() {
return m('.widget', [
@@ -552,22 +573,6 @@ const SubscribedLobbies = {
},
};
-const PublicLeftLobbies = {
- view() {
- return [
- m('h5.lefttitle', 'public:'),
- m(LobbyList, {
- rooms: Object.values(ChatRoomsModel.allRooms || {}).filter(
- (info) => !ChatRoomsModel.subscribed(info)
- ),
- tagname: '.leftlobby.public',
- lobbytagname: 'leftname',
- onclick: ChatLobbyModel.setupEvent,
- }),
- ];
- },
-};
-
const PublicLobbies = {
view() {
return m('.widget', [
@@ -583,78 +588,438 @@ const PublicLobbies = {
},
};
-const LobbyName = () => {
- return m(
- 'h3.lobbyName',
- m('.mobile-menu-icons', [
- m('i.fas.fa-bars', { onclick: () => MobileState.toggleLobbies() }),
- ]),
- ChatLobbyModel.isSubscribed
- ? [m('span.chatusername', ChatLobbyModel.lobby_user), m('span.chatatchar', '@')]
- : [],
- ChatLobbyModel.currentLobby.chatType === 2
- ? m('i.fas.fa-circle', {
- style: {
- color:
- ChatLobbyModel.currentLobby.status === 2
- ? '#2ecc71' // Green (Can Talk)
- : ChatLobbyModel.currentLobby.status === 1
- ? '#f39c12' // Orange (Tunnel Down)
- : ChatLobbyModel.currentLobby.status === 3
- ? '#e74c3c' // Red (Remotely Closed)
- : '#95a5a6', // Grey (Unknown)
- fontSize: '0.6em',
- marginRight: '10px',
- verticalAlign: 'middle',
- },
- title:
- ChatLobbyModel.currentLobby.status === 2
- ? 'Tunnel Active (Can Talk)'
- : ChatLobbyModel.currentLobby.status === 1
- ? 'Tunnel Down (Negotiating...)'
- : ChatLobbyModel.currentLobby.status === 3
- ? 'Remotely Closed'
- : 'Status Unknown',
- })
- : [],
- m('span.chatlobbyname', ChatLobbyModel.currentLobby.lobby_name),
- m('.mobile-menu-icons', [
- m('i.fas.fa-users', { onclick: () => MobileState.toggleUsers() }),
- ]),
- m.route.param('subaction') !== 'setup' && ChatLobbyModel.currentLobby.chatType === 3
- ? [
- m('i.fas.fa-cog.setupicon', {
- title: 'configure lobby',
- onclick: () =>
- m.route.set(
- '/chat/:lobby/:subaction',
- {
- lobby: m.route.param('lobby'),
- subaction: 'setup',
+// ************************* Chat Hub Sub-Components ****************************
+
+const ChatRoomHeader = () => {
+ return {
+ view: (vnode) => {
+ const room = vnode.attrs.room;
+ const lobbyHexId = rs.idToHex(room.lobby_id);
+ return m('.chat-hub-header-bar', [
+ m('.chat-header-info', [
+ m('.chat-header-name', room.lobby_name || ''),
+ m('.chat-header-topic', room.lobby_topic || 'No topic'),
+ ]),
+ m('.chat-header-actions', [
+ m(
+ 'button.red',
+ {
+ title: 'Leave Room',
+ onclick: () => {
+ ChatLobbyModel.unsubscribeChatLobby(lobbyHexId, () => {
+ ChatHubState.selectedRoom = null;
+ ChatHubState.selectedRoomId = null;
+ ChatHubState.selectedRoomType = null;
+ m.route.set('/chat');
+ });
},
- { replace: true }
- ),
- }),
- ]
- : [],
- ChatLobbyModel.isSubscribed
- ? [
- m('i.fas.fa-sign-out-alt.leaveicon', {
- title: 'leaving lobby',
- onclick: () =>
- ChatLobbyModel.unsubscribeChatLobby(m.route.param('lobby'), () => {
- m.route.set('/chat', null, { replace: true });
- }),
- }),
- ]
- : []
- );
+ },
+ [m('i.fas.fa-sign-out-alt'), ' Leave']
+ ),
+ ]),
+ ]);
+ },
+ };
+};
+
+function scrollChatToBottom() {
+ setTimeout(() => {
+ const element = document.querySelector('.chat-hub-messages');
+ if (element) {
+ element.scrollTop = element.scrollHeight;
+ }
+ }, 50);
+}
+
+const ChatConversationView = () => {
+ return {
+ oninit: () => {
+ scrollChatToBottom();
+ },
+ view: () => {
+ return m('.chat-hub-conversation-layout', [
+ m('.chat-hub-conversation-main', [
+ m(
+ '.chat-hub-messages',
+ {
+ oncreate: () => scrollChatToBottom(),
+ onupdate: () => scrollChatToBottom(),
+ },
+ ChatLobbyModel.messages
+ ),
+ m(
+ '.chat-hub-input-area',
+ [
+ m('textarea.chat-hub-textarea', {
+ placeholder: 'Type a message... Press Enter to send',
+ enterkeyhint: 'send',
+ onkeydown: (e) => {
+ if ((e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey) {
+ const msg = e.target.value;
+ if (msg.trim() === '') return false;
+ e.target.value = ' sending ... ';
+ ChatLobbyModel.sendMessage(msg, () => {
+ e.target.value = '';
+ scrollChatToBottom();
+ });
+ return false;
+ }
+ },
+ }),
+ m(
+ 'button.chat-hub-send-btn',
+ {
+ onclick: (e) => {
+ const textarea = e.target.closest('.chat-hub-input-area').querySelector('textarea');
+ const msg = textarea.value;
+ if (msg.trim() === '') return;
+ textarea.value = ' sending ... ';
+ ChatLobbyModel.sendMessage(msg, () => {
+ textarea.value = '';
+ scrollChatToBottom();
+ });
+ },
+ },
+ m('i.fas.fa-paper-plane')
+ ),
+ ]
+ ),
+ ]),
+ m('.chat-hub-rightbar', [
+ m('.rightbar-title', 'Participants'),
+ m('.rightbar-users-list', ChatLobbyModel.users.map((user) => {
+ const gxsId = user.key;
+ const name = user.name;
+
+ // Load details for avatar if not cached
+ if (gxsId && ChatHubState.gxsDetails[gxsId] === undefined) {
+ ChatHubState.gxsDetails[gxsId] = null; // Mark as loading
+ rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (data) => {
+ if (data && data.details) {
+ ChatHubState.gxsDetails[gxsId] = data.details;
+ m.redraw();
+ }
+ });
+ }
+
+ const avatar = getSafeAvatar(ChatHubState.gxsDetails[gxsId]);
+ const firstLetter = (name || '?').slice(0, 1).toUpperCase();
+
+ return m('.user', [
+ m(peopleUtil.UserAvatar, { avatar, firstLetter }),
+ m('span.user-name', name),
+ ]);
+ }))
+ ])
+ ]);
+ },
+ };
};
// ***************************** Page Layouts ******************************
+const ChatRoomDetailView = () => {
+ return {
+ view: () => {
+ const room = ChatHubState.selectedRoom;
+ if (!room) return null;
+
+ let participantCount = 0;
+ let participantNames = [];
+ if (room.gxs_ids) {
+ if (Array.isArray(room.gxs_ids)) {
+ participantCount = room.gxs_ids.length;
+ participantNames = room.gxs_ids.map((u) => rs.userList.username(u.key) || u.key);
+ } else if (typeof room.gxs_ids === 'object') {
+ const keys = Object.keys(room.gxs_ids);
+ participantCount = keys.length;
+ participantNames = keys.map((key) => rs.userList.username(key) || key);
+ }
+ }
+ participantNames.sort((a, b) => a.localeCompare(b));
+
+ const lobbyHexId = rs.idToHex(room.lobby_id);
+
+ return m('.chat-room-detail-view', [
+ m('.detail-section', [
+ m('h3', 'Room Info'),
+ m('.info-grid', [
+ m('.info-label', 'Room Name'),
+ m('.info-value', room.lobby_name || ''),
+ m('.info-label', 'Topic'),
+ m('.info-value', room.lobby_topic || 'None'),
+ m('.info-label', 'Participants'),
+ m('.info-value', participantCount + ' users'),
+ m('.info-label', 'Your Identity'),
+ m('.info-value', rs.userList.username(room.gxs_id) || room.gxs_id || '???'),
+ m('.info-label', 'Lobby ID'),
+ m('.info-value', lobbyHexId),
+ ]),
+ ]),
+
+ m('.detail-section', [
+ m('h3', 'Participants (' + participantCount + ')'),
+ participantNames.length > 0
+ ? m(
+ '.participants-grid',
+ participantNames.map((name) =>
+ m('.participant-card', m('.participant-name', name))
+ )
+ )
+ : m('p.no-participants', 'No participant information available'),
+ ]),
+ ]);
+ },
+ };
+};
+
+const ChatRoomJoinView = () => {
+ let ownIds = [];
+ return {
+ oninit: () => peopleUtil.ownIds((data) => (ownIds = data)),
+ view: () => {
+ const room = ChatHubState.selectedRoom;
+ if (!room) return null;
+
+ const lobbyHexId = rs.idToHex(room.lobby_id);
+ const participantCount = room.total_number_of_peers || 0;
+
+ return m('.chat-room-detail-view', [
+ m('.detail-section', [
+ m('h3', 'Room Info'),
+ m('.info-grid', [
+ m('.info-label', 'Room Name'),
+ m('.info-value', room.lobby_name || ''),
+ m('.info-label', 'Topic'),
+ m('.info-value', room.lobby_topic || 'None'),
+ m('.info-label', 'Participants'),
+ m('.info-value', participantCount + ' users'),
+ ]),
+ ]),
+
+ m('.detail-section', [
+ m('h3', 'Join Room'),
+ m('p.join-description', 'Select an identity to join this chat room:'),
+ m(
+ '.identities-grid',
+ ownIds.map((nick) =>
+ m(
+ '.identity-card',
+ { onclick: () => ChatLobbyModel.enterPublicLobby(lobbyHexId, nick) },
+ [
+ m('.identity-name', rs.userList.username(nick) || nick),
+ m('i.fas.fa-sign-in-alt'),
+ ]
+ )
+ )
+ ),
+ ]),
+ ]);
+ },
+ };
+};
+
const Layout = {
- view: () => m('.node-panel.chat-panel.chat-hub', [m(SubscribedLobbies), m(PublicLobbies)]),
+ oninit: () => {
+ ChatHubState.activeTab = 'chat';
+ const lobbyId = m.route.param('lobby');
+ if (lobbyId) {
+ ChatHubState.selectedRoomId = lobbyId;
+ ChatLobbyModel.loadLobby(lobbyId);
+ }
+ },
+ onupdate: () => {
+ const lobbyId = m.route.param('lobby');
+ if (lobbyId && ChatHubState.selectedRoomId !== lobbyId) {
+ ChatHubState.selectedRoomId = lobbyId;
+ ChatLobbyModel.loadLobby(lobbyId);
+ }
+ },
+ view: () => {
+ const search = ChatHubState.searchString.toLowerCase();
+
+ const subscribedRooms = sortLobbies(
+ Object.values(ChatRoomsModel.subscribedRooms)
+ ).filter((info) => (info.lobby_name || '').toLowerCase().includes(search));
+
+ const publicRooms = (ChatRoomsModel.allRooms || [])
+ .filter((info) => !ChatRoomsModel.subscribed(info))
+ .filter((info) => (info.lobby_name || '').toLowerCase().includes(search));
+
+ const isSelected = (info, type) =>
+ ChatHubState.selectedRoomId === rs.idToHex(info.lobby_id);
+
+ const lobbyId = ChatHubState.selectedRoomId;
+ let selectedRoom = null;
+ let selectedRoomType = null;
+
+ if (lobbyId) {
+ if (ChatRoomsModel.subscribedRooms[lobbyId]) {
+ selectedRoom = ChatRoomsModel.subscribedRooms[lobbyId];
+ selectedRoomType = 'subscribed';
+ } else {
+ selectedRoom = ChatRoomsModel.allRooms.find(
+ (r) => rs.idToHex(r.lobby_id) === lobbyId
+ );
+ if (selectedRoom) {
+ selectedRoomType = 'public';
+ }
+ }
+ }
+
+ if (selectedRoom) {
+ ChatHubState.selectedRoom = selectedRoom;
+ ChatHubState.selectedRoomType = selectedRoomType;
+ } else if (!m.route.param('lobby')) {
+ ChatHubState.selectedRoom = null;
+ ChatHubState.selectedRoomId = null;
+ ChatHubState.selectedRoomType = null;
+ }
+
+ return m('.chat-hub-container', [
+ m('.chat-hub-left-pane', [
+ m('.chat-own-profile-card', [
+ m('.profile-header', [
+ m('i.fas.fa-comments', { style: { fontSize: '1.5rem', color: '#3ba4d7' } }),
+ m('.profile-info', [
+ m('.profile-name', 'Chat rooms'),
+ ]),
+ ]),
+ ]),
+
+ m('.chat-rooms-list-container', [
+ m('.searchbar-container', [
+ m('input.searchbar', {
+ type: 'text',
+ placeholder: 'Search chat rooms...',
+ value: ChatHubState.searchString,
+ oninput: (e) => {
+ ChatHubState.searchString = e.target.value;
+ },
+ }),
+ ]),
+ m('.rooms-scroll', [
+ subscribedRooms.length > 0 && [
+ m('.rooms-section-title', [
+ m('i.fas.fa-bookmark'),
+ m('span', 'Subscribed (' + subscribedRooms.length + ')'),
+ ]),
+ subscribedRooms.map((info) => {
+ const hexId = rs.idToHex(info.lobby_id);
+ let count = 0;
+ if (info.gxs_ids) {
+ if (Array.isArray(info.gxs_ids)) count = info.gxs_ids.length;
+ else if (typeof info.gxs_ids === 'object')
+ count = Object.keys(info.gxs_ids).length;
+ }
+ return m(
+ '.chat-room-list-item' +
+ (isSelected(info, 'subscribed') ? '.selected' : ''),
+ {
+ key: hexId,
+ onclick: () => {
+ m.route.set('/chat/:lobby', { lobby: hexId });
+ },
+ },
+ [
+ m('.room-icon', m('i.fas.fa-comments')),
+ m('.room-meta', [
+ m('.room-name', info.lobby_name || ''),
+ m('.room-topic', info.lobby_topic || 'No topic'),
+ ]),
+ count > 0 && m('.room-badge', count),
+ ]
+ );
+ }),
+ ],
+
+ publicRooms.length > 0 && [
+ m('.rooms-section-title', [
+ m('i.fas.fa-globe'),
+ m('span', 'Public (' + publicRooms.length + ')'),
+ ]),
+ publicRooms.map((info) => {
+ const hexId = rs.idToHex(info.lobby_id);
+ const count = info.total_number_of_peers || 0;
+ return m(
+ '.chat-room-list-item.public-room' +
+ (isSelected(info, 'public') ? '.selected' : ''),
+ {
+ key: hexId,
+ onclick: () => {
+ m.route.set('/chat/:lobby', { lobby: hexId });
+ },
+ },
+ [
+ m('.room-icon', m('i.fas.fa-globe')),
+ m('.room-meta', [
+ m('.room-name', info.lobby_name || ''),
+ m('.room-topic', info.lobby_topic || 'No topic'),
+ ]),
+ count > 0 && m('.room-badge', count),
+ ]
+ );
+ }),
+ ],
+
+ subscribedRooms.length === 0 &&
+ publicRooms.length === 0 &&
+ m('p.no-rooms', 'No chat rooms found'),
+ ]),
+ ]),
+ ]),
+
+ m('.chat-hub-right-pane', [
+ ChatHubState.selectedRoom
+ ? [
+ ChatHubState.selectedRoomType === 'subscribed'
+ ? [
+ m(ChatRoomHeader, { room: ChatHubState.selectedRoom }),
+ m('.chat-hub-tabs-container', [
+ m('.chat-hub-tabs', [
+ m(
+ 'button.tab-btn' +
+ (ChatHubState.activeTab === 'chat' ? '.active' : ''),
+ {
+ onclick: () => {
+ ChatHubState.activeTab = 'chat';
+ scrollChatToBottom();
+ },
+ },
+ [m('i.fas.fa-comments'), ' Chat']
+ ),
+ m(
+ 'button.tab-btn' +
+ (ChatHubState.activeTab === 'details' ? '.active' : ''),
+ {
+ onclick: () => {
+ ChatHubState.activeTab = 'details';
+ },
+ },
+ [m('i.fas.fa-info-circle'), ' Details']
+ ),
+ ]),
+ ]),
+ m('.chat-hub-tab-content', { style: { padding: ChatHubState.activeTab === 'chat' ? '0' : '1.5rem' } }, [
+ ChatHubState.activeTab === 'chat'
+ ? m(ChatConversationView)
+ : m(ChatRoomDetailView),
+ ]),
+ ]
+ : [
+ m('.chat-hub-tab-content', m(ChatRoomJoinView)),
+ ],
+ ]
+ : m('.chat-pane-placeholder', [
+ m('i.fas.fa-comments'),
+ m(
+ 'p',
+ 'Select a chat room from the left panel to view details or join a conversation.'
+ ),
+ ]),
+ ]),
+ ]);
+ },
};
const LayoutSingle = () => {
@@ -681,10 +1046,7 @@ const LayoutSingle = () => {
},
[
m('.chat-overlay', { onclick: () => MobileState.closeAll() }),
- LobbyName(),
- !isPrivate && m('.lobbies', m(SubscribedLeftLobbies), m(PublicLeftLobbies)),
m('.messages', { onclick: () => MobileState.closeAll() }, ChatLobbyModel.messages),
- m('.rightbar', ChatLobbyModel.users),
m(
'.chatMessage',
{},
@@ -723,40 +1085,6 @@ const LayoutSingle = () => {
};
};
-const LayoutSetup = () => {
- let ownIds = [];
- return {
- oninit: () => peopleUtil.ownIds((data) => (ownIds = data)),
- view: (vnode) =>
- m(
- '.node-panel.chat-panel.chat-room.chat-setup',
- {
- class:
- (MobileState.showLobbies ? 'show-lobbies ' : '') +
- (MobileState.showUsers ? 'show-users' : ''),
- },
- [
- m('.chat-overlay', { onclick: () => MobileState.closeAll() }),
- LobbyName(),
- m('.lobbies', m(SubscribedLeftLobbies), m(PublicLeftLobbies)),
- m('.setup', [
- m('h5.selectidentity', 'Select identity to use'),
- ownIds.map((nick) =>
- m(
- '.identity' +
- (ChatLobbyModel.currentLobby.gxs_id === nick ? '.selectedidentity' : ''),
- {
- onclick: () => ChatLobbyModel.setupAction(m.route.param('lobby'), nick),
- },
- rs.userList.username(nick)
- )
- ),
- ]),
- ]
- ),
- };
-};
-
/*
/rsChats/initiateDistantChatConnexion
* @param[in] to_pid RsGxsId to start the connection
@@ -802,16 +1130,13 @@ const LayoutCreateDistant = () => {
module.exports = {
oninit: () => {
ChatRoomsModel.loadSubscribedRooms();
+ loadOwnChatProfile();
},
view: (vnode) => {
- if (m.route.param('lobby') === undefined) {
- return m(Layout);
- } else if (m.route.param('subaction') === 'setup') {
- return m(LayoutSetup);
- } else if (m.route.param('subaction') === 'createdistantchat') {
+ if (m.route.param('subaction') === 'createdistantchat') {
return m(LayoutCreateDistant);
} else {
- return m(LayoutSingle);
+ return m(Layout);
}
},
};
diff --git a/webui-src/app/scss/pages/_chat.scss b/webui-src/app/scss/pages/_chat.scss
index 84293e5..9f7958e 100644
--- a/webui-src/app/scss/pages/_chat.scss
+++ b/webui-src/app/scss/pages/_chat.scss
@@ -1,346 +1,1067 @@
-@use '../abstracts' as *;
-
-.lobby {
- margin: 10px;
- border: 1px solid #aaa;
- border-radius: 20px;
-}
-
-.lobby .mainname {
- margin: 20px;
- font-weight: 100;
- font-size: 1.2em;
-}
-
-.topic {
- color: #666;
-}
-
-.lobby>.topic {
- font-size: 0.95em;
- margin-left: 25px;
- margin-bottom: 5px;
-}
-
-.lefttitle {
- margin-top: 15px;
- margin-bottom: 0;
- font-weight: 100;
- font-size: 1.2em;
-}
-
-.leftname {
- margin-top: 5px;
- margin-bottom: 5px;
- padding: 5px;
- font-weight: 100;
- font-size: 1em;
-}
-
-.leftlobby>.topic {
- font-size: 0.75em;
- margin-left: 15px;
- margin-bottom: 5px;
-}
-
-.subscribed,
-.public {
- cursor: pointer;
-}
-
-.leftlobby {
- border: 1px solid #aaa;
- border-radius: 10px;
- margin-top: 5px;
- background-color: white;
-}
-
-.leftlobby.selected-lobby,
-.selectedidentity {
- color: white;
- background-color: #3ba4d7;
-}
-
-.rightbar {
- position: absolute;
- width: 185px;
- background-color: white;
- overflow: auto;
- top: 130px;
- bottom: 15px;
- right: 15px;
-}
-
-.user {
- padding: 5px;
-}
-
-.lobbyName {
- padding: 15px;
- margin-top: 2rem;
-}
-
-.lobbies {
- position: absolute;
- width: 185px;
- left: 165px;
- bottom: 15px;
- top: 130px;
- overflow: auto;
-}
-
-.messages,
-.setup {
- position: absolute;
- background-color: white;
- top: 130px;
- left: 360px;
- right: 215px;
- overflow: auto;
-}
-
-.messages {
- bottom: 115px;
-}
-
-.messagetext {
- white-space: break-spaces;
- margin-right: 5px;
-}
-
-.message>* {
- margin-left: 5px;
-}
-
-.username {
- color: darkgreen;
- font-weight: bolder;
-}
-
-.chatMessage {
- position: absolute;
- background-color: white;
- height: 85px;
- bottom: 15px;
- right: 215px;
- left: 360px;
-}
-
-textarea.chatMsg {
- height: 100%;
- width: 100%;
-}
-
-.chatatchar {
- margin-left: 0.2em;
- margin-right: 0.2em;
- color: silver;
-}
-
-.setupicon {
- margin-left: 1em;
- cursor: pointer;
-}
-
-.leaveicon {
- margin-left: 1em;
- cursor: pointer;
- color: #d40000;
-}
-
-.selectidentity {
- margin: 15px;
- font-size: 1.2em;
-}
-
-.setup>.identity {
- cursor: pointer;
-}
-
-.setup {
- bottom: 15px;
-}
-
-.createDistantChat {
- margin-top: 1em;
-}
-
-.no-lobbies {
-
- .messages,
- .chatMessage,
- .setup {
- left: 165px;
- }
-}
-
-/* CHAT ROOM (Single Chat) - Desktop Grid Layout */
-@media (min-width: 900px) {
- .node-panel.chat-room {
- display: grid !important;
- grid-template-columns: 250px 1fr 200px !important;
- /* Lobbies, Chat, Users */
- grid-template-rows: auto 1fr auto !important;
- /* Header, Messages, Input */
- grid-template-areas:
- "lobbies header rightbar"
- "lobbies messages rightbar"
- "lobbies input rightbar" !important;
- padding: 0 !important;
- height: 100% !important;
- }
-
- .node-panel.chat-room .lobbyName {
- grid-area: header;
- padding: 10px;
- border-bottom: 1px solid #eee;
- margin: 0;
- z-index: 10;
- background: white;
- }
-
- .node-panel.chat-room .lobbies {
- grid-area: lobbies;
- position: static !important;
- width: auto !important;
- height: auto !important;
- border-right: 1px solid #ccc;
- overflow-y: auto;
- display: block !important;
- top: auto !important;
- bottom: auto !important;
- left: auto !important;
- }
-
- .node-panel.chat-room .messages {
- grid-area: messages;
- position: static !important;
- width: auto !important;
- height: auto !important;
- overflow-y: auto;
- padding: 10px;
- left: auto !important;
- right: auto !important;
- top: auto !important;
- bottom: auto !important;
- margin: 0 !important;
- }
-
- .node-panel.chat-room .rightbar {
- grid-area: rightbar;
- position: static !important;
- width: auto !important;
- border-left: 1px solid #ccc;
- overflow-y: auto;
- display: block !important;
- }
-
- .node-panel.chat-room .chatMessage {
- grid-area: input;
- position: static !important;
- width: auto !important;
- height: auto !important;
- border-top: 1px solid #eee;
- left: auto !important;
- right: auto !important;
- bottom: auto !important;
- flex: 0 0 auto;
- padding: 10px !important;
- background: white;
- z-index: 10;
- }
-}
-
-/* Mobile Overrides - Ensure Flex Column */
-@media (max-width: 899px) {
- .node-panel.chat-room {
- display: flex !important;
- flex-direction: column !important;
- height: 100% !important;
- position: relative !important;
- }
-
- .node-panel.chat-room .lobbyName {
- flex: 0 0 auto;
- }
-
- .node-panel.chat-room .messages {
- flex: 1 !important;
- overflow-y: auto !important;
- position: relative !important;
- top: 0 !important;
- bottom: 0 !important;
- left: 0 !important;
- right: 0 !important;
- width: 100% !important;
- height: auto !important;
- margin: 0 !important;
- }
-
- .node-panel.chat-room .chatMessage {
- flex: 0 0 auto !important;
- position: relative !important;
- bottom: 0 !important;
- left: 0 !important;
- right: 0 !important;
- width: 100% !important;
- height: auto !important;
- z-index: 100;
- }
-
- .node-panel.chat-room .rightbar,
- .node-panel.chat-room .lobbies {
- display: none !important;
- position: fixed !important;
- top: 60px !important;
- bottom: 0 !important;
- width: 80% !important;
- background: white !important;
- z-index: 200 !important;
- box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2) !important;
- }
-
- .node-panel.chat-room.show-lobbies .lobbies {
- display: block !important;
- left: 0 !important;
- }
-
- .node-panel.chat-room.show-users .rightbar {
- display: block !important;
- right: 0 !important;
- }
-
- .chat-overlay {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.4);
- z-index: 150;
- }
-
- .show-lobbies .chat-overlay,
- .show-users .chat-overlay {
- display: block;
- }
-
- /* Mobile Icons in Header */
- .mobile-menu-icons {
- display: flex;
- gap: 15px;
- font-size: 1.2rem;
- }
-
- .mobile-menu-icons i {
- cursor: pointer;
- padding: 5px;
- }
-}
-
-@media (min-width: 900px) {
- .mobile-menu-icons {
- display: none;
- }
-}
\ No newline at end of file
+@use '../abstracts' as *;
+
+.lobby {
+ margin: 10px;
+ border: 1px solid #aaa;
+ border-radius: 20px;
+}
+
+.lobby .mainname {
+ margin: 20px;
+ font-weight: 100;
+ font-size: 1.2em;
+}
+
+.topic {
+ color: #666;
+}
+
+.lobby>.topic {
+ font-size: 0.95em;
+ margin-left: 25px;
+ margin-bottom: 5px;
+}
+
+.lefttitle {
+ margin-top: 15px;
+ margin-bottom: 0;
+ font-weight: 100;
+ font-size: 1.2em;
+}
+
+.leftname {
+ margin-top: 5px;
+ margin-bottom: 5px;
+ padding: 5px;
+ font-weight: 100;
+ font-size: 1em;
+}
+
+.leftlobby>.topic {
+ font-size: 0.75em;
+ margin-left: 15px;
+ margin-bottom: 5px;
+}
+
+.subscribed,
+.public {
+ cursor: pointer;
+}
+
+.leftlobby {
+ border: 1px solid #aaa;
+ border-radius: 10px;
+ margin-top: 5px;
+ background-color: white;
+}
+
+.leftlobby.selected-lobby,
+.selectedidentity {
+ color: white;
+ background-color: #3ba4d7;
+}
+
+.rightbar {
+ position: absolute;
+ width: 185px;
+ background-color: white;
+ overflow: auto;
+ top: 130px;
+ bottom: 15px;
+ right: 15px;
+}
+
+.user {
+ padding: 5px;
+}
+
+.lobbyName {
+ padding: 15px;
+ margin-top: 2rem;
+}
+
+.lobbies {
+ position: absolute;
+ width: 185px;
+ left: 165px;
+ bottom: 15px;
+ top: 130px;
+ overflow: auto;
+}
+
+.messages,
+.setup {
+ position: absolute;
+ background-color: white;
+ top: 130px;
+ left: 360px;
+ right: 215px;
+ overflow: auto;
+}
+
+.messages {
+ bottom: 115px;
+}
+
+.messagetext {
+ white-space: break-spaces;
+ margin-right: 5px;
+}
+
+.message>* {
+ margin-left: 5px;
+}
+
+.username {
+ color: darkgreen;
+ font-weight: bolder;
+}
+
+.chatMessage {
+ position: absolute;
+ background-color: white;
+ height: 85px;
+ bottom: 15px;
+ right: 215px;
+ left: 360px;
+}
+
+textarea.chatMsg {
+ height: 100%;
+ width: 100%;
+}
+
+.chatatchar {
+ margin-left: 0.2em;
+ margin-right: 0.2em;
+ color: silver;
+}
+
+.setupicon {
+ margin-left: 1em;
+ cursor: pointer;
+}
+
+.leaveicon {
+ margin-left: 1em;
+ cursor: pointer;
+ color: #d40000;
+}
+
+.selectidentity {
+ margin: 15px;
+ font-size: 1.2em;
+}
+
+.setup>.identity {
+ cursor: pointer;
+}
+
+.setup {
+ bottom: 15px;
+}
+
+.createDistantChat {
+ margin-top: 1em;
+}
+
+.no-lobbies {
+
+ .messages,
+ .chatMessage,
+ .setup {
+ left: 165px;
+ }
+}
+
+/* CHAT ROOM (Single Chat) - Desktop Grid Layout */
+@media (min-width: 900px) {
+ .node-panel.chat-room {
+ display: grid !important;
+ grid-template-columns: 250px 1fr 200px !important;
+ /* Lobbies, Chat, Users */
+ grid-template-rows: auto 1fr auto !important;
+ /* Header, Messages, Input */
+ grid-template-areas:
+ "lobbies header rightbar"
+ "lobbies messages rightbar"
+ "lobbies input rightbar" !important;
+ padding: 0 !important;
+ height: 100% !important;
+ }
+
+ .node-panel.chat-room .lobbyName {
+ grid-area: header;
+ padding: 10px;
+ border-bottom: 1px solid #eee;
+ margin: 0;
+ z-index: 10;
+ background: white;
+ }
+
+ .node-panel.chat-room .lobbies {
+ grid-area: lobbies;
+ position: static !important;
+ width: auto !important;
+ height: auto !important;
+ border-right: 1px solid #ccc;
+ overflow-y: auto;
+ display: block !important;
+ top: auto !important;
+ bottom: auto !important;
+ left: auto !important;
+ }
+
+ .node-panel.chat-room .messages {
+ grid-area: messages;
+ position: static !important;
+ width: auto !important;
+ height: auto !important;
+ overflow-y: auto;
+ padding: 10px;
+ left: auto !important;
+ right: auto !important;
+ top: auto !important;
+ bottom: auto !important;
+ margin: 0 !important;
+ }
+
+ .node-panel.chat-room .rightbar {
+ grid-area: rightbar;
+ position: static !important;
+ width: auto !important;
+ border-left: 1px solid #ccc;
+ overflow-y: auto;
+ display: block !important;
+ }
+
+ .node-panel.chat-room .chatMessage {
+ grid-area: input;
+ position: static !important;
+ width: auto !important;
+ height: auto !important;
+ border-top: 1px solid #eee;
+ left: auto !important;
+ right: auto !important;
+ bottom: auto !important;
+ flex: 0 0 auto;
+ padding: 10px !important;
+ background: white;
+ z-index: 10;
+ }
+}
+
+/* Mobile Overrides - Ensure Flex Column */
+@media (max-width: 899px) {
+ .node-panel.chat-room {
+ display: flex !important;
+ flex-direction: column !important;
+ height: 100% !important;
+ position: relative !important;
+ }
+
+ .node-panel.chat-room .lobbyName {
+ flex: 0 0 auto;
+ }
+
+ .node-panel.chat-room .messages {
+ flex: 1 !important;
+ overflow-y: auto !important;
+ position: relative !important;
+ top: 0 !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ width: 100% !important;
+ height: auto !important;
+ margin: 0 !important;
+ }
+
+ .node-panel.chat-room .chatMessage {
+ flex: 0 0 auto !important;
+ position: relative !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ width: 100% !important;
+ height: auto !important;
+ z-index: 100;
+ }
+
+ .node-panel.chat-room .rightbar,
+ .node-panel.chat-room .lobbies {
+ display: none !important;
+ position: fixed !important;
+ top: 60px !important;
+ bottom: 0 !important;
+ width: 80% !important;
+ background: white !important;
+ z-index: 200 !important;
+ box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2) !important;
+ }
+
+ .node-panel.chat-room.show-lobbies .lobbies {
+ display: block !important;
+ left: 0 !important;
+ }
+
+ .node-panel.chat-room.show-users .rightbar {
+ display: block !important;
+ right: 0 !important;
+ }
+
+ .chat-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 150;
+ }
+
+ .show-lobbies .chat-overlay,
+ .show-users .chat-overlay {
+ display: block;
+ }
+
+ /* Mobile Icons in Header */
+ .mobile-menu-icons {
+ display: flex;
+ gap: 15px;
+ font-size: 1.2rem;
+ }
+
+ .mobile-menu-icons i {
+ cursor: pointer;
+ padding: 5px;
+ }
+}
+
+@media (min-width: 900px) {
+ .mobile-menu-icons {
+ display: none;
+ }
+}
+
+/* =====================================================
+ CHAT HUB - Two-Pane Layout (matching Network page)
+ ===================================================== */
+
+.chat-hub-container {
+ display: flex;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.chat-hub-left-pane {
+ width: 320px;
+ min-width: 300px;
+ max-width: 350px;
+ border-right: 1px solid #cbd5e1;
+ display: flex;
+ flex-direction: column;
+ background: #ffffff;
+ box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
+}
+
+.chat-own-profile-card {
+ padding: 1.25rem;
+ border-bottom: 1px solid #e2e8f0;
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+
+ .profile-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+
+ .profile-info {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+
+ .profile-name {
+ font-weight: 700;
+ color: #1e293b;
+ font-size: 1.1rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .profile-status {
+ font-size: 0.85rem;
+ color: #10b981;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background-color: #10b981;
+ border-radius: 50%;
+ }
+ }
+ }
+}
+
+.chat-rooms-list-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ .searchbar-container {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e2e8f0;
+
+ input.searchbar {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9rem;
+ border: 1px solid #cbd5e1;
+ border-radius: 0.375rem;
+ background-color: #f8fafc;
+ outline: none;
+ transition: all 0.2s;
+
+ &:focus {
+ background-color: #ffffff;
+ border-color: #3ba4d7;
+ box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15);
+ }
+ }
+ }
+
+ .rooms-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem 0;
+ }
+}
+
+.rooms-section-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+
+ i {
+ font-size: 0.7rem;
+ color: #94a3b8;
+ }
+}
+
+.chat-room-list-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ margin: 0.125rem 0.5rem;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ background-color: #f1f5f9;
+ }
+
+ &.selected {
+ background-color: #e0f2fe;
+
+ .room-name {
+ color: #0369a1;
+ font-weight: 600;
+ }
+ }
+
+ .room-icon {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ border-radius: 0.5rem;
+ background: linear-gradient(135deg, #3ba4d7, #0ea5e9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #ffffff;
+ font-size: 0.85rem;
+ }
+
+ &.public-room .room-icon {
+ background: linear-gradient(135deg, #10b981, #059669);
+ }
+
+ .room-meta {
+ flex: 1;
+ min-width: 0;
+
+ .room-name {
+ font-size: 0.95rem;
+ color: #334155;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: color 0.2s;
+ }
+
+ .room-topic {
+ font-size: 0.8rem;
+ color: #94a3b8;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .room-badge {
+ flex-shrink: 0;
+ min-width: 24px;
+ height: 24px;
+ border-radius: 12px;
+ background-color: #e2e8f0;
+ color: #475569;
+ font-size: 0.75rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0.375rem;
+ }
+}
+
+.chat-hub-right-pane {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #f8fafc;
+}
+
+.chat-pane-placeholder {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ color: #94a3b8;
+ gap: 1rem;
+ padding: 2rem;
+ text-align: center;
+
+ i {
+ font-size: 4rem;
+ color: #cbd5e1;
+ }
+
+ p {
+ font-size: 1.1rem;
+ max-width: 400px;
+ }
+}
+
+.chat-hub-tab-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.5rem;
+}
+
+.chat-room-detail-view {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+
+ .detail-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 1.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid #e2e8f0;
+ flex-wrap: wrap;
+
+ .detail-title {
+ flex: 1;
+ min-width: 200px;
+
+ h2 {
+ font-size: 1.75rem;
+ font-weight: 800;
+ color: #1e293b;
+ margin-bottom: 0.25rem;
+ }
+
+ .detail-subtitle {
+ font-size: 0.9rem;
+ color: #64748b;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+ }
+
+ .detail-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+ }
+ }
+ }
+
+ .detail-section {
+ background-color: #ffffff;
+ border-radius: 0.5rem;
+ border: 1px solid #e2e8f0;
+ padding: 1.25rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
+
+ h3 {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #334155;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid #f1f5f9;
+ }
+
+ .info-grid {
+ display: grid;
+ grid-template-columns: 130px 1fr;
+ row-gap: 0.75rem;
+ font-size: 0.9rem;
+
+ .info-label {
+ font-weight: 600;
+ color: #64748b;
+ }
+
+ .info-value {
+ color: #1e293b;
+ word-break: break-all;
+ }
+ }
+ }
+}
+
+.participants-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.5rem;
+}
+
+.participant-card {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background-color: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.375rem;
+
+ .participant-name {
+ font-size: 0.875rem;
+ color: #334155;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.no-participants {
+ color: #94a3b8;
+ font-size: 0.9rem;
+ font-style: italic;
+}
+
+.detail-actions-footer {
+ display: flex;
+ gap: 0.75rem;
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+ }
+}
+
+.join-description {
+ color: #64748b;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+.identities-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.75rem;
+}
+
+.identity-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background-color: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ background-color: #e0f2fe;
+ border-color: #3ba4d7;
+ }
+
+ .identity-name {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #334155;
+ }
+
+ i {
+ color: #3ba4d7;
+ font-size: 0.9rem;
+ }
+}
+
+.no-rooms {
+ padding: 1rem;
+ color: #94a3b8;
+ text-align: center;
+ font-style: italic;
+}
+
+/* Chat Hub Responsive - Mobile */
+@media (max-width: 899px) {
+ .chat-hub-container {
+ flex-direction: column;
+ }
+
+ .chat-hub-left-pane {
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ max-height: 45%;
+ border-right: none;
+ border-bottom: 1px solid #cbd5e1;
+ }
+
+ .chat-hub-right-pane {
+ flex: 1;
+ min-height: 0;
+ }
+}
+
+/* =====================================================
+ CHAT HUB - Right Pane Conversation & Tabs Styling
+ ===================================================== */
+
+.chat-hub-header-bar {
+ padding: 0.75rem 1.5rem;
+ background-color: #ffffff;
+ border-bottom: 1px solid #e2e8f0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 65px;
+ flex-shrink: 0;
+}
+
+.chat-hub-header-bar .chat-header-info {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.chat-hub-header-bar .chat-header-info .chat-header-name {
+ font-size: 1.15rem;
+ font-weight: 800;
+ color: #1e293b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-hub-header-bar .chat-header-info .chat-header-topic {
+ font-size: 0.85rem;
+ color: #64748b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 0.125rem;
+}
+
+.chat-hub-header-bar .chat-header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.chat-hub-header-bar .chat-header-actions button {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.375rem 0.75rem;
+ font-size: 0.85rem;
+}
+
+.chat-hub-tabs-container {
+ background-color: #ffffff;
+ border-bottom: 1px solid #cbd5e1;
+ padding: 0.5rem 1.5rem 0;
+}
+
+.chat-hub-tabs {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.chat-hub-tabs .tab-btn {
+ padding: 0.625rem 1.25rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #64748b;
+ background: transparent;
+ border: none;
+ border-radius: 0.375rem 0.375rem 0 0;
+ border-bottom: 3px solid transparent;
+ cursor: pointer;
+ box-shadow: none;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.chat-hub-tabs .tab-btn:hover {
+ color: #334155;
+ background-color: #f1f5f9;
+}
+
+.chat-hub-tabs .tab-btn.active {
+ color: #3ba4d7;
+ border-bottom-color: #3ba4d7;
+ background-color: transparent;
+}
+
+.chat-hub-tab-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #f8fafc;
+}
+
+.chat-hub-conversation-layout {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.chat-hub-conversation-main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ height: 100%;
+ overflow: hidden;
+}
+
+.chat-hub-rightbar {
+ width: 200px;
+ border-left: 1px solid #cbd5e1;
+ background-color: #ffffff;
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+}
+
+.chat-hub-rightbar .rightbar-title {
+ padding: 0.75rem 1rem;
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.chat-hub-rightbar .rightbar-users-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+}
+
+.chat-hub-rightbar .user {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9rem;
+ color: #334155;
+ border-radius: 0.375rem;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-hub-rightbar .user:hover {
+ background-color: #f1f5f9;
+ color: #0f172a;
+}
+
+.chat-hub-rightbar .user .defaultAvatar {
+ width: 2rem;
+ height: 2rem;
+ font-size: 0.9rem;
+ flex-shrink: 0;
+}
+
+.chat-hub-rightbar .user img.avatar {
+ width: 2rem;
+ height: 2rem;
+ flex-shrink: 0;
+}
+
+@media (max-width: 899px) {
+ .chat-hub-rightbar {
+ display: none;
+ }
+}
+
+.chat-hub-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.25rem 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+/* Chat bubble overrides for two-pane layout */
+.chat-hub-messages .message {
+ display: flex;
+ flex-direction: column;
+ max-width: 70%;
+ padding: 0.625rem 0.875rem;
+ border-radius: 0.75rem;
+ font-size: 0.925rem;
+ line-height: 1.4;
+ word-break: break-word;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.chat-hub-messages .message.incoming {
+ align-self: flex-start;
+ align-items: flex-start;
+ background-color: #ffffff;
+ color: #1e293b;
+ border: 1px solid #e2e8f0;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.chat-hub-messages .message.outgoing {
+ align-self: flex-end;
+ align-items: flex-end;
+ background-color: #3ba4d7;
+ color: #ffffff;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.chat-hub-messages .message .username {
+ font-size: 0.75rem;
+ margin-bottom: 0.25rem;
+ padding: 0 0.125rem;
+ font-weight: 700;
+}
+
+.chat-hub-messages .message.incoming .username {
+ color: #0369a1;
+}
+
+.chat-hub-messages .message.outgoing .username {
+ color: #e0f2fe;
+}
+
+.chat-hub-messages .message .messagetext {
+ white-space: break-spaces;
+ margin: 0;
+}
+
+.chat-hub-messages .message .datetime {
+ font-size: 0.7rem;
+ margin-top: 0.25rem;
+ padding: 0 0.125rem;
+ opacity: 0.8;
+}
+
+.chat-hub-messages .message.incoming .datetime {
+ color: #64748b;
+}
+
+.chat-hub-messages .message.outgoing .datetime {
+ color: #f1f5f9;
+}
+
+.chat-hub-input-area {
+ padding: 1rem 1.5rem;
+ background-color: #ffffff;
+ border-top: 1px solid #cbd5e1;
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.chat-hub-input-area textarea.chat-hub-textarea {
+ flex: 1;
+ resize: none;
+ height: 40px;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #cbd5e1;
+ border-radius: 0.375rem;
+ font-size: 0.9rem;
+ outline: none;
+ transition: all 0.2s;
+ background-color: #f8fafc;
+}
+
+.chat-hub-input-area textarea.chat-hub-textarea:focus {
+ background-color: #ffffff;
+ border-color: #3ba4d7;
+ box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15);
+}
+
+.chat-hub-input-area button.chat-hub-send-btn {
+ padding: 0.5rem 1.25rem;
+ font-size: 0.9rem;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.375rem;
+}
+
\ No newline at end of file
diff --git a/webui-src/styles.css b/webui-src/styles.css
index d7f2c37..acb0af8 100644
--- a/webui-src/styles.css
+++ b/webui-src/styles.css
@@ -623,3 +623,725 @@ h1{font-size:3rem}h2{font-size:2.25rem}h3{font-size:1.875rem}h4{font-size:1.5rem
font-weight: 600;
font-size: 0.9rem;
}
+
+/* =====================================================
+ CHAT HUB - Two-Pane Layout
+ ===================================================== */
+
+.chat-hub-container {
+ display: flex;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.chat-hub-left-pane {
+ width: 320px;
+ min-width: 300px;
+ max-width: 350px;
+ border-right: 1px solid #cbd5e1;
+ display: flex;
+ flex-direction: column;
+ background: #ffffff;
+ box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
+}
+
+.chat-own-profile-card {
+ padding: 1.25rem;
+ border-bottom: 1px solid #e2e8f0;
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+}
+
+.chat-own-profile-card .profile-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.chat-own-profile-card .profile-info {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+.chat-own-profile-card .profile-info .profile-name {
+ font-weight: 700;
+ color: #1e293b;
+ font-size: 1.1rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-own-profile-card .profile-info .profile-status {
+ font-size: 0.85rem;
+ color: #10b981;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.chat-own-profile-card .profile-info .profile-status::before {
+ content: '';
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background-color: #10b981;
+ border-radius: 50%;
+}
+
+.chat-rooms-list-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.chat-rooms-list-container .searchbar-container {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.chat-rooms-list-container .searchbar-container input.searchbar {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9rem;
+ border: 1px solid #cbd5e1;
+ border-radius: 0.375rem;
+ background-color: #f8fafc;
+ outline: none;
+ transition: all 0.2s;
+}
+
+.chat-rooms-list-container .searchbar-container input.searchbar:focus {
+ background-color: #ffffff;
+ border-color: #3ba4d7;
+ box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15);
+}
+
+.chat-rooms-list-container .rooms-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem 0;
+}
+
+.rooms-section-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.rooms-section-title i {
+ font-size: 0.7rem;
+ color: #94a3b8;
+}
+
+.chat-room-list-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ margin: 0.125rem 0.5rem;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.chat-room-list-item:hover {
+ background-color: #f1f5f9;
+}
+
+.chat-room-list-item.selected {
+ background-color: #e0f2fe;
+}
+
+.chat-room-list-item.selected .room-meta .room-name {
+ color: #0369a1;
+ font-weight: 600;
+}
+
+.chat-room-list-item .room-icon {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ border-radius: 0.5rem;
+ background: linear-gradient(135deg, #3ba4d7, #0ea5e9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #ffffff;
+ font-size: 0.85rem;
+}
+
+.chat-room-list-item.public-room .room-icon {
+ background: linear-gradient(135deg, #10b981, #059669);
+}
+
+.chat-room-list-item .room-meta {
+ flex: 1;
+ min-width: 0;
+}
+
+.chat-room-list-item .room-meta .room-name {
+ font-size: 0.95rem;
+ color: #334155;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: color 0.2s;
+}
+
+.chat-room-list-item .room-meta .room-topic {
+ font-size: 0.8rem;
+ color: #94a3b8;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-room-list-item .room-badge {
+ flex-shrink: 0;
+ min-width: 24px;
+ height: 24px;
+ border-radius: 12px;
+ background-color: #e2e8f0;
+ color: #475569;
+ font-size: 0.75rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0.375rem;
+}
+
+.chat-hub-right-pane {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #f8fafc;
+}
+
+.chat-pane-placeholder {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ color: #94a3b8;
+ gap: 1rem;
+ padding: 2rem;
+ text-align: center;
+}
+
+.chat-pane-placeholder i {
+ font-size: 4rem;
+ color: #cbd5e1;
+}
+
+.chat-pane-placeholder p {
+ font-size: 1.1rem;
+ max-width: 400px;
+}
+
+.chat-hub-tab-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.5rem;
+}
+
+.chat-room-detail-view {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.chat-room-detail-view .detail-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 1.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid #e2e8f0;
+ flex-wrap: wrap;
+}
+
+.chat-room-detail-view .detail-header .detail-title {
+ flex: 1;
+ min-width: 200px;
+}
+
+.chat-room-detail-view .detail-header .detail-title h2 {
+ font-size: 1.75rem;
+ font-weight: 800;
+ color: #1e293b;
+ margin-bottom: 0.25rem;
+}
+
+.chat-room-detail-view .detail-header .detail-title .detail-subtitle {
+ font-size: 0.9rem;
+ color: #64748b;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.chat-room-detail-view .detail-header .detail-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.chat-room-detail-view .detail-header .detail-actions button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+}
+
+.chat-room-detail-view .detail-section {
+ background-color: #ffffff;
+ border-radius: 0.5rem;
+ border: 1px solid #e2e8f0;
+ padding: 1.25rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
+}
+
+.chat-room-detail-view .detail-section h3 {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #334155;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.chat-room-detail-view .detail-section .info-grid {
+ display: grid;
+ grid-template-columns: 130px 1fr;
+ row-gap: 0.75rem;
+ font-size: 0.9rem;
+}
+
+.chat-room-detail-view .detail-section .info-grid .info-label {
+ font-weight: 600;
+ color: #64748b;
+}
+
+.chat-room-detail-view .detail-section .info-grid .info-value {
+ color: #1e293b;
+ word-break: break-all;
+}
+
+.participants-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.5rem;
+}
+
+.participant-card {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background-color: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.375rem;
+}
+
+.participant-card .participant-name {
+ font-size: 0.875rem;
+ color: #334155;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.no-participants {
+ color: #94a3b8;
+ font-size: 0.9rem;
+ font-style: italic;
+}
+
+.detail-actions-footer {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.detail-actions-footer button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+}
+
+.join-description {
+ color: #64748b;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+.identities-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.75rem;
+}
+
+.identity-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background-color: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.identity-card:hover {
+ background-color: #e0f2fe;
+ border-color: #3ba4d7;
+}
+
+.identity-card .identity-name {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #334155;
+}
+
+.identity-card i {
+ color: #3ba4d7;
+ font-size: 0.9rem;
+}
+
+.no-rooms {
+ padding: 1rem;
+ color: #94a3b8;
+ text-align: center;
+ font-style: italic;
+}
+
+/* Chat Hub Responsive - Mobile */
+@media (max-width: 899px) {
+ .chat-hub-container {
+ flex-direction: column;
+ }
+
+ .chat-hub-left-pane {
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ max-height: 45%;
+ border-right: none;
+ border-bottom: 1px solid #cbd5e1;
+ }
+
+ .chat-hub-right-pane {
+ flex: 1;
+ min-height: 0;
+ }
+}
+
+/* =====================================================
+ CHAT HUB - Right Pane Conversation & Tabs Styling
+ ===================================================== */
+
+.chat-hub-header-bar {
+ padding: 0.75rem 1.5rem;
+ background-color: #ffffff;
+ border-bottom: 1px solid #e2e8f0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 65px;
+ flex-shrink: 0;
+}
+
+.chat-hub-header-bar .chat-header-info {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.chat-hub-header-bar .chat-header-info .chat-header-name {
+ font-size: 1.15rem;
+ font-weight: 800;
+ color: #1e293b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-hub-header-bar .chat-header-info .chat-header-topic {
+ font-size: 0.85rem;
+ color: #64748b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 0.125rem;
+}
+
+.chat-hub-header-bar .chat-header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.chat-hub-header-bar .chat-header-actions button {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.375rem 0.75rem;
+ font-size: 0.85rem;
+}
+
+.chat-hub-tabs-container {
+ background-color: #ffffff;
+ border-bottom: 1px solid #cbd5e1;
+ padding: 0.5rem 1.5rem 0;
+}
+
+.chat-hub-tabs {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.chat-hub-tabs .tab-btn {
+ padding: 0.625rem 1.25rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #64748b;
+ background: transparent;
+ border: none;
+ border-radius: 0.375rem 0.375rem 0 0;
+ border-bottom: 3px solid transparent;
+ cursor: pointer;
+ box-shadow: none;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.chat-hub-tabs .tab-btn:hover {
+ color: #334155;
+ background-color: #f1f5f9;
+}
+
+.chat-hub-tabs .tab-btn.active {
+ color: #3ba4d7;
+ border-bottom-color: #3ba4d7;
+ background-color: transparent;
+}
+
+.chat-hub-tab-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #f8fafc;
+}
+
+.chat-hub-conversation-layout {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.chat-hub-conversation-main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ height: 100%;
+ overflow: hidden;
+}
+
+.chat-hub-rightbar {
+ width: 200px;
+ border-left: 1px solid #cbd5e1;
+ background-color: #ffffff;
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+}
+
+.chat-hub-rightbar .rightbar-title {
+ padding: 0.75rem 1rem;
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.chat-hub-rightbar .rightbar-users-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+}
+
+.chat-hub-rightbar .user {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9rem;
+ color: #334155;
+ border-radius: 0.375rem;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-hub-rightbar .user:hover {
+ background-color: #f1f5f9;
+ color: #0f172a;
+}
+
+.chat-hub-rightbar .user .defaultAvatar {
+ width: 2rem;
+ height: 2rem;
+ font-size: 0.9rem;
+ flex-shrink: 0;
+}
+
+.chat-hub-rightbar .user img.avatar {
+ width: 2rem;
+ height: 2rem;
+ flex-shrink: 0;
+}
+
+@media (max-width: 899px) {
+ .chat-hub-rightbar {
+ display: none;
+ }
+}
+
+.chat-hub-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.25rem 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+/* Chat bubble overrides for two-pane layout */
+.chat-hub-messages .message {
+ display: flex;
+ flex-direction: column;
+ max-width: 70%;
+ padding: 0.625rem 0.875rem;
+ border-radius: 0.75rem;
+ font-size: 0.925rem;
+ line-height: 1.4;
+ word-break: break-word;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.chat-hub-messages .message.incoming {
+ align-self: flex-start;
+ align-items: flex-start;
+ background-color: #ffffff;
+ color: #1e293b;
+ border: 1px solid #e2e8f0;
+ border-bottom-left-radius: 0.125rem;
+}
+
+.chat-hub-messages .message.outgoing {
+ align-self: flex-end;
+ align-items: flex-end;
+ background-color: #3ba4d7;
+ color: #ffffff;
+ border-bottom-right-radius: 0.125rem;
+}
+
+.chat-hub-messages .message .username {
+ font-size: 0.75rem;
+ margin-bottom: 0.25rem;
+ padding: 0 0.125rem;
+ font-weight: 700;
+}
+
+.chat-hub-messages .message.incoming .username {
+ color: #0369a1;
+}
+
+.chat-hub-messages .message.outgoing .username {
+ color: #e0f2fe;
+}
+
+.chat-hub-messages .message .messagetext {
+ white-space: break-spaces;
+ margin: 0;
+}
+
+.chat-hub-messages .message .datetime {
+ font-size: 0.7rem;
+ margin-top: 0.25rem;
+ padding: 0 0.125rem;
+ opacity: 0.8;
+}
+
+.chat-hub-messages .message.incoming .datetime {
+ color: #64748b;
+}
+
+.chat-hub-messages .message.outgoing .datetime {
+ color: #f1f5f9;
+}
+
+.chat-hub-input-area {
+ padding: 1rem 1.5rem;
+ background-color: #ffffff;
+ border-top: 1px solid #cbd5e1;
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.chat-hub-input-area textarea.chat-hub-textarea {
+ flex: 1;
+ resize: none;
+ height: 40px;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #cbd5e1;
+ border-radius: 0.375rem;
+ font-size: 0.9rem;
+ outline: none;
+ transition: all 0.2s;
+ background-color: #f8fafc;
+}
+
+.chat-hub-input-area textarea.chat-hub-textarea:focus {
+ background-color: #ffffff;
+ border-color: #3ba4d7;
+ box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15);
+}
+
+.chat-hub-input-area button.chat-hub-send-btn {
+ padding: 0.5rem 1.25rem;
+ font-size: 0.9rem;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.375rem;
+}
+
+
From 4742a87040c5110cfa4dc003d0336323ae949066 Mon Sep 17 00:00:00 2001
From: defnax <9952056+defnax@users.noreply.github.com>
Date: Mon, 6 Jul 2026 15:46:55 +0200
Subject: [PATCH 04/40] Added identicon display
---
webui-src/app/chat/chat.js | 2 +-
webui-src/app/jdenticon.js | 102 ++++++++++++++++++++++++++
webui-src/app/mail/mail_util.js | 1 +
webui-src/app/main.js | 2 +-
webui-src/app/network/network.js | 35 ++++-----
webui-src/app/network/network_data.js | 67 +++++++++++------
webui-src/app/people/people.js | 34 ++++++---
webui-src/app/people/people_ownids.js | 1 +
webui-src/app/people/people_util.js | 64 +++++++++++++---
9 files changed, 239 insertions(+), 69 deletions(-)
create mode 100644 webui-src/app/jdenticon.js
diff --git a/webui-src/app/chat/chat.js b/webui-src/app/chat/chat.js
index 987dbe6..6f6f57a 100644
--- a/webui-src/app/chat/chat.js
+++ b/webui-src/app/chat/chat.js
@@ -706,7 +706,7 @@ const ChatConversationView = () => {
const firstLetter = (name || '?').slice(0, 1).toUpperCase();
return m('.user', [
- m(peopleUtil.UserAvatar, { avatar, firstLetter }),
+ m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: gxsId }),
m('span.user-name', name),
]);
}))
diff --git a/webui-src/app/jdenticon.js b/webui-src/app/jdenticon.js
new file mode 100644
index 0000000..b8b21eb
--- /dev/null
+++ b/webui-src/app/jdenticon.js
@@ -0,0 +1,102 @@
+const CORNER_SPRITES = {
+ 0: [[0.5, 1.0], [1.0, 0.0], [1.0, 1.0]],
+ 1: [[0.5, 0.0], [1.0, 0.0], [0.5, 1.0], [0.0, 1.0]],
+ 2: [[0.5, 0], [1, 0], [1, 1], [0.5, 1], [1, 0.5]],
+ 3: [[0, 0.5], [0.5, 0], [1, 0.5], [0.5, 1], [0.5, 0.5]],
+ 4: [[0, 0.5], [1, 0], [1, 1], [0, 1], [1, 0.5]],
+ 5: [[1, 0], [1, 1], [0.5, 1], [1, 0.5], [0.5, 0.5]],
+ 6: [[0, 0], [1, 0], [1, 0.5], [0, 0], [0.5, 1], [0, 1]],
+ 7: [[0, 0], [0.5, 0], [1, 0.5], [0.5, 1], [0, 1], [0.5, 0.5]],
+ 8: [[0.5, 0], [0.5, 0.5], [1, 0.5], [1, 1], [0.5, 1], [0.5, 0.5], [0, 0.5]],
+ 9: [[0, 0], [1, 0], [0.5, 0.5], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]],
+ 10: [[0, 0.5], [0.5, 1], [1, 0.5], [0.5, 0], [1, 0], [1, 1], [0, 1]],
+ 11: [[0.5, 0], [1, 0], [1, 1], [0.5, 1], [1, 0.75], [0.5, 0.5], [1, 0.25]],
+ 12: [[0, 0.5], [0.5, 0], [0.5, 0.5], [1, 0], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]],
+ 13: [[0, 0], [1, 0], [1, 1], [0, 1], [1, 0.5], [0.5, 0.25], [0.5, 0.75], [0, 0.5], [0.5, 0.25]],
+ 14: [[0, 0.5], [0.5, 0.5], [0.5, 0], [1, 0], [0.5, 0.5], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]],
+ 15: [[0, 0], [1, 0], [0.5, 0.5], [0.5, 0], [0, 0.5], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]]
+};
+
+const CENTER_SPRITES = {
+ 0: [],
+ 1: [[0, 0], [1, 0], [1, 1], [0, 1]],
+ 2: [[0.5, 0], [1, 0.5], [0.5, 1], [0, 0.5]],
+ 3: [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0.5], [0.5, 1], [1, 0.5], [0.5, 0], [0, 0.5]],
+ 4: [[0.25, 0], [0.75, 0], [0.5, 0.5], [1, 0.25], [1, 0.75], [0.5, 0.5], [0.75, 1], [0.25, 1], [0.5, 0.5], [0, 0.75], [0, 0.25], [0.5, 0.5]],
+ 5: [[0, 0], [0.5, 0.25], [1, 0], [0.75, 0.5], [1, 1], [0.5, 0.75], [0, 1], [0.25, 0.5]],
+ 6: [[0.33, 0.33], [0.67, 0.33], [0.67, 0.67], [0.33, 0.67]],
+ 7: [[0, 0], [0.33, 0], [0.33, 0.33], [0.66, 0.33], [0.67, 0], [1, 0], [1, 0.33], [0.67, 0.33], [0.67, 0.67], [1, 0.67], [1, 1], [0.67, 1], [0.67, 0.67], [0.33, 0.67], [0.33, 1], [0, 1], [0, 0.67], [0.33, 0.67], [0.33, 0.33], [0, 0.33]]
+};
+
+function getSpritePoints(shapePoints, size) {
+ return shapePoints.map(([rx, ry]) => `${(rx - 0.5) * size},${(ry - 0.5) * size}`).join(' ');
+}
+
+function renderPolygon(shapePoints, x, y, angle, shapeAngle, size, color) {
+ if (!shapePoints || shapePoints.length === 0) return '';
+ const halfSize = size / 2;
+ const pointsStr = getSpritePoints(shapePoints, size);
+ return ``;
+}
+
+function toSvg(hash, width) {
+ if (!hash || hash.length < 18) {
+ hash = "00000000000000000000000000000000";
+ }
+
+ const csh = parseInt(hash.substr(0, 1), 16);
+ const ssh = parseInt(hash.substr(1, 1), 16);
+ const xsh = parseInt(hash.substr(2, 1), 16) & 7;
+
+ // We rotate shape by default (rotate = true)
+ const cro = 90 * (parseInt(hash.substr(3, 1), 16) & 3);
+ const sro = 90 * (parseInt(hash.substr(4, 1), 16) & 3);
+ const xbg = parseInt(hash.substr(5, 1), 16) % 2;
+
+ const cfr = parseInt(hash.substr(6, 2), 16);
+ const cfg = parseInt(hash.substr(8, 2), 16);
+ const cfb = parseInt(hash.substr(10, 2), 16);
+
+ const sfr = parseInt(hash.substr(12, 2), 16);
+ const sfg = parseInt(hash.substr(14, 2), 16);
+ const sfb = parseInt(hash.substr(16, 2), 16);
+
+ const fillCorner = `rgb(${cfr}, ${cfg}, ${cfb})`;
+ const fillSide = `rgb(${sfr}, ${sfg}, ${sfb})`;
+
+ let fillCenter;
+ if (xbg > 0 && (Math.abs(cfr - sfr) > 127 || Math.abs(cfg - sfg) > 127 || Math.abs(cfb - sfb) > 127)) {
+ fillCenter = fillSide;
+ } else {
+ fillCenter = fillCorner;
+ }
+
+ const size = width / 3;
+ const totalsize = width;
+
+ let svgContent = ``;
+
+ // Draw corners
+ const cornerPoints = CORNER_SPRITES[csh] || CORNER_SPRITES[15];
+ svgContent += renderPolygon(cornerPoints, 0, 0, 0, cro, size, fillCorner);
+ svgContent += renderPolygon(cornerPoints, totalsize, 0, 90, cro, size, fillCorner);
+ svgContent += renderPolygon(cornerPoints, totalsize, totalsize, 180, cro, size, fillCorner);
+ svgContent += renderPolygon(cornerPoints, 0, totalsize, 270, cro, size, fillCorner);
+
+ // Draw sides
+ const sidePoints = CORNER_SPRITES[ssh] || CORNER_SPRITES[15];
+ svgContent += renderPolygon(sidePoints, 0, size, 0, sro, size, fillSide);
+ svgContent += renderPolygon(sidePoints, 2 * size, 0, 90, sro, size, fillSide);
+ svgContent += renderPolygon(sidePoints, 3 * size, 2 * size, 180, sro, size, fillSide);
+ svgContent += renderPolygon(sidePoints, size, 3 * size, 270, sro, size, fillSide);
+
+ // Draw center
+ const centerPoints = CENTER_SPRITES[xsh] !== undefined ? CENTER_SPRITES[xsh] : CORNER_SPRITES[15];
+ svgContent += renderPolygon(centerPoints, size, size, 0, 0, size, fillCenter);
+
+ return ``;
+}
+
+module.exports = {
+ toSvg
+};
\ No newline at end of file
diff --git a/webui-src/app/mail/mail_util.js b/webui-src/app/mail/mail_util.js
index ca5511f..1242de4 100644
--- a/webui-src/app/mail/mail_util.js
+++ b/webui-src/app/mail/mail_util.js
@@ -256,6 +256,7 @@ const MessageView = () => {
firstLetter: rs.userList.userMap[MailData.sender._addr_string]
? rs.userList.userMap[MailData.sender._addr_string].slice(0, 1).toUpperCase()
: '',
+ identityId: MailData.sender._addr_string,
}),
m('.msg-details__info', [
MailData.sender &&
diff --git a/webui-src/app/main.js b/webui-src/app/main.js
index 2e61068..0f034bc 100644
--- a/webui-src/app/main.js
+++ b/webui-src/app/main.js
@@ -117,7 +117,7 @@ const Layout = () => {
links: {
home: '/home',
network: '/network',
- people: '/people/OwnIdentity',
+ people: '/people/MyContacts',
chat: '/chat',
mail: '/mail/inbox',
files: '/files/files',
diff --git a/webui-src/app/network/network.js b/webui-src/app/network/network.js
index 1d9be87..132d08c 100644
--- a/webui-src/app/network/network.js
+++ b/webui-src/app/network/network.js
@@ -12,6 +12,7 @@ const State = {
ssl_id: '',
gpg_id: '',
customState: '',
+ avatar: '',
},
ownGxsIds: [],
selectedOwnGxsId: '',
@@ -51,6 +52,14 @@ function loadOwnProfile() {
m.redraw();
}
});
+
+ // Fetch own SSL avatar using our own Location SSL ID
+ rs.rsJsonApiRequest('/rsChats/getAvatar', { pid: State.ownProfile.ssl_id }, (avatarData) => {
+ if (avatarData && avatarData.retval && avatarData.avatar_base64_string) {
+ State.ownProfile.avatar = avatarData.avatar_base64_string;
+ m.redraw();
+ }
+ });
}
m.redraw();
}
@@ -203,25 +212,10 @@ const ConfirmRemove = () => {
};
};
-// Helper: get avatar safely for UserAvatar (must pass undefined, not null)
-function getSafeAvatar(details) {
- if (
- details &&
- details.mAvatar &&
- details.mAvatar.mData &&
- details.mAvatar.mData.base64 !== ''
- ) {
- return details.mAvatar;
- }
- return undefined;
-}
-
const OwnProfileCard = () => {
return {
view: () => {
- const ownGxsId = State.ownProfile.gpg_id ? State.gpgToGxsIdMap[State.ownProfile.gpg_id.toLowerCase()] : null;
- const ownDetails = ownGxsId ? State.gxsIdToDetailsMap[ownGxsId] : null;
- const avatar = getSafeAvatar(ownDetails);
+ const avatar = State.ownProfile.avatar ? { mData: { base64: State.ownProfile.avatar } } : undefined;
const firstLetter = (State.ownProfile.name || 'U').slice(0, 1).toUpperCase();
return m('.own-profile-card', [
@@ -271,9 +265,7 @@ const FriendsList = () => {
: filteredFriends
.sort((a, b) => (a[1].isOnline === b[1].isOnline ? 0 : a[1].isOnline ? -1 : 1))
.map(([gpgId, friend]) => {
- const friendGxsId = State.gpgToGxsIdMap[gpgId.toLowerCase()];
- const friendDetails = friendGxsId ? State.gxsIdToDetailsMap[friendGxsId] : null;
- const avatar = getSafeAvatar(friendDetails);
+ const avatar = friend.avatar ? { mData: { base64: friend.avatar } } : undefined;
const firstLetter = (friend.name || '?').slice(0, 1).toUpperCase();
const isSelected = State.selectedFriendGpgId === gpgId;
@@ -330,6 +322,11 @@ const DetailsTab = () => {
return m('.network-detail-view', [
m('.detail-header', [
+ m('.friend-avatar', m(peopleUtil.UserAvatar, {
+ avatar: friend.avatar ? { mData: { base64: friend.avatar } } : undefined,
+ firstLetter: (friend.name || '?').slice(0, 1).toUpperCase(),
+ size: 128,
+ })),
m('.detail-title', [
m('h2', friend.name),
m('.detail-subtitle', [
diff --git a/webui-src/app/network/network_data.js b/webui-src/app/network/network_data.js
index a626ed4..427cbf3 100644
--- a/webui-src/app/network/network_data.js
+++ b/webui-src/app/network/network_data.js
@@ -46,31 +46,50 @@ Data.refreshGpgDetails = async function () {
)
.catch(() => {})
.then(() => {
- const gpgId = (data.gpg_id || '').toLowerCase();
- const loc = {
- name: data.location,
- id: data.id,
- lastSeen: data.lastConnect,
- isOnline,
- gpg_id: gpgId,
- customState,
- };
+ let avatar = '';
+ return rs
+ .rsJsonApiRequest(
+ '/rsChats/getAvatar',
+ { pid: data.id },
+ (avatarData) => {
+ if (avatarData && avatarData.retval && avatarData.avatar_base64_string) {
+ avatar = avatarData.avatar_base64_string;
+ }
+ }
+ )
+ .catch(() => {})
+ .then(() => {
+ const gpgId = (data.gpg_id || '').toLowerCase();
+ const loc = {
+ name: data.location,
+ id: data.id,
+ lastSeen: data.lastConnect,
+ isOnline,
+ gpg_id: gpgId,
+ customState,
+ avatar,
+ };
- if (details[gpgId] === undefined) {
- details[gpgId] = {
- name: data.name,
- isSearched: true,
- isOnline,
- locations: [loc],
- customState,
- };
- } else {
- details[gpgId].locations.push(loc);
- if (!details[gpgId].customState || (isOnline && customState)) {
- details[gpgId].customState = customState;
- }
- }
- details[gpgId].isOnline = details[gpgId].isOnline || isOnline;
+ if (details[gpgId] === undefined) {
+ details[gpgId] = {
+ name: data.name,
+ isSearched: true,
+ isOnline,
+ locations: [loc],
+ customState,
+ avatar: avatar || '',
+ };
+ } else {
+ details[gpgId].locations.push(loc);
+ if (avatar) {
+ details[gpgId].avatar = avatar;
+ }
+ if (!details[gpgId].customState || (isOnline && customState)) {
+ details[gpgId].customState = customState;
+ }
+ }
+ details[gpgId].isOnline = details[gpgId].isOnline || isOnline;
+ });
});
});
})
diff --git a/webui-src/app/people/people.js b/webui-src/app/people/people.js
index d270465..bfa392a 100644
--- a/webui-src/app/people/people.js
+++ b/webui-src/app/people/people.js
@@ -11,7 +11,7 @@ const { CreateIdentity, EditIdentity, DeleteIdentity } = ownIdsLayout;
const State = {
searchString: '',
selectedId: null, // GXS ID of the selected identity
- activeFilter: 'all', // 'all' | 'contacts' | 'own'
+ activeFilter: 'contacts', // 'all' | 'contacts' | 'own'
gxsIdToDetailsMap: {},
ownGxsIds: [],
gpgToGxsIdMap: {},
@@ -210,7 +210,13 @@ const DetailsTab = () => {
return m('.network-detail-view', [
m('.detail-header', [
- m('.friend-avatar', m(peopleUtil.UserAvatar, { avatar: getSafeAvatar(details), firstLetter: (name || '?').slice(0, 1).toUpperCase() })),
+ m('.friend-avatar', m(peopleUtil.UserAvatar, {
+ avatar: getSafeAvatar(details),
+ firstLetter: (name || '?').slice(0, 1).toUpperCase(),
+ identityId: State.selectedId,
+ size: 128,
+ isSquare: true,
+ })),
m('.detail-title', [
m('h2', name),
m('.detail-subtitle', [
@@ -491,15 +497,6 @@ const PeopleLayout = () => {
m('.people-left-pane', [
// Filter Tabs Group
m('.people-filter-group', [
- m(
- 'button.filter-btn' + (State.activeFilter === 'all' ? '.active' : ''),
- {
- onclick: () => {
- m.route.set('/people/All');
- },
- },
- 'All'
- ),
m(
'button.filter-btn' + (State.activeFilter === 'contacts' ? '.active' : ''),
{
@@ -518,6 +515,15 @@ const PeopleLayout = () => {
},
'My Identities'
),
+ m(
+ 'button.filter-btn' + (State.activeFilter === 'all' ? '.active' : ''),
+ {
+ onclick: () => {
+ m.route.set('/people/All');
+ },
+ },
+ 'All'
+ ),
]),
// Create Identity container (only shown for "My Identities")
@@ -583,7 +589,11 @@ const PeopleLayout = () => {
},
},
[
- m('.friend-avatar', m(peopleUtil.UserAvatar, { avatar: itemAvatar, firstLetter: itemFirstLetter })),
+ m('.friend-avatar', m(peopleUtil.UserAvatar, {
+ avatar: itemAvatar,
+ firstLetter: itemFirstLetter,
+ identityId: gxsId,
+ })),
m('.friend-meta', [
m('.friend-name', displayName),
m(
diff --git a/webui-src/app/people/people_ownids.js b/webui-src/app/people/people_ownids.js
index cb8590f..91eed52 100644
--- a/webui-src/app/people/people_ownids.js
+++ b/webui-src/app/people/people_ownids.js
@@ -276,6 +276,7 @@ const Identity = () => {
m(peopleUtil.UserAvatar, {
avatar: details.mAvatar,
firstLetter: details.mNickname.slice(0, 1).toUpperCase(),
+ identityId: details.mId,
}),
m('.details', [
m('p', 'ID:'),
diff --git a/webui-src/app/people/people_util.js b/webui-src/app/people/people_util.js
index 80c4322..c1da6e2 100644
--- a/webui-src/app/people/people_util.js
+++ b/webui-src/app/people/people_util.js
@@ -1,5 +1,6 @@
const rs = require('rswebui');
const m = require('mithril');
+const jdenticon = require('jdenticon');
function checksudo(id) {
return id === '0000000000000000';
@@ -8,20 +9,58 @@ function checksudo(id) {
const UserAvatar = () => ({
view: (v) => {
const imageURI = v.attrs.avatar;
- return imageURI === undefined || imageURI.mData.base64 === ''
- ? m(
- 'div.defaultAvatar',
- {
- // image isn't getting loaded
- // ? m('img.defaultAvatar', {
- // src: '../data/user.png'
- // })
- },
- m('p', v.attrs.firstLetter)
- )
- : m('img.avatar', {
+ const identityId = v.attrs.identityId || v.attrs.id;
+ const rawSize = v.attrs.size || 48;
+ const sizeStr = typeof rawSize === 'number' ? `${rawSize}px` : rawSize;
+ const pxSize = typeof rawSize === 'number' ? rawSize : parseInt(rawSize) || 48;
+ const isSquare = !!v.attrs.isSquare;
+
+ if (imageURI && imageURI.mData && imageURI.mData.base64 !== '') {
+ return m('img.avatar', {
src: 'data:image/png;base64,' + imageURI.mData.base64,
+ style: {
+ width: sizeStr,
+ height: sizeStr,
+ borderRadius: isSquare ? '0' : '',
+ }
});
+ }
+
+ if (identityId && identityId !== '0000000000000000') {
+ const svgString = jdenticon.toSvg(identityId, pxSize);
+ return m('div.jdenticon-avatar', {
+ style: {
+ display: 'inline-block',
+ width: sizeStr,
+ height: sizeStr,
+ borderRadius: isSquare ? '0' : '50%',
+ overflow: 'hidden',
+ verticalAlign: 'middle',
+ marginRight: '0.3em',
+ },
+ oncreate: (vnode) => {
+ const svg = vnode.dom.querySelector('svg');
+ if (svg) {
+ svg.style.width = '100%';
+ svg.style.height = '100%';
+ svg.style.display = 'block';
+ }
+ }
+ }, m.trust(svgString));
+ }
+
+ return m(
+ 'div.defaultAvatar',
+ {
+ style: {
+ width: sizeStr,
+ height: sizeStr,
+ borderRadius: isSquare ? '0' : '50%',
+ fontSize: `calc(${sizeStr} * 0.4)`,
+ }
+ },
+ m('p', v.attrs.firstLetter)
+ );
},
});
@@ -120,6 +159,7 @@ const regularcontactInfo = () => {
m(UserAvatar, {
avatar: details.mAvatar,
firstLetter: details.mNickname.slice(0, 1).toUpperCase(),
+ identityId: details.mId || v.attrs.id.mGroupId,
}),
m('.details', [
m('p', 'ID:'),
From f6a9dbef413d7dbbcd4255474ab1670cb6dec711 Mon Sep 17 00:00:00 2001
From: defnax <9952056+defnax@users.noreply.github.com>
Date: Mon, 6 Jul 2026 20:50:20 +0200
Subject: [PATCH 05/40] removed bubble chat from rooms
---
webui-src/app/chat/chat.js | 35 +++++++++++++++++++--
webui-src/app/scss/pages/_chat.scss | 49 +++++++++++++++++++++++++++++
2 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/webui-src/app/chat/chat.js b/webui-src/app/chat/chat.js
index 6f6f57a..578fd3c 100644
--- a/webui-src/app/chat/chat.js
+++ b/webui-src/app/chat/chat.js
@@ -56,6 +56,16 @@ function sortLobbies(lobbies) {
return []; // return empty array instead of undefined
}
+function getNicknameColor(id, name) {
+ const hashString = id && id !== '00000000000000000000000000000000' ? id : (name || '');
+ let hash = 0;
+ for (let i = 0; i < hashString.length; i++) {
+ hash = hashString.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const hue = Math.abs(hash) % 360;
+ return `hsl(${hue}, 75%, 35%)`;
+}
+
// ***************************** models ***********************************
const MobileState = {
@@ -191,6 +201,20 @@ const Message = () => {
const text = (msg.msg || msg.message || '')
.replaceAll('
', '\n')
.replace(new RegExp('|<[^>]*>', 'gm'), '');
-
- const chatType = ChatLobbyModel.currentLobby && ChatLobbyModel.currentLobby.chatType;
- const isRoom = chatType === 3;
-
- if (isRoom) {
- const nickColor = getNicknameColor(gxsId, username);
- return m(
- '.message.compact',
- m('span.datetime', datetime),
- m('span.username', { style: { color: nickColor } }, username + ':'),
- m('span.messagetext', renderTextWithEmoji(text))
- );
- }
-
- return m(
- '.message' + (msg.incoming ? '.incoming' : '.outgoing'),
- m('span.datetime', datetime),
- m('span.username', username),
- m('span.messagetext', renderTextWithEmoji(text))
- );
- },
- };
-};
-
-function getStatusColor(status) {
- switch (status) {
- case 1: return '#eab308'; // Yellow
- case 2: return '#22c55e'; // Green
- case 3: return '#ef4444'; // Red
- default: return '#94a3b8'; // Grey
- }
-}
-
-function getStatusTooltip(status) {
- switch (status) {
- case 1: return 'Tunnel is pending. Please wait...';
- case 2: return 'End-to-end encrypted conversation established. You can talk!';
- case 3: return 'Your partner closed the conversation.';
- default: return 'Remote status unknown.';
- }
-}
-
-const ChatLobbyModel = {
- currentLobby: {
- lobby_name: '...',
- },
- lobby_user: '...',
- isSubscribed: false,
- messages: [],
- users: [],
- messageKeys: new Set(),
- lastLobbyId: null,
- distantChatStatus: null,
- statusPollInterval: null,
-
- pollDistantChatStatus() {
- if (!this.currentLobby || this.currentLobby.chatType !== 2) return;
- rs.rsJsonApiRequest(
- '/rsChats/getDistantChatStatus',
- {
- pid: this.currentLobby.lobby_id,
- },
- (detail, success) => {
- if (success && detail.retval) {
- const oldStatus = this.distantChatStatus ? this.distantChatStatus.status : null;
- this.distantChatStatus = detail.info;
-
- if (oldStatus !== null && oldStatus !== detail.info.status) {
- if (detail.info.status === 2) {
- this.addMessages([{
- chat_id: this.chatId(),
- isSystem: true,
- msg: 'Tunnel is secured. You can talk!',
- sendTime: Math.floor(Date.now() / 1000)
- }]);
- } else if (detail.info.status === 3) {
- this.addMessages([{
- chat_id: this.chatId(),
- isSystem: true,
- msg: 'Your partner closed the conversation.',
- sendTime: Math.floor(Date.now() / 1000)
- }]);
- }
- }
- m.redraw();
- }
- }
- );
- },
-
- startStatusPolling() {
- this.stopStatusPolling();
- this.pollDistantChatStatus();
- this.statusPollInterval = setInterval(() => this.pollDistantChatStatus(), 3000);
- },
-
- stopStatusPolling() {
- if (this.statusPollInterval) {
- clearInterval(this.statusPollInterval);
- this.statusPollInterval = null;
- }
- this.distantChatStatus = null;
- },
-
- // Helper to generate a unique key for deduplication
- getMessageKey(msg) {
- if (msg.msgId && msg.msgId !== 0) return 'id_' + msg.msgId;
- // Fallback for live messages or history without IDs
- const text = msg.msg || msg.message || '';
- return 't_' + msg.sendTime + '_' + text.substring(0, 32);
- },
-
- addMessages(newMsgs, scroll = false) {
- let added = false;
- newMsgs.forEach((msg) => {
- const key = this.getMessageKey(msg);
- if (!this.messageKeys.has(key)) {
- // Near-duplicate check for messages without IDs (live events vs optimistic echo)
- const text = msg.msg || msg.message || '';
- const isNearDuplicate = this.messages.some((existingMsg) => {
- const eAttrs = existingMsg.attrs;
- const eText = eAttrs.msg || eAttrs.message || '';
- return (
- eText === text &&
- Math.abs(eAttrs.sendTime - msg.sendTime) < 5 // 5 seconds window
- );
- });
-
- if (!isNearDuplicate) {
- this.messageKeys.add(key);
- this.messages.push(m(Message, msg));
- added = true;
- }
- }
- });
-
- if (added) {
- this.messages.sort((a, b) => a.attrs.sendTime - b.attrs.sendTime);
- m.redraw();
- if (scroll) {
- setTimeout(() => {
- const element = document.querySelector('.messages');
- if (element) {
- element.scrollTop = element.scrollHeight;
- }
- }, 100);
- }
- }
- },
-
- loadHistory(id, type) {
- const chatPeerId = {
- broadcast_status_peer_id: '00000000000000000000000000000000',
- type: type,
- peer_id: '00000000000000000000000000000000',
- distant_chat_id: '00000000000000000000000000000000',
- lobby_id: { xstr64: '0' },
- };
-
- if (type === 3) chatPeerId.lobby_id.xstr64 = id;
- else if (type === 2) chatPeerId.distant_chat_id = id;
- else if (type === 1) chatPeerId.peer_id = id;
-
- rs.rsJsonApiRequest(
- '/rsHistory/getMessages',
- {
- chatPeerId: chatPeerId,
- loadCount: 20,
- },
- (data, success) => {
- if (success && data.msgs) {
- this.addMessages(data.msgs);
- }
- }
- );
- },
- setupAction: (lobbyId, nick) => { },
- setIdentity(lobbyId, nick) {
- rs.rsJsonApiRequest(
- '/rsChats/setIdentityForChatLobby',
- {
- lobby_id: { xstr64: lobbyId },
- nick: nick,
- },
- () => m.route.set('/chat/:lobby', { lobby: lobbyId }),
- true
- );
- },
- enterPublicLobby(lobbyId, nick) {
- // Set lobby nickname
- rs.rsJsonApiRequest(
- '/rsChats/joinVisibleChatLobby',
- {
- lobby_id: { xstr64: lobbyId },
- own_id: nick,
- },
- () => {
- loadLobbyDetails(lobbyId, (info) => {
- ChatRoomsModel.subscribedRooms[lobbyId] = info;
- ChatRoomsModel.loadSubscribedRooms(() => {
- m.route.set('/chat/:lobby', { lobby: rs.idToHex(info.lobby_id) });
- });
- });
- },
- true
- );
- },
- unsubscribeChatLobby(lobbyId, follow) {
- // Unsubscribe
- rs.rsJsonApiRequest(
- '/rsChats/unsubscribeChatLobby',
- {
- lobby_id: { xstr64: lobbyId },
- },
- (data, success) => {
- if (success) {
- ChatRoomsModel.loadSubscribedRooms(follow);
- }
- },
- true
- );
- },
- chatId() {
- const type = (this.currentLobby && this.currentLobby.chatType) || 3;
- const id = this.lastLobbyId || m.route.param('lobby');
- const cid = {
- broadcast_status_peer_id: '00000000000000000000000000000000',
- type: type,
- peer_id: '00000000000000000000000000000000',
- distant_chat_id: '00000000000000000000000000000000',
- lobby_id: { xstr64: '0' },
- };
- if (type === 3) cid.lobby_id.xstr64 = id;
- else if (type === 2) cid.distant_chat_id = id;
- else if (type === 1) cid.peer_id = id;
- return cid;
- },
- loadLobby(currentlobbyid) {
- this.stopStatusPolling();
- this.lastLobbyId = currentlobbyid;
-
- const finishLoad = (detail) => {
- this.setupAction = this.setIdentity;
- this.currentLobby = detail;
- this.isSubscribed = true;
- this.lobby_user = rs.userList.username(detail.gxs_id) || '???';
-
- // Reset local state for this lobby
- this.messages = [];
- this.messageKeys.clear();
-
- // Load history first
- this.loadHistory(currentlobbyid, detail.chatType);
-
- // Apply existing messages from live cache
- const cid = this.chatId();
- rs.events[15].chatMessages(cid, rs.events[15], (l) => {
- this.addMessages(l);
- });
-
- // Register for chatEvents for future messages
- rs.events[15].notify = (chatMessage) => {
- // DEBUG: Log incoming message structure
- console.log('[RS-DEBUG] Incoming Chat Message:', JSON.stringify(chatMessage, null, 2));
-
- const msgCid = chatMessage.chat_id;
- let msgId;
-
- if (msgCid.type === 3) {
- msgId = rs.idToHex(msgCid.lobby_id);
- } else if (msgCid.type === 2) {
- // For Distant Chat, the ID is the distant_chat_id
- msgId = rs.idToHex(msgCid.distant_chat_id);
- } else if (msgCid.type === 1) {
- // For Private Chat, the ID is the peer_id
- msgId = rs.idToHex(msgCid.peer_id);
- } else {
- // Fallback
- msgId = rs.idToHex(msgCid);
- }
-
- console.log('[RS-DEBUG] Resolved Msg ID:', msgId, 'Current Lobby ID:', currentlobbyid, 'Match:', msgId === currentlobbyid);
-
- if (msgId === currentlobbyid) {
- this.addMessages([chatMessage]);
- }
- };
-
- // Lookup for chat-user names
- let list = [];
- if (detail.gxs_ids) {
- if (Array.isArray(detail.gxs_ids)) {
- list = detail.gxs_ids.map((u) => {
- const key = u.key;
- return { key, name: rs.userList.username(key) || key, lastAct: get64Num(u.value) };
- });
- } else if (typeof detail.gxs_ids === 'object') {
- list = Object.keys(detail.gxs_ids).map((key) => {
- return { key, name: rs.userList.username(key) || key, lastAct: get64Num(detail.gxs_ids[key]) };
- });
- }
- }
-
- const ownId = detail.gxs_id;
- if (ownId && ownId !== '00000000000000000000000000000000') {
- const hasOwn = list.some((u) => u.key === ownId);
- if (!hasOwn) {
- list.push({
- key: ownId,
- name: rs.userList.username(ownId) || ownId,
- lastAct: Math.floor(Date.now() / 1000)
- });
- }
- }
-
- if (list.length === 0) {
- list = [{ key: ownId || '', name: rs.userList.username(ownId) || detail.lobby_name || '???', lastAct: Math.floor(Date.now() / 1000) }];
- }
-
- list.sort((a, b) => a.name.localeCompare(b.name));
- this.users = list;
-
- if (detail.chatType === 2) {
- this.startStatusPolling();
- }
-
- m.redraw();
- };
-
- loadLobbyDetails(currentlobbyid, (detail) => {
- if (detail) {
- finishLoad(detail);
- } else {
- // Fallback to Distant Chat
- loadDistantChatDetails(currentlobbyid, (dDetail) => {
- if (dDetail) {
- finishLoad(dDetail);
- }
- });
- }
- });
- },
- loadPublicLobby(currentlobbyid) {
- this.setupAction = this.enterPublicLobby;
- this.isSubscribed = false;
- ChatRoomsModel.allRooms.forEach((it) => {
- if (rs.idToHex(it.lobby_id) === currentlobbyid) {
- this.currentLobby = it;
- this.lobby_user = '???';
- this.lobbyid = currentlobbyid;
- }
- });
- this.users = [];
- },
- sendMessage(msg, onsuccess) {
- const cid = this.chatId();
- // Optimistic echo for immediate feedback
- const echoMsg = {
- chat_id: cid,
- msg: msg,
- sendTime: Math.floor(Date.now() / 1000),
- lobby_peer_gxs_id: this.currentLobby.gxs_id,
- };
- this.addMessages([echoMsg], true);
-
- rs.rsJsonApiRequest(
- '/rsChats/sendChat',
- {
- id: cid,
- msg: msg,
- },
- (data, success) => {
- if (success) {
- onsuccess();
- } else {
- console.error('[RS] Failed to send chat message');
- onsuccess(); // Clear the input even on failure to avoid stuck 'sending...' state
- }
- }
- );
- },
- selected(info, selName, defaultName) {
- const currid = rs.idToHex(ChatLobbyModel.currentLobby.lobby_id || { xstr64: m.route.param('lobby') });
- return (rs.idToHex(info.lobby_id) === currid ? selName : '') + defaultName;
- },
- switchToEvent(info) {
- return () => {
- ChatLobbyModel.currentLobby = info;
- m.route.set('/chat/:lobby', { lobby: rs.idToHex(info.lobby_id) });
- ChatLobbyModel.loadLobby(rs.idToHex(info.lobby_id)); // update
- };
- },
- setupEvent(info) {
- return () => {
- m.route.set('/chat/:lobby/setup', { lobby: rs.idToHex(info.lobby_id) });
- ChatLobbyModel.loadPublicLobby(rs.idToHex(info.lobby_id)); // update
- };
- },
-};
-
-// ************************* Chat Hub State ****************************
-
-function getSafeAvatar(details) {
- if (
- details &&
- details.mAvatar &&
- details.mAvatar.mData &&
- details.mAvatar.mData.base64 !== ''
- ) {
- return details.mAvatar;
- }
- return undefined;
-}
-
-const ChatHubState = {
- selectedRoomId: null,
- selectedRoom: null,
- selectedRoomType: null,
- searchString: '',
- ownProfile: { name: 'Loading...' },
- gxsDetails: {},
- hoveredUser: null,
- mutedUsers: new Set(),
- activeMenu: null,
- showAttachModal: false,
- attachPath: '',
- attachBrowseHint: false,
- isHashing: false,
- hashingError: '',
- showEmojiPicker: false,
- emojiSearch: '',
- emojiCategory: 'Smileys',
- showCreateRoomModal: false,
- newRoomName: '',
- newRoomTopic: '',
- newRoomIdentity: '',
- newRoomPublic: true,
- newRoomSigned: false,
- ownGxsIdentities: [],
- createRoomError: '',
- userSortMethod: 'name',
- showInviteModal: false,
- friendsList: [],
- selectedFriendsToInvite: new Set(),
-};
-
-// ========================= Emoji Data =========================
-const EMOJI_CATEGORIES = ['Smileys', 'People', 'Animals', 'Food', 'Travel', 'Activities', 'Objects', 'Symbols'];
-const EMOJI_ICONS = {
- Smileys: '😊', People: '👥', Animals: '🐾', Food: '🍎',
- Travel: '✈️', Activities: '⚽', Objects: '💡', Symbols: '❤️',
-};
-const EMOJI_DATA = {
- Smileys: [
- '😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','😘','🥰','😗','😙','😚',
- '🙂','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😯','😪','😫','🥱',
- '😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','☹️','🙁','😖','😞','😟',
- '😤','😢','😭','😦','😧','😨','😩','🤯','😬','😰','😱','🥵','🥶','😳','🤪','😵','😡','😠',
- '🤬','😷','🤒','🤕','🤢','🤮','🤧','🥴','😇','🥳','🥺','🤠','🤡','🤥','🤫','🤭','🧐','🤓',
- '😈','👿','👹','👺','💀','☠️','👻','👽','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿','😾',
- ],
- People: [
- '👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇',
- '☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','✍️','💅','🤳','💪','🦾',
- '🦿','🦵','🦶','👂','🦻','👃','🫀','🫁','🧠','🦷','🦴','👀','👁️','👅','👄','🫦','👶','🧒',
- '👦','👧','🧑','👱','👨','🧔','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇',
- '🤦','🤷','👮','🕵️','💂','🥷','👷','🫅','🤴','👸','👲','🧕','🤵','👰','🤰','🫃','🫄','🤱',
- '👼','🎅','🤶','🧑🎄','🦸','🦹','🧙','🧝','🧛','🧟','🧞','🧜','🧚','🧑🤝🧑','👫','👬','👭','💏','💑','👪',
- ],
- Animals: [
- '🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻❄️','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉',
- '🙊','🐒','🦆','🦅','🦉','🦇','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪲','🦗','🪳','🕷️','🦂',
- '🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🦭',
- '🐊','🐅','🐆','🦓','🦍','🦧','🦣','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🦬','🐃','🐂','🐄',
- '🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🦮','🐕🦺','🐈','🐈⬛','🐓','🦃','🦤','🦚','🦜',
- '🦢','🦩','🕊️','🐇','🦝','🦨','🦡','🦫','🦦','🦥','🐁','🐀','🐿️','🦔','🐾','🐉','🐲','🌵',
- ],
- Food: [
- '🍎','🍊','🍋','🍌','🍍','🥭','🍓','🍒','🍑','🥝','🍅','🥥','🥑','🍆','🥔','🥕','🌽','🌶️',
- '🫑','🥒','🥬','🥦','🧄','🧅','🍄','🥜','🌰','🍞','🥐','🥖','🫓','🥨','🧀','🥚','🍳','🧈',
- '🥞','🧇','🥓','🥩','🍗','🍖','🦴','🌭','🍔','🍟','🍕','🫔','🌮','🌯','🥙','🧆','🥚','🍱',
- '🍘','🍙','🍚','🍛','🍜','🍝','🍠','🍢','🍣','🍤','🍥','🥮','🍡','🥟','🥠','🥡','🦪','🍦',
- '🍧','🍨','🍩','🍪','🎂','🍰','🧁','🥧','🍫','🍬','🍭','🍮','🍯','🍼','🥛','☕','🫖','🍵',
- '🧃','🥤','🧋','🍶','🍺','🍻','🥂','🍷','🥃','🍸','🍹','🧉','🍾','🧊','🥄','🍴','🍽️','🥢',
- ],
- Travel: [
- '🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🦯','🦽','🦼','🛺',
- '🚲','🛴','🛵','🏍️','🛺','🚨','🚔','🚍','🚘','🚖','🚡','🚠','🚟','🚃','🚋','🚞','🚝','🚄',
- '🚅','🚈','🚂','🚆','🚇','🚊','🚉','✈️','🛫','🛬','🛩️','💺','🛸','🚁','🛶','⛵','🚤','🛥️',
- '🛳️','⛴️','🚢','⚓','🗺️','🧭','🏔️','⛰️','🌋','🗻','🏕️','🏖️','🏜️','🏝️','🏞️','🏟️','🏛️','🏗️',
- '🧱','🪨','🪵','🛖','🏘️','🏚️','🏠','🏡','🏢','🏣','🏤','🏥','🏦','🏨','🏩','🏪','🏫','🏬',
- '🏭','🏯','🏰','💒','🗼','🗽','⛪','🕌','🛕','🕍','⛩️','🕋','⛲','⛺','🌁','🌃','🏙️','🌄',
- ],
- Activities: [
- '⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🏓','🏸','🏒','🏑','🥍','🏏','🪃','🥅',
- '⛳','🪁','🛝','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛷','⛸️','🥌','🎿','⛷️','🏂','🪂','🏋️',
- '🤼','🤸','⛹️','🤺','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🏆','🥇','🥈','🥉','🏅',
- '🎖️','🏵️','🎗️','🎫','🎟️','🎪','🤹','🎭','🩰','🎨','🖼️','🎰','🎲','🧩','🪄','🎯','🪅','🎮',
- '🕹️','🎳','🎻','🎷','🥁','🪘','🎺','🎸','🪗','🎹','🎵','🎶','🎼','🎤','🎧','📻','🎙️','🎚️',
- '🎬','📽️','🎞️','📱','📲','☎️','📞','📟','📠','🔋','🪫','🔌','💡','🔦','🕯️','💸','💵','🪙',
- ],
- Objects: [
- '⌚','📱','📲','💻','⌨️','🖥️','🖨️','🖱️','🖲️','💾','💿','📀','🧮','📷','📸','📹','🎥','📽️',
- '📞','☎️','📟','📠','📺','📻','🧭','⏱️','⏲️','⏰','🕰️','⌛','⏳','📡','🔋','🪫','🔌','💡',
- '🔦','🕯️','🪔','🧱','💰','💴','💵','💶','💷','💸','💳','🪙','💹','✉️','📧','📨','📩','📤',
- '📥','📦','📫','📪','📬','📭','📮','🗳️','✏️','✒️','🖊️','🖋️','📝','📁','📂','🗂️','📅','📆',
- '🗒️','🗓️','📇','📈','📉','📊','📋','📌','📍','🗺️','📏','📐','✂️','🗃️','🗄️','🗑️','🔒','🔓',
- '🔏','🔐','🔑','🗝️','🔨','🪓','⛏️','⚒️','🛠️','🗡️','⚔️','🔫','🪃','🏹','🛡️','🪚','🔧','🪛',
- ],
- Symbols: [
- '❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝',
- '💟','☮️','✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐','⛎','♈','♉','♊','♋','♌',
- '♍','♎','♏','♐','♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺',
- '🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️','🆘',
- '❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️','🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❕',
- '❓','❔','‼️','⁉️','🔅','🔆','📶','🛜','📳','📴','🔱','📛','🔰','♻️','✅','🈯','💹','❎',
- '🌐','💠','Ⓜ️','🌀','💤','🏧','🚾','♿','🅿️','🛗','🈳','🈹','🚰','🔤','🔡','🔠','🆖','🆗',
- '🆙','🆒','🆕','🆓','🔟','📊','🔣','✔️','☑️','🔘','🔲','🔳','⬛','⬜','◼️','◻️','◾','◽',
- '▪️','▫️','🔶','🔷','🔸','🔹','🔺','🔻','💠','🔘','🔲','🔳','🏁','🚩','🎌','🏴','🏳️','⭐',
- '🌟','💫','✨','🌈','☀️','🌤️','⛅','🌥️','☁️','🌦️','🌧️','⛈️','🌩️','🌨️','❄️','☃️','⛄','🌬️',
- ],
-};
-
-function insertEmojiIntoTextarea(emoji) {
- const textarea = document.querySelector('.chat-hub-textarea');
- if (!textarea) return;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const before = textarea.value.substring(0, start);
- const after = textarea.value.substring(end);
- textarea.value = before + emoji + after;
- const newPos = start + emoji.length;
- textarea.selectionStart = newPos;
- textarea.selectionEnd = newPos;
- textarea.focus();
-}
-
-const EmojiPicker = () => ({
- view: () => {
- const search = ChatHubState.emojiSearch.toLowerCase();
- const cat = ChatHubState.emojiCategory;
- let emojis;
- if (search) {
- emojis = Object.values(EMOJI_DATA).flat();
- } else {
- emojis = EMOJI_DATA[cat] || [];
- }
- return m('.emoji-picker', [
- // Search bar
- m('.emoji-search-row', [
- m('i.fas.fa-search.emoji-search-icon'),
- m('input.emoji-search-input[type=text][placeholder=Search emoji...]', {
- value: ChatHubState.emojiSearch,
- oninput: (e) => { ChatHubState.emojiSearch = e.target.value; },
- }),
- ChatHubState.emojiSearch && m('button.emoji-search-clear', {
- onclick: () => { ChatHubState.emojiSearch = ''; },
- }, m('i.fas.fa-times')),
- ]),
- // Category tabs (hidden while searching)
- !search && m('.emoji-categories', EMOJI_CATEGORIES.map(c =>
- m('button.emoji-cat-btn' + (c === cat ? '.active' : ''), {
- title: c,
- onclick: () => { ChatHubState.emojiCategory = c; },
- }, EMOJI_ICONS[c])
- )),
- // Emoji grid
- m('.emoji-grid',
- emojis.map(e =>
- m('button.emoji-btn', {
- onclick: () => {
- insertEmojiIntoTextarea(e);
- ChatHubState.showEmojiPicker = false;
- m.redraw();
- },
- }, e)
- )
- ),
- ]);
- },
-});
+// ************************* helpers ****************************
function loadOwnChatProfile() {
rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (data) => {
@@ -858,6 +35,66 @@ function loadOwnChatProfile() {
});
}
+function loadFriendsForInvite() {
+ ChatHubState.friendsList = [];
+ rs.rsJsonApiRequest('/rsPeers/getFriendList', {}, (data) => {
+ if (data && data.sslIds) {
+ data.sslIds.forEach((sslId) => {
+ rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (detData) => {
+ if (detData && detData.det) {
+ rs.rsJsonApiRequest('/rsPeers/isOnline', { sslId }, (onlineData) => {
+ ChatHubState.friendsList.push({
+ id: sslId,
+ name: detData.det.name,
+ online: onlineData ? onlineData.retval : false
+ });
+ ChatHubState.friendsList.sort((a, b) => {
+ if (a.online !== b.online) return a.online ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ m.redraw();
+ });
+ }
+ });
+ });
+ }
+ });
+}
+
+function scrollChatToBottom() {
+ setTimeout(() => {
+ const element = document.querySelector('.chat-hub-messages');
+ if (element) {
+ element.scrollTop = element.scrollHeight;
+ }
+ }, 50);
+}
+
+function pollHashStatus(localpath) {
+ rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data) => {
+ if (data && data.retval && data.info && data.info.hash && data.info.hash !== '0000000000000000000000000000000000000000') {
+ const info = data.info;
+ const sizeNum = info.size.xint64 || parseInt(info.size.xstr64) || info.size;
+ const fileLink = `${info.name} (${rs.formatBytes(sizeNum)})`;
+
+ const textarea = document.querySelector('.chat-hub-textarea');
+ if (textarea) {
+ const val = textarea.value;
+ textarea.value = val ? val + '\n' + fileLink : fileLink;
+ }
+
+ ChatHubState.showAttachModal = false;
+ ChatHubState.isHashing = false;
+ ChatHubState.attachPath = '';
+ m.redraw();
+ } else {
+ if (ChatHubState.isHashing) {
+ setTimeout(() => pollHashStatus(localpath), 1000);
+ }
+ }
+ });
+}
+
// ************************* views ****************************
const Lobby = () => {
@@ -929,33 +166,6 @@ const PublicLobbies = {
// ************************* Chat Hub Sub-Components ****************************
-function loadFriendsForInvite() {
- ChatHubState.friendsList = [];
- rs.rsJsonApiRequest('/rsPeers/getFriendList', {}, (data) => {
- if (data && data.sslIds) {
- data.sslIds.forEach((sslId) => {
- rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }, (detData) => {
- if (detData && detData.det) {
- rs.rsJsonApiRequest('/rsPeers/isOnline', { sslId }, (onlineData) => {
- ChatHubState.friendsList.push({
- id: sslId,
- name: detData.det.name,
- online: onlineData ? onlineData.retval : false
- });
- // Sort online friends first, then alphabetical name
- ChatHubState.friendsList.sort((a, b) => {
- if (a.online !== b.online) return a.online ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
- m.redraw();
- });
- }
- });
- });
- }
- });
-}
-
const ChatRoomHeader = () => {
return {
view: (vnode) => {
@@ -1040,40 +250,6 @@ const ChatRoomHeader = () => {
};
};
-function scrollChatToBottom() {
- setTimeout(() => {
- const element = document.querySelector('.chat-hub-messages');
- if (element) {
- element.scrollTop = element.scrollHeight;
- }
- }, 50);
-}
-
-function pollHashStatus(localpath) {
- rs.rsJsonApiRequest('/rsFiles/ExtraFileStatus', { localpath }, (data) => {
- if (data && data.retval && data.info && data.info.hash && data.info.hash !== '0000000000000000000000000000000000000000') {
- const info = data.info;
- const sizeNum = info.size.xint64 || parseInt(info.size.xstr64) || info.size;
- const fileLink = `${info.name} (${rs.formatBytes(sizeNum)})`;
-
- const textarea = document.querySelector('.chat-hub-textarea');
- if (textarea) {
- const val = textarea.value;
- textarea.value = val ? val + '\n' + fileLink : fileLink;
- }
-
- ChatHubState.showAttachModal = false;
- ChatHubState.isHashing = false;
- ChatHubState.attachPath = '';
- m.redraw();
- } else {
- if (ChatHubState.isHashing) {
- setTimeout(() => pollHashStatus(localpath), 1000);
- }
- }
- });
-}
-
const ChatConversationView = () => {
function onDocClick(e) {
if (ChatHubState.showEmojiPicker && !e.target.closest('.emoji-picker-wrapper')) {
@@ -1136,7 +312,7 @@ const ChatConversationView = () => {
},
'😊'
),
- ChatHubState.showEmojiPicker && m(EmojiPicker),
+ ChatHubState.showEmojiPicker && m(chatEmoji.EmojiPicker),
]),
m('textarea.chat-hub-textarea', {
placeholder: canTalk ? 'Type a message... Press Enter to send' : 'Waiting for tunnel to be secured...',
@@ -1193,24 +369,20 @@ const ChatConversationView = () => {
m('h4', 'Attach File to Chat'),
]),
m('p', 'Browse for a file or type the absolute path on your local system:'),
- // Hidden native file input for browsing
m('input#attach-file-picker[type=file]', {
style: 'display:none',
onchange: (e) => {
const file = e.target.files && e.target.files[0];
if (file) {
- // file.path is only available in Electron/desktop; browsers restrict full path
const fullPath = file.path;
const hasFullPath = fullPath && (fullPath.includes('/') || fullPath.includes('\\')) && fullPath !== file.name;
if (hasFullPath) {
ChatHubState.attachPath = fullPath;
ChatHubState.attachBrowseHint = false;
} else {
- // Browser security: only the filename is available, not the full path
ChatHubState.attachPath = file.name;
ChatHubState.attachBrowseHint = true;
}
- // Reset the picker so the same file can be re-selected
e.target.value = '';
ChatHubState.hashingError = '';
m.redraw();
@@ -1223,7 +395,7 @@ const ChatConversationView = () => {
value: ChatHubState.attachPath,
oninput: (e) => {
ChatHubState.attachPath = e.target.value;
- ChatHubState.attachBrowseHint = false; // user is editing manually, hint no longer relevant
+ ChatHubState.attachBrowseHint = false;
},
disabled: ChatHubState.isHashing,
}),
@@ -1306,9 +478,8 @@ const ChatConversationView = () => {
const gxsId = user.key;
const name = user.name;
- // Load details for avatar if not cached
if (gxsId && ChatHubState.gxsDetails[gxsId] === undefined) {
- ChatHubState.gxsDetails[gxsId] = null; // Mark as loading
+ ChatHubState.gxsDetails[gxsId] = null;
rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (data) => {
if (data && data.details) {
ChatHubState.gxsDetails[gxsId] = data.details;
@@ -1325,32 +496,31 @@ const ChatConversationView = () => {
const isBanned = opinion === 0;
if (isBanned) return null;
- // Calculate status color and tooltip
const now = Math.floor(Date.now() / 1000);
const tLastAct = user.lastAct || 0;
const isOwn = gxsId === rs.idToHex(ChatLobbyModel.currentLobby.gxs_id || '');
const isMuted = ChatHubState.mutedUsers && ChatHubState.mutedUsers.has(gxsId);
- let statusColor = '#22c55e'; // active (green)
+ let statusColor = '#22c55e';
let statusTooltip = 'Active';
if (isMuted) {
- statusColor = '#ef4444'; // muted (red)
+ statusColor = '#ef4444';
statusTooltip = 'Muted';
} else if (isOwn) {
- statusColor = '#3ba4d7'; // own identity (blue)
+ statusColor = '#3ba4d7';
statusTooltip = 'You';
} else if (tLastAct + 600 < now) {
- statusColor = '#cbd5e1'; // inactive > 10 mins (grey)
+ statusColor = '#cbd5e1';
statusTooltip = 'Inactive';
} else if (tLastAct + 300 < now) {
- statusColor = '#eab308'; // away > 5 mins (yellow)
+ statusColor = '#eab308';
statusTooltip = 'Away';
}
return m('.user', {
onmouseenter: (e) => {
- if (ChatHubState.activeMenu) return; // skip tooltip if menu is open
+ if (ChatHubState.activeMenu) return;
const rect = e.currentTarget.getBoundingClientRect();
const rightbar = document.querySelector('.chat-hub-rightbar');
if (rightbar) {
@@ -1365,7 +535,7 @@ const ChatConversationView = () => {
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
- ChatHubState.hoveredUser = null; // hide tooltip
+ ChatHubState.hoveredUser = null;
const rect = e.currentTarget.getBoundingClientRect();
const rightbar = document.querySelector('.chat-hub-rightbar');
@@ -1389,7 +559,7 @@ const ChatConversationView = () => {
oncontextmenu: (e) => {
e.preventDefault();
e.stopPropagation();
- ChatHubState.hoveredUser = null; // hide tooltip
+ ChatHubState.hoveredUser = null;
const rect = e.currentTarget.getBoundingClientRect();
const rightbar = document.querySelector('.chat-hub-rightbar');
@@ -1800,7 +970,6 @@ const Layout = {
}
window.addEventListener('click', Layout.dismissMenu);
- // Load own identities for room creation
peopleUtil.ownIds((ids) => {
ChatHubState.ownGxsIdentities = ids || [];
if (ChatHubState.ownGxsIdentities.length > 0) {
@@ -2055,8 +1224,8 @@ const Layout = {
const isPublic = ChatHubState.newRoomPublic;
const isSigned = ChatHubState.newRoomSigned;
let flags = 0;
- if (isPublic) flags |= 4; // RS_CHAT_LOBBY_FLAGS_PUBLIC
- if (isSigned) flags |= 8; // RS_CHAT_LOBBY_FLAGS_SIGNED_ONLY
+ if (isPublic) flags |= 4;
+ if (isSigned) flags |= 8;
rs.rsJsonApiRequest('/rsChats/createChatLobby', {
lobby_name: name,
@@ -2071,7 +1240,6 @@ const Layout = {
ChatHubState.newRoomTopic = '';
ChatHubState.newRoomSigned = false;
ChatHubState.createRoomError = '';
- // Refresh rooms list
ChatRoomsModel.loadSubscribedRooms();
m.redraw();
} else {
diff --git a/webui-src/app/chat/chat_emoji.js b/webui-src/app/chat/chat_emoji.js
new file mode 100644
index 0000000..4d94d98
--- /dev/null
+++ b/webui-src/app/chat/chat_emoji.js
@@ -0,0 +1,149 @@
+const m = require('mithril');
+
+const EMOJI_CATEGORIES = ['Smileys', 'People', 'Animals', 'Food', 'Travel', 'Activities', 'Objects', 'Symbols'];
+const EMOJI_ICONS = {
+ Smileys: '😊', People: '👥', Animals: '🐾', Food: '🍎',
+ Travel: '✈️', Activities: '⚽', Objects: '💡', Symbols: '❤️',
+};
+const EMOJI_DATA = {
+ Smileys: [
+ '😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','😘','🥰','😗','😙','😚',
+ '🙂','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😯','😪','😫','🥱',
+ '😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','☹️','🙁','😖','😞','😟',
+ '😤','😢','😭','😦','😧','😨','😩','🤯','😬','😰','😱','🥵','🥶','😳','🤪','😵','😡','😠',
+ '🤬','😷','🤒','🤕','🤢','🤮','🤧','🥴','😇','🥳','🥺','🤠','🤡','🤥','🤫','🤭','🧐','🤓',
+ '😈','👿','👹','👺','💀','☠️','👻','👽','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿','😾',
+ ],
+ People: [
+ '👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇',
+ '☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','✍️','💅','🤳','💪','🦾',
+ '🦿','🦵','🦶','👂','🦻','👃','🫀','🫁','🧠','🦷','🦴','👀','👁️','👅','👄','🫦','👶','🧒',
+ '👦','👧','🧑','👱','👨','🧔','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇',
+ '🤦','🤷','👮','🕵️','💂','🥷','👷','🫅','🤴','👸','👲','🧕','🤵','👰','🤰','🫃','🫄','🤱',
+ '👼','🎅','🤶','🧑🎄','🦸','🦹','🧙','🧝','🧛','🧟','🧞','🧜','🧚','🧑🤝🧑','👫','👬','👭','💏','💑','👪',
+ ],
+ Animals: [
+ '🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻❄️','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉',
+ '🙊','🐒','🦆','🦅','🦉','🦇','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪲','🦗','🪳','🕷️','🦂',
+ '🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🦭',
+ '🐊','🐅','🐆','🦓','🦍','🦧','🦣','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🦬','🐃','🐂','🐄',
+ '🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🦮','🐕🦺','🐈','🐈⬛','🐓','🦃','🦤','🦚','🦜',
+ '🦢','🦩','🕊️','🐇','🦝','🦨','🦡','🦫','🦦','🦥','🐁','🐀','🐿️','🦔','🐾','🐉','🐲','🌵',
+ ],
+ Food: [
+ '🍎','🍊','🍋','🍌','🍍','🥭','🍓','🍒','🍑','🥝','🍅','🥥','🥑','🍆','🥔','🥕','🌽','🌶️',
+ '🫑','🥒','🥬','🥦','🧄','🧅','🍄','🥜','🌰','🍞','🥐','🥖','🫓','🥨','🧀','🥚','🍳','🧈',
+ '🥞','🧇','🥓','🥩','🍗','🍖','🦴','🌭','🍔','🍟','🍕','🫔','🌮','🌯','🥙','🧆','🥚','🍱',
+ '🍘','🍙','🍚','🍛','🍜','🍝','🍠','🍢','🍣','🍤','🍥','🥮','🍡','🥟','🥠','🥡','🦪','🍦',
+ '🍧','🍨','🍩','🍪','🎂','🍰','🧁','🥧','🍫','🍬','🍭','🍮','🍯','🍼','🥛','☕','🫖','🍵',
+ '🧃','🥤','🧋','🍶','🍺','🍻','🥂','🍷','🥃','🍸','🍹','🧉','🍾','🧊','🥄','🍴','🍽️','🥢',
+ ],
+ Travel: [
+ '🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🦯','🦽','🦼','🛺',
+ '🚲','🛴','🛵','🏍️','🛺','🚨','🚔','🚍','🚘','🚖','🚡','🚠','🚟','🚃','🚋','🚞','🚝','🚄',
+ '🚅','🚈','🚂','🚆','🚇','🚊','🚉','✈️','🛫','🛬','🛩️','💺','🛸','🚁','🛶','⛵','🚤','🛥️',
+ '🛳️','⛴️','🚢','⚓','🗺️','🧭','🏔️','⛰️','🌋','🗻','🏕️','🏖️','🏜️','🏝️','🏞️','🏟️','🏛️','🏗️',
+ '🧱','🪨','🪵','🛖','🏘️','🏚️','🏠','🏡','🏢','🏣','🏤','🏥','🏦','🏨','🏩','🏪','🏫','🏬',
+ '🏭','🏯','🏰','💒','🗼','🗽','⛪','🕌','🛕','🕍','⛩️','🕋','⛲','⛺','🌁','🌃','🏙️','🌄',
+ ],
+ Activities: [
+ '⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🏓','🏸','🏒','🏑','🥍','🏏','🪃','🥅',
+ '⛳','🪁','🛝','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛷','⛸️','🥌','🎿','⛷️','🏂','🪂','🏋️',
+ '🤼','🤸','⛹️','🤺','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🏆','🥇','🥈','🥉','🏅',
+ '🎖️','🏵️','🎗️','🎫','🎟️','🎪','🤹','🎭','🩰','🎨','🖼️','🎰','🎲','🧩','🪄','🎯','🪅','🎮',
+ '🕹️','🎳','🎻','🎷','🥁','🪘','🎺','🎸','🪗','🎹','🎵','🎶','🎼','🎤','🎧','📻','🎙️','🎚️',
+ '🎬','📽️','🎞️','📱','📲','☎️','📞','📟','📠','🔋','🪫','🔌','💡','🔦','🕯️','💸','💵','🪙',
+ ],
+ Objects: [
+ '⌚','📱','📲','💻','⌨️','🖥️','🖨️','🖱️','🖲️','💾','💿','📀','🧮','📷','📸','📹','🎥','📽️',
+ '📞','☎️','📟','📠','📺','📻','🧭','⏱️','⏲️','⏰','🕰️','⌛','⏳','📡','🔋','🪫','🔌','💡',
+ '🔦','🕯️','🪔','🧱','💰','💴','💵','💶','💷','💸','💳','🪙','💹','✉️','📧','📨','📩','📤',
+ '📥','📦','📫','📪','📬','📭','📮','🗳️','✏️','✒️','🖊️','🖋️','📝','📁','📂','🗂️','📅','📆',
+ '🗒️','🗓️','📇','📈','📉','📊','📋','📌','📍','🗺️','📏','📐','✂️','🗃️','🗄️','🗑️','🔒','🔓',
+ '🔏','🔐','🔑','🗝️','🔨','🪓','⛏️','⚒️','🛠️','🗡️','⚔️','🔫','🪃','🏹','🛡️','🪚','🔧','🪛',
+ ],
+ Symbols: [
+ '❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝',
+ '💟','☮️','✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐','⛎','♈','♉','♊','♋','♌',
+ '♍','♎','♏','♐','♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺',
+ '🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️','🆘',
+ '❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️','🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❕',
+ '❓','❔','‼️','⁉️','🔅','🔆','📶','🛜','📳','📴','🔱','📛','🔰','♻️','✅','🈯','💹','❎',
+ '🌐','💠','Ⓜ️','🌀','💤','🏧','🚾','♿','🅿️','🛗','🈳','🈹','🚰','🔤','🔡','🔠','🆖','🆗',
+ '🆙','🆒','🆕','🆓','🔟','📊','🔣','✔️','☑️','🔘','🔲','🔳','⬛','⬜','◼️','◻️','◾','◽',
+ '▪️','▫️','🔶','🔷','🔸','🔹','🔺','🔻','💠','🔘','🔲','🔳','🏁','🚩','🎌','🏴','🏳️','⭐',
+ '🌟','💫','✨','🌈','☀️','🌤️','⛅','🌥️','☁️','🌦️','🌧️','⛈️','🌩️','🌨️','❄️','☃️','⛄','🌬️',
+ ],
+};
+
+function insertEmojiIntoTextarea(emoji) {
+ const textarea = document.querySelector('.chat-hub-textarea');
+ if (!textarea) return;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const before = textarea.value.substring(0, start);
+ const after = textarea.value.substring(end);
+ textarea.value = before + emoji + after;
+ const newPos = start + emoji.length;
+ textarea.selectionStart = newPos;
+ textarea.selectionEnd = newPos;
+ textarea.focus();
+}
+
+const EmojiPicker = () => ({
+ view: () => {
+ const search = ChatHubState.emojiSearch.toLowerCase();
+ const cat = ChatHubState.emojiCategory;
+ let emojis;
+ if (search) {
+ emojis = Object.values(EMOJI_DATA).flat();
+ } else {
+ emojis = EMOJI_DATA[cat] || [];
+ }
+ return m('.emoji-picker', [
+ m('.emoji-search-row', [
+ m('i.fas.fa-search.emoji-search-icon'),
+ m('input.emoji-search-input[type=text][placeholder=Search emoji...]', {
+ value: ChatHubState.emojiSearch,
+ oninput: (e) => { ChatHubState.emojiSearch = e.target.value; },
+ }),
+ ChatHubState.emojiSearch && m('button.emoji-search-clear', {
+ onclick: () => { ChatHubState.emojiSearch = ''; },
+ }, m('i.fas.fa-times')),
+ ]),
+ !search && m('.emoji-categories', EMOJI_CATEGORIES.map(c =>
+ m('button.emoji-cat-btn' + (c === cat ? '.active' : ''), {
+ title: c,
+ onclick: () => { ChatHubState.emojiCategory = c; },
+ }, EMOJI_ICONS[c])
+ )),
+ m('.emoji-grid',
+ emojis.map(e =>
+ m('button.emoji-btn', {
+ onclick: () => {
+ insertEmojiIntoTextarea(e);
+ ChatHubState.showEmojiPicker = false;
+ m.redraw();
+ },
+ }, e)
+ )
+ ),
+ ]);
+ },
+});
+
+// Lazy reference set by chat.js to avoid circular dependency
+let ChatHubState = null;
+
+function setDependencies(deps) {
+ ChatHubState = deps.ChatHubState;
+}
+
+module.exports = {
+ EMOJI_CATEGORIES,
+ EMOJI_ICONS,
+ EMOJI_DATA,
+ insertEmojiIntoTextarea,
+ EmojiPicker,
+ setDependencies,
+};
diff --git a/webui-src/app/chat/chat_state.js b/webui-src/app/chat/chat_state.js
new file mode 100644
index 0000000..54a4e62
--- /dev/null
+++ b/webui-src/app/chat/chat_state.js
@@ -0,0 +1,695 @@
+const m = require('mithril');
+const rs = require('rswebui');
+const peopleUtil = require('people/people_util');
+const people = require('people/people');
+
+// **************** utility functions ********************
+
+function get64Num(val) {
+ if (!val) return 0;
+ if (typeof val === 'object') {
+ return val.xint64 || parseInt(val.xstr64) || 0;
+ }
+ return Number(val) || 0;
+}
+
+function loadLobbyDetails(id, apply) {
+ rs.rsJsonApiRequest(
+ '/rsChats/getChatLobbyInfo',
+ {
+ id: { xstr64: id },
+ },
+ (detail, success) => {
+ if (success && detail.retval) {
+ detail.info.chatType = 3; // LOBBY
+ apply(detail.info);
+ } else {
+ apply(null);
+ }
+ },
+ true
+ );
+}
+
+function loadDistantChatDetails(pid, apply) {
+ rs.rsJsonApiRequest(
+ '/rsChats/getDistantChatStatus',
+ {
+ pid: pid,
+ },
+ (detail, success) => {
+ if (success && detail.retval) {
+ const info = detail.info;
+ info.chatType = 2; // DISTANT (matches TYPE_PRIVATE_DISTANT in rschats.h)
+ info.lobby_name = rs.userList.username(info.to_id) || 'Distant Chat ' + pid;
+ info.lobby_topic = 'Private Encrypted Chat';
+ info.gxs_id = info.own_id;
+ info.lobby_id = pid; // Distant IDs are 128-bit hex strings, NO xstr64 wrapper
+ apply(info);
+ } else {
+ apply(null);
+ }
+ },
+ true
+ );
+}
+
+function sortLobbies(lobbies) {
+ if (lobbies !== undefined && lobbies !== null) {
+ const list = [...lobbies];
+ list.sort((a, b) => a.lobby_name.localeCompare(b.lobby_name));
+ return list;
+ }
+ return [];
+}
+
+function getNicknameColor(id, name) {
+ const hashString = id && id !== '00000000000000000000000000000000' ? id : (name || '');
+ let hash = 0;
+ for (let i = 0; i < hashString.length; i++) {
+ hash = hashString.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const hue = Math.abs(hash) % 360;
+ return `hsl(${hue}, 75%, 35%)`;
+}
+
+function getStatusColor(status) {
+ switch (status) {
+ case 1: return '#eab308'; // Yellow
+ case 2: return '#22c55e'; // Green
+ case 3: return '#ef4444'; // Red
+ default: return '#94a3b8'; // Grey
+ }
+}
+
+function getStatusTooltip(status) {
+ switch (status) {
+ case 1: return 'Tunnel is pending. Please wait...';
+ case 2: return 'End-to-end encrypted conversation established. You can talk!';
+ case 3: return 'Your partner closed the conversation.';
+ default: return 'Remote status unknown.';
+ }
+}
+
+/**
+ * Wraps emoji characters in a span so CSS can size them independently.
+ */
+function renderTextWithEmoji(text) {
+ if (!text) return '';
+ const emojiRegex = /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})(?:[\u{1F3FB}-\u{1F3FF}])?(?:\u{FE0F})?(?:\u{20E3})?(?:(?:\u{200D}(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})(?:[\u{1F3FB}-\u{1F3FF}])?(?:\u{FE0F})?)*)/gu;
+ const parts = [];
+ let last = 0;
+ let match;
+ // eslint-disable-next-line no-cond-assign
+ while ((match = emojiRegex.exec(text)) !== null) {
+ if (match[0].length === 0) { emojiRegex.lastIndex++; continue; }
+ if (match.index > last) parts.push(text.slice(last, match.index));
+ parts.push(m('span.chat-emoji', match[0]));
+ last = match.index + match[0].length;
+ }
+ if (last < text.length) parts.push(text.slice(last));
+ return parts.length > 0 ? parts : text;
+}
+
+function getSafeAvatar(details) {
+ if (
+ details &&
+ details.mAvatar &&
+ details.mAvatar.mData &&
+ details.mAvatar.mData.base64 !== ''
+ ) {
+ return details.mAvatar;
+ }
+ return undefined;
+}
+
+// ***************************** models ***********************************
+
+const MobileState = {
+ showLobbies: false,
+ showUsers: false,
+ toggleLobbies() {
+ this.showLobbies = !this.showLobbies;
+ this.showUsers = false;
+ },
+ toggleUsers() {
+ this.showUsers = !this.showUsers;
+ this.showLobbies = false;
+ },
+ closeAll() {
+ this.showLobbies = false;
+ this.showUsers = false;
+ },
+};
+
+const ChatRoomsModel = {
+ allRooms: [],
+ knownSubscrIds: [],
+ subscribedRooms: {},
+ loadPublicRooms() {
+ rs.rsJsonApiRequest(
+ '/rsChats/getListOfNearbyChatLobbies',
+ {},
+ (data) => {
+ if (data && data.public_lobbies) {
+ const seen = new Set();
+ const uniqueLobbies = data.public_lobbies.filter((lobby) => {
+ const id = rs.idToHex(lobby.lobby_id);
+ if (seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ });
+ ChatRoomsModel.allRooms = sortLobbies(uniqueLobbies);
+ } else {
+ ChatRoomsModel.allRooms = [];
+ }
+ }
+ );
+ },
+ loadSubscribedRooms(after = null) {
+ rs.rsJsonApiRequest(
+ '/rsChats/getChatLobbyList',
+ {},
+ (data) => {
+ if (data && data.cl_list) {
+ const ids = [...new Set(data.cl_list.map((lid) => rs.idToHex(lid)))];
+ ChatRoomsModel.knownSubscrIds = ids;
+
+ Object.keys(ChatRoomsModel.subscribedRooms).forEach((id) => {
+ if (!ids.includes(id)) {
+ delete ChatRoomsModel.subscribedRooms[id];
+ }
+ });
+
+ if (ids.length === 0) {
+ ChatRoomsModel.loadPublicRooms();
+ if (after != null) after();
+ m.redraw();
+ return;
+ }
+
+ let count = 0;
+ ids.forEach((id) =>
+ loadLobbyDetails(id, (info) => {
+ if (info) {
+ ChatRoomsModel.subscribedRooms[id] = info;
+ }
+ count++;
+ if (count === ids.length) {
+ ChatRoomsModel.loadPublicRooms();
+ if (after != null) {
+ after();
+ }
+ m.redraw();
+ }
+ })
+ );
+ } else {
+ ChatRoomsModel.loadPublicRooms();
+ }
+ }
+ );
+ },
+ subscribed(info) {
+ return this.knownSubscrIds.includes(rs.idToHex(info.lobby_id));
+ },
+};
+
+/**
+ * Message displays a single Chat-Message
+ * currently removes formatting and in consequence inline links
+ */
+const Message = () => {
+ return {
+ view: (vnode) => {
+ const msg = vnode.attrs;
+ const datetime = new Date(msg.sendTime * 1000).toLocaleTimeString();
+ if (msg.isSystem) {
+ const text = msg.msg || msg.message;
+ const isSecured = text.includes('secured') || text.includes('talk');
+ const bgColor = isSecured ? '#fffbeb' : '#f8fafc';
+ const borderColor = isSecured ? '#fcd34d' : '#cbd5e1';
+ const textColor = isSecured ? '#b45309' : '#475569';
+ const borderStyle = isSecured ? 'solid' : 'dashed';
+
+ return m(
+ '.message.incoming',
+ [
+ m('span.datetime', datetime),
+ m('span.username', 'Chat status'),
+ m('.messagetext', {
+ style: {
+ backgroundColor: bgColor,
+ border: `1px ${borderStyle} ${borderColor}`,
+ color: textColor,
+ padding: '0.5rem 0.75rem',
+ borderRadius: '0.375rem',
+ display: 'inline-block',
+ marginTop: '0.25rem',
+ }
+ }, text)
+ ]
+ );
+ }
+ const rawGxsId = msg.lobby_peer_gxs_id || msg.peerId;
+ let gxsId = rs.idToHex(rawGxsId);
+
+ const isZero = (id) => !id || id === '00000000000000000000000000000000';
+ if (isZero(gxsId)) {
+ const lobby = ChatLobbyModel.currentLobby;
+ if (lobby && (lobby.chatType === 1 || lobby.chatType === 2)) {
+ gxsId = msg.incoming ? rs.idToHex(lobby.to_id || lobby.peer_id || lobby.distant_chat_id) : rs.idToHex(lobby.own_id || lobby.gxs_id);
+ }
+ }
+
+ const isMuted = ChatHubState.mutedUsers && ChatHubState.mutedUsers.has(gxsId);
+ const details = ChatHubState.gxsDetails[gxsId];
+ const opinion = details && details.mReputation ? details.mReputation.mOwnOpinion : 1;
+ const isBanned = opinion === 0;
+
+ if (isMuted || isBanned) {
+ return null;
+ }
+
+ let username = rs.userList.username(gxsId) || msg.peerName || '???';
+ if (username === gxsId && msg.peerName) {
+ username = msg.peerName;
+ }
+ if (username === gxsId && gxsId && gxsId.length > 12) {
+ username = gxsId.substring(0, 8) + '...';
+ }
+ const text = (msg.msg || msg.message || '')
+ .replaceAll('
', '\n')
+ .replace(new RegExp('|<[^>]*>', 'gm'), '');
-
- return m(
- '.chat-bubble-container' + (isOwn ? '.outgoing' : '.incoming'),
- [
- !isOwn && m('.chat-sender', senderName),
- m('.chat-bubble', text),
- m('.chat-time', time),
- ]
- );
- })
- ),
- m('.chat-input-area', [
- m('textarea.chat-textarea', {
- placeholder: 'Type your message... Press Enter to send',
- value: State.chatInputMsg,
- oninput: (e) => {
- State.chatInputMsg = e.target.value;
- },
- onkeydown: (e) => {
- if (e.code === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendDirectChatMessage();
- }
- },
- }),
- m(
- 'button.send-btn',
- {
- onclick: () => sendDirectChatMessage(),
- },
- [m('i.fas.fa-paper-plane'), ' Send']
- ),
- ]),
- ]);
- },
- };
-};
+const {
+ State,
+ loadOwnProfile,
+ loadGxsIdentities,
+ fetchIdDetails,
+ startDirectChat,
+ getOnlineSslId,
+} = require('network/network_state');
+const { OwnProfileCard, FriendsList } = require('network/network_friends_list');
+const DetailsTab = require('network/network_details_tab');
+const ChatTab = require('network/network_chat_tab');
const NetworkLayout = () => {
return {
@@ -565,7 +22,6 @@ const NetworkLayout = () => {
loadGxsIdentities();
},
onremove: () => {
- // Clean up notify callback when page is left
if (rs.events[15]) {
rs.events[15].notify = () => {};
}
@@ -580,7 +36,7 @@ const NetworkLayout = () => {
: null;
if (State.selectedFriendGpgId && !selectedGxsId && State.gxsIdentities) {
- State.gxsIdentities.forEach(gxsId => fetchIdDetails(gxsId));
+ State.gxsIdentities.forEach((gxsId) => fetchIdDetails(gxsId));
}
return m('.network-container', [
@@ -618,10 +74,12 @@ const NetworkLayout = () => {
]
: m('.network-pane-placeholder', [
m('i.fas.fa-network-wired'),
- m('p', 'Select a friend node from the left side panel to view locations details or start a private chat.'),
+ m(
+ 'p',
+ 'Select a friend node from the left side panel to view locations details or start a private chat.'
+ ),
]),
]),
- // Mail composer overlay popup
State.showMailCompose &&
State.selectedFriendGpgId &&
m(
diff --git a/webui-src/app/network/network_chat_tab.js b/webui-src/app/network/network_chat_tab.js
new file mode 100644
index 0000000..a10bf86
--- /dev/null
+++ b/webui-src/app/network/network_chat_tab.js
@@ -0,0 +1,125 @@
+const m = require('mithril');
+const Data = require('network/network_data');
+const { State, startDirectChat, getOnlineSslId, sendDirectChatMessage } = require('network/network_state');
+
+const ChatTab = () => {
+ return {
+ view: () => {
+ const gpgId = State.selectedFriendGpgId;
+ const friend = Data.gpgDetails[gpgId];
+ if (!friend) return null;
+
+ const sslId = getOnlineSslId(gpgId);
+
+ if (!sslId) {
+ return m('.network-chat-view', [
+ m('.chat-warning', [
+ m('i.fas.fa-exclamation-triangle'),
+ m('h4', 'No Location Found'),
+ m('p', 'This friend has no known locations to start a direct chat with.'),
+ ]),
+ ]);
+ }
+
+ if (!State.currentChatPeerId) {
+ return m('.network-chat-view', [
+ m('.chat-warning', [
+ m('i.fas.fa-comments'),
+ m('h4', 'Direct Chat'),
+ m('p', 'Click below to start a direct chat with ' + friend.name + '.'),
+ m(
+ 'button',
+ {
+ onclick: () => startDirectChat(sslId),
+ },
+ 'Start Chat'
+ ),
+ ]),
+ ]);
+ }
+
+ return m('.network-chat-view', [
+ (() => {
+ const activeLoc = friend.locations.find((loc) => loc.id === State.currentChatPeerId);
+ const locName = activeLoc ? activeLoc.name : 'Unknown Location';
+ const locOnline = activeLoc ? activeLoc.isOnline : false;
+ return m('.chat-header-bar', {
+ style: {
+ padding: '0.75rem 1rem',
+ backgroundColor: '#ffffff',
+ borderBottom: '1px solid #cbd5e1',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ }
+ }, [
+ m('.chat-header-info', [
+ m('.chat-header-name', { style: { fontWeight: '700', color: '#1e293b' } }, friend.name),
+ m('.chat-header-location', { style: { fontSize: '0.8rem', color: '#64748b', display: 'flex', alignItems: 'center', marginTop: '0.25rem' } }, [
+ m('span', 'Location: ' + locName),
+ m('span.status-dot', {
+ style: {
+ display: 'inline-block',
+ width: '8px',
+ height: '8px',
+ borderRadius: '50%',
+ backgroundColor: locOnline ? '#10b981' : '#ef4444',
+ marginLeft: '6px',
+ marginRight: '4px'
+ }
+ }),
+ m('span', { style: { color: locOnline ? '#10b981' : '#ef4444', fontWeight: '500' } }, locOnline ? 'Online' : 'Offline')
+ ])
+ ])
+ ]);
+ })(),
+ m(
+ '.chat-messages[id=chat-messages-container]',
+ State.chatMessages.map((msg) => {
+ const isOwn = msg.own === true;
+ const senderName = isOwn
+ ? (State.ownProfile.name || 'Me')
+ : friend.name;
+ const time = new Date(msg.sendTime * 1000).toLocaleTimeString();
+ const text = (msg.msg || '')
+ .replaceAll('
', '\n')
+ .replace(new RegExp('|<[^>]*>', 'gm'), '');
+ if (cleanText) {
+ parts.push(renderTextWithEmoji(cleanText));
+ }
+ }
+
+ const src = match[1];
+ if (src) {
+ parts.push(
+ m('img.chat-embedded-image', {
+ src: src,
+ style: {
+ maxWidth: '100%',
+ maxHeight: '300px',
+ borderRadius: '0.375rem',
+ marginTop: '0.25rem',
+ marginBottom: '0.25rem',
+ display: 'block',
+ cursor: 'pointer',
+ boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
+ },
+ onclick: () => {
+ const w = window.open('');
+ if (w) {
+ w.document.write(`
`);
+ }
+ }
+ })
+ );
+ }
+
+ lastIndex = imgRegex.lastIndex;
+ }
+
+ if (lastIndex < rawText.length) {
+ const trailingText = rawText.substring(lastIndex);
+ const cleanText = trailingText
+ .replaceAll('
', '\n')
+ .replaceAll('
', '\n')
+ .replace(new RegExp('|<[^>]*>', 'gm'), '');
+
+ return renderTextWithEmoji(cleanText);
+}
+
/**
* Wraps emoji characters in a span so CSS can size them independently.
*/
@@ -278,9 +376,7 @@ const Message = () => {
if (username === gxsId && gxsId && gxsId.length > 12) {
username = gxsId.substring(0, 8) + '...';
}
- const text = (msg.msg || msg.message || '')
- .replaceAll('
', '\n')
- .replace(new RegExp('|<[^>]*>', 'gm'), '');
if (cleanText) {
- parts.push(renderTextWithEmoji(cleanText));
+ parts.push(renderFormattedMessageText(cleanText));
}
}
@@ -182,11 +182,61 @@ function renderChatMessage(rawText) {
// 3. Normal text message
const cleanText = rawText
+ .replace(/]*>/gi, '\n> ')
+ .replace(/<\/blockquote>/gi, '\n')
.replaceAll('
', '\n')
.replaceAll('
', '\n')
.replace(new RegExp('