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] 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('