mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-12 19:50:04 +05:00
Improved Network page
This commit is contained in:
parent
a28d77fe9a
commit
c1eb3a767c
@ -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-----
|
||||
<br>
|
||||
<b>From: </b>
|
||||
<a href="retroshare://message?id=${senderId}">${rs.userList.userMap[senderId]}</a>
|
||||
<a href="retroshare://message?id=${senderId}">${rs.userList.username(senderId)}</a>
|
||||
<br>
|
||||
<b>To: </b>
|
||||
${Object.keys(recipientList).map(
|
||||
(recip) => `
|
||||
<a href="retroshare://message?id=${recip}">
|
||||
${rs.userList.userMap[recipientList[recip]._addr_string] || 'Unknown'},
|
||||
${rs.userList.username(recipientList[recip]._addr_string) || 'Unknown'},
|
||||
</a>
|
||||
`
|
||||
)}
|
||||
@ -94,7 +132,7 @@ const Layout = () => {
|
||||
<br>
|
||||
<span>
|
||||
On ${timeStamp.toLocaleDateString()} ${time},
|
||||
<a href="retroshare://message?id=${senderId}">${rs.userList.userMap[senderId]}</a>
|
||||
<a href="retroshare://message?id=${senderId}">${rs.userList.username(senderId)}</a>
|
||||
wrote:
|
||||
</span>
|
||||
`;
|
||||
@ -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'
|
||||
)
|
||||
)
|
||||
|
||||
@ -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('<br/>', '\n')
|
||||
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', '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 NetworkLayout = () => {
|
||||
return {
|
||||
oninit: () => {
|
||||
Data.refreshGpgDetails().then(() => m.redraw());
|
||||
loadOwnProfile();
|
||||
loadGxsIdentities();
|
||||
},
|
||||
onremove: () => {
|
||||
// Clean up notify callback when page is left
|
||||
if (rs.events[15]) {
|
||||
rs.events[15].notify = () => {};
|
||||
}
|
||||
},
|
||||
view: () => {
|
||||
const selectedFriend = State.selectedFriendGpgId
|
||||
? Data.gpgDetails[State.selectedFriendGpgId]
|
||||
: null;
|
||||
|
||||
const selectedGxsId = State.selectedFriendGpgId
|
||||
? State.gpgToGxsIdMap[State.selectedFriendGpgId.toLowerCase()]
|
||||
: null;
|
||||
|
||||
return m('.network-container', [
|
||||
m('.network-left-pane', [m(OwnProfileCard), m(FriendsList)]),
|
||||
m('.network-right-pane', [
|
||||
selectedFriend
|
||||
? [
|
||||
m('.network-tabs', [
|
||||
m(
|
||||
'button.tab-btn' + (State.activeTab === 'details' ? '.active' : ''),
|
||||
{
|
||||
onclick: () => {
|
||||
State.activeTab = 'details';
|
||||
},
|
||||
},
|
||||
'Details View'
|
||||
),
|
||||
m(
|
||||
'button.tab-btn' + (State.activeTab === 'chat' ? '.active' : ''),
|
||||
{
|
||||
onclick: () => {
|
||||
State.activeTab = 'chat';
|
||||
const sslId = getOnlineSslId(State.selectedFriendGpgId);
|
||||
if (sslId && !State.currentChatPeerId) {
|
||||
startDirectChat(sslId);
|
||||
}
|
||||
},
|
||||
},
|
||||
'Chat Conversation'
|
||||
),
|
||||
]),
|
||||
m('.network-tab-content', [
|
||||
State.activeTab === 'details' ? m(DetailsTab) : m(ChatTab),
|
||||
]),
|
||||
]
|
||||
: 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.'),
|
||||
]),
|
||||
]),
|
||||
// Mail composer overlay popup
|
||||
State.showMailCompose &&
|
||||
State.selectedFriendGpgId &&
|
||||
m(
|
||||
'.composePopupOverlay#mailComposerPopup',
|
||||
{ style: { display: 'block' } },
|
||||
m(
|
||||
'.composePopup',
|
||||
m(compose, {
|
||||
msgType: 'compose',
|
||||
toId: selectedGxsId || State.selectedFriendGpgId,
|
||||
friendName: selectedFriend ? selectedFriend.name : 'Unknown Friend',
|
||||
isDirectMail: true,
|
||||
setShowCompose: (val) => {
|
||||
State.showMailCompose = val;
|
||||
},
|
||||
}),
|
||||
m(
|
||||
'button.red.close-btn',
|
||||
{
|
||||
onclick: () => {
|
||||
State.showMailCompose = false;
|
||||
},
|
||||
},
|
||||
m('i.fas.fa-times')
|
||||
)
|
||||
)
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = NetworkLayout;
|
||||
|
||||
@ -33,28 +33,49 @@ Data.refreshGpgDetails = async function () {
|
||||
(stat) => (isOnline = stat.retval)
|
||||
)
|
||||
.then(() => {
|
||||
const loc = {
|
||||
name: data.location,
|
||||
id: data.id,
|
||||
lastSeen: data.lastConnect,
|
||||
isOnline,
|
||||
gpg_id: data.gpg_id,
|
||||
};
|
||||
let customState = '';
|
||||
return rs
|
||||
.rsJsonApiRequest(
|
||||
'/rsChats/getCustomStateString',
|
||||
{ peer_id: data.id },
|
||||
(statusData) => {
|
||||
if (statusData && statusData.retval) {
|
||||
customState = statusData.retval;
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
const gpgId = (data.gpg_id || '').toLowerCase();
|
||||
const loc = {
|
||||
name: data.location,
|
||||
id: data.id,
|
||||
lastSeen: data.lastConnect,
|
||||
isOnline,
|
||||
gpg_id: gpgId,
|
||||
customState,
|
||||
};
|
||||
|
||||
if (details[data.gpg_id] === undefined) {
|
||||
details[data.gpg_id] = {
|
||||
name: data.name,
|
||||
isSearched: true,
|
||||
isOnline,
|
||||
locations: [loc],
|
||||
};
|
||||
} else {
|
||||
details[data.gpg_id].locations.push(loc);
|
||||
}
|
||||
details[data.gpg_id].isOnline = details[data.gpg_id].isOnline || isOnline;
|
||||
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;
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
Data.gpgDetails = details;
|
||||
};
|
||||
module.exports = Data;
|
||||
|
||||
@ -1,46 +1,544 @@
|
||||
@use '../abstracts' as *;
|
||||
|
||||
.friend {
|
||||
color: #444;
|
||||
font-size: 1.2em;
|
||||
margin: 1rem 0.5rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 20px;
|
||||
& i {
|
||||
float: left;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
& h4 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
& button {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
& .brief-info.online {
|
||||
color: green;
|
||||
.network-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.network-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);
|
||||
}
|
||||
|
||||
.own-profile-card {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
& .location {
|
||||
margin: 5px;
|
||||
border-top: 1px solid #bbb;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: start;
|
||||
}
|
||||
& .brief-info {
|
||||
@include flex($align: center);
|
||||
justify-self: start;
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .fa-times-circle {
|
||||
color: #555;
|
||||
}
|
||||
& .fa-check-circle {
|
||||
color: green;
|
||||
.own-identity-select-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
select.own-identity-select {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #ffffff;
|
||||
color: #334155;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: #3ba4d7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.friends-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.friends-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.friend-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;
|
||||
|
||||
.friend-name {
|
||||
color: #0369a1;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friend-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.friend-name {
|
||||
font-size: 0.95rem;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.friend-status {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
|
||||
&.online {
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.network-right-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.network-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;
|
||||
}
|
||||
}
|
||||
|
||||
.network-tabs {
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
padding: 0.5rem 1rem 0;
|
||||
gap: 0.5rem;
|
||||
|
||||
.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;
|
||||
|
||||
&:hover {
|
||||
color: #334155;
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #3ba4d7;
|
||||
border-bottom-color: #3ba4d7;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.network-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.network-detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
|
||||
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;
|
||||
|
||||
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: 120px 1fr;
|
||||
row-gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.locations-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.location-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
|
||||
.loc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.loc-name {
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.loc-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&.online {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loc-body {
|
||||
font-size: 0.85rem;
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
.loc-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.loc-val {
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.loc-footer {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.network-chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f8fafc;
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chat-bubble-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
|
||||
&.outgoing {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
|
||||
.chat-bubble {
|
||||
background-color: #3ba4d7;
|
||||
color: #ffffff;
|
||||
border-bottom-right-radius: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.incoming {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
.chat-bubble {
|
||||
background-color: #ffffff;
|
||||
color: #1e293b;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-bottom-left-radius: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-sender {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.4;
|
||||
white-space: break-spaces;
|
||||
word-break: break-word;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-time {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1rem;
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #cbd5e1;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
textarea.chat-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;
|
||||
|
||||
&:focus {
|
||||
border-color: #3ba4d7;
|
||||
box-shadow: 0 0 0 3px rgba(59, 164, 215, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
button.send-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-warning {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
i {
|
||||
font-size: 3rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 350px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user