diff --git a/webui-src/app/chat/chat.js b/webui-src/app/chat/chat.js
index 0069b84..6f27b49 100644
--- a/webui-src/app/chat/chat.js
+++ b/webui-src/app/chat/chat.js
@@ -1,488 +1,146 @@
const m = require('mithril');
const rs = require('rswebui');
const peopleUtil = require('people/people_util');
+const people = require('people/people');
+const chatState = require('chat/chat_state');
+const chatEmoji = require('chat/chat_emoji');
+const HistoryBrowserModal = require('people/people_history');
-// **************** utility functions ********************
+const {
+ get64Num,
+ loadLobbyDetails,
+ loadDistantChatDetails,
+ sortLobbies,
+ getNicknameColor,
+ getStatusColor,
+ getStatusTooltip,
+ renderTextWithEmoji,
+ getSafeAvatar,
+ MobileState,
+ ChatRoomsModel,
+ Message,
+ ChatLobbyModel,
+ ChatHubState,
+} = chatState;
-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);
+chatEmoji.setDependencies({ ChatHubState });
+
+// Mirroring C++ RsHtml::makeEmbeddedImage for resizing chat images to fit RetroShare max packet limit (~30KB)
+function formatChatImage(file, callback) {
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ const img = new Image();
+ img.onload = () => {
+ // Bounding box for chat images: 420x320 max
+ const maxWidth = 420;
+ const maxHeight = 320;
+ 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 28,000 characters (28KB)
+ let quality = 0.70;
+ let dataUrl = canvas.toDataURL('image/jpeg', quality);
+ while (dataUrl.length > 28000 && quality > 0.15) {
+ quality -= 0.10;
+ dataUrl = canvas.toDataURL('image/jpeg', quality);
+ }
+
+ if (dataUrl.length <= 32000) {
+ callback(`
`);
} else {
- apply(null);
+ alert('Image file is too large to send over RetroShare chat packet size limit.');
+ callback(null);
}
- },
- true
- );
-}
-
-function loadDistantChatDetails(pid, apply) {
- // pid is DistantChatPeerId (uint32)
- rs.rsJsonApiRequest(
- '/rsChats/getDistantChatStatus',
- {
- pid: pid,
- },
- (detail, success) => {
- if (success && detail.retval) {
- // Map to lobby-like structure for UI compatibility
- 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 []; // return empty array instead of 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: [], // to exclude subscribed from public rooms (subscribedRooms filled to late)
- subscribedRooms: {},
- loadPublicRooms() {
- // TODO: this doesn't preserve id of rooms,
- // use regex on response to extract ids.
- rs.rsJsonApiRequest(
- '/rsChats/getListOfNearbyChatLobbies',
- {},
- (data) => {
- if (data && data.public_lobbies) {
- // Deduplicate by ID to avoid double display if backend returns redundant info
- 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 {
- // No public lobbies
- ChatRoomsModel.allRooms = [];
- }
- }
- );
- },
- loadSubscribedRooms(after = null) {
- rs.rsJsonApiRequest(
- '/rsChats/getChatLobbyList',
- {},
- (data) => {
- if (data && data.cl_list) {
- // Robust deduplication of IDs
- const ids = [...new Set(data.cl_list.map((lid) => rs.idToHex(lid)))];
- ChatRoomsModel.knownSubscrIds = ids;
-
- // Remove stale entries that are no longer in the subscribed list
- 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(); // Load public rooms after we know all subscribed IDs
- if (after != null) {
- after();
- }
- m.redraw();
- }
- })
- );
- } else {
- // No subscribed lobbies
- 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
- * msg: Message to Display
- */
-const Message = () => {
- return {
- view: (vnode) => {
- const msg = vnode.attrs;
- const datetime = new Date(msg.sendTime * 1000).toLocaleTimeString();
- // Handle both HistoryMsg (peerId) and ChatMessage (lobby_peer_gxs_id)
- const rawGxsId = msg.lobby_peer_gxs_id || msg.peerId;
- let gxsId = rs.idToHex(rawGxsId);
-
- // Fallback for 1-to-1 chats where sender ID might be missing (zeros)
- const isZero = (id) => !id || id === '00000000000000000000000000000000';
- if (isZero(gxsId)) {
- const lobby = ChatLobbyModel.currentLobby;
- // Types 1 (Private), 2 (Distant) are "private" conversations here
- 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);
- }
- }
-
- let username = rs.userList.username(gxsId) || msg.peerName || '???';
- // If we only have the hex ID, try to fallback to the peerName from the message
- if (username === gxsId && msg.peerName) {
- username = msg.peerName;
- }
- if (username === gxsId && gxsId && gxsId.length > 12) {
- username = gxsId.substring(0, 8) + '...';
- }
- const text = (msg.msg || msg.message || '')
- .replaceAll('
', '\n')
- .replace(new RegExp('|<[^>]*>', 'gm'), '');
+ if (cleanText) {
+ parts.push(renderTextWithEmoji(cleanText));
+ }
+ }
+
+ const src = match[1];
+ if (src) {
+ parts.push(
+ m('img.chat-embedded-image', {
+ src: src,
+ style: {
+ maxWidth: '100%',
+ maxHeight: '300px',
+ borderRadius: '0.375rem',
+ marginTop: '0.25rem',
+ marginBottom: '0.25rem',
+ display: 'block',
+ cursor: 'pointer',
+ boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
+ },
+ onclick: () => {
+ const w = window.open('');
+ if (w) {
+ w.document.write(`
`);
+ }
+ }
+ })
+ );
+ }
+
+ lastIndex = imgRegex.lastIndex;
+ }
+
+ if (lastIndex < rawText.length) {
+ const trailingText = rawText.substring(lastIndex);
+ const cleanText = trailingText
+ .replaceAll('
', '\n')
+ .replaceAll('
', '\n')
+ .replace(new RegExp('|<[^>]*>', 'gm'), '');
+
+ return 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,
+};
diff --git a/webui-src/app/config/config_chat.js b/webui-src/app/config/config_chat.js
new file mode 100644
index 0000000..6b6714e
--- /dev/null
+++ b/webui-src/app/config/config_chat.js
@@ -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;
diff --git a/webui-src/app/config/config_network.js b/webui-src/app/config/config_network.js
index ffbb2d7..1af47d6 100644
--- a/webui-src/app/config/config_network.js
+++ b/webui-src/app/config/config_network.js
@@ -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 }),
+ ]),
+ ]),
]),
};
};
diff --git a/webui-src/app/config/config_people.js b/webui-src/app/config/config_people.js
index 979c717..e3c7944 100644
--- a/webui-src/app/config/config_people.js
+++ b/webui-src/app/config/config_people.js
@@ -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,
+ },
+ () => {}
+ ),
+ }),
]),
]),
]),
diff --git a/webui-src/app/config/config_resolver.js b/webui-src/app/config/config_resolver.js
index fe21627..dd9f82e 100644
--- a/webui-src/app/config/config_resolver.js
+++ b/webui-src/app/config/config_resolver.js
@@ -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'),
};
diff --git a/webui-src/app/home.js b/webui-src/app/home.js
index bb9e932..461aaff 100644
--- a/webui-src/app/home.js
+++ b/webui-src/app/home.js
@@ -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
),
diff --git a/webui-src/app/jdenticon.js b/webui-src/app/jdenticon.js
new file mode 100644
index 0000000..b8b21eb
--- /dev/null
+++ b/webui-src/app/jdenticon.js
@@ -0,0 +1,102 @@
+const CORNER_SPRITES = {
+ 0: [[0.5, 1.0], [1.0, 0.0], [1.0, 1.0]],
+ 1: [[0.5, 0.0], [1.0, 0.0], [0.5, 1.0], [0.0, 1.0]],
+ 2: [[0.5, 0], [1, 0], [1, 1], [0.5, 1], [1, 0.5]],
+ 3: [[0, 0.5], [0.5, 0], [1, 0.5], [0.5, 1], [0.5, 0.5]],
+ 4: [[0, 0.5], [1, 0], [1, 1], [0, 1], [1, 0.5]],
+ 5: [[1, 0], [1, 1], [0.5, 1], [1, 0.5], [0.5, 0.5]],
+ 6: [[0, 0], [1, 0], [1, 0.5], [0, 0], [0.5, 1], [0, 1]],
+ 7: [[0, 0], [0.5, 0], [1, 0.5], [0.5, 1], [0, 1], [0.5, 0.5]],
+ 8: [[0.5, 0], [0.5, 0.5], [1, 0.5], [1, 1], [0.5, 1], [0.5, 0.5], [0, 0.5]],
+ 9: [[0, 0], [1, 0], [0.5, 0.5], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]],
+ 10: [[0, 0.5], [0.5, 1], [1, 0.5], [0.5, 0], [1, 0], [1, 1], [0, 1]],
+ 11: [[0.5, 0], [1, 0], [1, 1], [0.5, 1], [1, 0.75], [0.5, 0.5], [1, 0.25]],
+ 12: [[0, 0.5], [0.5, 0], [0.5, 0.5], [1, 0], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]],
+ 13: [[0, 0], [1, 0], [1, 1], [0, 1], [1, 0.5], [0.5, 0.25], [0.5, 0.75], [0, 0.5], [0.5, 0.25]],
+ 14: [[0, 0.5], [0.5, 0.5], [0.5, 0], [1, 0], [0.5, 0.5], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]],
+ 15: [[0, 0], [1, 0], [0.5, 0.5], [0.5, 0], [0, 0.5], [1, 0.5], [0.5, 1], [0.5, 0.5], [0, 1]]
+};
+
+const CENTER_SPRITES = {
+ 0: [],
+ 1: [[0, 0], [1, 0], [1, 1], [0, 1]],
+ 2: [[0.5, 0], [1, 0.5], [0.5, 1], [0, 0.5]],
+ 3: [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0.5], [0.5, 1], [1, 0.5], [0.5, 0], [0, 0.5]],
+ 4: [[0.25, 0], [0.75, 0], [0.5, 0.5], [1, 0.25], [1, 0.75], [0.5, 0.5], [0.75, 1], [0.25, 1], [0.5, 0.5], [0, 0.75], [0, 0.25], [0.5, 0.5]],
+ 5: [[0, 0], [0.5, 0.25], [1, 0], [0.75, 0.5], [1, 1], [0.5, 0.75], [0, 1], [0.25, 0.5]],
+ 6: [[0.33, 0.33], [0.67, 0.33], [0.67, 0.67], [0.33, 0.67]],
+ 7: [[0, 0], [0.33, 0], [0.33, 0.33], [0.66, 0.33], [0.67, 0], [1, 0], [1, 0.33], [0.67, 0.33], [0.67, 0.67], [1, 0.67], [1, 1], [0.67, 1], [0.67, 0.67], [0.33, 0.67], [0.33, 1], [0, 1], [0, 0.67], [0.33, 0.67], [0.33, 0.33], [0, 0.33]]
+};
+
+function getSpritePoints(shapePoints, size) {
+ return shapePoints.map(([rx, ry]) => `${(rx - 0.5) * size},${(ry - 0.5) * size}`).join(' ');
+}
+
+function renderPolygon(shapePoints, x, y, angle, shapeAngle, size, color) {
+ if (!shapePoints || shapePoints.length === 0) return '';
+ const halfSize = size / 2;
+ const pointsStr = getSpritePoints(shapePoints, size);
+ return ``;
+}
+
+function toSvg(hash, width) {
+ if (!hash || hash.length < 18) {
+ hash = "00000000000000000000000000000000";
+ }
+
+ const csh = parseInt(hash.substr(0, 1), 16);
+ const ssh = parseInt(hash.substr(1, 1), 16);
+ const xsh = parseInt(hash.substr(2, 1), 16) & 7;
+
+ // We rotate shape by default (rotate = true)
+ const cro = 90 * (parseInt(hash.substr(3, 1), 16) & 3);
+ const sro = 90 * (parseInt(hash.substr(4, 1), 16) & 3);
+ const xbg = parseInt(hash.substr(5, 1), 16) % 2;
+
+ const cfr = parseInt(hash.substr(6, 2), 16);
+ const cfg = parseInt(hash.substr(8, 2), 16);
+ const cfb = parseInt(hash.substr(10, 2), 16);
+
+ const sfr = parseInt(hash.substr(12, 2), 16);
+ const sfg = parseInt(hash.substr(14, 2), 16);
+ const sfb = parseInt(hash.substr(16, 2), 16);
+
+ const fillCorner = `rgb(${cfr}, ${cfg}, ${cfb})`;
+ const fillSide = `rgb(${sfr}, ${sfg}, ${sfb})`;
+
+ let fillCenter;
+ if (xbg > 0 && (Math.abs(cfr - sfr) > 127 || Math.abs(cfg - sfg) > 127 || Math.abs(cfb - sfb) > 127)) {
+ fillCenter = fillSide;
+ } else {
+ fillCenter = fillCorner;
+ }
+
+ const size = width / 3;
+ const totalsize = width;
+
+ let svgContent = ``;
+
+ // Draw corners
+ const cornerPoints = CORNER_SPRITES[csh] || CORNER_SPRITES[15];
+ svgContent += renderPolygon(cornerPoints, 0, 0, 0, cro, size, fillCorner);
+ svgContent += renderPolygon(cornerPoints, totalsize, 0, 90, cro, size, fillCorner);
+ svgContent += renderPolygon(cornerPoints, totalsize, totalsize, 180, cro, size, fillCorner);
+ svgContent += renderPolygon(cornerPoints, 0, totalsize, 270, cro, size, fillCorner);
+
+ // Draw sides
+ const sidePoints = CORNER_SPRITES[ssh] || CORNER_SPRITES[15];
+ svgContent += renderPolygon(sidePoints, 0, size, 0, sro, size, fillSide);
+ svgContent += renderPolygon(sidePoints, 2 * size, 0, 90, sro, size, fillSide);
+ svgContent += renderPolygon(sidePoints, 3 * size, 2 * size, 180, sro, size, fillSide);
+ svgContent += renderPolygon(sidePoints, size, 3 * size, 270, sro, size, fillSide);
+
+ // Draw center
+ const centerPoints = CENTER_SPRITES[xsh] !== undefined ? CENTER_SPRITES[xsh] : CORNER_SPRITES[15];
+ svgContent += renderPolygon(centerPoints, size, size, 0, 0, size, fillCenter);
+
+ return ``;
+}
+
+module.exports = {
+ toSvg
+};
\ No newline at end of file
diff --git a/webui-src/app/mail/mail_attachment.js b/webui-src/app/mail/mail_attachment.js
index 042d9e6..89ecbe8 100644
--- a/webui-src/app/mail/mail_attachment.js
+++ b/webui-src/app/mail/mail_attachment.js
@@ -52,6 +52,7 @@ const Layout = () => {
'tbody',
v.attrs.list.map((msg) =>
m(util.MessageSummary, {
+ key: msg.msgId,
details: msg,
category: 'attachment',
})
diff --git a/webui-src/app/mail/mail_compose.js b/webui-src/app/mail/mail_compose.js
index faff4f9..230d376 100644
--- a/webui-src/app/mail/mail_compose.js
+++ b/webui-src/app/mail/mail_compose.js
@@ -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}-----
From:
- ${rs.userList.userMap[senderId]}
+ ${rs.userList.username(senderId)}
To:
- ${Object.keys(recipientList).map(
+ ${recipientList ? Object.keys(recipientList).map(
(recip) => `
- ${rs.userList.userMap[recipientList[recip]._addr_string] || 'Unknown'},
+ ${rs.userList.username(recipientList[recip]._addr_string) || 'Unknown'},
`
- )}
+ ).join('') : ''}
Sent:
@@ -92,13 +277,15 @@ const Layout = () => {
${subject}
+ ${msgType !== 'forward' ? `
On ${timeStamp.toLocaleDateString()} ${time},
- ${rs.userList.userMap[senderId]}
+ ${rs.userList.username(senderId)}
wrote:
+ ` : ''}
`;
- tmb.innerHTML = `
+ const bodyHtml = `
@@ -108,7 +295,15 @@ const Layout = () => {
`;
- 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 = `${mailBodyElement.innerHTML}
`;
- 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 = `
+
Attachments (${attachments.length}):
+
+ ${attachments.map(att => `- 📎 ${att.name} (${att.size})
`).join('')}
+
+ `;
+ fullMailBody += attHtml;
+ }
+
+ const mailBody = `${fullMailBody}
`;
+
+ 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(`
`);
+ };
+ 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)
+ )
+ ),
+ ])
+ ]),
]),
]),
]);
diff --git a/webui-src/app/mail/mail_resolver.js b/webui-src/app/mail/mail_resolver.js
index 7568d84..de0648e 100644
--- a/webui-src/app/mail/mail_resolver.js
+++ b/webui-src/app/mail/mail_resolver.js
@@ -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]),
})
);
},
diff --git a/webui-src/app/mail/mail_util.js b/webui-src/app/mail/mail_util.js
index ca5511f..a46f073 100644
--- a/webui-src/app/mail/mail_util.js
+++ b/webui-src/app/mail/mail_util.js
@@ -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,
diff --git a/webui-src/app/main.js b/webui-src/app/main.js
index 2e61068..867acd3 100644
--- a/webui-src/app/main.js
+++ b/webui-src/app/main.js
@@ -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
+ () => {}
);
}
diff --git a/webui-src/app/network/network.js b/webui-src/app/network/network.js
index 2b3d494..99d7752 100644
--- a/webui-src/app/network/network.js
+++ b/webui-src/app/network/network.js
@@ -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;
diff --git a/webui-src/app/network/network_chat_tab.js b/webui-src/app/network/network_chat_tab.js
new file mode 100644
index 0000000..a10bf86
--- /dev/null
+++ b/webui-src/app/network/network_chat_tab.js
@@ -0,0 +1,125 @@
+const m = require('mithril');
+const Data = require('network/network_data');
+const { State, startDirectChat, getOnlineSslId, sendDirectChatMessage } = require('network/network_state');
+
+const ChatTab = () => {
+ return {
+ view: () => {
+ const gpgId = State.selectedFriendGpgId;
+ const friend = Data.gpgDetails[gpgId];
+ if (!friend) return null;
+
+ const sslId = getOnlineSslId(gpgId);
+
+ if (!sslId) {
+ return m('.network-chat-view', [
+ m('.chat-warning', [
+ m('i.fas.fa-exclamation-triangle'),
+ m('h4', 'No Location Found'),
+ m('p', 'This friend has no known locations to start a direct chat with.'),
+ ]),
+ ]);
+ }
+
+ if (!State.currentChatPeerId) {
+ return m('.network-chat-view', [
+ m('.chat-warning', [
+ m('i.fas.fa-comments'),
+ m('h4', 'Direct Chat'),
+ m('p', 'Click below to start a direct chat with ' + friend.name + '.'),
+ m(
+ 'button',
+ {
+ onclick: () => startDirectChat(sslId),
+ },
+ 'Start Chat'
+ ),
+ ]),
+ ]);
+ }
+
+ return m('.network-chat-view', [
+ (() => {
+ const activeLoc = friend.locations.find((loc) => loc.id === State.currentChatPeerId);
+ const locName = activeLoc ? activeLoc.name : 'Unknown Location';
+ const locOnline = activeLoc ? activeLoc.isOnline : false;
+ return m('.chat-header-bar', {
+ style: {
+ padding: '0.75rem 1rem',
+ backgroundColor: '#ffffff',
+ borderBottom: '1px solid #cbd5e1',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ }
+ }, [
+ m('.chat-header-info', [
+ m('.chat-header-name', { style: { fontWeight: '700', color: '#1e293b' } }, friend.name),
+ m('.chat-header-location', { style: { fontSize: '0.8rem', color: '#64748b', display: 'flex', alignItems: 'center', marginTop: '0.25rem' } }, [
+ m('span', 'Location: ' + locName),
+ m('span.status-dot', {
+ style: {
+ display: 'inline-block',
+ width: '8px',
+ height: '8px',
+ borderRadius: '50%',
+ backgroundColor: locOnline ? '#10b981' : '#ef4444',
+ marginLeft: '6px',
+ marginRight: '4px'
+ }
+ }),
+ m('span', { style: { color: locOnline ? '#10b981' : '#ef4444', fontWeight: '500' } }, locOnline ? 'Online' : 'Offline')
+ ])
+ ])
+ ]);
+ })(),
+ m(
+ '.chat-messages[id=chat-messages-container]',
+ State.chatMessages.map((msg) => {
+ const isOwn = msg.own === true;
+ const senderName = isOwn
+ ? (State.ownProfile.name || 'Me')
+ : friend.name;
+ const time = new Date(msg.sendTime * 1000).toLocaleTimeString();
+ const text = (msg.msg || '')
+ .replaceAll('
', '\n')
+ .replace(new RegExp('