Merge pull request #119 from defnax/improve-network

Improved Network, People, Chat & Mail & StatusBar
This commit is contained in:
Sumit Kumar Soni 2026-07-26 19:35:41 +05:30 committed by GitHub
commit 6b799b89a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 9621 additions and 1580 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
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, onSelect) {
if (typeof onSelect === 'function') {
onSelect(emoji);
return;
}
const textarea = document.querySelector('.chat-hub-textarea, .chat-textarea');
if (!textarea) return;
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
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: ({ attrs: { onSelect } }) => {
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, onSelect);
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,
};

View File

@ -0,0 +1,909 @@
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.';
}
}
function renderChatMessage(rawText) {
if (!rawText) return '';
// 1. Check for <img ... src="..."> HTML tags
const imgRegex = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
if (imgRegex.test(rawText)) {
imgRegex.lastIndex = 0;
const parts = [];
let lastIndex = 0;
let match;
while ((match = imgRegex.exec(rawText)) !== null) {
if (match.index > lastIndex) {
const precedingText = rawText.substring(lastIndex, match.index);
const cleanText = precedingText
.replaceAll('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', '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(`<body style="margin:0;background:#0f172a;display:flex;justify-content:center;align-items:center;min-height:100vh;"><img src="${src}" style="max-width:100%;max-height:100vh;object-fit:contain;"/></body>`);
}
}
})
);
}
lastIndex = imgRegex.lastIndex;
}
if (lastIndex < rawText.length) {
const trailingText = rawText.substring(lastIndex);
const cleanText = trailingText
.replaceAll('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', 'gm'), '');
if (cleanText) {
parts.push(renderFormattedMessageText(cleanText));
}
}
return parts.length > 0 ? parts : '';
}
// 2. Check for raw data:image/... base64 URLs
if (rawText.trim().startsWith('data:image/')) {
const src = rawText.trim();
return 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(`<body style="margin:0;background:#0f172a;display:flex;justify-content:center;align-items:center;min-height:100vh;"><img src="${src}" style="max-width:100%;max-height:100vh;object-fit:contain;"/></body>`);
}
}
});
}
// 3. Normal text message
const cleanText = rawText
.replace(/<blockquote[^>]*>/gi, '\n> ')
.replace(/<\/blockquote>/gi, '\n')
.replaceAll('<br/>', '\n')
.replaceAll('<br>', '\n')
.replace(new RegExp('<style[^<]*</style>|<[^>]*>', 'gm'), '');
return renderFormattedMessageText(cleanText);
}
function renderFormattedMessageText(text) {
if (!text) return '';
const lines = text.split('\n');
const elements = [];
let currentQuoteLines = [];
const flushQuote = () => {
if (currentQuoteLines.length > 0) {
const quoteText = currentQuoteLines.join('\n');
elements.push(
m('blockquote.chat-quote-block', {
style: {
borderLeft: '3px solid #3b82f6',
backgroundColor: '#f8fafc',
color: '#475569',
padding: '0.35rem 0.65rem',
margin: '0.35rem 0',
borderRadius: '0 0.375rem 0.375rem 0',
fontSize: '0.9em',
fontStyle: 'italic',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}
}, renderTextWithEmoji(quoteText))
);
currentQuoteLines = [];
}
};
lines.forEach((line, idx) => {
if (line.trim().startsWith('>')) {
const lineContent = line.trim().replace(/^>\s?/, '');
currentQuoteLines.push(lineContent);
} else {
flushQuote();
if (line) {
elements.push(renderTextWithEmoji(line));
}
if (idx < lines.length - 1) {
elements.push(m('br'));
}
}
});
flushQuote();
return elements.length > 0 ? elements : renderTextWithEmoji(text);
}
/**
* 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 rawText = msg.msg || msg.message || '';
const chatType = ChatLobbyModel.currentLobby && ChatLobbyModel.currentLobby.chatType;
const isRoom = chatType === 3;
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
const sel = window.getSelection() ? window.getSelection().toString() : '';
const targetText = sel && sel.trim() ? sel : rawText;
ChatHubState.messageContextMenu = {
show: true,
x: e.clientX,
y: e.clientY,
messageText: targetText,
username: username,
gxsId: gxsId,
};
m.redraw();
};
if (isRoom) {
const nickColor = getNicknameColor(gxsId, username);
return m(
'.message.compact',
{
oncontextmenu: handleContextMenu,
},
[
m('span.datetime', datetime),
m('span.username', { style: { color: nickColor } }, username + ':'),
m('span.messagetext', renderChatMessage(rawText)),
]
);
}
return m(
'.message' + (msg.incoming ? '.incoming' : '.outgoing'),
m('span.datetime', datetime),
m('span.username', username),
m('span.messagetext', renderChatMessage(rawText))
);
},
};
};
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;
},
getMessageKey(msg) {
if (msg.msgId && msg.msgId !== 0) return 'id_' + msg.msgId;
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)) {
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
);
});
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);
}
}
);
},
loadAllHistoryForRoom(lobbyId, callback) {
ChatHubState.isHistoryLoading = true;
ChatHubState.fullHistoryMessages = [];
m.redraw();
const chatType = this.currentLobby && this.currentLobby.chatType;
const isDistant = chatType === 2;
const chatPeerId = {
broadcast_status_peer_id: '00000000000000000000000000000000',
type: isDistant ? 2 : 3,
peer_id: '00000000000000000000000000000000',
distant_chat_id: isDistant ? (lobbyId || '') : '00000000000000000000000000000000',
lobby_id: { xstr64: isDistant ? '0' : (lobbyId || '0') },
};
rs.rsJsonApiRequest(
'/rsHistory/getMessages',
{
chatPeerId: chatPeerId,
loadCount: 0,
},
(data, success) => {
let msgs = (success && data && data.msgs) ? data.msgs : [];
msgs.sort((a, b) => (a.sendTime || a.recvTime) - (b.sendTime || b.recvTime));
ChatHubState.fullHistoryMessages = msgs;
ChatHubState.isHistoryLoading = false;
m.redraw();
if (callback) callback();
}
);
},
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) {
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) {
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) || '???';
this.messages = [];
this.messageKeys.clear();
this.loadHistory(currentlobbyid, detail.chatType);
const cid = this.chatId();
rs.events[15].chatMessages(cid, rs.events[15], (l) => {
this.addMessages(l);
});
rs.events[15].notify = (chatMessage) => {
const msgCid = chatMessage.chat_id;
let msgId;
if (msgCid.type === 3) {
msgId = rs.idToHex(msgCid.lobby_id);
} else if (msgCid.type === 2) {
msgId = rs.idToHex(msgCid.distant_chat_id);
} else if (msgCid.type === 1) {
msgId = rs.idToHex(msgCid.peer_id);
} else {
msgId = rs.idToHex(msgCid);
}
if (msgId === currentlobbyid) {
this.addMessages([chatMessage]);
}
};
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 {
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();
rs.rsJsonApiRequest(
'/rsChats/sendChat',
{
id: cid,
msg: msg,
},
(data, success) => {
if (success) {
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);
if (onsuccess) onsuccess();
} else {
console.error('[RS] Failed to send chat message:', data);
alert('Failed to send chat message. The image/payload exceeds RetroShare max chat packet size.');
if (onsuccess) onsuccess();
}
}
);
},
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));
};
},
setupEvent(info) {
return () => {
m.route.set('/chat/:lobby/setup', { lobby: rs.idToHex(info.lobby_id) });
ChatLobbyModel.loadPublicLobby(rs.idToHex(info.lobby_id));
};
},
};
// ************************* Chat Hub State ****************************
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(),
showHistoryModal: false,
historySearchQuery: '',
fullHistoryMessages: [],
isHistoryLoading: false,
messageContextMenu: {
show: false,
x: 0,
y: 0,
messageText: '',
username: '',
gxsId: '',
},
};
module.exports = {
get64Num,
loadLobbyDetails,
loadDistantChatDetails,
sortLobbies,
getNicknameColor,
getStatusColor,
getStatusTooltip,
renderTextWithEmoji,
renderChatMessage,
getSafeAvatar,
MobileState,
ChatRoomsModel,
Message,
ChatLobbyModel,
ChatHubState,
};

View File

@ -0,0 +1,205 @@
const m = require('mithril');
const rs = require('rswebui');
const peopleUtil = require('people/people_util');
const peopleState = require('people/people_state');
const ConfigChat = () => {
let defaultIdentity = '';
let ownIdentities = [];
let acceptChatFrom = 0; // 0 = Everyone, 1 = Contacts Only, 2 = Nobody
let maxStorageDays = 10;
// History states
let historyEnable = { private: true, distant: true, lobby: true };
let historySaveCount = { private: 500, distant: 500, lobby: 500 };
function loadSettings() {
// Load Own Identities
peopleUtil.ownIds((ids) => {
ownIdentities = ids || [];
ownIdentities.forEach((id) => {
peopleState.fetchIdDetails(id);
});
m.redraw();
});
// Load Default Lobby Identity
rs.rsJsonApiRequest('/rsChats/getDefaultIdentityForChatLobby', {}, (data) => {
if (data && data.id) {
defaultIdentity = data.id;
peopleState.fetchIdDetails(defaultIdentity);
m.redraw();
}
});
// Load Distant Chat Accept Permission Flags (silent error fallback)
rs.rsJsonApiRequest('/rsChats/getDistantChatPermissionFlags', {}, (data, success) => {
if (success && data && data.retval !== undefined) {
acceptChatFrom = data.retval;
m.redraw();
}
}, true);
// Load Max Storage Duration (silent error fallback)
rs.rsJsonApiRequest('/rsHistory/getMaxStorageDuration', {}, (data, success) => {
if (success && data && data.retval !== undefined) {
maxStorageDays = Math.round(data.retval / 86400);
m.redraw();
}
}, true);
// Load History Enables & Save Counts
const types = [
{ key: 'private', type: 1 },
{ key: 'distant', type: 3 },
{ key: 'lobby', type: 2 },
];
types.forEach(({ key, type }) => {
rs.rsJsonApiRequest('/rsHistory/getEnable', { chat_type: type }, (data, success) => {
if (success && data && data.retval !== undefined) {
historyEnable[key] = data.retval;
m.redraw();
}
}, true);
rs.rsJsonApiRequest('/rsHistory/getSaveCount', { chat_type: type }, (data, success) => {
if (success && data && data.retval !== undefined) {
historySaveCount[key] = data.retval;
m.redraw();
}
}, true);
});
}
return {
oninit: () => {
loadSettings();
},
view: () => {
const selectedDetails = defaultIdentity ? peopleState.State.gxsIdToDetailsMap[defaultIdentity] : null;
return m('.node-config', [
// General Chat Settings
m('.widget', [
m('.widget__heading', m('h3', 'General Chat Settings')),
m('.widget__body', [
m('.config-grid', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 500; color: #334155;' }, 'Default identity for chat rooms:'),
m('.default-id-selector', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
m(peopleUtil.UserAvatar, {
avatar: selectedDetails ? selectedDetails.mAvatar : null,
identityId: defaultIdentity,
size: 24,
}),
m('select', {
style: 'padding: 0.35rem 0.6rem; border-radius: 0.375rem; border: 1px solid #cbd5e1; outline: none; background: #ffffff; min-width: 240px; font-weight: 600;',
value: defaultIdentity,
onchange: (e) => {
defaultIdentity = e.target.value;
rs.rsJsonApiRequest('/rsChats/setDefaultIdentityForChatLobby', { id: defaultIdentity }, () => {});
},
}, [
m('option', { value: '' }, '-- Select Default Identity --'),
ownIdentities.map((id) => {
const det = peopleState.State.gxsIdToDetailsMap[id];
const nick = (det ? det.mNickname : null) || rs.userList.username(id) || id;
return m('option', { value: id }, nick);
}),
]),
]),
m('label', { style: 'font-weight: 500; color: #334155;' }, 'Accept chat from:'),
m('select', {
style: 'padding: 0.35rem 0.6rem; border-radius: 0.375rem; border: 1px solid #cbd5e1; outline: none; background: #ffffff; max-width: 320px; font-weight: 600;',
value: acceptChatFrom,
onchange: (e) => {
acceptChatFrom = parseInt(e.target.value);
rs.rsJsonApiRequest('/rsChats/setDistantChatPermissionFlags', { flags: acceptChatFrom }, () => {}, true);
},
}, [
m('option', { value: 0 }, 'Everyone'),
m('option', { value: 1 }, 'Contacts Only'),
m('option', { value: 2 }, 'Nobody'),
]),
]),
]),
]),
// Chat History Settings
m('.widget', [
m('.widget__heading', m('h3', 'Chat History Settings')),
m('.widget__body', [
m('.config-grid', { style: 'display: flex; align-items: center; justify-content: space-between; background: #f8fafc; padding: 0.75rem 1rem; border-radius: 0.5rem; border: 1px solid #e2e8f0; margin-bottom: 1.25rem; max-width: 500px; width: 100%;' }, [
m('div', [
m('span', { style: 'font-weight: 600; color: #1e293b; display: block;' }, 'Max Storage Duration'),
m('span', { style: 'font-size: 0.8rem; color: #64748b;' }, 'Global expiration period for messages stored in history database'),
]),
m('.storage-input-group', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
m('input[type=number][min=1][max=365]', {
style: 'width: 70px; padding: 0.35rem 0.5rem; border-radius: 0.375rem; border: 1px solid #cbd5e1; outline: none; font-weight: 600; text-align: center;',
value: maxStorageDays,
oninput: (e) => (maxStorageDays = parseInt(e.target.value) || 1),
onchange: () => {
rs.rsJsonApiRequest('/rsHistory/setMaxStorageDuration', { seconds: maxStorageDays * 86400 }, () => {}, true);
},
}),
m('span', { style: 'font-weight: 500; color: #475569; font-size: 0.85rem;' }, 'Days'),
]),
]),
m('.table-container', { style: 'border: 1px solid #e2e8f0; border-radius: 0.5rem; overflow: hidden; background: #ffffff; max-width: 500px; width: 100%;' }, [
m('table.history-config-table', { style: 'width: 100%; border-collapse: collapse; text-align: left;' }, [
m('thead', [
m('tr', { style: 'background: #f8fafc; border-bottom: 1px solid #e2e8f0;' }, [
m('th', { style: 'padding: 0.75rem 0.75rem; color: #475569; font-weight: 600; font-size: 0.85rem; width: 220px; text-align: left;' }, 'Chat Type'),
m('th', { style: 'padding: 0.75rem 0.75rem; color: #475569; font-weight: 600; font-size: 0.85rem; width: 120px; text-align: center;' }, 'Enable History'),
m('th', { style: 'padding: 0.75rem 0.75rem; color: #475569; font-weight: 600; font-size: 0.85rem; width: 160px; text-align: center;' }, 'Max Saved Messages'),
]),
]),
m('tbody', [
[
{ label: 'Direct Chat (Private)', icon: 'fa-user-lock', key: 'private', type: 1 },
{ label: 'Distant Chat', icon: 'fa-network-wired', key: 'distant', type: 3 },
{ label: 'Chat Rooms (Lobbies)', icon: 'fa-comments', key: 'lobby', type: 2 },
].map(({ label, icon, key, type }) =>
m('tr', { style: 'border-bottom: 1px solid #f1f5f9; transition: background 0.15s ease;' }, [
m('td', { style: 'padding: 0.75rem 0.75rem; font-weight: 600; color: #1e293b; text-align: left;' }, [
m('i.fas.' + icon, { style: 'margin-right: 0.5rem; color: #64748b; font-size: 0.9rem;' }),
label,
]),
m('td', { style: 'padding: 0.75rem 0.75rem; text-align: center;' }, [
m('input[type=checkbox]', {
style: 'width: 17px; height: 17px; cursor: pointer; accent-color: #3b82f6;',
checked: historyEnable[key],
oninput: (e) => {
historyEnable[key] = e.target.checked;
rs.rsJsonApiRequest('/rsHistory/setEnable', { chat_type: type, enable: historyEnable[key] }, () => {}, true);
},
}),
]),
m('td', { style: 'padding: 0.75rem 0.75rem; text-align: center;' }, [
m('div', { style: 'display: flex; align-items: center; justify-content: center; gap: 0.4rem;' }, [
m('input[type=number][min=0][max=50000]', {
style: 'width: 80px; padding: 0.3rem 0.4rem; border-radius: 0.375rem; border: 1px solid #cbd5e1; outline: none; text-align: center; font-weight: 500;',
value: historySaveCount[key],
oninput: (e) => (historySaveCount[key] = parseInt(e.target.value) || 0),
onchange: () => {
rs.rsJsonApiRequest('/rsHistory/setSaveCount', { chat_type: type, count: historySaveCount[key] }, () => {}, true);
},
}),
m('span', { style: 'font-size: 0.8rem; color: #94a3b8;' }, 'msgs'),
]),
]),
])
),
]),
]),
]),
]),
]),
]);
},
};
};
module.exports = ConfigChat;

View File

@ -10,6 +10,10 @@ const SetNwMode = () => {
'Inverted: DHT only',
'Dark Net: None',
];
const hiddenModes = [
'Discovery On (recommended)',
'Discovery Off',
];
let vsDisc = 0;
let vsDht = 0;
@ -17,8 +21,41 @@ const SetNwMode = () => {
let sslId = '';
let details = {};
const updateSelectedMode = (isHiddenMode) => {
if (!details || details.vs_dht === undefined) return;
if (isHiddenMode) {
if (details.vs_disc === util.RS_VS_DISC_OFF) {
selectedMode = hiddenModes[1];
} else {
selectedMode = hiddenModes[0];
}
} else {
if (
details.vs_dht === util.RS_VS_DHT_FULL &&
details.vs_disc === util.RS_VS_DISC_FULL
) {
selectedMode = networkModes[0];
} else if (
details.vs_dht === util.RS_VS_DHT_OFF &&
details.vs_disc === util.RS_VS_DISC_FULL
) {
selectedMode = networkModes[1];
} else if (
details.vs_dht === util.RS_VS_DHT_FULL &&
details.vs_disc === util.RS_VS_DISC_OFF
) {
selectedMode = networkModes[2];
} else if (
details.vs_dht === util.RS_VS_DHT_OFF &&
details.vs_disc === util.RS_VS_DISC_OFF
) {
selectedMode = networkModes[3];
}
}
};
return {
oninit: () => {
oninit: (vnode) => {
rs.rsJsonApiRequest('/rsAccounts/getCurrentAccountId').then((res) => {
if (res.body.retval) {
sslId = res.body.id;
@ -27,57 +64,53 @@ const SetNwMode = () => {
}).then((res) => {
if (res.body.retval) {
details = res.body.det;
if (
details.vs_dht === util.RS_VS_DHT_FULL &&
details.vs_disc === util.RS_VS_DISC_FULL
) {
selectedMode = networkModes[0];
} else if (
details.vs_dht === util.RS_VS_DHT_OFF &&
details.vs_disc === util.RS_VS_DISC_FULL
) {
selectedMode = networkModes[1];
} else if (
details.vs_dht === util.RS_VS_DHT_FULL &&
details.vs_disc === util.RS_VS_DISC_OFF
) {
selectedMode = networkModes[2];
} else if (
details.vs_dht === util.RS_VS_DHT_OFF &&
details.vs_disc === util.RS_VS_DISC_OFF
) {
selectedMode = networkModes[3];
}
updateSelectedMode(vnode.attrs && vnode.attrs.isHiddenMode);
m.redraw();
}
});
}
});
},
view: () => {
onupdate: (vnode) => {
updateSelectedMode(vnode.attrs && vnode.attrs.isHiddenMode);
},
view: (vnode) => {
const isHiddenMode = vnode.attrs && vnode.attrs.isHiddenMode;
const hideLabel = vnode.attrs && vnode.attrs.hideLabel;
const modes = isHiddenMode ? hiddenModes : networkModes;
return [
m('p', 'Network mode:'),
!hideLabel && m('p', isHiddenMode ? 'Discovery:' : 'Network mode:'),
m(
'select',
{
style: 'flex: 1; max-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: selectedMode,
onchange: (e) => {
selectedMode = networkModes[e.target.selectedIndex];
if (e.target.selectedIndex === 0) {
// Public: DHT & Discovery
vsDisc = util.RS_VS_DISC_FULL;
vsDht = util.RS_VS_DHT_FULL;
} else if (e.target.selectedIndex === 1) {
// Private: Discovery only
vsDisc = util.RS_VS_DISC_FULL;
vsDht = util.RS_VS_DHT_OFF;
} else if (e.target.selectedIndex === 2) {
// Inverted: DHT only
vsDisc = util.RS_VS_DISC_OFF;
vsDht = util.RS_VS_DHT_FULL;
} else if (e.target.selectedIndex === 3) {
// Dark Net: None
vsDisc = util.RS_VS_DISC_OFF;
vsDht = util.RS_VS_DHT_OFF;
const idx = e.target.selectedIndex;
selectedMode = modes[idx];
if (isHiddenMode) {
if (idx === 0) {
vsDisc = util.RS_VS_DISC_FULL;
vsDht = util.RS_VS_DHT_OFF;
} else if (idx === 1) {
vsDisc = util.RS_VS_DISC_OFF;
vsDht = util.RS_VS_DHT_OFF;
}
} else {
if (idx === 0) {
vsDisc = util.RS_VS_DISC_FULL;
vsDht = util.RS_VS_DHT_FULL;
} else if (idx === 1) {
vsDisc = util.RS_VS_DISC_FULL;
vsDht = util.RS_VS_DHT_OFF;
} else if (idx === 2) {
vsDisc = util.RS_VS_DISC_OFF;
vsDht = util.RS_VS_DHT_FULL;
} else if (idx === 3) {
vsDisc = util.RS_VS_DISC_OFF;
vsDht = util.RS_VS_DHT_OFF;
}
}
if (
details &&
@ -92,59 +125,13 @@ const SetNwMode = () => {
}
},
},
[networkModes.map((o) => m('option', { value: o }, o))]
[modes.map((o) => m('option', { value: o }, o))]
),
];
},
};
};
const SetNAT = () => {
let sslId;
let netMode;
return {
oninit: () => {
rs.rsJsonApiRequest('/rsAccounts/getCurrentAccountId').then((res) => {
if (res.body.retval) {
sslId = res.body.id;
rs.rsJsonApiRequest('/rsPeers/getPeerDetails', {
sslId,
}).then((res) => {
if (res.body.retval) {
netMode = res.body.det.netMode;
}
});
}
});
},
view: () => [
m('p', 'NAT:'),
m(
'select',
{
value: netMode,
onchange: (e) => {
rs.rsJsonApiRequest('/rsPeers/setNetworkMode', {
sslId,
netMode,
}).then((res) => {
if (res.body.retval) {
netMode = e.target.value;
}
});
},
},
[
m('option', { value: util.RS_NETMODE_UPNP }, 'Automatic (UPnP)'),
m('option', { value: util.RS_NETMODE_UDP }, 'FireWalled'),
m('option', { value: util.RS_NETMODE_EXT }, 'Manually Forwarded Port'),
]
),
],
};
};
const SetLimits = () => {
let dlim = undefined;
let ulim = undefined;
@ -160,37 +147,41 @@ const SetLimits = () => {
ulim = data.outKb;
}),
view: () => [
m(
'p',
util.tooltip(
'The download limit covers the whole application. ' +
'However, in some situations, such as when transfering ' +
'many files at once, the estimated bandwidth becomes ' +
'unreliable and the total value reported by Retroshare ' +
'might exceed that limit.'
),
'Download limit(KB/s):'
),
m('input[type=number][name=download]', {
value: dlim,
oninput: (e) => (dlim = Number(e.target.value)),
onchange: setMaxRates,
}),
m(
'p',
util.tooltip(
'The upload limit covers the entire software. ' +
'Too small an upload limit may eventually block ' +
'low priority services(forums, channels). ' +
'A minimum recommended value is 50KB/s.'
),
'Upload limit(KB/s):'
),
m('input[type=number][name=upload]', {
value: ulim,
oninput: (e) => (ulim = Number(e.target.value)),
onchange: setMaxRates,
}),
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center; margin-bottom: 0.75rem;' }, [
m('p', { style: 'font-weight: 600; color: #475569;' }, [
util.tooltip(
'The download limit covers the whole application. ' +
'However, in some situations, such as when transfering ' +
'many files at once, the estimated bandwidth becomes ' +
'unreliable and the total value reported by Retroshare ' +
'might exceed that limit.'
),
'Download limit(KB/s):'
]),
m('input[type=number][name=download]', {
style: 'padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; max-width: 320px; width: 100%;',
value: dlim,
oninput: (e) => (dlim = Number(e.target.value)),
onchange: setMaxRates,
}),
]),
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center; margin-bottom: 0.75rem;' }, [
m('p', { style: 'font-weight: 600; color: #475569;' }, [
util.tooltip(
'The upload limit covers the entire software. ' +
'Too small an upload limit may eventually block ' +
'low priority services(forums, channels). ' +
'A minimum recommended value is 50KB/s.'
),
'Upload limit(KB/s):'
]),
m('input[type=number][name=upload]', {
style: 'padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; max-width: 320px; width: 100%;',
value: ulim,
oninput: (e) => (ulim = Number(e.target.value)),
onchange: setMaxRates,
}),
]),
],
};
};
@ -205,88 +196,292 @@ const SetOpMode = () => {
oninit: () =>
rs.rsJsonApiRequest('/rsConfig/getOperatingMode', {}, (data) => (opmode = data.retval)),
view: () => [
m(
'p',
'Operating mode:',
util.tooltip(
`No Anon D/L: Switches off file forwarding\n
Gaming Mode: 25% standard traffic and TODO: Reduced popups\n
Low traffic: 10% standard traffic and TODO: pause all file transfers\n`
)
),
m(
'select',
{
oninput: (e) => (opmode = e.target.value),
value: opmode,
onchange: setmode,
},
['Normal', 'No Anon D/L', 'Gaming', 'Low traffic'].map((val, i) =>
m(`option[value=${i + 1}]`, val)
)
),
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center; margin-bottom: 0.75rem;' }, [
m('p', { style: 'font-weight: 600; color: #475569;' }, [
'Operating mode: ',
util.tooltip(
`No Anon D/L: Switches off file forwarding\n
Gaming Mode: 25% standard traffic and TODO: Reduced popups\n
Low traffic: 10% standard traffic and TODO: pause all file transfers\n`
)
]),
m(
'select',
{
style: 'padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; max-width: 320px; width: 100%;',
oninput: (e) => (opmode = e.target.value),
value: opmode,
onchange: setmode,
},
['Normal', 'No Anon D/L', 'Gaming', 'Low traffic'].map((val, i) =>
m(`option[value=${i + 1}]`, val)
)
),
]),
],
};
};
const displayLocalIPAddress = () => {
return {
view: ({ attrs: { details } }) =>
details && [m('p', 'Local Address: '), m('p', details.localAddr)],
};
};
const displayExternalIPAddress = () => {
return {
view: ({ attrs: { details } }) =>
details && [m('p', 'External Address: '), m('p', details.extAddr)],
};
};
const displayIPAddresses = () => {
return {
view: ({ attrs: { details } }) =>
details && [
m('p', 'External Address: '),
details && m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem;' }, [
m('p', { style: 'font-weight: 600; color: #475569;' }, 'External Address: '),
m(
'ul.external-address',
details.ipAddressList.map((ip) => m('li', ip))
),
],
]),
};
};
const SetDynamicDNS = () => {
let addr = '';
const NetworkConfigForm = () => {
let sslId = '';
let details = {};
let netStatus = {};
let localAddr = '';
let localPort = 0;
let extAddr = '';
let extPort = 0;
let dyndns = '';
let netMode = util.RS_NETMODE_EXT;
const loadData = () => {
rs.rsJsonApiRequest('/rsAccounts/getCurrentAccountId').then((res) => {
if (res.body.retval) {
sslId = res.body.id;
rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }).then((pRes) => {
if (pRes.body.retval) {
details = pRes.body.det;
localAddr = details.localAddr || '';
localPort = details.localPort || 0;
extAddr = details.extAddr || '';
extPort = details.extPort || 0;
dyndns = details.dyndns || '';
netMode = details.netMode || util.RS_NETMODE_EXT;
m.redraw();
}
});
rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}).then((nRes) => {
if (nRes.body) {
netStatus = nRes.body;
if (netStatus.localPort) localPort = netStatus.localPort;
if (netStatus.extPort) extPort = netStatus.extPort;
m.redraw();
}
});
}
});
};
const saveLocalAddress = () => {
if (!sslId) return;
rs.rsJsonApiRequest('/rsPeers/setLocalAddress', {
sslId,
addr: localAddr,
port: parseInt(localPort) || 0,
}).then(() => loadData());
};
const saveExtAddress = () => {
if (!sslId) return;
rs.rsJsonApiRequest('/rsPeers/setExtAddress', {
sslId,
addr: extAddr,
port: parseInt(extPort) || 0,
}).then(() => loadData());
};
const saveDynDNS = () => {
if (!sslId) return;
rs.rsJsonApiRequest('/rsPeers/setDynDNS', {
sslId,
addr: dyndns,
});
};
const saveNetMode = (newMode) => {
if (!sslId) return;
netMode = newMode;
rs.rsJsonApiRequest('/rsPeers/setNetworkMode', {
sslId,
netMode: parseInt(newMode),
}).then(() => loadData());
};
return {
oninit: () => {
rs.rsJsonApiRequest('/rsAccounts/getCurrentAccountId').then((res) => {
if (res.body.retval) {
sslId = res.body.id;
rs.rsJsonApiRequest('/rsPeers/getPeerDetails', {
sslId,
}).then((res) => {
if (res.body.retval) {
addr = res.body.det.dyndns;
loadData();
},
view: ({ attrs: { isHiddenMode } }) => {
const isUpnpOk = Boolean(netStatus.netUpnpOk || netStatus.uPnPActive);
const isLocalOk = Boolean(netStatus.netLocalOk !== false);
const isExtOk = Boolean(netStatus.netExtAddressOk);
return m('.network-config-form', {
style: {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
width: '100%',
}
}, [
// Network Mode row
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, 'Network Mode'),
m('.nw-mode-group', { style: 'display: flex; align-items: center; gap: 1rem;' }, [
m(SetNwMode, { isHiddenMode, hideLabel: true }),
isHiddenMode && m('.status-indicator', { style: 'display: flex; align-items: center; gap: 0.4rem;' }, [
m('.bullet', {
style: 'width: 10px; height: 10px; border-radius: 50%; background-color: #22c55e;'
}),
m('span', { style: 'font-size: 0.85rem; font-weight: 700; color: #000000;' }, '[Hidden mode]'),
]),
]),
]),
// NAT row + UPnP status bullet
!isHiddenMode && m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, 'NAT'),
m('.nat-control-group', { style: 'display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;' }, [
m('select', {
style: 'flex: 1; max-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: netMode,
onchange: (e) => saveNetMode(e.target.value),
}, [
m('option', { value: util.RS_NETMODE_UPNP }, 'Automatic (UPnP)'),
m('option', { value: util.RS_NETMODE_UDP }, 'FireWalled'),
m('option', { value: util.RS_NETMODE_EXT }, 'Manually Forwarded Port'),
]),
m('.status-indicator', { style: 'display: flex; align-items: center; gap: 0.4rem;' }, [
m('.bullet', {
style: `width: 10px; height: 10px; border-radius: 50%; background-color: ${isUpnpOk ? '#22c55e' : '#475569'};`
}),
m('span', { style: 'font-size: 0.85rem; font-weight: 600; color: #334155;' }, 'UPnP'),
]),
]),
]),
// Local Address + Port + Local network status bullet
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, 'Local Address'),
m('.addr-control-group', { style: 'display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;' }, [
m('input[type=text]', {
style: 'flex: 1; max-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: localAddr,
oninput: (e) => (localAddr = e.target.value),
onchange: saveLocalAddress,
}),
m('.port-group', { style: 'display: flex; align-items: center; gap: 0.4rem;' }, [
m('span', { style: 'font-size: 0.85rem; font-weight: 600; color: #475569;' }, 'Port:'),
m('input[type=number]', {
style: 'width: 90px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: localPort,
oninput: (e) => (localPort = parseInt(e.target.value) || 0),
onchange: saveLocalAddress,
}),
]),
!isHiddenMode && m('.status-indicator', { style: 'display: flex; align-items: center; gap: 0.4rem; margin-left: 0.5rem;' }, [
m('.bullet', {
style: `width: 10px; height: 10px; border-radius: 50%; background-color: ${isLocalOk ? '#22c55e' : '#ef4444'};`
}),
m('span', { style: 'font-size: 0.85rem; font-weight: 600; color: #334155;' }, 'Local network'),
]),
]),
]),
// External Address + Port + External ip address finder status bullet
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, 'External Address'),
m('.addr-control-group', { style: 'display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;' }, [
m('input[type=text]', {
style: 'flex: 1; max-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: isHiddenMode ? 'Hidden' : extAddr,
disabled: isHiddenMode,
oninput: (e) => (extAddr = e.target.value),
onchange: saveExtAddress,
}),
!isHiddenMode && m('.port-group', { style: 'display: flex; align-items: center; gap: 0.4rem;' }, [
m('span', { style: 'font-size: 0.85rem; font-weight: 600; color: #475569;' }, 'Port:'),
m('input[type=number]', {
style: 'width: 90px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: extPort,
oninput: (e) => (extPort = parseInt(e.target.value) || 0),
onchange: saveExtAddress,
}),
]),
!isHiddenMode && m('.status-indicator', { style: 'display: flex; align-items: center; gap: 0.4rem; margin-left: 0.5rem;' }, [
m('.bullet', {
style: `width: 10px; height: 10px; border-radius: 50%; background-color: ${isExtOk ? '#22c55e' : '#808080'};`
}),
m('span', { style: 'font-size: 0.85rem; font-weight: 600; color: #334155;' }, 'External ip address finder'),
]),
]),
]),
// Dynamic DNS row
!isHiddenMode && m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, 'Dynamic DNS'),
m('input[type=text]', {
style: 'flex: 1; max-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: dyndns,
oninput: (e) => (dyndns = e.target.value),
onchange: saveDynDNS,
}),
]),
]);
}
};
};
const checkPortReachable = (addr, port, timeoutMs = 800) => {
if (!addr || !port) return Promise.resolve(false);
return new Promise((resolve) => {
let resolved = false;
const start = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
controller.abort();
// Timeout expired without server connection -> Port is closed / not enabled!
resolve(false);
}
}, timeoutMs);
fetch(`http://${addr}:${port}`, {
mode: 'no-cors',
signal: controller.signal,
cache: 'no-store',
})
.then(() => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
resolve(true);
}
})
.catch((err) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (err && err.name === 'AbortError') {
resolve(true);
} else {
// Tor SOCKS port returns HTTP 501 ("Tor is not an HTTP Proxy").
// Connection refused fails in < 15ms. If Tor server responded (501 / > 20ms), port is OPEN!
const duration = Date.now() - start;
if (duration > 20 || (err && err.message && err.message.includes('501'))) {
resolve(true);
} else {
resolve(false);
}
});
}
}
});
},
view: () => [
m('p', 'Set Dynamic DNS:'),
m('input[type=text]', {
value: addr,
oninput: (e) => (addr = e.target.value),
onchange: () => {
rs.rsJsonApiRequest('/rsPeers/setDynDNS', {
sslId,
addr,
});
},
}),
],
};
});
};
const SetSocksProxy = () => {
@ -296,14 +491,16 @@ const SetSocksProxy = () => {
};
const fetchOutgoing = () => {
Object.keys(socksProxyObj).forEach((proxyItem) => {
fetch(`http://${socksProxyObj[proxyItem].addr}:${socksProxyObj[proxyItem].port}`)
.then(() => {
socksProxyObj[proxyItem].outgoing = true;
const item = socksProxyObj[proxyItem];
if (item.retval && item.addr && item.port) {
checkPortReachable(item.addr, item.port).then((isReachable) => {
item.outgoing = isReachable;
m.redraw();
})
.catch(() => {
socksProxyObj[proxyItem].outgoing = false;
});
} else {
item.outgoing = false;
m.redraw();
}
});
};
const handleProxyChange = (proxyItem) => {
@ -311,7 +508,12 @@ const SetSocksProxy = () => {
type: util[`RS_HIDDEN_TYPE_${proxyItem.toUpperCase()}`],
addr: socksProxyObj[proxyItem].addr,
port: socksProxyObj[proxyItem].port,
}).then(fetchOutgoing);
}).then((res) => {
if (res && res.body) {
socksProxyObj[proxyItem] = res.body;
}
fetchOutgoing();
});
};
return {
oninit: () => {
@ -320,7 +522,7 @@ const SetSocksProxy = () => {
type: util[`RS_HIDDEN_TYPE_${proxyItem.toUpperCase()}`],
})
.then((res) => {
if (res.body.retval) {
if (res && res.body) {
socksProxyObj[proxyItem] = res.body;
}
})
@ -328,46 +530,98 @@ const SetSocksProxy = () => {
});
},
view: () =>
m('.proxy-server', [
m(
'p',
m('.proxy-server-form', { style: 'display: flex; flex-direction: column; gap: 0.75rem; width: 100%;' }, [
m('p.proxy-description', { style: 'margin-bottom: 0.5rem; color: #475569;' },
'Configure your TOR and I2P SOCKS proxy here. It will allow you to also connect to hidden nodes.'
),
Object.keys(socksProxyObj).map((proxyItem) => {
return m(`.proxy-server__${proxyItem}`, [
m('h6', `${proxyItem.toUpperCase()} Socks Proxy: `),
m('input[type=text]', {
value: socksProxyObj[proxyItem].addr,
oninput: (e) => (socksProxyObj[proxyItem].addr = e.target.value),
onchange: () => handleProxyChange(proxyItem),
}),
m('input[type=number]', {
value: socksProxyObj[proxyItem].port,
oninput: (e) => (socksProxyObj[proxyItem].port = parseInt(e.target.value)),
onchange: () => handleProxyChange(proxyItem),
}),
socksProxyObj[proxyItem].outgoing !== undefined &&
m('.proxy-outgoing', [
m('.proxy-outgoing__status', {
style: {
backgroundColor: socksProxyObj[proxyItem].outgoing ? '#00dd44' : '#808080',
},
}),
m(
'p',
`${proxyItem.toUpperCase()} outgoing ${
socksProxyObj[proxyItem].outgoing ? 'on' : 'off'
}`
),
]),
const isTor = proxyItem === 'tor';
const labelText = isTor ? 'TOR Socks Proxy:' : 'I2P Socks Proxy:';
const outgoingText = isTor ? 'TOR outgoing' : 'I2P outgoing';
const notEnabledText = isTor ? 'Tor proxy is not enabled' : 'I2P proxy is not enabled';
const isOutgoing = socksProxyObj[proxyItem].outgoing;
return m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, labelText),
m('.proxy-control-group', { style: 'display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;' }, [
m('input[type=text]', {
style: 'flex: 1; max-width: 480px; min-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: socksProxyObj[proxyItem].addr || '',
oninput: (e) => (socksProxyObj[proxyItem].addr = e.target.value),
onchange: () => handleProxyChange(proxyItem),
}),
m('input[type=number]', {
style: 'width: 90px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px;',
value: socksProxyObj[proxyItem].port || 0,
oninput: (e) => (socksProxyObj[proxyItem].port = parseInt(e.target.value) || 0),
onchange: () => handleProxyChange(proxyItem),
}),
socksProxyObj[proxyItem].outgoing !== undefined &&
m('.status-indicator', { style: 'display: flex; align-items: center; gap: 0.4rem; margin-left: 0.5rem;' }, [
m('.bullet', {
style: `width: 10px; height: 10px; border-radius: 50%; background-color: ${isOutgoing ? '#22c55e' : '#808080'};`,
title: isOutgoing ? 'Proxy seems to work.' : notEnabledText,
}),
m('span', { style: 'font-size: 0.85rem; font-weight: 600; color: #334155;' },
`${outgoingText} ${isOutgoing ? 'on' : 'off'}`
),
]),
]),
]);
}),
]),
};
};
const displayHiddenServiceInfo = () => {
return {
view: ({ attrs: { details } }) =>
details && details.hiddenNodeAddress &&
m('.hidden-service-info', { style: 'display: flex; flex-direction: column; gap: 0.75rem; width: 100%;' }, [
m('p.proxy-description', { style: 'margin-bottom: 0.5rem; color: #475569;' }, details.hiddenType === 4
? "I2P has been automatically configured by Retroshare. You shouldn't need to change anything here."
: "Tor has been automatically configured by Retroshare. You shouldn't need to change anything here."
),
// Local Address + Local Port row
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, 'Local Address:'),
m('.addr-port-group', { style: 'display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;' }, [
m('input[type=text]', {
style: 'flex: 1; max-width: 480px; min-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; background-color: #f8fafc; color: #334155;',
readOnly: true,
value: details.localAddr || '127.0.0.1',
}),
m('input[type=number]', {
style: 'width: 90px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; background-color: #f8fafc; color: #334155;',
readOnly: true,
value: details.localPort || 0,
}),
]),
]),
// Onion / I2P Address + Service Port row
m('.nw-config-row', { style: 'display: grid; grid-template-columns: 200px 1fr; gap: 1rem; align-items: center;' }, [
m('label', { style: 'font-weight: 600; color: #475569;' }, details.hiddenType === 4 ? 'I2P Address:' : 'Onion Address:'),
m('.addr-port-group', { style: 'display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;' }, [
m('input[type=text]', {
style: 'flex: 1; max-width: 480px; min-width: 320px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; background-color: #f8fafc; color: #334155; font-family: monospace;',
readOnly: true,
value: details.hiddenNodeAddress,
}),
details.hiddenNodePort && m('input[type=number]', {
style: 'width: 90px; padding: 0.4rem; border: 1px solid #cbd5e1; border-radius: 4px; background-color: #f8fafc; color: #334155;',
readOnly: true,
value: details.hiddenNodePort,
}),
]),
]),
]),
};
};
const Component = () => {
let details;
let isHiddenMode = false;
return {
oninit: () => {
rs.rsJsonApiRequest('/rsAccounts/getCurrentAccountId').then((res) => {
@ -377,28 +631,44 @@ const Component = () => {
}).then((res) => {
if (res.body.retval) {
details = res.body.det;
isHiddenMode = Boolean(
details && (
details.hiddenType === util.RS_HIDDEN_TYPE_TOR ||
details.hiddenType === util.RS_HIDDEN_TYPE_I2P ||
details.extAddr === 'Hidden'
)
);
m.redraw();
}
});
}
});
},
view: () =>
m('.widget', [
m('.widget__heading', m('h3', 'Network Configuration')),
m('.widget__body', [
m('.grid-2col', [
m(SetNwMode),
m(SetNAT),
m(displayLocalIPAddress, { details }),
m(displayExternalIPAddress, { details }),
m(SetDynamicDNS),
m('.config-network', { style: 'display:flex; flex-direction:column; gap:1rem;' }, [
m('.widget', [
m('.widget__heading', m('h3', 'Network Configuration')),
m('.widget__body', [
m(NetworkConfigForm, { isHiddenMode }),
m('hr', { style: 'margin: 1rem 0; border: none; border-top: 1px solid #e2e8f0;' }),
m(SetLimits),
m(SetOpMode),
m(displayIPAddresses, { details }),
!isHiddenMode && m(SetOpMode),
!isHiddenMode && m(displayIPAddresses, { details }),
]),
m('.widget__heading', m('h3', 'Hidden Service Configuration')),
m('.widget__body', [m('.grid-2col', [m(SetSocksProxy)])]),
]),
m('.widget', [
m('.widget__heading', m('h3', 'Hidden Service Configuration')),
m('.widget__body', [
m(SetSocksProxy),
]),
]),
isHiddenMode &&
m('.widget', [
m('.widget__heading', m('h3', details && details.hiddenType === 4 ? 'Incoming I2P' : 'Incoming Tor')),
m('.widget__body', [
m(displayHiddenServiceInfo, { details }),
]),
]),
]),
};
};

View File

@ -5,6 +5,7 @@ const Reputation = () => {
let addFriendIdAsContacts = undefined;
let usePositiveDefault = undefined;
let deleteBannedAfter = undefined;
let rememberBannedAfter = undefined;
let negativeThreshold = undefined;
let positiveThreshold = undefined;
@ -25,6 +26,11 @@ const Reputation = () => {
{},
(data) => (deleteBannedAfter = data.retval)
);
rs.rsJsonApiRequest(
'/rsreputations/rememberBannedIdThreshold',
{},
(data) => (rememberBannedAfter = data.retval)
);
rs.rsJsonApiRequest(
'/rsreputations/thresholdForRemotelyPositiveReputation',
{},
@ -95,7 +101,7 @@ const Reputation = () => {
() => {}
),
}),
m('p', 'Delete banned identities after(in days, 0 means indefinitely):'),
m('p', 'Delete banned identities after(0 means never):'),
m('input[type=number]', {
oninput: (e) => (deleteBannedAfter = parseInt(e.target.value)),
value: deleteBannedAfter,
@ -108,6 +114,19 @@ const Reputation = () => {
() => {}
),
}),
m('p', 'Reset reputation of banned identities after (0 means never):'),
m('input[type=number]', {
oninput: (e) => (rememberBannedAfter = parseInt(e.target.value)),
value: rememberBannedAfter,
onchange: () =>
rs.rsJsonApiRequest(
'/rsreputations/setRememberBannedIdThreshold',
{
days: rememberBannedAfter,
},
() => {}
),
}),
]),
]),
]),

View File

@ -7,6 +7,7 @@ const sections = {
services: require('config/config_services'),
files: require('config/config_files'),
people: require('config/config_people'),
chat: require('config/config_chat'),
mail: require('config/config_mail'),
};

View File

@ -72,6 +72,11 @@ const ConfirmCopied = () => {
};
const retroshareId = () => {
function autoResize(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
return {
view(v) {
return m('.retroshareID', [
@ -83,6 +88,8 @@ const retroshareId = () => {
onclick: () => {
document.getElementById('retroId').select();
},
oncreate: (vnode) => autoResize(vnode.dom),
onupdate: (vnode) => autoResize(vnode.dom),
},
v.attrs.ownCert
),

102
webui-src/app/jdenticon.js Normal file
View File

@ -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 `<polygon points="${pointsStr}" fill="${color}" transform="translate(${x}, ${y}) rotate(${angle}) translate(${halfSize}, ${halfSize}) rotate(${shapeAngle})"/>`;
}
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 = `<rect width="${totalsize}" height="${totalsize}" fill="rgb(230,230,230)"/>`;
// 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${totalsize}" height="${totalsize}" viewBox="0 0 ${totalsize} ${totalsize}">${svgContent}</svg>`;
}
module.exports = {
toSvg
};

View File

@ -52,6 +52,7 @@ const Layout = () => {
'tbody',
v.attrs.list.map((msg) =>
m(util.MessageSummary, {
key: msg.msgId,
details: msg,
category: 'attachment',
})

View File

@ -2,13 +2,34 @@ const m = require('mithril');
const rs = require('rswebui');
const widget = require('widgets');
const peopleUtil = require('people/people_util');
const chatEmoji = require('chat/chat_emoji');
const UserAvatarsCache = {};
const MAX_RECIPIENTS = 20;
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
const Layout = () => {
let showCc = false;
let showBcc = false;
let ownAvatars = {};
let attachments = [];
let showEmojiPicker = false;
let emojiSearch = '';
let emojiCategory = 'Smileys';
const Data = {
allUsers: [],
ownId: [],
subject: '',
identity: null,
bodyHtml: '',
recipients: {
to: {
inputVal: '',
@ -27,40 +48,203 @@ const Layout = () => {
},
},
};
async function loadMailUserDetails(msgType, senderId, recipientList) {
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'
function insertContentIntoMailBody(content) {
const mailBody = document.querySelector('#composerMailBody');
if (!mailBody) return;
mailBody.focus();
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
if (mailBody.contains(range.commonAncestorContainer)) {
range.deleteContents();
if (typeof content === 'string') {
const temp = document.createElement('div');
temp.innerHTML = content;
const frag = document.createDocumentFragment();
let node, lastNode;
while ((node = temp.firstChild)) {
lastNode = frag.appendChild(node);
}
range.insertNode(frag);
if (lastNode) {
range.setStartAfter(lastNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
} else if (content instanceof Node) {
range.insertNode(content);
range.setStartAfter(content);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
return;
}
if (msgType === 'reply') {
Data.identity = Data.ownId.filter((id) =>
Object.prototype.hasOwnProperty.call(recipientList, id)
)[0];
}
if (typeof content === 'string') {
mailBody.innerHTML += content;
} else if (content instanceof Node) {
mailBody.appendChild(content);
}
}
function insertEmoji(emoji) {
insertContentIntoMailBody(document.createTextNode(emoji));
}
async function loadMailUserDetails(msgType, senderId, recipientList, isDirectMail, ccList) {
Data.allUsers = await peopleUtil.sortUsers(rs.userList.users);
// 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);
Data.ownId.forEach((id) => {
if (!ownAvatars[id]) {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id },
(data) => {
if (data?.details) {
ownAvatars[id] = data.details.mAvatar;
}
}
);
}
});
// 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;
}
}
const resolvedSenderId = await senderId;
if (msgType === 'reply' || msgType === 'replyAll') {
Data.allUsers.forEach((user) => {
if (user.mGroupId === resolvedSenderId) {
Data.recipients.to.sendList.push(user);
if (!UserAvatarsCache[resolvedSenderId]) {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: resolvedSenderId },
(data) => {
if (data?.details) {
UserAvatarsCache[resolvedSenderId] = data.details.mAvatar;
}
}
);
}
}
});
}
if (msgType === 'replyAll') {
// Add other "To" recipients
if (recipientList) {
Object.keys(recipientList).forEach((recip) => {
if (recip !== resolvedSenderId && !Data.ownId.includes(recip)) {
const user = Data.allUsers.find((u) => u.mGroupId === recip);
if (user && !Data.recipients.to.sendList.some((item) => item.mGroupId === recip)) {
Data.recipients.to.sendList.push(user);
if (!UserAvatarsCache[recip]) {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: recip },
(data) => {
if (data?.details) {
UserAvatarsCache[recip] = data.details.mAvatar;
}
}
);
}
}
}
});
}
// Add other "Cc" recipients
if (ccList) {
Object.keys(ccList).forEach((recip) => {
if (recip !== resolvedSenderId && !Data.ownId.includes(recip)) {
const user = Data.allUsers.find((u) => u.mGroupId === recip);
if (user && !Data.recipients.cc.sendList.some((item) => item.mGroupId === recip)) {
Data.recipients.cc.sendList.push(user);
if (!UserAvatarsCache[recip]) {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: recip },
(data) => {
if (data?.details) {
UserAvatarsCache[recip] = data.details.mAvatar;
}
}
);
}
}
}
});
}
}
if (msgType === 'reply' || msgType === 'replyAll' || msgType === 'forward') {
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, ccList, isDirectMail } = await attrs;
await loadMailUserDetails(msgType, senderId, recipientList, isDirectMail, ccList);
Object.keys(Data.recipients).forEach((item) => {
Data.recipients[item].inputList = Data.allUsers;
});
if (Data.recipients.cc.sendList.length > 0) showCc = true;
if (Data.recipients.bcc.sendList.length > 0) showBcc = true;
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') {
if (msgType === 'reply' || msgType === 'replyAll' || msgType === 'forward') {
const { subject, replyMessage, timeStamp } = await attrs;
const tmb = document.querySelector('#composerMailBody');
const time = timeStamp.toLocaleTimeString('UTC', { hour: '2-digit', minute: '2-digit' });
@ -69,20 +253,21 @@ const Layout = () => {
month: 'long',
day: 'numeric',
});
const headerTitle = msgType === 'forward' ? 'Forwarded Message' : 'Original Message';
const replyMessageHeader = `
-----Original Message-----
-----${headerTitle}-----
<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(
${recipientList ? 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>
`
)}
).join('') : ''}
<br>
<br>
<b>Sent: </b>
@ -92,13 +277,15 @@ const Layout = () => {
<span>${subject}</span>
<br>
<br>
${msgType !== 'forward' ? `
<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>
` : ''}
`;
tmb.innerHTML = `
const bodyHtml = `
<br>
<br>
<div>
@ -108,7 +295,15 @@ const Layout = () => {
</div>
</div>
`;
Data.subject = subject.substring(0, 4) === 'Re: ' ? subject : `Re: ${subject}`;
if (tmb) {
tmb.innerHTML = bodyHtml;
}
Data.bodyHtml = bodyHtml;
if (msgType === 'forward') {
Data.subject = subject.substring(0, 5) === 'Fwd: ' ? subject : `Fwd: ${subject}`;
} else {
Data.subject = subject.substring(0, 4) === 'Re: ' ? subject : `Re: ${subject}`;
}
}
}
return {
@ -121,8 +316,25 @@ const Layout = () => {
item.mGroupName.toLowerCase().includes(e.target.value.toLowerCase())
);
}
function totalRecipients() {
return Data.recipients.to.sendList.length +
Data.recipients.cc.sendList.length +
Data.recipients.bcc.sendList.length;
}
function handleClick(item, recipientType) {
if (totalRecipients() >= MAX_RECIPIENTS) return;
Data.recipients[recipientType].sendList.push(item);
if (item.mGroupId && !UserAvatarsCache[item.mGroupId]) {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: item.mGroupId },
(data) => {
if (data?.details) {
UserAvatarsCache[item.mGroupId] = data.details.mAvatar;
}
}
);
}
// reset current input values after a sender is selected
Data.recipients[recipientType].inputVal = '';
Data.recipients[recipientType].inputList = Data.allUsers;
@ -133,37 +345,103 @@ const Layout = () => {
);
}
function sendMail() {
// Auto-add inputVal if user typed recipient but didn't click dropdown item
['to', 'cc', 'bcc'].forEach((type) => {
const val = Data.recipients[type].inputVal ? Data.recipients[type].inputVal.trim() : '';
if (val) {
const match = Data.allUsers.find((u) => u.mGroupName && (u.mGroupName.toLowerCase() === val.toLowerCase() || u.mGroupId === val));
if (match && !Data.recipients[type].sendList.some((item) => item.mGroupId === match.mGroupId)) {
Data.recipients[type].sendList.push(match);
} else if (!match && val.length > 5) {
Data.recipients[type].sendList.push({ mGroupId: val, mGroupName: val });
}
Data.recipients[type].inputVal = '';
}
});
const to = Data.recipients.to.sendList.map((toItem) => toItem.mGroupId);
const cc = Data.recipients.cc.sendList.map((ccItem) => ccItem.mGroupId);
const bcc = Data.recipients.bcc.sendList.map((bccItem) => bccItem.mGroupId);
const { identity: from, subject } = Data;
let from = Data.identity;
if (!from && Data.ownId && Data.ownId.length > 0) {
from = Data.ownId[0];
Data.identity = from;
}
if (to.length === 0) {
widget.popupMessage(
m('.widget', [
m('.widget__heading', m('h3', 'Missing Recipient')),
m('.widget__body', m('p', 'Please select at least one recipient in the "To" field.')),
])
);
return;
}
if (!from) {
widget.popupMessage(
m('.widget', [
m('.widget__heading', m('h3', 'Missing Identity')),
m('.widget__body', m('p', 'Please select a "From" identity.')),
])
);
return;
}
const subject = Data.subject || '(No Subject)';
const mailBodyElement = document.querySelector('#composerMailBody');
const mailBody = `<div>${mailBodyElement.innerHTML}</div>`;
rs.rsJsonApiRequest('/rsMail/sendMail', { from, subject, mailBody, to, cc, bcc }).then(
(res) => {
if (res.body.retval) {
Object.keys(Data.recipients).forEach((recipientType) => {
Data.recipients[recipientType].sendList = [];
});
Data.subject = '';
mailBodyElement.innerHTML = '';
v.attrs.setShowCompose(false);
}
const success = res.body.retval === 1;
widget.popupMessage(
m('.widget', [
m('.widget__heading', m('h3', success ? 'Success' : 'Error')),
m('.widget__body', m('p', success ? 'Mail sent successfully' : res.body.errorMsg)),
])
);
let fullMailBody = mailBodyElement ? mailBodyElement.innerHTML : '';
if (attachments.length > 0) {
const attHtml = `
<br/><hr style="border:none;border-top:1px solid #e2e8f0;margin:1rem 0;"/><div style="margin-top:10px;font-weight:bold;color:#475569;font-size:0.9rem;">Attachments (${attachments.length}):</div>
<ul style="list-style:none;padding:0;margin:6px 0;">
${attachments.map(att => `<li style="padding:4px 0;color:#1e293b;font-size:0.875rem;">📎 <b>${att.name}</b> <span style="color:#94a3b8;font-size:0.8em;">(${att.size})</span></li>`).join('')}
</ul>
`;
fullMailBody += attHtml;
}
const mailBody = `<div>${fullMailBody}</div>`;
rs.rsJsonApiRequest('/rsMail/sendMail', { from, subject, mailBody, to, cc, bcc }, (data, success) => {
const isOk = success && data && (
data.retval > 0 ||
data.retval === true ||
(Array.isArray(data.trackingIds) && data.trackingIds.length > 0)
);
if (isOk) {
Object.keys(Data.recipients).forEach((recipientType) => {
Data.recipients[recipientType].sendList = [];
});
Data.subject = '';
if (mailBodyElement) mailBodyElement.innerHTML = '';
attachments = [];
v.attrs.setShowCompose(false);
}
);
widget.popupMessage(
m('.widget', [
m('.widget__heading', m('h3', isOk ? 'Success' : 'Error')),
m('.widget__body', m('p', isOk ? 'Mail sent successfully' : (data?.errorMsg || data?.errorMessage || 'Failed to send mail'))),
])
);
m.redraw();
});
}
return m('.widget', [
m('.widget__heading', m('h3', 'Compose a mail')),
m('.widget__body.compose-mail', [
m('.compose-mail__from', [
m('label[for=idtags].bold', 'From: '),
Data.identity && m(peopleUtil.UserAvatar, {
avatar: ownAvatars[Data.identity],
firstLetter: rs.userList.userMap[Data.identity] && typeof rs.userList.userMap[Data.identity] === 'string'
? rs.userList.userMap[Data.identity].slice(0, 1).toUpperCase()
: (rs.userList.username(Data.identity) || '').slice(0, 1).toUpperCase(),
identityId: Data.identity,
size: 24,
}),
m(
'select[id=idtags]',
{
@ -172,13 +450,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'
)
)
@ -191,6 +472,12 @@ const Layout = () => {
Data.recipients.to.sendList.length > 0 &&
Data.recipients.to.sendList.map((recipient) =>
m('.recipients__selected', [
m(peopleUtil.UserAvatar, {
avatar: UserAvatarsCache[recipient.mGroupId],
firstLetter: recipient.mGroupName ? recipient.mGroupName.slice(0, 1).toUpperCase() : '',
identityId: recipient.mGroupId,
size: 20,
}),
m('span', recipient.mGroupName),
m('i.fas.fa-times', {
onclick: () => removeSelectedItem(recipient, 'to'),
@ -201,6 +488,8 @@ const Layout = () => {
m('input[type=text].recipients__input-field', {
value: Data.recipients.to.inputVal,
oninput: (e) => handleInput(e, 'to'),
placeholder: totalRecipients() >= MAX_RECIPIENTS ? 'Max recipients reached' : '',
disabled: totalRecipients() >= MAX_RECIPIENTS,
}),
m('ul.recipients__input-list[autocomplete=off]', [
Data.recipients.to.inputList.length > 0
@ -211,14 +500,40 @@ const Layout = () => {
]),
]),
]),
m('.compose-mail__recipients__toggles', {
style: {
display: 'flex',
gap: '1rem',
alignItems: 'center',
marginLeft: 'auto',
paddingRight: '0.5rem',
userSelect: 'none',
}
}, [
m('span.bold', {
style: { cursor: 'pointer', color: showCc ? '#019DFF' : '#555' },
onclick: () => showCc = !showCc
}, 'Cc'),
m('span.bold', {
style: { cursor: 'pointer', color: showBcc ? '#019DFF' : '#555' },
onclick: () => showBcc = !showBcc
}, 'Bcc')
])
]),
['cc', 'bcc'].map((recipientType) =>
m('.compose-mail__recipients__container', [
['cc', 'bcc'].map((recipientType) => {
const isVisible = recipientType === 'cc' ? showCc : showBcc;
return isVisible && m('.compose-mail__recipients__container', [
m('label.bold', `${recipientType}: `),
m('.recipients', [
Data.recipients[recipientType].sendList.length > 0 &&
Data.recipients[recipientType].sendList.map((recipient) =>
m('.recipients__selected', [
m(peopleUtil.UserAvatar, {
avatar: UserAvatarsCache[recipient.mGroupId],
firstLetter: recipient.mGroupName ? recipient.mGroupName.slice(0, 1).toUpperCase() : '',
identityId: recipient.mGroupId,
size: 20,
}),
m('span', recipient.mGroupName),
m('i.fas.fa-times', {
onclick: () => removeSelectedItem(recipient, recipientType),
@ -229,6 +544,8 @@ const Layout = () => {
m('input[type=text].recipients__input-field', {
value: Data.recipients[recipientType].inputVal,
oninput: (e) => handleInput(e, recipientType),
placeholder: totalRecipients() >= MAX_RECIPIENTS ? 'Max recipients reached' : '',
disabled: totalRecipients() >= MAX_RECIPIENTS,
}),
m('ul.recipients__input-list[autocomplete=off]', [
Data.recipients[recipientType].inputList.length > 0
@ -243,19 +560,173 @@ const Layout = () => {
]),
]),
]),
])
),
]);
}),
totalRecipients() >= MAX_RECIPIENTS && m('.compose-mail__recipient-limit', {
style: { color: '#e67e22', fontSize: '0.85rem', padding: '0.25rem 0' }
}, `Maximum of ${MAX_RECIPIENTS} recipients reached. Remove a recipient to add more.`),
]),
m('input.compose-mail__subject[type=text][placeholder=Subject]', {
value: Data.subject,
oninput: (e) => (Data.subject = e.target.value),
}),
// Hidden File Inputs
m('input#mail-file-attach[type=file]', {
style: 'display: none;',
multiple: true,
onchange: (e) => {
const files = Array.from(e.target.files || []);
files.forEach((file) => {
attachments.push({
name: file.name,
size: formatFileSize(file.size),
type: file.type,
rawFile: file,
});
});
e.target.value = '';
m.redraw();
},
}),
m('input#mail-image-attach[type=file]', {
style: 'display: none;',
accept: 'image/*',
onchange: (e) => {
const file = e.target.files && e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target.result;
insertContentIntoMailBody(`<img src="${src}" style="max-width: 100%; max-height: 400px; border-radius: 0.375rem; margin: 0.5rem 0;" />`);
};
reader.readAsDataURL(file);
}
e.target.value = '';
m.redraw();
},
}),
// File Attachments Bar
attachments.length > 0 &&
m('.mail-attachments-bar', {
style: 'margin: 0.5rem 0; padding: 0.5rem 0.75rem; background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 0.375rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;'
}, [
m('span', { style: 'font-weight: 600; font-size: 0.85rem; color: #475569; display: flex; align-items: center; gap: 0.35rem; margin-right: 0.25rem;' }, [
m('i.fas.fa-paperclip', { style: 'color: #019DFF;' }),
`Attachments (${attachments.length}):`
]),
attachments.map((att, index) =>
m('.mail-attachment-chip', {
style: 'display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.65rem; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 1rem; font-size: 0.825rem; font-weight: 500; color: #1e293b; box-shadow: 0 1px 2px rgba(0,0,0,0.05);'
}, [
m('i.fas.fa-file-alt', { style: 'color: #3b82f6;' }),
m('span', att.name),
m('span', { style: 'color: #94a3b8; font-size: 0.75rem;' }, `(${att.size})`),
m('i.fas.fa-times', {
style: 'cursor: pointer; color: #ef4444; margin-left: 0.2rem; font-size: 0.8rem;',
onclick: () => attachments.splice(index, 1),
})
])
)
]),
m('.compose-mail__message', [
m('.compose-mail__message-body[placeholder=Message][contenteditable]#composerMailBody'),
]),
m('button.compose-mail__send-btn', { onclick: sendMail }, [
m('span', 'Send Mail'),
m('i.fas.fa-paper-plane'),
m('.compose-mail__message-body[placeholder=Message][contenteditable]#composerMailBody', {
oncreate: (vnode) => {
if (Data.bodyHtml) {
vnode.dom.innerHTML = Data.bodyHtml;
}
}
}),
// Modern Mail Composer Bottom Toolbar
m('.mail-compose-toolbar', {
style: 'display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.75rem; background: #ffffff; border: 1px solid #cbd5e1; border-top: 1px solid #e2e8f0; border-radius: 0 0 0.375rem 0.375rem; position: relative;'
}, [
m('.toolbar-left', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
m('button.mail-compose-send-btn', {
style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 1.25rem; background: #019DFF; color: #ffffff; border: none; border-radius: 1.5rem; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: background 0.15s ease; box-shadow: 0 2px 4px rgba(1,157,255,0.25);',
onclick: sendMail,
}, [
m('span', 'Send'),
m('i.fas.fa-paper-plane', { style: 'font-size: 0.85rem;' }),
]),
m('.toolbar-divider', { style: 'width: 1px; height: 22px; background: #cbd5e1; margin: 0 0.25rem;' }),
m('button.mail-tool-btn', {
type: 'button',
title: 'Attach files',
style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;',
onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'),
onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'),
onclick: () => {
const input = document.getElementById('mail-file-attach');
if (input) input.click();
},
}, m('i.fas.fa-paperclip', { style: 'font-size: 1.05rem;' })),
m('button.mail-tool-btn', {
type: 'button',
title: 'Insert image',
style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;',
onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'),
onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'),
onclick: () => {
const input = document.getElementById('mail-image-attach');
if (input) input.click();
},
}, m('i.fas.fa-image', { style: 'font-size: 1.05rem;' })),
m('button.mail-tool-btn', {
type: 'button',
title: 'Insert emoji',
style: `width: 34px; height: 34px; border-radius: 50%; border: none; background: ${showEmojiPicker ? '#e0f2fe' : 'transparent'}; color: ${showEmojiPicker ? '#0284c7' : '#475569'}; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;`,
onclick: () => (showEmojiPicker = !showEmojiPicker),
}, m('i.fas.fa-smile', { style: 'font-size: 1.05rem;' })),
]),
// Floating Emoji Picker Popover
showEmojiPicker && m('.mail-emoji-picker-popover', {
style: 'position: absolute; bottom: 50px; left: 130px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 0.5rem; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.15), 0 8px 10px -6px rgba(0,0,0,0.1); width: 320px; max-height: 340px; z-index: 2000; display: flex; flex-direction: column; overflow: hidden;',
onclick: (e) => e.stopPropagation(),
}, [
m('.emoji-search-bar', { style: 'padding: 0.5rem; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 0.5rem;' }, [
m('i.fas.fa-search', { style: 'color: #94a3b8; font-size: 0.85rem;' }),
m('input[type=text][placeholder=Search emoji...]', {
style: 'border: none; outline: none; width: 100%; font-size: 0.85rem;',
value: emojiSearch,
oninput: (e) => (emojiSearch = e.target.value),
}),
emojiSearch && m('i.fas.fa-times', {
style: 'cursor: pointer; color: #94a3b8; font-size: 0.85rem;',
onclick: () => (emojiSearch = ''),
}),
]),
!emojiSearch && m('.emoji-cat-bar', { style: 'display: flex; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0.25rem; overflow-x: auto;' },
chatEmoji.EMOJI_CATEGORIES.map(c =>
m('button', {
style: `border: none; background: ${c === emojiCategory ? '#ffffff' : 'transparent'}; border-radius: 0.25rem; padding: 0.3rem 0.4rem; cursor: pointer; font-size: 1rem; box-shadow: ${c === emojiCategory ? '0 1px 2px rgba(0,0,0,0.1)' : 'none'};`,
title: c,
onclick: () => (emojiCategory = c),
}, chatEmoji.EMOJI_ICONS[c])
)
),
m('.emoji-grid-body', { style: 'padding: 0.5rem; display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.25rem; max-height: 230px; overflow-y: auto;' },
(emojiSearch
? Object.values(chatEmoji.EMOJI_DATA).flat().filter(e => e.includes(emojiSearch))
: (chatEmoji.EMOJI_DATA[emojiCategory] || [])
).map(e =>
m('button', {
style: 'border: none; background: transparent; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; transition: background 0.15s ease;',
onmouseenter: (ev) => (ev.currentTarget.style.background = '#f1f5f9'),
onmouseleave: (ev) => (ev.currentTarget.style.background = 'transparent'),
onclick: () => {
insertEmoji(e);
showEmojiPicker = false;
},
}, e)
)
),
])
]),
]),
]),
]);

View File

@ -41,6 +41,22 @@ const Messages = {
Messages.spam = Messages.all.filter((msg) => msg.msgflags & util.RS_MSG_SPAM);
Messages.attachment = Messages.all.filter((msg) => msg.count);
Messages.important = Messages.all.filter(
(msg) => msg.msgtags && msg.msgtags.includes(util.RS_MSGTAGTYPE_IMPORTANT)
);
Messages.work = Messages.all.filter(
(msg) => msg.msgtags && msg.msgtags.includes(util.RS_MSGTAGTYPE_WORK)
);
Messages.personal = Messages.all.filter(
(msg) => msg.msgtags && msg.msgtags.includes(util.RS_MSGTAGTYPE_PERSONAL)
);
Messages.todo = Messages.all.filter(
(msg) => msg.msgtags && msg.msgtags.includes(util.RS_MSGTAGTYPE_TODO)
);
Messages.later = Messages.all.filter(
(msg) => msg.msgtags && msg.msgtags.includes(util.RS_MSGTAGTYPE_LATER)
);
}
});
},
@ -98,7 +114,14 @@ const Layout = () => {
return [
m('.side-bar', [
m('button.mail-compose-btn', { onclick: () => setShowCompose(true) }, 'Compose'),
m(
'button.mail-compose-btn',
{
style: 'display: flex; align-items: center; justify-content: center; gap: 0.5rem;',
onclick: () => setShowCompose(true),
},
[m('i.fas.fa-pen'), 'Compose']
),
m(util.Sidebar, {
tabs: Object.keys(sections),
size: sectionsSize,
@ -128,9 +151,8 @@ const Layout = () => {
vnode.children,
])
),
m(
showCompose && m(
'.composePopupOverlay#mailComposerPopup',
{ style: { display: showCompose ? 'block' : 'none' } },
m(
'.composePopup',
m(compose, { msgType: 'compose', setShowCompose }),
@ -142,20 +164,80 @@ const Layout = () => {
};
};
const tabConfig = {
inbox: { title: 'Inbox', category: 'inbox' },
outbox: { title: 'Outbox', category: 'outbox' },
drafts: { title: 'Draft', category: 'drafts' },
sent: { title: 'Sent', category: 'sent' },
trash: { title: 'Trash', category: 'trash' },
starred: { title: 'Starred', category: 'starred' },
system: { title: 'System', category: 'system' },
spam: { title: 'Spam', category: 'spam' },
important: { title: 'Important', category: 'important' },
work: { title: 'Work', category: 'work' },
todo: { title: 'Todo', category: 'todo' },
later: { title: 'Later', category: 'later' },
personal: { title: 'Personal', category: 'personal' },
};
const GenericMailList = () => {
return {
view: (vnode) => {
const { title, category, list } = vnode.attrs;
return [
m('.widget__heading', m('h3', title)),
m('.widget__body', [
m(
util.Table,
m(
'tbody',
list.map((msg) =>
m(util.MessageSummary, {
key: msg.msgId,
details: msg,
category: category,
})
)
)
),
]),
];
},
};
};
module.exports = {
view: ({ attrs, attrs: { tab, msgId } }) => {
// TODO: utilize multiple routing params
if (Object.prototype.hasOwnProperty.call(attrs, 'msgId')) {
return m(Layout, m(util.MessageView, { msgId }));
}
if (tab === 'attachment') {
return m(
Layout,
m(sectionsquickview.attachment, {
list: util.sortList(Messages[tab]),
})
);
}
const config = tabConfig[tab];
if (config) {
return m(
Layout,
m(GenericMailList, {
title: config.title,
category: config.category,
list: util.sortList(Messages[tab]),
})
);
}
return m(
Layout,
m(sections[tab] || sectionsquickview[tab], {
list: (Messages[tab] || []).sort((msgA, msgB) => {
const msgADate = new Date((msgA.ts.xint64 || 0) * 1000);
const msgBDate = new Date((msgB.ts.xint64 || 0) * 1000);
return msgADate < msgBDate;
}),
list: util.sortList(Messages[tab]),
})
);
},

View File

@ -34,6 +34,85 @@ const MSG_ADDRESS_MODE_BCC = 0x03;
const BOX_ALL = 0x06;
const MessageCache = {};
const UserNicknamesCache = {};
const MailGxsDetailsCache = {};
const MailHoverState = {
hoveredUser: null,
};
function renderMailUserTooltip() {
if (!MailHoverState.hoveredUser) return null;
const hUser = MailHoverState.hoveredUser;
const details = MailGxsDetailsCache[hUser.gxsId];
if (!details) return null;
const avatar = details.mAvatar && details.mAvatar.base64 ? details.mAvatar.base64 : null;
const firstLetter = (hUser.name || '?').slice(0, 1).toUpperCase();
const votes = details.mReputation
? ((details.mReputation.mFriendsPositiveVotes || 0) - (details.mReputation.mFriendsNegativeVotes || 0))
: 0;
const top = hUser.rect.top - 10;
const left = Math.min(Math.max(hUser.rect.left, 140), window.innerWidth - 280);
return m('.user-tooltip', {
style: {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
transform: 'translateY(-100%)',
zIndex: 10000,
}
}, [
m('.tooltip-avatar', m(peopleUtil.UserAvatar, { avatar, firstLetter, identityId: hUser.gxsId, size: 64 })),
m('.tooltip-details', [
m('.tooltip-row', [m('span.tooltip-label', 'Identity name: '), m('span.tooltip-value', hUser.name)]),
m('.tooltip-row', [m('span.tooltip-label', 'Identity Id: '), m('span.tooltip-value.tooltip-id', hUser.gxsId)]),
details.mPgpId && details.mPgpId !== '0000000000000000' && m('.tooltip-row', [
m('span.tooltip-label', 'Node: '),
m('span.tooltip-value', `${rs.userList.username(details.mPgpId) || hUser.name} [${details.mPgpId}]`)
]),
m('.tooltip-row', [
m('span.tooltip-label', 'Votes: '),
m('span.tooltip-value', {
style: {
color: votes >= 0 ? '#22c55e' : '#ef4444',
fontWeight: 'bold'
}
}, (votes >= 0 ? '+' : '') + votes)
])
])
]);
}
const tagTypesCache = {};
const defaultTagTypes = {
1: { name: 'Important', color: '#ef4444' },
2: { name: 'Work', color: '#f97316' },
3: { name: 'Personal', color: '#22c55e' },
4: { name: 'Todo', color: '#3b82f6' },
5: { name: 'Later', color: '#a855f7' },
};
function getTagDetails(tagId) {
return tagTypesCache[tagId] || defaultTagTypes[tagId] || { name: `Tag ${tagId}`, color: '#cbd5e1' };
}
function loadTagTypes() {
rs.rsJsonApiRequest('/rsMail/getMessageTagTypes', {}, (res) => {
if (res && res.body && res.body.tags && res.body.tags.types) {
res.body.tags.types.forEach((tag) => {
tagTypesCache[tag.key] = {
name: tag.value.first,
color: `#${tag.value.second.toString(16).padStart(6, '0')}`,
};
});
}
});
}
loadTagTypes();
// Utility functions
const humanReadableSize = (fileSize) => {
return fileSize / 1024 > 1024
@ -65,10 +144,12 @@ const MessageSummary = () => {
.then((res) => {
if (res.body.retval) {
details = res.body.msg;
details.msgtags = v.attrs.details.msgtags;
files = details.files;
isStarred = (details.msgflags & 0xf00) === RS_MSG_STAR;
const flag = details.msgflags & 0xf0;
msgStatus = flag === RS_MSG_NEW || flag === RS_MSG_UNREAD_BY_USER ? 'unread' : 'read';
MessageCache[v.attrs.details.msgId] = details;
}
})
.then(() => {
@ -76,7 +157,13 @@ const MessageSummary = () => {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: details.from._addr_string },
(data) => (fromUserInfo = data.details)
(data) => {
fromUserInfo = data.details;
if (fromUserInfo) {
UserNicknamesCache[details.from._addr_string] = fromUserInfo.mNickname || '';
MailGxsDetailsCache[details.from._addr_string] = fromUserInfo;
}
}
);
}
});
@ -85,18 +172,18 @@ const MessageSummary = () => {
m(
'tr.msgbody',
{
key: details.msgId,
key: v.attrs.details.msgId,
class: msgStatus,
onclick: () =>
m.route.set('/mail/:tab/:msgId', { tab: v.attrs.category, msgId: details.msgId }),
m.route.set('/mail/:tab/:msgId', { tab: v.attrs.category, msgId: v.attrs.details.msgId }),
},
[
m(
'td',
m(`input.star-check[type=checkbox][id=msg-${details.msgId}]`, { checked: isStarred }),
m(`input.star-check[type=checkbox][id=msg-${v.attrs.details.msgId}]`, { checked: isStarred }),
// Use label with [for] to manipulate hidden checkbox
m(
`label.star-check[for=msg-${details.msgId}]`,
`label.star-check[for=msg-${v.attrs.details.msgId}]`,
{
onclick: starMessage,
class: (details.msgflags & 0xf00) === RS_MSG_STAR ? 'starred' : 'unstarred',
@ -105,10 +192,70 @@ const MessageSummary = () => {
)
),
files && m('td', files.length),
m('td', details.title),
m('td', { style: 'border-bottom: inherit;' }, [
m('div', {
style: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}
}, [
m('span', details.title),
details.msgtags && details.msgtags.length > 0 && m('.mail-tags-container', { style: 'display: inline-flex; gap: 0.25rem;' },
details.msgtags.map((tagId) => {
const tag = getTagDetails(tagId);
return m('span.mail-tag-badge', {
title: tag.name,
style: `display: inline-block; width: 10px; height: 10px; border-radius: 2px; background-color: ${tag.color};`
});
})
)
])
]),
m(
'td',
fromUserInfo && Number(fromUserInfo.mId) !== 0 ? fromUserInfo.mNickname : '[Unknown]'
m(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'start',
cursor: 'pointer',
},
onmouseenter: (e) => {
if (!details?.from?._addr_string) return;
const gxsId = details.from._addr_string;
const name = fromUserInfo && Number(fromUserInfo.mId) !== 0 ? fromUserInfo.mNickname : '[Unknown]';
const rect = e.currentTarget.getBoundingClientRect();
MailHoverState.hoveredUser = { gxsId, name, rect };
if (fromUserInfo) MailGxsDetailsCache[gxsId] = fromUserInfo;
if (!MailGxsDetailsCache[gxsId]) {
rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (d) => {
if (d && d.details) {
MailGxsDetailsCache[gxsId] = d.details;
m.redraw();
}
});
}
m.redraw();
},
onmouseleave: () => {
MailHoverState.hoveredUser = null;
m.redraw();
}
},
[
m(peopleUtil.UserAvatar, {
avatar: fromUserInfo?.mAvatar,
firstLetter: (fromUserInfo?.mNickname || '').slice(0, 1).toUpperCase(),
identityId: details.from?._addr_string,
size: 24,
}),
m('span', fromUserInfo && Number(fromUserInfo.mId) !== 0 ? fromUserInfo.mNickname : '[Unknown]'),
]
)
),
m('td', new Date(details.ts * 1000).toLocaleString()),
]
@ -158,6 +305,7 @@ const AttachmentSection = () => {
const MessageView = () => {
let showCompose = false;
let composeType = 'reply';
// setFunction like react to show/hide popup
function setShowCompose(bool) {
showCompose = bool;
@ -222,11 +370,27 @@ const MessageView = () => {
} else if (mode === MSG_ADDRESS_MODE_BCC && !MailData.bccList[addrString]) {
MailData.bccList[addrString] = destDetail;
}
if (addrString && !UserNicknamesCache[addrString]) {
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: addrString },
(data) => {
if (data?.details) {
UserNicknamesCache[addrString] = data.details.mNickname || '';
}
}
);
}
});
rs.rsJsonApiRequest(
'/rsIdentity/getIdDetails',
{ id: MailData?.sender?._addr_string },
(data) => (MailData.avatar = data?.details?.mAvatar)
(data) => {
if (data?.details) {
MailData.avatar = data.details.mAvatar;
UserNicknamesCache[MailData.sender._addr_string] = data.details.mNickname || '';
}
}
);
}
},
@ -241,9 +405,9 @@ const MessageView = () => {
m('i.fas.fa-arrow-left')
),
m('.msg-view-nav__action', [
m('button', { onclick: () => setShowCompose(true) }, 'Reply'),
m('button', 'Reply All'),
m('button', 'Forward'),
m('button', { onclick: () => { composeType = 'reply'; setShowCompose(true); } }, 'Reply'),
m('button', { onclick: () => { composeType = 'replyAll'; setShowCompose(true); } }, 'Reply All'),
m('button', { onclick: () => { composeType = 'forward'; setShowCompose(true); } }, 'Forward'),
m('button', { onclick: confirmMailDelete }, 'Delete'),
]),
]),
@ -253,15 +417,36 @@ const MessageView = () => {
MailData.sender &&
m(peopleUtil.UserAvatar, {
avatar: MailData.avatar,
firstLetter: rs.userList.userMap[MailData.sender._addr_string]
? rs.userList.userMap[MailData.sender._addr_string].slice(0, 1).toUpperCase()
: '',
firstLetter: (UserNicknamesCache[MailData.sender._addr_string] || rs.userList.username(MailData.sender._addr_string) || '').slice(0, 1).toUpperCase(),
identityId: MailData.sender._addr_string,
}),
m('.msg-details__info', [
MailData.sender &&
m('.msg-details__info-item', [
m('.msg-details__info-item', {
style: { cursor: 'pointer', display: 'inline-flex', gap: '0.25rem', alignItems: 'center' },
onmouseenter: (e) => {
if (!MailData.sender._addr_string) return;
const gxsId = MailData.sender._addr_string;
const name = UserNicknamesCache[gxsId] || rs.userList.username(gxsId) || 'Unknown';
const rect = e.currentTarget.getBoundingClientRect();
MailHoverState.hoveredUser = { gxsId, name, rect };
if (!MailGxsDetailsCache[gxsId]) {
rs.rsJsonApiRequest('/rsIdentity/getIdDetails', { id: gxsId }, (d) => {
if (d && d.details) {
MailGxsDetailsCache[gxsId] = d.details;
m.redraw();
}
});
}
m.redraw();
},
onmouseleave: () => {
MailHoverState.hoveredUser = null;
m.redraw();
}
}, [
m('b', 'From: '),
rs.userList.userMap[MailData.sender._addr_string] || 'Unknown',
UserNicknamesCache[MailData.sender._addr_string] || rs.userList.username(MailData.sender._addr_string) || 'Unknown',
]),
m('.msg-details__info-item', [
m('b', 'To: '),
@ -269,7 +454,7 @@ const MessageView = () => {
? [
m('#truncate.truncated-view', [
Object.keys(MailData.toList).map((key, index) =>
m('span', { key: index }, `${rs.userList.userMap[key] || 'Unknown'}, `)
m('span', { key: index }, `${UserNicknamesCache[key] || rs.userList.username(key) || 'Unknown'}, `)
),
]),
m(
@ -294,7 +479,7 @@ const MessageView = () => {
m('.msg-details__info-item', [
m('b', 'Cc: '),
Object.keys(MailData.ccList).map((key, index) =>
m('p', { key: index }, `${rs.userList.userMap[key]}, `)
m('span', { key: index }, `${UserNicknamesCache[key] || rs.userList.username(key) || 'Unknown'}, `)
),
]),
MailData.bccList &&
@ -302,7 +487,7 @@ const MessageView = () => {
m('.msg-details__info-item', [
m('b', 'Bcc: '),
Object.keys(MailData.bccList).map((key, index) =>
m('p', { key: index }, `${rs.userList.userMap[key]}, `)
m('span', { key: index }, `${UserNicknamesCache[key] || rs.userList.username(key) || 'Unknown'}, `)
),
]),
]),
@ -315,16 +500,16 @@ const MessageView = () => {
m('.msg-view__attachment-items', m(AttachmentSection, { files: MailData.files })),
]),
],
m(
showCompose && m(
'.composePopupOverlay#mailComposerPopup',
{ style: { display: showCompose ? 'block' : 'none' } },
m(
'.composePopup',
MailData.sender._addr_string
? m(compose, {
msgType: 'reply',
msgType: composeType,
senderId: MailData.sender._addr_string,
recipientList: MailData.toList,
ccList: MailData.ccList,
subject: MailData.subject,
replyMessage: MailData.message,
timeStamp: new Date(MailData.timeStamp * 1000),
@ -333,24 +518,181 @@ const MessageView = () => {
: m('.widget', m('.widget__heading', m('h3', 'Sender is not known'))),
m('button.red.close-btn', { onclick: () => setShowCompose(false) }, m('i.fas.fa-times'))
)
)
),
renderMailUserTooltip(),
),
};
};
const SortState = {
column: 'date',
direction: 'desc',
};
function setSort(column) {
if (SortState.column === column) {
SortState.direction = SortState.direction === 'asc' ? 'desc' : 'asc';
} else {
SortState.column = column;
SortState.direction = (column === 'date' || column === 'attachments' || column === 'starred') ? 'desc' : 'asc';
}
}
function sortList(list) {
if (!list) return [];
return [...list].sort((msgA, msgB) => {
let valA, valB;
switch (SortState.column) {
case 'starred': {
const aStarred = (MessageCache[msgA.msgId]?.msgflags & 0xf00) === RS_MSG_STAR || (msgA.msgflags & 0xf00) === RS_MSG_STAR;
const bStarred = (MessageCache[msgB.msgId]?.msgflags & 0xf00) === RS_MSG_STAR || (msgB.msgflags & 0xf00) === RS_MSG_STAR;
valA = aStarred ? 1 : 0;
valB = bStarred ? 1 : 0;
break;
}
case 'attachments': {
const aCount = MessageCache[msgA.msgId]?.files?.length || msgA.count || 0;
const bCount = MessageCache[msgB.msgId]?.files?.length || msgB.count || 0;
valA = Number(aCount);
valB = Number(bCount);
break;
}
case 'subject': {
const aTitle = MessageCache[msgA.msgId]?.title || msgA.title || '';
const bTitle = MessageCache[msgB.msgId]?.title || msgB.title || '';
valA = aTitle.toLowerCase();
valB = bTitle.toLowerCase();
break;
}
case 'from': {
const aSenderId = MessageCache[msgA.msgId]?.from?._addr_string || msgA.from?._addr_string;
const bSenderId = MessageCache[msgB.msgId]?.from?._addr_string || msgB.from?._addr_string;
const aName = aSenderId && rs.userList.userMap[aSenderId];
const bName = bSenderId && rs.userList.userMap[bSenderId];
const aFrom = (UserNicknamesCache[aSenderId] || (aName && aName.name) || aName || '') + '';
const bFrom = (UserNicknamesCache[bSenderId] || (bName && bName.name) || bName || '') + '';
valA = aFrom.toLowerCase();
valB = bFrom.toLowerCase();
break;
}
case 'date':
default: {
const aTs = MessageCache[msgA.msgId]?.ts || msgA.ts?.xint64 || msgA.ts || 0;
const bTs = MessageCache[msgB.msgId]?.ts || msgB.ts?.xint64 || msgB.ts || 0;
valA = Number(aTs);
valB = Number(bTs);
break;
}
}
if (valA < valB) return SortState.direction === 'asc' ? -1 : 1;
if (valA > valB) return SortState.direction === 'asc' ? 1 : -1;
return 0;
});
}
const Table = () => {
let currentPage = 0;
const pageSize = 50;
return {
view: (v) =>
m('table.mails', [
m('tr', [
m('th[title=starred]', m('i.fas.fa-star')),
m('th[title=attachments]', m('i.fas.fa-paperclip')),
m('th', 'Subject'),
m('th', 'From'),
m('th', 'Date'),
view: (v) => {
const renderHeader = (colName, label, isIcon = false) => {
const isActive = SortState.column === colName;
const iconClass = isActive
? (SortState.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down')
: 'fas fa-sort';
return m(
'th.sortable-th',
{
onclick: () => setSort(colName),
style: { cursor: 'pointer', userSelect: 'none' },
},
[
isIcon ? label : m('span', label),
' ',
m(`i.${iconClass}`, {
style: {
marginLeft: '0.25rem',
opacity: isActive ? 1 : 0.2,
transition: 'opacity 0.2s',
},
}),
]
);
};
let totalItems = 0;
let tbody = v.children[0];
if (tbody && tbody.children) {
const flatChildren = Array.isArray(tbody.children) ? tbody.children.flat().filter(Boolean) : [tbody.children].filter(Boolean);
totalItems = flatChildren.length;
const start = currentPage * pageSize;
const end = start + pageSize;
tbody.children = flatChildren.slice(start, end);
}
const totalPages = Math.ceil(totalItems / pageSize) || 1;
if (currentPage >= totalPages) currentPage = totalPages - 1;
if (currentPage < 0) currentPage = 0;
const paginationUI = totalItems > pageSize && m('.pagination', {
style: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '1rem',
padding: '1rem',
borderTop: '1px solid #eee',
fontSize: '1rem',
color: '#555',
userSelect: 'none'
}
}, [
m('button', {
disabled: currentPage === 0,
onclick: () => currentPage--,
style: {
padding: '0.4rem 0.8rem',
background: currentPage === 0 ? '#ccc' : '#019dff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: currentPage === 0 ? 'not-allowed' : 'pointer',
boxShadow: 'none'
}
}, m('i.fas.fa-chevron-left')),
m('span.bold', `${totalItems > 0 ? currentPage * pageSize + 1 : 0} - ${Math.min((currentPage + 1) * pageSize, totalItems)} of ${totalItems}`),
m('button', {
disabled: currentPage >= totalPages - 1,
onclick: () => currentPage++,
style: {
padding: '0.4rem 0.8rem',
background: currentPage >= totalPages - 1 ? '#ccc' : '#019dff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: currentPage >= totalPages - 1 ? 'not-allowed' : 'pointer',
boxShadow: 'none'
}
}, m('i.fas.fa-chevron-right'))
]);
return m('.table-pagination-container', [
m('table.mails', [
m('tr', [
renderHeader('starred', m('i.fas.fa-star'), true),
renderHeader('attachments', m('i.fas.fa-paperclip'), true),
renderHeader('subject', 'Subject'),
renderHeader('from', 'From'),
renderHeader('date', 'Date'),
]),
tbody,
]),
v.children,
]),
paginationUI,
renderMailUserTooltip(),
]);
},
};
};
@ -375,25 +717,48 @@ const activeSideLink = {
quicksideactive: -1,
};
const sidebarIcons = {
inbox: m('i.fas.fa-inbox', { style: 'color: #3b82f6; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
outbox: m('i.fas.fa-envelope-open-text', { style: 'color: #10b981; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
drafts: m('i.fas.fa-edit', { style: 'color: #6b7280; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
sent: m('i.fas.fa-envelope-open', { style: 'color: #f59e0b; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
trash: m('i.fas.fa-trash-alt', { style: 'color: #ef4444; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
starred: m('i.fas.fa-star', { style: 'color: #eab308; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
system: m('i.fas.fa-bell', { style: 'color: #3b82f6; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
spam: m('i.fas.fa-fire', { style: 'color: #f97316; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
attachment: m('i.fas.fa-paperclip', { style: 'color: #06b6d4; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
important: m('i.fas.fa-square', { style: 'color: #ef4444; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
work: m('i.fas.fa-square', { style: 'color: #f97316; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
personal: m('i.fas.fa-square', { style: 'color: #22c55e; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
todo: m('i.fas.fa-square', { style: 'color: #3b82f6; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
later: m('i.fas.fa-square', { style: 'color: #a855f7; margin-right: 0.75rem; font-size: 24px; width: 24px; text-align: center;' }),
};
const Sidebar = () => {
return {
view: ({ attrs: { tabs, baseRoute, size } }) =>
m(
'.sidebar',
tabs.map((panelName, index) =>
m(
tabs.map((panelName, index) => {
const displayName = panelName.charAt(0).toUpperCase() + panelName.slice(1);
const labelText = size[panelName] > 0 ? `${displayName} (${size[panelName]})` : displayName;
return m(
m.route.Link,
{
class: index === activeSideLink.sideactive ? 'selected-sidebar-link' : '',
style: 'display: flex; align-items: center;',
onclick: () => {
activeSideLink.sideactive = index;
activeSideLink.quicksideactive = -1;
},
href: baseRoute + panelName,
},
size[panelName] > 0 ? `${panelName} (${size[panelName]})` : panelName
)
)
[
sidebarIcons[panelName] || null,
labelText,
]
);
})
),
};
};
@ -405,21 +770,27 @@ const SidebarQuickView = () => {
m(
'.sidebarquickview',
m('h6.bold', 'Quick View'),
tabs.map((panelName, index) =>
m(
tabs.map((panelName, index) => {
const displayName = panelName.charAt(0).toUpperCase() + panelName.slice(1);
const labelText = size[panelName] > 0 ? `${displayName} (${size[panelName]})` : displayName;
return m(
m.route.Link,
{
class:
index === activeSideLink.quicksideactive ? 'selected-sidebarquickview-link' : '',
style: 'display: flex; align-items: center;',
onclick: () => {
activeSideLink.quicksideactive = index;
activeSideLink.sideactive = -1;
},
href: baseRoute + panelName,
},
size[panelName] > 0 ? `${panelName} (${size[panelName]})` : panelName
)
)
[
sidebarIcons[panelName] || null,
labelText,
]
);
})
),
};
};
@ -432,6 +803,9 @@ module.exports = {
SearchBar,
Sidebar,
SidebarQuickView,
SortState,
setSort,
sortList,
RS_MSG_BOXMASK,
RS_MSG_INBOX,
RS_MSG_SENTBOX,

View File

@ -12,6 +12,7 @@ const channels = require('channels/channels');
const forums = require('forums/forums');
const boards = require('boards/boards');
const config = require('config/config_resolver');
const statusbar = require('statusbar');
const navIcon = {
home: m('i.fas.fa-home.sidenav-icon'),
@ -44,7 +45,7 @@ const navbar = () => {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginRight: '10px',
marginRight: isCollapsed ? 0 : '10px',
},
},
[
@ -52,28 +53,11 @@ const navbar = () => {
src: 'images/retroshare.svg',
alt: 'retroshare_icon',
}),
m('i.fas.fa-circle', {
style: {
color: rs.connectionState.status ? '#2ecc71' : '#e74c3c',
fontSize: '0.6em',
marginTop: '5px',
transition: 'color 0.3s ease',
},
title: rs.connectionState.status ? 'Connected to RetroShare Core' : 'Connection Lost',
}),
m('span.webui-version', { style: { fontSize: '0.7em', marginTop: '3px', color: '#888' } }, 'v131'),
m('i.fas.fa-sync-alt.refresh-icon', {
style: { fontSize: '0.8em', marginTop: '2px', cursor: 'pointer', color: '#888' },
onclick: () => window.location.reload(true),
title: 'Force reload application',
}),
]
),
m('.nav-menu__logo-text', [
m('h5', 'RetroShare'),
]),
m('.nav-menu__logo-text', [m('h5', 'RetroShare')]),
]),
m('.nav-menu__box', [
m('.nav-menu__box', { style: { flex: 1 } }, [
Object.keys(vnode.attrs.links).map((linkName) => {
const active = m.route.get().split('/')[1] === linkName;
return m(
@ -82,20 +66,9 @@ const navbar = () => {
href: vnode.attrs.links[linkName],
class: (active ? 'active-link' : '') + ' item',
},
[
navIcon[linkName],
m('span', linkName.charAt(0).toUpperCase() + linkName.slice(1)),
]
[navIcon[linkName], m('span', linkName.charAt(0).toUpperCase() + linkName.slice(1))]
);
}),
m(
'a.logout-link.item',
{
onclick: () => rs.logout(),
style: { marginTop: 'auto', cursor: 'pointer' },
},
[m('i.fas.fa-sign-out-alt'), m('span', 'Logout')]
),
m(
'button.toggle-nav',
{
@ -104,6 +77,84 @@ const navbar = () => {
m('i.fas.fa-angle-double-left')
),
]),
m(
'.nav-menu__footer',
{
style: {
marginTop: 'auto',
padding: '0.75rem 0 0',
color: '#888',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.75rem',
},
},
[
m(
'.nav-menu__status',
{
style: {
display: 'flex',
flexDirection: isCollapsed ? 'column' : 'row',
alignItems: 'center',
justifyContent: 'center',
gap: isCollapsed ? '0.35rem' : '0.6rem',
},
},
[
m('i.fas.fa-circle', {
style: {
color: rs.connectionState.status ? '#2ecc71' : '#e74c3c',
fontSize: '0.6em',
transition: 'color 0.3s ease',
},
title: rs.connectionState.status
? 'Connected to RetroShare Core'
: 'Connection Lost',
}),
m('span.webui-version', { style: { fontSize: '0.7em' } }, 'v131'),
m('i.fas.fa-sync-alt.refresh-icon', {
style: { cursor: 'pointer', fontSize: '0.8em' },
onclick: () => window.location.reload(true),
title: 'Force reload application',
}),
]
),
m(
'a.logout-link.item',
{
onclick: () => rs.logout(),
style: {
cursor: 'pointer',
margin: 0,
padding: isCollapsed ? '0.675rem 0' : '0.675rem 0.5rem',
width: isCollapsed ? '2.5rem' : '10rem',
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
lineHeight: 1,
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#ccc',
textTransform: 'capitalize',
},
},
[
m('i.fas.fa-sign-out-alt.sidenav-icon', {
style: {
width: '2.5rem',
height: '1.4rem',
display: 'grid',
placeItems: 'center',
},
}),
!isCollapsed && m('span', 'Logout'),
]
),
]
),
]
),
};
@ -117,7 +168,7 @@ const Layout = () => {
links: {
home: '/home',
network: '/network',
people: '/people/OwnIdentity',
people: '/people/MyContacts',
chat: '/chat',
mail: '/mail/inbox',
files: '/files/files',
@ -127,7 +178,22 @@ const Layout = () => {
config: '/config/network',
},
}),
m('.tab-content', vnode.children),
m(
'.main-container',
{
style: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflow: 'hidden',
},
},
[
m('.tab-content', { style: { flex: '1', overflow: 'auto' } }, vnode.children),
m(statusbar),
]
),
]),
};
};
@ -204,8 +270,8 @@ m.route(document.getElementById('main'), '/', {
if (rs.loginKey.isVerified && rs.loginKey.username && rs.loginKey.passwd) {
rs.logon(
{ Authorization: `Basic ${btoa(`${rs.loginKey.username}:${rs.loginKey.passwd}`)}` },
() => { }, // displayAuthError
() => { }, // displayErrorMessage
() => { }
() => {}, // displayAuthError
() => {}, // displayErrorMessage
() => {}
);
}

View File

@ -1,149 +1,115 @@
const m = require('mithril');
const rs = require('rswebui');
const widget = require('widgets');
const Data = require('network/network_data');
const compose = require('mail/mail_compose');
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 ConfirmRemove = () => {
return {
view: (vnode) => [
m('h3', 'Remove Friend'),
m('hr'),
m('p', 'Are you sure you want to end connections with this node?'),
m(
'button',
{
onclick: () => {
rs.rsJsonApiRequest('/rsPeers/removeFriend', {
pgpId: vnode.attrs.gpg_id,
});
m.redraw();
},
},
'Confirm'
),
],
};
};
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'
),
])
),
],
};
};
const Friend = () => {
return {
isExpanded: false,
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),
]),
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 = () => {
const NetworkLayout = () => {
return {
oninit: () => {
Data.refreshGpgDetails();
Data.refreshGpgDetails().then(() => m.redraw());
loadOwnProfile();
loadGxsIdentities();
},
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 });
}),
onremove: () => {
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;
if (State.selectedFriendGpgId && !selectedGxsId && State.gxsIdentities) {
State.gxsIdentities.forEach((gxsId) => fetchIdDetails(gxsId));
}
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.'
),
]),
]),
]),
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')
)
)
),
]);
},
};
};
const Layout = () => {
return {
view: () => m('.node-panel', m(FriendsList)),
};
};
module.exports = Layout;
module.exports = NetworkLayout;

View File

@ -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('<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']
),
]),
]);
},
};
};
module.exports = ChatTab;

View File

@ -33,28 +33,68 @@ 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(() => {
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[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,
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;
});
});
});
})
);
Data.gpgDetails = details;
};
module.exports = Data;

View File

@ -0,0 +1,151 @@
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 { State, startDirectChat, getOnlineSslId } = require('network/network_state');
const ConfirmRemove = () => {
return {
view: (vnode) => [
m('h3', 'Remove Friend'),
m('hr'),
m('p', 'Are you sure you want to end connections with this node?'),
m(
'button',
{
onclick: () => {
rs.rsJsonApiRequest('/rsPeers/removeFriend', {
pgpId: vnode.attrs.gpg_id,
});
State.selectedFriendGpgId = null;
Data.refreshGpgDetails().then(() => m.redraw());
widget.popupMessage(m('p', 'Friend removed successfully.'));
},
},
'Confirm'
),
],
};
};
const DetailsTab = () => {
return {
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('.friend-avatar', m(peopleUtil.UserAvatar, {
avatar: friend.avatar ? { mData: { base64: friend.avatar } } : undefined,
firstLetter: (friend.name || '?').slice(0, 1).toUpperCase(),
size: 128,
seed: gpgId,
})),
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 = DetailsTab;

View File

@ -0,0 +1,107 @@
const m = require('mithril');
const Data = require('network/network_data');
const peopleUtil = require('people/people_util');
const { State, startDirectChat, getOnlineSslId } = require('network/network_state');
const OwnProfileCard = () => {
return {
view: () => {
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', [
m('.profile-header', [
m(peopleUtil.UserAvatar, { avatar, firstLetter, seed: State.ownProfile.name }),
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
),
]),
]),
]);
},
};
};
const FriendsList = () => {
return {
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 avatar = friend.avatar ? { mData: { base64: friend.avatar } } : undefined;
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, seed: gpgId })),
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
),
]),
]
);
}),
]),
]);
},
};
};
module.exports = {
OwnProfileCard,
FriendsList,
};

View File

@ -0,0 +1,190 @@
const m = require('mithril');
const rs = require('rswebui');
const Data = require('network/network_data');
const peopleUtil = require('people/people_util');
const State = {
ownProfile: {
name: 'Loading...',
ssl_id: '',
gpg_id: '',
customState: '',
avatar: '',
},
ownGxsIds: [],
selectedOwnGxsId: '',
selectedOwnGxsDetails: null,
selectedFriendGpgId: null,
activeTab: 'details', // 'details' | 'chat'
searchString: '',
gpgToGxsIdMap: {},
gxsIdToDetailsMap: {},
gxsIdentities: [],
currentChatPeerId: null,
chatMessages: [],
chatInputMsg: '',
showMailCompose: false,
};
function loadOwnProfile() {
rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (data) => {
if (data && data.status) {
State.ownProfile.name = data.status.ownName || 'Unknown';
State.ownProfile.ssl_id = data.status.ownId || '';
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();
}
});
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();
}
});
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();
}
});
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();
}
}
);
}
function fetchIdDetails(gxsId) {
if (!gxsId) return;
if (State.gxsIdToDetailsMap[gxsId] === undefined) {
State.gxsIdToDetailsMap[gxsId] = null;
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();
}
});
}
}
function loadGxsIdentities() {
rs.rsJsonApiRequest('/rsIdentity/getIdentitiesSummaries', {}, (data) => {
if (data && data.ids) {
State.gxsIdentities = data.ids.map((u) => u.mGroupId);
m.redraw();
}
});
}
function startDirectChat(sslId) {
State.currentChatPeerId = sslId;
State.chatMessages = [];
loadDirectChatMessages();
}
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;
}
function loadDirectChatMessages() {
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();
}
};
}
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) {
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);
}
module.exports = {
State,
loadOwnProfile,
loadSelectedOwnGxsDetails,
fetchIdDetails,
loadGxsIdentities,
startDirectChat,
getOnlineSslId,
loadDirectChatMessages,
sendDirectChatMessage,
scrollChatToBottom,
};

View File

@ -1,25 +1,210 @@
const m = require('mithril');
const rs = require('rswebui');
const Data = require('network/network_data');
const compose = require('mail/mail_compose');
const {
State,
fetchIdDetails,
loadGxsIdentities,
loadOwnGxsIds,
preloadAllChatHistory,
syncFilter,
startStatusPolling,
stopStatusPolling,
initializeDistantChat,
} = require('people/people_state');
const peopleUtil = require('people/people_util');
const PeopleSidebar = require('people/people_sidebar');
const DetailsTab = require('people/people_details_tab');
const ChatTab = require('people/people_chat_tab');
const PeopleLayout = () => {
const dismissMenu = () => {
if (State.activeMenu) {
State.activeMenu = null;
m.redraw();
}
};
const AllContacts = () => {
const list = peopleUtil.sortUsers(rs.userList.users);
return {
oninit: (vnode) => {
syncFilter(vnode.attrs.tab);
Data.refreshGpgDetails().then(() => m.redraw());
loadGxsIdentities();
loadOwnGxsIds().then(() => preloadAllChatHistory());
preloadAllChatHistory();
window.addEventListener('click', dismissMenu);
// Register for chatEvents to receive live incoming messages
rs.events[15].notify = (chatMessage) => {
const msgCid = chatMessage.chat_id;
if (msgCid && msgCid.type === 2) {
const msgPid = rs.idToHex(msgCid.distant_chat_id);
// Find active session matching this distant chat PID
let session = null;
let targetGxsId = null;
Object.keys(State.activeDistantChats || {}).forEach((id) => {
if (State.activeDistantChats[id] && State.activeDistantChats[id].pid === msgPid) {
session = State.activeDistantChats[id];
targetGxsId = id;
}
});
if (session) {
const isNearDuplicate = session.messages.some(
(m) => (m.msg || m.message) === chatMessage.msg && Math.abs(m.sendTime - chatMessage.sendTime) < 5
);
if (!isNearDuplicate) {
session.messages.push(chatMessage);
session.messages.sort((a, b) => a.sendTime - b.sendTime);
if (targetGxsId) {
State.chatHistoryMap[targetGxsId] = {
lastMsg: chatMessage.msg || chatMessage.message || '',
lastTime: chatMessage.sendTime || Math.floor(Date.now() / 1000),
};
}
m.redraw();
if (State.selectedId === targetGxsId) {
setTimeout(() => {
const element = document.querySelector('.chat-messages');
if (element) element.scrollTop = element.scrollHeight;
}, 100);
}
}
} else if (State.chatPid && msgPid === State.chatPid) {
const isNearDuplicate = State.chatMessages.some(
(m) => (m.msg || m.message) === chatMessage.msg && Math.abs(m.sendTime - chatMessage.sendTime) < 5
);
if (!isNearDuplicate) {
State.chatMessages.push(chatMessage);
State.chatMessages.sort((a, b) => a.sendTime - b.sendTime);
m.redraw();
setTimeout(() => {
const element = document.querySelector('.chat-messages');
if (element) element.scrollTop = element.scrollHeight;
}, 100);
}
}
}
};
if (State.chatPid && !State.chatDisconnected) {
startStatusPolling();
}
},
onremove: () => {
if (rs.events[15]) {
rs.events[15].notify = () => {};
}
stopStatusPolling();
window.removeEventListener('click', dismissMenu);
},
onupdate: (vnode) => {
syncFilter(vnode.attrs.tab);
},
view: () => {
return m('.widget', [
m('.widget__heading', [
m('h3', 'Contacts', m('span.counter', list.length)),
m(peopleUtil.SearchBar),
fetchIdDetails(State.selectedId);
const details = State.selectedId ? State.gxsIdToDetailsMap[State.selectedId] : null;
const name = details ? details.mNickname || details.mGroupName || 'Unknown' : '';
return m('.people-container', [
// Left Side Panel
m(PeopleSidebar),
// Right Side Details / Actions Pane
m('.people-right-pane', [
State.selectedId && details
? [
m('.network-tabs', [
m(
'button.tab-btn' + (State.activeTab === 'details' ? '.active' : ''),
{
onclick: () => {
State.activeTab = 'details';
stopStatusPolling();
},
},
'Profile Details'
),
m(
'button.tab-btn' + (State.activeTab === 'chat' ? '.active' : ''),
{
onclick: () => {
State.activeTab = 'chat';
initializeDistantChat();
},
},
'Chat Conversation'
),
]),
m('.network-tab-content', [
State.activeTab === 'details' ? m(DetailsTab) : m(ChatTab),
]),
]
: m('.network-pane-placeholder', [
m('i.fas.fa-users'),
m('p', 'Select an identity from the left panel to view profile details or perform actions.'),
]),
]),
m('.widget__body', [list.map((id) => m(peopleUtil.regularcontactInfo, { id }))]),
// Mail composer overlay popup
State.showMailCompose &&
State.selectedId &&
m(
'.composePopupOverlay#mailComposerPopup',
{ style: { display: 'block' } },
m(
'.composePopup',
m(compose, {
msgType: 'compose',
toId: State.selectedId,
friendName: name,
isDirectMail: false,
setShowCompose: (val) => {
State.showMailCompose = val;
},
}),
m(
'button.red.close-btn',
{
onclick: () => {
State.showMailCompose = false;
},
},
m('i.fas.fa-times')
)
)
),
]);
},
};
};
module.exports = {
view: () => {
return m(AllContacts);
},
PeopleLayout.setSelectedId = (id, activeTab = 'details', showCompose = false) => {
const isOwn = State.ownGxsIds.includes(id);
const entry = rs.userList.userMap[id];
const isContact = entry && entry.isContact;
let filter = 'all';
let route = '/people/All';
if (isOwn) {
filter = 'own';
route = '/people/OwnIdentity';
} else if (isContact) {
filter = 'contacts';
route = '/people/MyContacts';
}
State.activeFilter = filter;
State.selectedId = id;
State.activeTab = activeTab;
if (showCompose) {
State.showMailCompose = true;
}
m.route.set(route);
};
module.exports = PeopleLayout;

View File

@ -0,0 +1,341 @@
const m = require('mithril');
const rs = require('rswebui');
const {
State,
fetchIdDetails,
getStatusColor,
getStatusTooltip,
initializeDistantChat,
sendDistantChatMessage,
stopStatusPolling,
loadAllHistoryForSelectedPeer,
} = require('people/people_state');
const { renderChatMessage } = require('chat/chat_state');
const chatEmoji = require('chat/chat_emoji');
const peopleUtil = require('people/people_util');
const HistoryBrowserModal = require('people/people_history');
// Mirroring C++ Distant Chat packet size limit (200KB)
function formatChatImage(file, callback) {
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
// Bounding box for Distant Chat images: 800x600 max
const maxWidth = 800;
const maxHeight = 600;
let width = img.width;
let height = img.height;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Dynamically step down JPEG quality until base64 string is under 190,000 characters (190KB)
let quality = 0.85;
let dataUrl = canvas.toDataURL('image/jpeg', quality);
while (dataUrl.length > 190000 && quality > 0.20) {
quality -= 0.10;
dataUrl = canvas.toDataURL('image/jpeg', quality);
}
if (dataUrl.length <= 200000) {
callback(`<img src="${dataUrl}" />`);
} else {
alert('Image file is too large to send over Distant Chat 200KB packet size limit.');
callback(null);
}
};
img.onerror = () => callback(null);
img.src = evt.target.result;
};
reader.readAsDataURL(file);
}
const ChatTab = () => {
return {
view: () => {
fetchIdDetails(State.selectedId);
const details = State.selectedId ? State.gxsIdToDetailsMap[State.selectedId] : null;
if (!details) return null;
const name = details.mNickname || details.mGroupName || 'Unknown';
if (State.ownGxsIds.length === 0) {
return m('.chat-warning', [
m('i.fas.fa-exclamation-triangle'),
m('h4', 'No Identities Found'),
m('p', 'You need to create a GXS identity in the "My Identities" tab before you can start distant chats.'),
]);
}
if (State.chatDisconnected) {
return m('.chat-warning', [
m('i.fas.fa-unlink', { style: 'font-size: 2rem; color: #ef4444; margin-bottom: 1rem;' }),
m('h4', 'Conversation Ended'),
m('p', 'You have closed the distant chat tunnel. Click below to reconnect.'),
m('button.blue', {
style: 'margin-top: 1rem; padding: 0.5rem 1.5rem; border-radius: 0.375rem; border: none; font-weight: 600; cursor: pointer;',
onclick: () => initializeDistantChat(),
}, 'Reconnect'),
]);
}
if (!State.chatPid) {
return m('.chat-warning', [
m('i.fas.fa-spinner.fa-spin'),
m('h4', 'Connecting...'),
m('p', 'Initiating distant chat tunnel to the peer identity...'),
]);
}
const canTalk = State.distantChatStatus && State.distantChatStatus.status === 2;
// Filter history by search query for history modal
const query = (State.historySearchQuery || '').toLowerCase();
const filteredHistory = (State.fullHistoryMessages || []).filter((msg) => {
if (!query) return true;
const text = (msg.msg || msg.message || '').toLowerCase();
return text.includes(query);
});
return m('.network-chat-view', [
m('.chat-identity-select-container', {
style: 'padding: 0.5rem 1rem; background-color: #ffffff; border-bottom: 1px solid #cbd5e1; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem;',
}, [
m('.chat-tunnel-status', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
m('span', { style: 'color: #64748b; font-weight: 500;' }, 'Distant Chat Tunnel'),
m('i.fas.fa-circle', {
style: {
color: getStatusColor(State.distantChatStatus ? State.distantChatStatus.status : 0),
fontSize: '0.85rem',
transition: 'color 0.3s ease',
},
title: getStatusTooltip(State.distantChatStatus ? State.distantChatStatus.status : 0),
}),
]),
m('.chat-actions', { style: 'display: flex; align-items: center; gap: 0.75rem;' }, [
m('.select-own-profile', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
m('span', { style: 'color: #64748b;' }, 'Chatting as:'),
(() => {
const ownId = State.selectedOwnGxsIdForChat;
if (ownId) fetchIdDetails(ownId);
const ownDetails = State.gxsIdToDetailsMap[ownId];
return m('.own-profile-badge', { style: 'display: flex; align-items: center; gap: 0.4rem;' }, [
m(peopleUtil.UserAvatar, {
avatar: ownDetails ? ownDetails.mAvatar : null,
identityId: ownId,
size: 24,
}),
m('select', {
style: 'padding: 0.25rem 0.5rem; border-radius: 0.25rem; border: 1px solid #cbd5e1; outline: none; background: #f8fafc; font-weight: 600;',
value: ownId,
onchange: (e) => {
State.selectedOwnGxsIdForChat = e.target.value;
initializeDistantChat(true);
},
}, State.ownGxsIds.map((id) => m('option', { value: id }, rs.userList.username(id)))),
]);
})(),
]),
m('button.blue.history-btn', {
style: 'padding: 0.25rem 0.75rem; border-radius: 0.25rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem; border: none; cursor: pointer; background-color: #3b82f6; color: #ffffff; font-weight: 600;',
title: 'View all past chat history with this contact',
onclick: () => {
State.showHistoryModal = true;
State.historySearchQuery = '';
loadAllHistoryForSelectedPeer();
},
}, [
m('i.fas.fa-history', { style: 'color: #ffffff;' }),
'History',
]),
m('button.red.leave-btn', {
style: 'padding: 0.25rem 0.75rem; border-radius: 0.25rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.25rem; border: none; cursor: pointer; background-color: #ef4444; color: #ffffff;',
onclick: () => {
if (confirm('Are you sure you want to leave this distant chat conversation?')) {
rs.rsJsonApiRequest(
'/rsChats/closeDistantChatConnexion',
{
pid: State.chatPid,
},
(data, success) => {
if (success) {
if (State.selectedId && State.activeDistantChats[State.selectedId]) {
delete State.activeDistantChats[State.selectedId];
}
State.chatPid = null;
State.chatMessages = [];
State.distantChatStatus = null;
State.chatDisconnected = true;
stopStatusPolling();
m.redraw();
}
}
);
}
},
}, [
m('i.fas.fa-sign-out-alt'),
'Leave Chat',
]),
]),
]),
m('.chat-messages', [
State.chatMessages.length === 0
? m('.chat-warning', [
m('i.fas.fa-comments'),
m('h4', 'No Messages'),
m('p', 'Distant chats are secure and encrypted. Start the conversation by typing a message below.'),
])
: State.chatMessages.map((msg) => {
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('.chat-bubble-container.incoming', [
m('.chat-sender', 'Chat status'),
m('.chat-bubble', {
style: {
backgroundColor: bgColor,
border: `1px ${borderStyle} ${borderColor}`,
color: textColor,
},
}, text),
m('.chat-time', new Date(msg.sendTime * 1000).toLocaleTimeString()),
]);
}
const isIncoming = msg.incoming;
const senderName = isIncoming ? name : rs.userList.username(State.selectedOwnGxsIdForChat);
const rawText = msg.msg || msg.message || '';
return m('.chat-bubble-container' + (isIncoming ? '.incoming' : '.outgoing'), [
m('.chat-sender', senderName),
m('.chat-bubble', renderChatMessage(rawText)),
m('.chat-time', new Date(msg.sendTime * 1000).toLocaleTimeString()),
]);
}),
]),
m('.chat-input-area', { style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [
m('button.chat-hub-action-btn', {
disabled: !canTalk,
style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '',
title: 'Attach file link',
onclick: () => {
const path = prompt('Enter file path to attach as Retroshare link:');
if (path && path.trim()) {
const val = State.chatInputMsg || '';
State.chatInputMsg = val ? val + '\n' + path.trim() : path.trim();
m.redraw();
}
}
}, m('i.fas.fa-paperclip')),
m('.emoji-picker-wrapper', { style: 'position: relative;' }, [
m('button.chat-hub-action-btn', {
disabled: !canTalk,
style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '',
title: 'Insert emoji',
onclick: (e) => {
e.stopPropagation();
State.showEmojiPicker = !State.showEmojiPicker;
}
}, m('i.fas.fa-smile')),
State.showEmojiPicker && m(chatEmoji.EmojiPicker, {
onSelect: (emoji) => {
State.chatInputMsg = (State.chatInputMsg || '') + emoji;
State.showEmojiPicker = false;
m.redraw();
}
}),
]),
m('label.chat-hub-action-btn', {
title: 'Send image',
style: `cursor: ${canTalk ? 'pointer' : 'not-allowed'}; opacity: ${canTalk ? 1 : 0.5};`,
}, [
m('i.fas.fa-image'),
m('input[type=file][accept=image/*]', {
style: 'display: none;',
disabled: !canTalk,
onchange: (e) => {
if (!e.target.files || !e.target.files[0]) return;
const file = e.target.files[0];
formatChatImage(file, (imgTag) => {
if (imgTag) {
State.chatInputMsg = (State.chatInputMsg || '') + imgTag;
m.redraw();
}
});
e.target.value = '';
}
})
]),
m('textarea.chat-textarea', {
placeholder: canTalk ? 'Type your encrypted message here... (or paste image)' : 'Waiting for tunnel to be secured...',
disabled: !canTalk,
value: State.chatInputMsg,
style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0.5rem; font-family: inherit; font-size: 0.9rem; outline: none; min-height: 40px; max-height: 120px;',
oninput: (e) => {
State.chatInputMsg = e.target.value;
},
onpaste: (e) => {
if (!canTalk) return;
const items = (e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData))?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
formatChatImage(blob, (imgTag) => {
if (imgTag) {
State.chatInputMsg = (State.chatInputMsg || '') + imgTag;
m.redraw();
}
});
break;
}
}
},
onkeydown: (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canTalk) sendDistantChatMessage();
}
},
}),
m('button.send-btn.blue', {
disabled: !canTalk,
style: !canTalk ? 'opacity: 0.5; cursor: not-allowed; height: 38px;' : 'height: 38px;',
onclick: () => {
if (canTalk) sendDistantChatMessage();
},
}, [m('i.fas.fa-paper-plane'), ' Send']),
]),
// Chat History Browser Modal
m(HistoryBrowserModal),
]);
},
};
};
module.exports = ChatTab;

View File

@ -0,0 +1,231 @@
const m = require('mithril');
const rs = require('rswebui');
const widget = require('widgets');
const peopleUtil = require('people/people_util');
const ownIdsLayout = require('people/people_ownids');
const { EditIdentity, DeleteIdentity } = ownIdsLayout;
const {
State,
fetchIdDetails,
getSafeAvatar,
get64Num,
createUsageString,
loadGxsIdentities,
initializeDistantChat,
} = require('people/people_state');
const DetailsTab = () => {
return {
view: () => {
fetchIdDetails(State.selectedId);
const details = State.selectedId ? State.gxsIdToDetailsMap[State.selectedId] : null;
if (!details) return null;
const name = details.mNickname || details.mGroupName || 'Unknown';
const isOwn = State.ownGxsIds.includes(State.selectedId);
const entry = rs.userList.userMap[State.selectedId];
const isContact = entry && entry.isContact;
const pgpId = details.mPgpId;
return m('.network-detail-view', [
m('.detail-header', [
m('.avatar-container', {
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
marginRight: '1rem',
},
}, [
m('.friend-avatar', m(peopleUtil.UserAvatar, {
avatar: getSafeAvatar(details),
firstLetter: (name || '?').slice(0, 1).toUpperCase(),
identityId: State.selectedId,
size: 128,
isSquare: true,
})),
m('.identity-votes', {
style: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginTop: '0.5rem',
},
}, [
m('.vote-positive', {
style: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
color: '#22c55e',
fontSize: '1.25rem',
fontWeight: 'bold',
},
}, [
m('i.fas.fa-thumbs-up'),
m('span', details.mReputation ? details.mReputation.mFriendsPositiveVotes : 0),
]),
m('.vote-negative', {
style: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
color: '#ef4444',
fontSize: '1.25rem',
fontWeight: 'bold',
},
}, [
m('i.fas.fa-thumbs-down'),
m('span', details.mReputation ? details.mReputation.mFriendsNegativeVotes : 0),
]),
]),
]),
m('.detail-title', [
m('h2', name),
m('.detail-subtitle', [
m('i.fas.fa-id-card'),
m('span', isOwn ? 'My Identity' : isContact ? 'Saved Contact' : 'Discovered Identity'),
]),
m('.detail-actions', [
isOwn
? [
m(
'button.btn',
{
onclick: () =>
widget.popupMessage(
m(EditIdentity, {
details,
})
),
},
[m('i.fas.fa-edit'), ' Edit']
),
m(
'button.btn.red',
{
onclick: () =>
widget.popupMessage(
m(DeleteIdentity, {
id: details.mId,
name: details.mNickname,
})
),
},
[m('i.fas.fa-trash-alt'), ' Delete']
),
]
: [
m(
'button.btn.blue',
{
onclick: () => {
State.activeTab = 'chat';
initializeDistantChat();
},
},
[m('i.fas.fa-comment-alt'), ' Start Chat']
),
m(
'button.btn.blue',
{
onclick: () => {
State.showMailCompose = true;
},
},
[m('i.fas.fa-envelope'), ' Send Mail']
),
m(
'button.btn' + (isContact ? '.red' : '.blue'),
{
onclick: () => {
rs.rsJsonApiRequest(
'/rsIdentity/setAsRegularContact',
{ id: State.selectedId, isContact: !isContact },
() => {
rs.userList.loadUsers();
loadGxsIdentities();
}
);
},
},
isContact
? [m('i.fas.fa-user-minus'), ' Remove Contact']
: [m('i.fas.fa-user-plus'), ' Add Contact']
),
],
]),
]),
]),
m('.detail-section', [
m('h3', 'Identity Info'),
m('.info-grid', [
m('.info-label', 'GXS ID'),
m('.info-value', details.mId),
m('.info-label', 'Type'),
m('.info-value', details.mFlags === 14 ? 'Signed ID' : 'Anonymous ID'),
m('.info-label', 'Owner Node GPG'),
m('.info-value', pgpId && pgpId !== '0000000000000000' ? pgpId : 'None'),
m('.info-label', 'Created On'),
m(
'.info-value',
typeof details.mPublishTS === 'object'
? new Date(details.mPublishTS.xint64 * 1000).toLocaleString()
: 'Unknown'
),
m('.info-label', 'Last Used'),
m(
'.info-value',
typeof details.mLastUsageTS === 'object'
? new Date(details.mLastUsageTS.xint64 * 1000).toLocaleDateString()
: 'Unknown'
),
m('.info-label', 'Friend votes'),
m('.info-value', details.mReputation && (details.mReputation.mFriendsPositiveVotes > 0 || details.mReputation.mFriendsNegativeVotes > 0)
? `${details.mReputation.mFriendsPositiveVotes} positive, ${details.mReputation.mFriendsNegativeVotes} negative`
: 'No votes from friends'),
m('.info-label', 'Overall'),
m('.info-value', (() => {
const pos = details.mReputation ? details.mReputation.mFriendsPositiveVotes : 0;
const neg = details.mReputation ? details.mReputation.mFriendsNegativeVotes : 0;
if (pos > neg) return 'Positive';
if (pos < neg) return 'Negative';
return 'Neutral';
})()),
]),
]),
m('.detail-section', [
m('h3', 'Usage Statistics'),
m('.usage-list', [
(!details.mUseCases || details.mUseCases.length === 0)
? m('p.usage-placeholder', { style: 'font-style: italic; color: #64748b; padding: 0.5rem 0;' }, '[No record in current session]')
: (() => {
const sorted = [...details.mUseCases].sort((a, b) => get64Num(b.value) - get64Num(a.value));
return sorted.map((item) => {
const usage = item.key;
const ts = get64Num(item.value);
const dateStr = ts > 0 ? new Date(ts * 1000).toLocaleString() : 'Unknown';
return m('.usage-item', {
style: {
padding: '0.5rem 0',
borderBottom: '1px solid #f1f5f9',
fontSize: '0.9rem',
display: 'flex',
gap: '1rem',
alignItems: 'flex-start',
},
}, [
m('strong.usage-time', { style: 'color: #64748b; flex-shrink: 0; min-width: 150px;' }, dateStr),
m('span.usage-desc', createUsageString(usage)),
]);
});
})(),
]),
]),
]);
},
};
};
module.exports = DetailsTab;

View File

@ -0,0 +1,127 @@
const m = require('mithril');
const rs = require('rswebui');
const peopleState = require('people/people_state');
const HistoryBrowserModal = () => {
return {
oninit: (vnode) => {
const chatState = require('chat/chat_state');
const isRoom = vnode.attrs && vnode.attrs.isRoom;
if (isRoom) {
chatState.ChatHubState.historySearchQuery = '';
const lobbyId = chatState.ChatLobbyModel.currentLobby ? rs.idToHex(chatState.ChatLobbyModel.currentLobby.lobby_id) : null;
if (lobbyId) {
chatState.ChatLobbyModel.loadAllHistoryForRoom(lobbyId);
}
} else {
peopleState.State.historySearchQuery = '';
peopleState.loadAllHistoryForSelectedPeer();
}
},
view: (vnode) => {
const chatState = require('chat/chat_state');
const isRoom = vnode.attrs && vnode.attrs.isRoom;
const stateObj = isRoom ? chatState.ChatHubState : peopleState.State;
if (!stateObj.showHistoryModal) return null;
let name = 'Chat History';
if (isRoom) {
const lobby = chatState.ChatLobbyModel.currentLobby;
name = lobby ? lobby.lobby_name : 'Chat Room';
} else {
const details = peopleState.State.selectedId ? peopleState.State.gxsIdToDetailsMap[peopleState.State.selectedId] : null;
name = details ? (details.mNickname || details.mGroupName || 'Contact') : 'Contact';
}
const query = (stateObj.historySearchQuery || '').toLowerCase();
const filteredHistory = (stateObj.fullHistoryMessages || []).filter((msg) => {
if (!query) return true;
const text = (msg.msg || msg.message || '').toLowerCase();
return text.includes(query);
});
return m('.history-modal-overlay', {
style: 'position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 2000;',
onclick: (e) => {
if (e.target === e.currentTarget) stateObj.showHistoryModal = false;
}
}, [
m('.history-modal', {
style: 'background: #ffffff; border-radius: 0.5rem; width: 780px; max-width: 92%; height: 85vh; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); overflow: hidden;'
}, [
// Header
m('.history-modal-header', {
style: 'padding: 1rem 1.25rem; background: #f8fafc; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; justify-content: space-between;'
}, [
m('.history-title', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
m('i.fas.fa-history', { style: 'color: #3b82f6; font-size: 1.2rem;' }),
m('h3', { style: 'margin: 0; font-size: 1.1rem; font-weight: 700; color: #1e293b;' }, `Chat History Browser — ${name}`),
]),
m('button.close-btn', {
style: 'background: transparent; border: none; font-size: 1.25rem; color: #64748b; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem;',
title: 'Close history browser',
onclick: () => (stateObj.showHistoryModal = false),
}, m('i.fas.fa-times')),
]),
// Toolbar
m('.history-toolbar', {
style: 'padding: 0.75rem 1.25rem; background: #ffffff; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 1rem;'
}, [
m('.search-input-box', { style: 'position: relative; flex: 1;' }, [
m('i.fas.fa-search', { style: 'position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: #94a3b8; font-size: 0.85rem;' }),
m('input[type=text][placeholder=Search past messages or keywords...]', {
style: 'width: 100%; padding: 0.4rem 0.75rem 0.4rem 2.2rem; border-radius: 0.375rem; border: 1px solid #cbd5e1; outline: none; font-size: 0.85rem;',
value: stateObj.historySearchQuery || '',
oninput: (e) => (stateObj.historySearchQuery = e.target.value),
}),
]),
m('span.history-count', { style: 'font-size: 0.85rem; color: #64748b; font-weight: 600;' },
`${filteredHistory.length} messages`
),
]),
// Message Body
m('.history-message-list', {
style: 'flex: 1; overflow-y: auto; padding: 1rem 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; background: #f8fafc;'
}, [
stateObj.isHistoryLoading
? m('.loading-spinner', { style: 'text-align: center; padding: 3rem; color: #64748b;' }, [
m('i.fas.fa-spinner.fa-spin', { style: 'font-size: 2rem; margin-bottom: 0.75rem; color: #3b82f6;' }),
m('p', { style: 'font-weight: 600;' }, 'Fetching complete chat history from Retroshare database...'),
])
: filteredHistory.length === 0
? m('.empty-history', { style: 'text-align: center; padding: 3rem; color: #64748b;' }, [
m('i.far.fa-comments', { style: 'font-size: 2.5rem; color: #cbd5e1; margin-bottom: 0.75rem;' }),
m('p', 'No past chat messages found matching your query.'),
])
: filteredHistory.map((msg) => {
const isIncoming = msg.incoming;
let senderName = msg.peerName || (isIncoming ? name : 'You');
if (!isIncoming) {
const ownId = isRoom ? (chatState.ChatLobbyModel.currentLobby ? chatState.ChatLobbyModel.currentLobby.gxs_id : '') : peopleState.State.selectedOwnGxsIdForChat;
senderName = rs.userList.username(ownId) || 'You';
}
const timeStr = new Date((msg.sendTime || msg.recvTime || 0) * 1000).toLocaleString();
return m('.history-item', {
style: 'background: #ffffff; border: 1px solid #e2e8f0; border-radius: 0.5rem; padding: 0.75rem 1rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);'
}, [
m('.history-item-header', { style: 'display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem;' }, [
m('span.sender', { style: `font-weight: 700; font-size: 0.85rem; color: ${isIncoming ? '#3b82f6' : '#10b981'};` }, senderName),
m('span.time', { style: 'font-size: 0.75rem; color: #94a3b8;' }, timeStr),
]),
m('.history-item-body', { style: 'font-size: 0.9rem; color: #334155; word-break: break-word;' },
chatState.renderChatMessage(msg.msg || msg.message || '')
),
]);
})
]),
])
]);
},
};
};
module.exports = HistoryBrowserModal;

View File

@ -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:'),
@ -360,4 +361,8 @@ const Layout = () => {
};
};
Layout.CreateIdentity = CreateIdentity;
Layout.EditIdentity = EditIdentity;
Layout.DeleteIdentity = DeleteIdentity;
module.exports = Layout;

View File

@ -1,25 +1,9 @@
const m = require('mithril');
const widget = require('widgets');
const sections = {
OwnIdentity: require('people/people_ownids'),
MyContacts: require('people/people_own_contacts'),
All: require('people/people'),
};
const Layout = {
view: (vnode) => [
m(widget.Sidebar, {
tabs: Object.keys(sections),
baseRoute: '/people/',
}),
m('.node-panel .', vnode.children),
],
};
const PeopleLayout = require('people/people');
module.exports = {
view: (vnode) => {
const tab = vnode.attrs.tab;
return m(Layout, m(sections[tab]));
const tab = vnode.attrs.tab || 'All';
return m(PeopleLayout, { tab });
},
};

View File

@ -0,0 +1,397 @@
const m = require('mithril');
const rs = require('rswebui');
const widget = require('widgets');
const peopleUtil = require('people/people_util');
const ownIdsLayout = require('people/people_ownids');
const { CreateIdentity } = ownIdsLayout;
const {
State,
isSystemMsg,
preloadAllChatHistory,
fetchIdDetails,
loadGxsIdentities,
getSafeAvatar,
get64Num,
stopStatusPolling,
initializeDistantChat,
} = require('people/people_state');
function formatRelativeTime(ts) {
if (!ts) return '';
const now = Math.floor(Date.now() / 1000);
const diff = now - ts;
if (diff < 30) return 'Just Now';
if (diff < 3600) return `${Math.floor(diff / 60)} min${Math.floor(diff / 60) > 1 ? 's' : ''}`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hr${Math.floor(diff / 3600) > 1 ? 's' : ''}`;
return `${Math.floor(diff / 86400)} d`;
}
const PeopleSidebar = () => {
return {
oninit: () => {
preloadAllChatHistory();
},
view: () => {
// 1. Determine list based on mainTab ('people' vs 'chats')
let displayItems = [];
// 0. Compute active chats count (conversations with real message history)
const allUserGroupIds = new Set((rs.userList.users || []).map((u) => u.mGroupId));
Object.keys(State.chatHistoryMap || {}).forEach((id) => allUserGroupIds.add(id));
let activeChatsCount = 0;
allUserGroupIds.forEach((gxsId) => {
const hist = State.chatHistoryMap && State.chatHistoryMap[gxsId];
if (hist && hist.lastMsg && !isSystemMsg(hist.lastMsg)) {
activeChatsCount++;
}
});
if (State.mainTab === 'people') {
let baseList = [];
if (State.activeFilter === 'own') {
baseList = peopleUtil.sortIds(State.ownGxsIds) || [];
} else if (State.activeFilter === 'contacts') {
baseList = peopleUtil.contactlist(rs.userList.users) || [];
} else {
baseList = peopleUtil.sortUsers(rs.userList.users) || [];
}
displayItems = baseList.filter((item) => {
let name = State.activeFilter === 'own' ? (rs.userList.username(item) || 'Unknown') : (item.mGroupName || 'Unknown');
return name.toLowerCase().includes(State.searchString.toLowerCase());
});
displayItems.sort((a, b) => {
let nameA = State.activeFilter === 'own' ? (rs.userList.username(a) || '') : (a.mGroupName || '');
let nameB = State.activeFilter === 'own' ? (rs.userList.username(b) || '') : (b.mGroupName || '');
return nameA.localeCompare(nameB);
});
} else {
// Chats Tab: ONLY contacts and identities that have real chat history (ignoring system tunnel status logs)
displayItems = Array.from(allUserGroupIds)
.map((gxsId) => {
const entry = rs.userList.userMap[gxsId];
const name = entry && entry.name ? entry.name : (rs.userList.username(gxsId) || 'Unknown');
return { mGroupId: gxsId, mGroupName: name };
})
.filter((item) => {
const gxsId = item.mGroupId;
fetchIdDetails(gxsId);
const hist = State.chatHistoryMap && State.chatHistoryMap[gxsId];
const hasRealHistory = Boolean(hist && hist.lastMsg && !isSystemMsg(hist.lastMsg));
if (!hasRealHistory) return false;
const name = item.mGroupName || 'Unknown';
return name.toLowerCase().includes(State.searchString.toLowerCase());
});
// Sort by chat timestamp descending
displayItems.sort((a, b) => {
const histA = State.chatHistoryMap[a.mGroupId];
const histB = State.chatHistoryMap[b.mGroupId];
const detailsA = State.gxsIdToDetailsMap[a.mGroupId];
const detailsB = State.gxsIdToDetailsMap[b.mGroupId];
const timeA = histA ? histA.lastTime : (detailsA ? get64Num(detailsA.mLastUsageTS) : 0);
const timeB = histB ? histB.lastTime : (detailsB ? get64Num(detailsB.mLastUsageTS) : 0);
return timeB - timeA;
});
}
return m('.people-left-pane', [
// Sidebar Header Container
m('.people-sidebar-header', [
// 1. Top Search Bar
m('.searchbar-wrapper', [
m('i.fas.fa-search'),
m('input.searchbar-input[type=text][placeholder=Search...]', {
value: State.searchString,
oninput: (e) => {
State.searchString = e.target.value;
},
}),
]),
// 2. Dual Segmented Tab Control: [People] | [Chats]
m('.segmented-control', [
m(
'button.segment-tab' + (State.mainTab === 'people' ? '.active' : ''),
{
onclick: () => {
State.mainTab = 'people';
m.redraw();
},
},
[m('i.fas.fa-users'), ' People']
),
m(
'button.segment-tab' + (State.mainTab === 'chats' ? '.active' : ''),
{
onclick: () => {
State.mainTab = 'chats';
preloadAllChatHistory();
m.redraw();
},
},
[
m('i.fas.fa-comments'),
' Chats',
activeChatsCount > 0 && m('span.segment-badge', activeChatsCount),
]
),
]),
// 3. Sub-Filter Row (People Tab)
State.mainTab === 'people' &&
m('.sub-filter-row', [
m(
'select.filter-select',
{
value: State.activeFilter,
onchange: (e) => {
State.activeFilter = e.target.value;
m.route.set(
'/people/' +
(State.activeFilter === 'contacts'
? 'MyContacts'
: State.activeFilter === 'own'
? 'OwnIdentity'
: 'All')
);
},
},
[
m('option[value=contacts]', 'Contacts'),
m('option[value=own]', 'My Identities'),
m('option[value=all]', 'All Users'),
]
),
State.activeFilter === 'own' &&
m(
'button.btn-add-id[title=Create New Identity]',
{
onclick: () => widget.popupMessage(m(CreateIdentity)),
},
m('i.fas.fa-plus')
),
]),
]),
// Scrollable List Container
m('.friends-list-container', [
m('.friends-scroll', [
displayItems.length === 0
? m('.network-pane-placeholder', { style: 'padding: 2rem 0;' }, State.mainTab === 'chats' ? 'No active chats' : 'No identities found')
: displayItems.map((item) => {
let gxsId, displayName;
if (State.mainTab === 'people' && State.activeFilter === 'own') {
gxsId = item;
displayName = rs.userList.username(gxsId) || 'Unknown';
} else {
gxsId = item.mGroupId;
displayName = item.mGroupName || 'Unknown';
}
fetchIdDetails(gxsId);
const itemDetails = State.gxsIdToDetailsMap[gxsId];
const itemAvatar = getSafeAvatar(itemDetails);
const itemFirstLetter = (displayName || '?').slice(0, 1).toUpperCase();
const isSelected = State.selectedId === gxsId;
const itemEntry = rs.userList.userMap[gxsId];
const itemIsContact = itemEntry && itemEntry.isContact;
const itemIsOwn = State.ownGxsIds.includes(gxsId);
const hist = State.chatHistoryMap[gxsId];
const lastTS = hist ? hist.lastTime : (itemDetails ? get64Num(itemDetails.mLastUsageTS) : 0);
const relativeTimeStr = formatRelativeTime(lastTS);
const lastMsgText = hist && hist.lastMsg ? hist.lastMsg : (itemIsOwn ? 'My Identity' : itemIsContact ? 'Saved Contact' : 'Distant Chat');
if (State.mainTab === 'chats') {
return m(
'.chat-item',
{
class: isSelected ? 'selected' : '',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
State.activeMenu = null;
State.selectedId = gxsId;
State.activeTab = 'chat';
initializeDistantChat();
m.redraw();
},
oncontextmenu: (e) => {
e.preventDefault();
e.stopPropagation();
State.selectedId = gxsId;
const container = document.querySelector('.friends-list-container');
if (container) {
const parentRect = container.getBoundingClientRect();
const top = e.clientY - parentRect.top;
const left = Math.min(Math.max(e.clientX - parentRect.left, 10), 160);
State.activeMenu = { gxsId, displayName, isContact: itemIsContact, top, left };
}
m.redraw();
},
},
[
m('.chat-avatar-wrapper', [
m(peopleUtil.UserAvatar, {
avatar: itemAvatar,
firstLetter: itemFirstLetter,
identityId: gxsId,
size: 40,
}),
m('.status-dot', {
style: {
backgroundColor: itemIsContact || itemIsOwn ? '#22c55e' : '#cbd5e1',
},
}),
]),
m('.chat-info', [
m('.chat-name', displayName),
m('.chat-last-msg', lastMsgText),
]),
m('.chat-meta', [
relativeTimeStr && m('.chat-time', relativeTimeStr),
]),
]
);
}
// People tab list item
return m(
'.friend-list-item',
{
class: isSelected ? 'selected' : '',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
State.activeMenu = null;
const idChanged = State.selectedId !== gxsId;
State.selectedId = gxsId;
if (idChanged) {
State.chatPid = null;
State.chatMessages = [];
stopStatusPolling();
if (State.activeTab === 'chat') {
initializeDistantChat();
}
}
m.redraw();
},
oncontextmenu: (e) => {
e.preventDefault();
e.stopPropagation();
State.selectedId = gxsId;
const container = document.querySelector('.friends-list-container');
if (container) {
const parentRect = container.getBoundingClientRect();
const top = e.clientY - parentRect.top;
const left = Math.min(Math.max(e.clientX - parentRect.left, 10), 160);
State.activeMenu = { gxsId, displayName, isContact: itemIsContact, top, left };
}
m.redraw();
},
},
[
m('.friend-avatar', m(peopleUtil.UserAvatar, {
avatar: itemAvatar,
firstLetter: itemFirstLetter,
identityId: gxsId,
})),
m('.friend-meta', [
m('.friend-name', displayName),
m(
'.friend-status',
itemIsOwn
? 'My Identity'
: itemIsContact
? 'Contact'
: 'Identity'
),
]),
]
);
}),
]),
// Context Menu
State.activeMenu && (() => {
const menu = State.activeMenu;
const isOwn = State.ownGxsIds.includes(menu.gxsId);
return m('.people-context-menu', {
style: {
top: `${menu.top}px`,
left: menu.left !== undefined ? `${menu.left}px` : '10px',
position: 'absolute',
zIndex: 1000,
},
onclick: (e) => {
e.stopPropagation();
},
}, [
!isOwn && m('.menu-item', {
onclick: () => {
State.activeMenu = null;
State.selectedId = menu.gxsId;
State.activeTab = 'chat';
State.chatPid = null;
State.chatMessages = [];
initializeDistantChat();
m.redraw();
},
}, [
m('i.fas.fa-comments', { style: 'color: #3b82f6; margin-right: 0.5rem;' }),
'Start chat',
]),
!isOwn && m('.menu-item', {
onclick: () => {
State.activeMenu = null;
State.selectedId = menu.gxsId;
State.activeTab = 'details';
State.showMailCompose = true;
m.redraw();
},
}, [
m('i.fas.fa-envelope', { style: 'color: #10b981; margin-right: 0.5rem;' }),
'Send mail',
]),
!isOwn && m('.menu-item', {
onclick: () => {
State.activeMenu = null;
rs.rsJsonApiRequest(
'/rsIdentity/setAsRegularContact',
{ id: menu.gxsId, isContact: !menu.isContact },
(data, success) => {
if (success) {
loadGxsIdentities();
}
}
);
},
}, [
m('i.fas' + (menu.isContact ? '.fa-user-minus' : '.fa-user-plus'), {
style: {
color: menu.isContact ? '#ef4444' : '#3b82f6',
marginRight: '0.5rem',
},
}),
menu.isContact ? 'Remove from Contacts' : 'Add to Contacts',
]),
]);
})(),
]),
]);
},
};
};
module.exports = PeopleSidebar;

View File

@ -0,0 +1,628 @@
const m = require('mithril');
const rs = require('rswebui');
const Data = require('network/network_data');
const peopleUtil = require('people/people_util');
const State = {
searchString: '',
selectedId: null, // GXS ID of the selected identity
mainTab: 'people', // 'people' | 'chats'
activeFilter: 'contacts', // 'all' | 'contacts' | 'own'
gxsIdToDetailsMap: {},
ownGxsIds: [],
gpgToGxsIdMap: {},
chatHistoryMap: {}, // gxsId -> { lastMsg, lastTime }
showMailCompose: false,
activeTab: 'details',
selectedOwnGxsIdForChat: '',
chatPid: null,
chatMessages: [],
chatInputMsg: '',
distantChatStatus: null,
statusPollInterval: null,
chatDisconnected: false,
activeDistantChats: {}, // gxsId -> { pid, status, messages, inputMsg, disconnected }
activeMenu: null,
showHistoryModal: false,
historySearchQuery: '',
fullHistoryMessages: [],
isHistoryLoading: false,
};
function getDistantChatSession(gxsId) {
if (!gxsId) return null;
if (!State.activeDistantChats[gxsId]) {
State.activeDistantChats[gxsId] = {
pid: null,
status: null,
messages: [],
inputMsg: '',
disconnected: false,
};
}
return State.activeDistantChats[gxsId];
}
function fetchIdDetails(gxsId) {
if (!gxsId) return;
if (State.gxsIdToDetailsMap[gxsId] === undefined) {
State.gxsIdToDetailsMap[gxsId] = null; // Mark as loading
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();
}
});
}
}
function loadGxsIdentities() {
rs.rsJsonApiRequest('/rsIdentity/getIdentitiesSummaries', {}, (data) => {
if (data && data.ids) {
m.redraw();
}
});
}
function loadOwnGxsIds() {
return new Promise((resolve) => {
peopleUtil.ownIds((ids) => {
State.ownGxsIds = ids || [];
if (State.ownGxsIds.length > 0 && !State.selectedOwnGxsIdForChat) {
State.selectedOwnGxsIdForChat = State.ownGxsIds[0];
}
m.redraw();
resolve();
});
});
}
function get64Num(val) {
if (!val) return 0;
if (typeof val === 'object') {
return val.xint64 || parseInt(val.xstr64) || 0;
}
return Number(val) || 0;
}
function getServiceName(serviceId) {
switch (serviceId) {
case 1: return 'Channels';
case 2: return 'Forums';
case 3: return 'Boards';
case 4: return 'Chat';
case 5: return 'GxsCircles';
case 6: return 'GxsMail';
case 7: return 'GxsCircles';
case 8: return 'Wire';
default: return 'Unknown (' + serviceId + ')';
}
}
function createUsageString(u) {
if (!u) return '[Unknown]';
const serviceName = getServiceName(u.mServiceId);
const usageCode = u.mUsageCode;
switch (usageCode) {
case 0:
return '[Unknown]';
case 1:
return `Admin signature in service ${serviceName}`;
case 2:
return `Admin signature verification in service ${serviceName}`;
case 3:
return `Creation of author signature in service ${serviceName}`;
case 4:
case 7:
return `Group author for group ${u.mGrpId || 'Unknown'} in service ${serviceName}`;
case 5:
return `Message signature creation in group ${u.mGrpId || 'Unknown'} of service ${serviceName}`;
case 6:
case 8:
return `Vote/comment in ${serviceName} service (Group: ${u.mGrpId || 'Unknown'}, Msg: ${u.mMsgId || 'Unknown'})`;
case 9:
return `Message in chat room (Id: ${get64Num(u.mAdditionalId)})`;
case 10:
return 'Distant message signature validation.';
case 11:
return 'Distant message signature creation.';
case 12:
return 'Signature validation in distant tunnel system.';
case 13:
return 'Signature in distant tunnel system.';
case 14:
return 'Received from GXS sync.';
case 15:
return 'Received from GXS discovery.';
case 16:
return 'Explicit request to friend.';
case 17:
return 'Generic signature validation.';
case 18:
return 'Generic signature creation.';
case 19:
return 'Generic encryption.';
case 20:
return 'Generic decryption.';
case 21:
return 'Circle membership check.';
default:
return `Usage code ${usageCode} in service ${serviceName}`;
}
}
function getSafeAvatar(details) {
return details && details.mAvatar ? details.mAvatar : undefined;
}
function getOnlineSslId(gpgId) {
if (!gpgId) return null;
const friend = Data.gpgDetails[gpgId.toLowerCase()];
if (friend && friend.locations) {
const onlineLoc = friend.locations.find((loc) => loc.isOnline);
return onlineLoc ? onlineLoc.id : null;
}
return null;
}
function isIdentityOnline(gxsId) {
fetchIdDetails(gxsId);
const details = State.gxsIdToDetailsMap[gxsId];
if (details && details.mPgpId && details.mPgpId !== '0000000000000000') {
const friend = Data.gpgDetails[details.mPgpId.toLowerCase()];
return friend ? friend.isOnline : false;
}
return false;
}
function syncFilter(tab) {
let newFilter = 'all';
if (tab === 'OwnIdentity') {
newFilter = 'own';
} else if (tab === 'MyContacts') {
newFilter = 'contacts';
}
if (State.activeFilter !== newFilter) {
State.activeFilter = newFilter;
}
}
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.';
}
}
function pollDistantChatStatus() {
if (!State.chatPid) return;
const session = State.selectedId ? getDistantChatSession(State.selectedId) : null;
rs.rsJsonApiRequest(
'/rsChats/getDistantChatStatus',
{
pid: State.chatPid,
},
(detail, success) => {
if (success && detail.retval) {
State.distantChatStatus = detail.info;
if (session) session.status = detail.info;
if (detail.info.status === 2) {
const text = 'Tunnel is secured. You can talk!';
const exists = State.chatMessages.some(
(m) => m.isSystem && (m.msg === text || m.message === text)
);
if (!exists) {
State.chatMessages.push({
incoming: true,
isSystem: true,
msg: text,
sendTime: Math.floor(Date.now() / 1000),
});
State.chatMessages.sort((a, b) => a.sendTime - b.sendTime);
}
} else if (detail.info.status === 3) {
const text = 'Your partner closed the conversation.';
const exists = State.chatMessages.some(
(m) => m.isSystem && (m.msg === text || m.message === text)
);
if (!exists) {
State.chatMessages.push({
incoming: true,
isSystem: true,
msg: text,
sendTime: Math.floor(Date.now() / 1000),
});
State.chatMessages.sort((a, b) => a.sendTime - b.sendTime);
}
}
m.redraw();
}
}
);
}
function startStatusPolling() {
stopStatusPolling();
pollDistantChatStatus();
State.statusPollInterval = setInterval(pollDistantChatStatus, 3000);
}
function stopStatusPolling() {
if (State.statusPollInterval) {
clearInterval(State.statusPollInterval);
State.statusPollInterval = null;
}
}
function isSystemMsg(msgText) {
if (!msgText || typeof msgText !== 'string') return true;
const lower = msgText.toLowerCase();
return (
lower.includes('starting distant chat') ||
lower.includes('please wait for secure tunnel') ||
lower.includes('tunnel is secured') ||
lower.includes('chat initiated') ||
lower.includes('closed the conversation')
);
}
function initializeDistantChat(force = false) {
if (!State.selectedId || !State.selectedOwnGxsIdForChat) return;
const session = getDistantChatSession(State.selectedId);
// If chat session is already established/initiating for this peer and not forced/disconnected:
if (!force && session.pid && !session.disconnected) {
State.chatPid = session.pid;
State.chatMessages = session.messages;
State.distantChatStatus = session.status;
State.chatDisconnected = session.disconnected;
loadChatMessages();
pollDistantChatStatus();
startStatusPolling();
return;
}
// Otherwise, start a new tunnel for this peer
session.pid = null;
session.status = null;
session.messages = [
{
incoming: true,
isSystem: true,
msg: 'Starting distant chat... Please wait for secure tunnel.',
sendTime: Math.floor(Date.now() / 1000),
}
];
session.disconnected = false;
State.chatPid = null;
State.chatMessages = session.messages;
State.distantChatStatus = null;
State.chatDisconnected = false;
m.redraw();
rs.rsJsonApiRequest(
'/rsChats/initiateDistantChatConnexion',
{
to_pid: State.selectedId,
from_pid: State.selectedOwnGxsIdForChat,
notify: true,
},
(res) => {
if (res && res.pid) {
const hexPid = rs.idToHex(res.pid);
session.pid = hexPid;
State.chatPid = hexPid;
State.distantChatStatus = null;
loadChatMessages();
pollDistantChatStatus();
startStatusPolling();
}
}
);
}
function loadChatMessages() {
if (!State.chatPid) return;
const chatPeerId = {
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 2, // TYPE_PRIVATE_DISTANT
peer_id: '00000000000000000000000000000000',
distant_chat_id: State.chatPid,
lobby_id: { xstr64: '0' },
};
rs.rsJsonApiRequest(
'/rsHistory/getMessages',
{
chatPeerId: chatPeerId,
loadCount: 50,
},
(data, success) => {
if (success && data.msgs) {
State.chatMessages = data.msgs;
const realUserMsgs = data.msgs.filter(
(m) => !m.isSystem && !isSystemMsg(m.message || m.msg)
);
if (realUserMsgs.length > 0 && State.selectedId) {
const last = realUserMsgs[realUserMsgs.length - 1];
State.chatHistoryMap[State.selectedId] = {
lastMsg: last.message || last.msg || '',
lastTime: last.sendTime || last.recvTime || Math.floor(Date.now() / 1000),
};
} else if (State.selectedId) {
delete State.chatHistoryMap[State.selectedId];
}
m.redraw();
setTimeout(() => {
const element = document.querySelector('.chat-messages');
if (element) element.scrollTop = element.scrollHeight;
}, 100);
}
}
);
}
function sendDistantChatMessage() {
if (!State.chatInputMsg.trim() || !State.chatPid) return;
const cid = {
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 2, // TYPE_PRIVATE_DISTANT
peer_id: '00000000000000000000000000000000',
distant_chat_id: State.chatPid,
lobby_id: { xstr64: '0' },
};
const text = State.chatInputMsg;
State.chatInputMsg = '';
rs.rsJsonApiRequest(
'/rsChats/sendChat',
{
id: cid,
msg: text,
},
(data, success) => {
if (success) {
const echoMsg = {
chat_id: cid,
msg: text,
sendTime: Math.floor(Date.now() / 1000),
incoming: false,
lobby_peer_gxs_id: State.selectedOwnGxsIdForChat,
};
State.chatMessages.push(echoMsg);
if (State.selectedId) {
State.chatHistoryMap[State.selectedId] = {
lastMsg: text,
lastTime: Math.floor(Date.now() / 1000),
};
}
m.redraw();
setTimeout(() => {
const element = document.querySelector('.chat-messages');
if (element) element.scrollTop = element.scrollHeight;
}, 100);
} else {
console.error('[RS] Failed to send distant chat message:', data);
alert('Failed to send distant chat message. The image/payload exceeds RetroShare max chat packet size.');
State.chatInputMsg = text;
m.redraw();
}
}
);
}
function preloadAllChatHistory() {
rs.rsJsonApiRequest('/rsIdentity/getIdentitiesSummaries', {}, (data) => {
const ids = (data && data.ids) ? data.ids : (rs.userList.users || []);
if (!ids || ids.length === 0) return;
ids.forEach((u) => {
const gxsId = typeof u === 'object' ? u.mGroupId : u;
if (!gxsId) return;
// Check Distant Chat History (type: 2 - TYPE_PRIVATE_DISTANT)
const distantPeerId = {
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 2, // TYPE_PRIVATE_DISTANT
peer_id: '00000000000000000000000000000000',
distant_chat_id: gxsId,
lobby_id: { xstr64: '0' },
};
rs.rsJsonApiRequest(
'/rsHistory/getMessages',
{
chatPeerId: distantPeerId,
loadCount: 20,
},
(msgData, success) => {
if (success && msgData && msgData.msgs) {
const userMsgs = msgData.msgs.filter(
(m) => !m.isSystem && !isSystemMsg(m.message || m.msg)
);
if (userMsgs.length > 0) {
const last = userMsgs[userMsgs.length - 1];
State.chatHistoryMap[gxsId] = {
lastMsg: last.message || last.msg || '',
lastTime: last.sendTime || last.recvTime || Math.floor(Date.now() / 1000),
};
m.redraw();
}
}
}
);
// Also check Private Chat History (type: 1) if PGP ID is known
const details = State.gxsIdToDetailsMap[gxsId];
const pgpId = details ? details.mPgpId : (typeof u === 'object' ? u.mPgpId : null);
if (pgpId && pgpId !== '0000000000000000') {
const privatePeerId = {
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 1, // PRIVATE
peer_id: pgpId,
distant_chat_id: '00000000000000000000000000000000',
lobby_id: { xstr64: '0' },
};
rs.rsJsonApiRequest(
'/rsHistory/getMessages',
{
chatPeerId: privatePeerId,
loadCount: 20,
},
(msgData, success) => {
if (success && msgData && msgData.msgs) {
const userMsgs = msgData.msgs.filter(
(m) => !m.isSystem && !isSystemMsg(m.message || m.msg)
);
if (userMsgs.length > 0) {
const last = userMsgs[userMsgs.length - 1];
const existing = State.chatHistoryMap[gxsId];
const lastTime = last.sendTime || last.recvTime || Math.floor(Date.now() / 1000);
if (!existing || lastTime > existing.lastTime) {
State.chatHistoryMap[gxsId] = {
lastMsg: last.message || last.msg || '',
lastTime: lastTime,
};
m.redraw();
}
}
}
}
);
}
});
});
}
function loadAllHistoryForSelectedPeer(callback) {
if (!State.selectedId) return;
State.isHistoryLoading = true;
State.fullHistoryMessages = [];
m.redraw();
const queries = [];
// Query 1: Distant Chat History by active chatPid (type: 2 - TYPE_PRIVATE_DISTANT)
if (State.chatPid) {
queries.push({
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 2, // TYPE_PRIVATE_DISTANT
peer_id: '00000000000000000000000000000000',
distant_chat_id: State.chatPid,
lobby_id: { xstr64: '0' },
});
}
// Query 2: Distant Chat History by selectedId if different (type: 2 - TYPE_PRIVATE_DISTANT)
if (State.selectedId && State.selectedId !== State.chatPid) {
queries.push({
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 2, // TYPE_PRIVATE_DISTANT
peer_id: '00000000000000000000000000000000',
distant_chat_id: State.selectedId,
lobby_id: { xstr64: '0' },
});
}
// Query 3: Private Chat History by PGP ID if available (type: 1 - TYPE_PRIVATE)
const details = State.gxsIdToDetailsMap[State.selectedId];
const pgpId = details ? details.mPgpId : null;
if (pgpId && pgpId !== '0000000000000000') {
queries.push({
broadcast_status_peer_id: '00000000000000000000000000000000',
type: 1, // TYPE_PRIVATE
peer_id: pgpId,
distant_chat_id: '00000000000000000000000000000000',
lobby_id: { xstr64: '0' },
});
}
let accumulatedMsgs = [];
let completed = 0;
queries.forEach((chatPeerId) => {
rs.rsJsonApiRequest(
'/rsHistory/getMessages',
{
chatPeerId: chatPeerId,
loadCount: 0, // 0 = load all messages in C++
},
(msgData, success) => {
if (success && msgData && msgData.msgs) {
accumulatedMsgs = accumulatedMsgs.concat(msgData.msgs);
}
completed++;
if (completed === queries.length) {
const map = new Map();
accumulatedMsgs.forEach((mItem) => {
const text = mItem.msg || mItem.message || '';
const key = `${mItem.sendTime || mItem.recvTime}_${text}`;
if (!map.has(key)) map.set(key, mItem);
});
let uniqueMsgs = Array.from(map.values());
uniqueMsgs.sort((a, b) => (a.sendTime || a.recvTime) - (b.sendTime || b.recvTime));
State.fullHistoryMessages = uniqueMsgs;
State.isHistoryLoading = false;
m.redraw();
if (callback) callback();
}
}
);
});
}
module.exports = {
State,
getDistantChatSession,
isSystemMsg,
preloadAllChatHistory,
loadAllHistoryForSelectedPeer,
fetchIdDetails,
loadGxsIdentities,
loadOwnGxsIds,
get64Num,
getServiceName,
createUsageString,
getSafeAvatar,
getOnlineSslId,
isIdentityOnline,
syncFilter,
getStatusColor,
getStatusTooltip,
pollDistantChatStatus,
startStatusPolling,
stopStatusPolling,
initializeDistantChat,
loadChatMessages,
sendDistantChatMessage,
};

View File

@ -1,27 +1,87 @@
const rs = require('rswebui');
const m = require('mithril');
const jdenticon = require('jdenticon');
function checksudo(id) {
return id === '0000000000000000';
}
function getAvatarColor(seed) {
let hash = 0;
if (seed) {
for (let i = 0; i < seed.length; i++) {
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
}
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 60%, 60%)`;
}
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));
}
const seed = v.attrs.seed || v.attrs.firstLetter || '';
const backgroundColor = getAvatarColor(seed);
return m(
'div.defaultAvatar',
{
style: {
width: sizeStr,
height: sizeStr,
borderRadius: isSquare ? '0' : '50%',
backgroundColor: backgroundColor,
}
},
m('p', {
style: {
color: '#ffffff',
fontWeight: '900',
margin: '0',
fontSize: `calc(${sizeStr} * 0.55)`,
}
}, v.attrs.firstLetter || '?')
);
},
});
@ -120,6 +180,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:'),

View File

@ -3,3 +3,5 @@
@forward 'navbar';
@forward 'posts';
@forward 'progress-bar';
@forward 'statusbar';

View File

@ -11,7 +11,7 @@
box-shadow: 0 5px 5px #222;
@include flex(column, $align: center);
height: 100%;
padding: 0.5rem 0.25rem;
padding: 0.25rem;
margin-right: 0rem;
&__logo {
@ -225,4 +225,4 @@
.sidebarquickview>h6 {
display: none !important;
}
}
}

View File

@ -0,0 +1,42 @@
@use '../abstracts' as *;
/* Status Bar Styles */
.statusbar {
@include flex($justify: space-between, $align: center);
height: 28px;
background-color: $dark-color;
border-top: 1px solid #2e2e38;
padding: 0 1rem;
font-size: 0.8rem;
color: #94a3b8;
z-index: 100;
box-sizing: border-box;
user-select: none;
flex-shrink: 0;
&-left {
@include flex($align: center);
}
&-right {
@include flex($align: center, $gap: 1.5rem);
}
&-item {
@include flex($align: center);
}
&-divider {
width: 1px;
height: 14px;
background-color: #2e2e38;
}
}
.status-bullet {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
}

