mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-12 19:50:04 +05:00
Merge pull request #119 from defnax/improve-network
Improved Network, People, Chat & Mail & StatusBar
This commit is contained in:
commit
6b799b89a9
File diff suppressed because it is too large
Load Diff
153
webui-src/app/chat/chat_emoji.js
Normal file
153
webui-src/app/chat/chat_emoji.js
Normal 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,
|
||||
};
|
||||
909
webui-src/app/chat/chat_state.js
Normal file
909
webui-src/app/chat/chat_state.js
Normal 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,
|
||||
};
|
||||
205
webui-src/app/config/config_chat.js
Normal file
205
webui-src/app/config/config_chat.js
Normal 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;
|
||||
@ -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 }),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
() => {}
|
||||
),
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
|
||||
|
||||
@ -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
102
webui-src/app/jdenticon.js
Normal 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
|
||||
};
|
||||
@ -52,6 +52,7 @@ const Layout = () => {
|
||||
'tbody',
|
||||
v.attrs.list.map((msg) =>
|
||||
m(util.MessageSummary, {
|
||||
key: msg.msgId,
|
||||
details: msg,
|
||||
category: 'attachment',
|
||||
})
|
||||
|
||||
@ -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)
|
||||
)
|
||||
),
|
||||
])
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -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]),
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
125
webui-src/app/network/network_chat_tab.js
Normal file
125
webui-src/app/network/network_chat_tab.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
151
webui-src/app/network/network_details_tab.js
Normal file
151
webui-src/app/network/network_details_tab.js
Normal 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;
|
||||
107
webui-src/app/network/network_friends_list.js
Normal file
107
webui-src/app/network/network_friends_list.js
Normal 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,
|
||||
};
|
||||
190
webui-src/app/network/network_state.js
Normal file
190
webui-src/app/network/network_state.js
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
|
||||
341
webui-src/app/people/people_chat_tab.js
Normal file
341
webui-src/app/people/people_chat_tab.js
Normal 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;
|
||||
231
webui-src/app/people/people_details_tab.js
Normal file
231
webui-src/app/people/people_details_tab.js
Normal 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;
|
||||
127
webui-src/app/people/people_history.js
Normal file
127
webui-src/app/people/people_history.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
397
webui-src/app/people/people_sidebar.js
Normal file
397
webui-src/app/people/people_sidebar.js
Normal 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;
|
||||
628
webui-src/app/people/people_state.js
Normal file
628
webui-src/app/people/people_state.js
Normal 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,
|
||||
};
|
||||
|
||||
@ -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:'),
|
||||
|
||||
@ -3,3 +3,5 @@
|
||||
@forward 'navbar';
|
||||
@forward 'posts';
|
||||
@forward 'progress-bar';
|
||||
@forward 'statusbar';
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
webui-src/app/scss/components/_statusbar.scss
Normal file
42
webui-src/app/scss/components/_statusbar.scss
Normal 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
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
405
webui-src/app/statusbar.js
Normal 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
Loading…
Reference in New Issue
Block a user