File diff suppressed because it is too large Load Diff

View File

@ -89,3 +89,58 @@
.config-files {
@include flex(column, $gap: 1rem);
}
/* Hidden Service Configuration layout overrides */
.proxy-server-container {
width: 100%;
@include flex(column, $gap: 1rem);
}
.proxy-description {
color: #334155;
font-size: 0.95rem;
margin-bottom: 0.5rem;
}
.proxy-rows-container {
@include flex(column, $gap: 0.75rem);
width: 100%;
}
.proxy-row {
display: grid;
grid-template-columns: 160px 220px 220px auto;
gap: 0.75rem;
align-items: center;
width: 100%;
}
.proxy-label {
font-size: 0.95rem;
font-weight: 500;
color: #1e293b;
}
.proxy-addr-input,
.proxy-port-input {
width: 100% !important;
max-width: none !important;
}
.proxy-status-container {
@include flex($align: center, $gap: 0.5rem);
}
.proxy-status-bullet {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
border: 1px solid #475569;
}
.proxy-status-text {
font-size: 0.95rem;
color: #1e293b;
}

View File

@ -67,12 +67,14 @@
& .textArea {
padding: 0;
width: 100%;
min-height: 75px;
height: auto;
font-size: 1rem;
font-family: monospace;
background: transparent;
border: none;
resize: none;
overflow: hidden;
field-sizing: content;
}
& i {

View File

@ -12,7 +12,7 @@
.compose-mail {
&__from {
@include flex($justify: space-between);
@include flex($justify: flex-start, $align: center, $gap: 0.5rem);
padding-bottom: 0.5rem;
border-bottom: 2px solid $light-color;
}
@ -231,6 +231,17 @@ table.mails {
cursor: auto;
background-color: white;
}
& th.sortable-th {
cursor: pointer;
user-select: none;
transition: background-color 0.2s, color 0.2s;
&:hover {
background-color: $light-color;
color: darken($light-color, 80%);
}
}
}
/* hide checkbox */

View File

@ -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;
}
}
}

View File

@ -58,3 +58,140 @@ img.avatar {
color: green;
cursor: pointer;
}
.people-sidebar-header {
display: flex;
flex-direction: column;
padding: 0.75rem 1rem 0.5rem 1rem;
gap: 0.75rem;
border-bottom: 1px solid #e2e8f0;
background-color: #ffffff;
.searchbar-wrapper {
position: relative;
display: flex;
align-items: center;
i.fa-search {
position: absolute;
left: 0.85rem;
color: #94a3b8;
font-size: 0.9rem;
}
input.searchbar-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.9rem;
background-color: #f8fafc;
color: #1e293b;
outline: none;
transition: all 0.2s ease;
&:focus {
border-color: #3b82f6;
background-color: #ffffff;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
}
.segmented-control {
display: flex;
background-color: #f1f5f9;
padding: 3px;
border-radius: 0.5rem;
gap: 4px;
button.segment-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
font-weight: 600;
color: #64748b;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
box-shadow: none;
transition: all 0.2s ease;
&:hover {
color: #1e293b;
}
&.active {
background-color: #ffffff;
color: #0f172a;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
.segment-badge {
background-color: #019dff;
color: #ffffff;
}
}
.segment-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: #cbd5e1;
color: #334155;
font-size: 0.75rem;
font-weight: 700;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.35rem;
border-radius: 9999px;
line-height: 1;
transition: all 0.2s ease;
}
}
}
.sub-filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 32px;
select.filter-select {
padding: 0.35rem 0.6rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
font-size: 0.85rem;
font-weight: 600;
color: #475569;
background-color: #ffffff;
cursor: pointer;
outline: none;
}
.btn-add-id {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 0.375rem;
background-color: #3b82f6;
color: #ffffff;
border: none;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
&:hover {
background-color: #2563eb;
}
}
}
}

405
webui-src/app/statusbar.js Normal file
View File

@ -0,0 +1,405 @@
const m = require('mithril');
const rs = require('rswebui');
// RS_HIDDEN_TYPE constants (from config_util.js / retroshare/rspeers.h)
const RS_HIDDEN_TYPE_NONE = 0;
const RS_HIDDEN_TYPE_TOR = 2;
const RS_HIDDEN_TYPE_I2P = 4;
const State = {
friendCount: 0,
onlineCount: 0,
dhtActive: false,
dhtOk: false,
dhtRsNetSize: 0,
dhtNetSize: 0,
natState: 1, // BAD_UNKNOWN
firewalled: true,
forwardPort: false,
stunOk: false,
extAddressOk: false,
// Hidden-mode / Tor+I2P state (mirrors TorStatus widget in Qt)
hiddenType: RS_HIDDEN_TYPE_NONE, // 0=none, 2=Tor, 4=I2P
torProxyOk: null, // null=unchecked, true=ok, false=fail
torChecking: false,
// Bandwidth rate status (mirrors RatesStatus widget in Qt)
rateIn: 0.0,
totalIn: 0,
rateOut: 0.0,
totalOut: 0,
};
function parse64Num(val) {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return val;
if (typeof val === 'string') return parseFloat(val) || 0;
if (typeof val === 'object') {
if (val.xuint64 !== undefined) return parseFloat(val.xuint64) || 0;
if (val.xint64 !== undefined) return parseFloat(val.xint64) || 0;
if (val.xstr64 !== undefined) return parseFloat(val.xstr64) || 0;
}
return 0;
}
function formatUnit(val) {
const num = parse64Num(val);
if (!num) return '0';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'k';
return num.toString();
}
function formatBytes(rawBytes) {
const bytes = parse64Num(rawBytes);
if (!bytes || bytes <= 0 || isNaN(bytes)) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const safeI = Math.max(0, Math.min(i, sizes.length - 1));
return parseFloat((bytes / Math.pow(k, safeI)).toFixed(1)) + ' ' + sizes[safeI];
}
/**
* Fetch the own peer's hidden type and proxy status using /rsTor API.
* Mirrors Qt's TorStatus::getTorStatus().
*/
function updateTorStatus() {
if (!rs.loginKey.isVerified) return;
rs.rsJsonApiRequest('/rsAccounts/getCurrentAccountId').then((res) => {
if (!res || !res.body || !res.body.retval) return;
const sslId = res.body.id;
rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId }).then((pres) => {
if (!pres || !pres.body || !pres.body.retval) return;
const details = pres.body.det;
const isHiddenNode = Boolean(
details && (
details.hiddenType === RS_HIDDEN_TYPE_TOR ||
details.hiddenType === RS_HIDDEN_TYPE_I2P ||
details.extAddr === 'Hidden'
)
);
if (!isHiddenNode) {
State.hiddenType = RS_HIDDEN_TYPE_NONE;
State.torProxyOk = null;
State.torChecking = false;
m.redraw();
return;
}
const targetType = details.hiddenType || RS_HIDDEN_TYPE_TOR;
State.hiddenType = targetType;
// Check if node uses automated Tor management via /rsAccounts/isTorAuto (same as Qt)
rs.rsJsonApiRequest('/rsAccounts/isTorAuto', {}).then((autoRes) => {
const isAuto = autoRes && (autoRes.retval || (autoRes.body && autoRes.body.retval));
if (isAuto) {
Promise.all([
rs.rsJsonApiRequest('/rsTor/torStatus', {}),
rs.rsJsonApiRequest('/rsTor/torConnectivityStatus', {}),
]).then(([torRes, connRes]) => {
const torStatus = torRes && torRes.body ? torRes.body.retval : (torRes && torRes.retval !== undefined ? torRes.retval : 0);
const connStatus = connRes && connRes.body ? connRes.body.retval : (connRes && connRes.retval !== undefined ? connRes.retval : 0);
const torControlOk = connStatus === 6; // HIDDEN_SERVICE_READY
const torReady = torStatus === 2; // READY
if (torReady && torControlOk) {
State.torProxyOk = true;
State.torChecking = false;
} else if (torStatus === 1 || connStatus === 0 || connStatus === 1) {
// OFFLINE, ERROR, or NOT_CONNECTED
State.torProxyOk = false;
State.torChecking = false;
} else if (connStatus >= 2 && connStatus <= 5) {
// CONNECTING, SOCKET_CONNECTED, AUTHENTICATING, AUTHENTICATED
State.torProxyOk = null;
State.torChecking = true;
} else {
// UNKNOWN / default
State.torProxyOk = null;
State.torChecking = false;
}
m.redraw();
}).catch(() => {
State.torProxyOk = true;
State.torChecking = false;
m.redraw();
});
} else {
// Manual Tor / I2P proxy node
State.torProxyOk = true;
State.torChecking = false;
m.redraw();
}
}).catch(() => {
// Fallback for uncompiled backend: query /rsTor endpoints directly or assume active
Promise.all([
rs.rsJsonApiRequest('/rsTor/torStatus', {}),
rs.rsJsonApiRequest('/rsTor/torConnectivityStatus', {}),
]).then(([torRes, connRes]) => {
const torStatus = torRes && torRes.body ? torRes.body.retval : (torRes && torRes.retval !== undefined ? torRes.retval : 0);
const connStatus = connRes && connRes.body ? connRes.body.retval : (connRes && connRes.retval !== undefined ? connRes.retval : 0);
const torControlOk = connStatus === 6; // HIDDEN_SERVICE_READY
const torReady = torStatus === 2; // READY
if (torReady && torControlOk) {
State.torProxyOk = true;
State.torChecking = false;
} else if (torStatus === 1 || connStatus === 0 || connStatus === 1) {
State.torProxyOk = false;
State.torChecking = false;
} else if (connStatus >= 2 && connStatus <= 5) {
State.torProxyOk = null;
State.torChecking = true;
} else {
State.torProxyOk = null;
State.torChecking = false;
}
m.redraw();
}).catch(() => {
State.torProxyOk = true;
State.torChecking = false;
m.redraw();
});
});
});
}).catch(() => {
State.hiddenType = RS_HIDDEN_TYPE_NONE;
State.torProxyOk = null;
});
}
function updateStatus() {
if (!rs.loginKey.isVerified) return;
// 1. Friends count
rs.rsJsonApiRequest('/rsPeers/getFriendList', {}, (data) => {
if (data && data.sslIds) {
State.friendCount = data.sslIds.length;
}
});
rs.rsJsonApiRequest('/rsPeers/getOnlineList', {}, (data) => {
if (data && data.sslIds) {
State.onlineCount = data.sslIds.length;
}
});
// 2. Net / DHT config status & NAT state
rs.rsJsonApiRequest('/rsConfig/getConfigNetStatus', {}, (data) => {
if (data && data.status) {
State.dhtActive = data.status.DHTActive;
State.dhtOk = data.status.netDhtOk;
State.dhtRsNetSize = data.status.netDhtRsNetSize;
State.dhtNetSize = data.status.netDhtNetSize;
State.firewalled = data.status.firewalled;
State.forwardPort = data.status.forwardPort;
State.stunOk = data.status.netStunOk;
State.extAddressOk = data.status.netExtAddressOk;
// Compute NAT state directly from RsConfigNetStatus
if (!data.status.netLocalOk && !data.status.netExtAddressOk) {
State.natState = 2; // BAD_OFFLINE
} else if (data.status.firewalled && !data.status.forwardPort && !data.status.netUpnpOk) {
State.natState = 6; // WARNING_NATTED
} else if (data.status.forwardPort || data.status.netUpnpOk) {
State.natState = 9; // ADV_FORWARD
} else {
State.natState = 8; // GOOD
}
}
});
// 3. NAT netState from /rsConfig/getNetState
rs.rsJsonApiRequest('/rsConfig/getNetState', {}, (data) => {
if (data && data.retval !== undefined) {
State.natState = data.retval;
m.redraw();
} else if (data && data.body && data.body.retval !== undefined) {
State.natState = data.body.retval;
m.redraw();
}
});
// 4. Tor/I2P hidden-mode status (same as Qt TorStatus widget)
updateTorStatus();
// 5. Bandwidth rates (same as Qt RatesStatus widget)
rs.rsJsonApiRequest('/rsConfig/getTotalBandwidthRates', {}, (data) => {
const rates = (data && data.rates) || (data && data.body && data.body.rates);
if (rates) {
State.rateIn = rates.mRateIn !== undefined ? rates.mRateIn : (rates.rateIn || 0.0);
State.totalIn = rates.mTotalIn !== undefined ? rates.mTotalIn : (rates.totalIn || 0);
State.rateOut = rates.mRateOut !== undefined ? rates.mRateOut : (rates.rateOut || 0.0);
State.totalOut = rates.mTotalOut !== undefined ? rates.mTotalOut : (rates.totalOut || 0);
m.redraw();
}
});
}
let intervalId = null;
const StatusBar = {
oninit() {
updateStatus();
intervalId = setInterval(updateStatus, 10000); // update every 10s
},
onremove() {
if (intervalId) {
clearInterval(intervalId);
}
},
view() {
const isHiddenMode = State.hiddenType === RS_HIDDEN_TYPE_TOR ||
State.hiddenType === RS_HIDDEN_TYPE_I2P;
// ── DHT Status (hidden when in hidden/darknet mode) ────────────────────
let dhtColor = '#94a3b8'; // grey (off)
let dhtTooltip = 'DHT Off';
if (State.dhtActive) {
if (State.dhtOk) {
if (State.dhtRsNetSize < 10) {
dhtColor = '#eab308'; // yellow (searching)
dhtTooltip = 'DHT Searching for RetroShare Peers';
} else {
dhtColor = '#22c55e'; // green (good)
dhtTooltip = 'DHT Good';
}
} else {
dhtColor = '#ef4444'; // red (error)
dhtTooltip = 'No peer found in DHT';
}
}
// ── NAT Status (hidden when in hidden/darknet mode) ────────────────────
let natColor = '#94a3b8';
let natTooltip = 'Offline';
switch (State.natState) {
case 1: // BAD_UNKNOWN
natColor = '#eab308';
natTooltip = 'Network Status Unknown';
break;
case 2: // BAD_OFFLINE
natColor = '#94a3b8';
natTooltip = 'Offline';
break;
case 3: // BAD_NATSYM
case 4: // BAD_NODHT_NAT
natColor = '#ef4444';
natTooltip = State.natState === 4 ? 'DHT Disabled and Firewalled' : 'Nasty Firewall';
break;
case 5: // WARNING_RESTART
natColor = '#eab308';
natTooltip = 'Network Restarting';
break;
case 6: // WARNING_NATTED
natColor = '#eab308';
natTooltip = 'Behind Firewall';
break;
case 7: // WARNING_NODHT
natColor = '#eab308';
natTooltip = 'DHT Disabled';
break;
case 8: // GOOD
natColor = '#22c55e';
natTooltip = 'RetroShare Server';
break;
case 9: // ADV_FORWARD
natColor = '#22c55e';
natTooltip = 'Forwarded Port';
break;
}
// ── Tor / I2P status indicator ─────────────────────────────────────────
// Only shown when peer is in RS_NETMODE_HIDDEN with a proxy type set.
// Mirrors Qt TorStatus widget label + icon logic.
let torLabel, torColor, torIcon, torTooltip;
if (isHiddenMode) {
torLabel = State.hiddenType === RS_HIDDEN_TYPE_TOR ? 'Tor:' : 'I2P:';
if (State.torChecking) {
torColor = '#f59e0b';
torIcon = 'fas fa-spinner fa-spin';
torTooltip = 'Checking proxy…';
} else if (State.torProxyOk === null) {
torColor = '#94a3b8';
torIcon = 'fas fa-shield-alt';
torTooltip = State.hiddenType === RS_HIDDEN_TYPE_TOR
? 'No Tor configuration'
: 'No I2P configuration';
} else if (State.torProxyOk) {
torColor = '#22c55e';
torIcon = 'fas fa-shield-alt';
torTooltip = State.hiddenType === RS_HIDDEN_TYPE_TOR
? 'Tor proxy is OK'
: 'I2P proxy is OK';
} else {
torColor = '#ef4444';
torIcon = 'fas fa-shield-alt';
torTooltip = State.hiddenType === RS_HIDDEN_TYPE_TOR
? 'Tor proxy is not available'
: 'I2P proxy is not available';
}
}
return m('.statusbar', [
m('.statusbar-left', { style: 'display: flex; align-items: center; gap: 0.75rem;' }, [
m('.statusbar-item', [
m('i.fas.fa-users', { style: 'margin-right: 0.5rem; color: #94a3b8;' }),
m('span', `Friends: ${State.onlineCount}/${State.friendCount}`),
]),
// NAT — hidden when in hidden/darknet mode (same as Qt)
!isHiddenMode && m('.statusbar-divider'),
!isHiddenMode && m('.statusbar-item', { title: natTooltip, style: 'cursor: help;' }, [
m('span', { style: 'margin-right: 0.5rem;' }, 'NAT:'),
m('.status-bullet', { style: { backgroundColor: natColor } }),
]),
// DHT — hidden when in hidden/darknet mode (same as Qt)
!isHiddenMode && m('.statusbar-divider'),
!isHiddenMode && m('.statusbar-item', { title: dhtTooltip, style: 'cursor: help;' }, [
m('span', { style: 'margin-right: 0.5rem;' }, 'DHT:'),
m('.status-bullet', { style: { backgroundColor: dhtColor } }),
State.dhtActive && State.dhtOk && m('span', { style: 'margin-left: 0.5rem;' }, `${formatUnit(State.dhtRsNetSize)} (${formatUnit(State.dhtNetSize)})`),
]),
// Tor / I2P — only shown when in hidden/darknet mode (same as Qt)
isHiddenMode && m('.statusbar-divider'),
isHiddenMode && m('.statusbar-item.statusbar-item--tor', {
title: torTooltip,
style: 'cursor: help;',
}, [
m('span.tor-label', { style: 'margin-right: 0.4rem; font-weight: 600;' }, torLabel),
m('i.' + torIcon, { style: { color: torColor, fontSize: '1rem', transition: 'color 0.3s' } }),
]),
]),
// RatesStatus — Bandwidth speeds & total cumulative transfer (Down | Up)
m('.statusbar-right', { style: 'display: flex; align-items: center; gap: 0.75rem;' }, [
m('.statusbar-item', {
title: `Downloaded: ${formatBytes(State.totalIn)}`,
style: 'cursor: help; display: flex; align-items: center;'
}, [
m('i.fas.fa-arrow-down', { style: 'color: #22c55e; margin-right: 0.35rem;' }),
m('span', `Down: ${State.rateIn.toFixed(2)} kB/s`),
m('span', { style: 'color: #64748b; font-size: 0.8rem; margin-left: 0.35rem;' }, `(${formatBytes(State.totalIn)})`),
]),
m('.statusbar-divider'),
m('.statusbar-item', {
title: `Uploaded: ${formatBytes(State.totalOut)}`,
style: 'cursor: help; display: flex; align-items: center;'
}, [
m('i.fas.fa-arrow-up', { style: 'color: #3b82f6; margin-right: 0.35rem;' }),
m('span', `Up: ${State.rateOut.toFixed(2)} kB/s`),
m('span', { style: 'color: #64748b; font-size: 0.8rem; margin-left: 0.35rem;' }, `(${formatBytes(State.totalOut)})`),
]),
]),
]);
},
};
module.exports = StatusBar;

File diff suppressed because one or more lines are too long