mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
1108 lines
42 KiB
JavaScript
1108 lines
42 KiB
JavaScript
/**
|
|
* BetterDesk Console — Chat 2.0 Client
|
|
*
|
|
* Features:
|
|
* - E2E encryption (ECDH P-256 + AES-256-GCM) via chatCrypto.js
|
|
* - 1:1 and group conversations
|
|
* - File sharing (encrypted, up to 50MB)
|
|
* - Typing indicator, read receipts, online presence
|
|
* - Message search, push notifications, emoji reactions
|
|
* - Quick commands (/screenshot, /restart, /lock, /deploy)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
/* global _, Utils, Toast */
|
|
|
|
(function () {
|
|
// ==================== State ====================
|
|
let ws = null;
|
|
let reconnectTimer = null;
|
|
let reconnectDelay = 2000;
|
|
const MAX_RECONNECT = 60000;
|
|
|
|
let contacts = [];
|
|
let groups = [];
|
|
let conversations = new Map(); // conv_id → { messages: [], unread: 0 }
|
|
let activeConvId = null;
|
|
let activeConvType = 'contact'; // 'contact' | 'group'
|
|
let myPresence = 'online';
|
|
let typingTimers = new Map(); // conv_id → timeout
|
|
let lastTypingSent = 0;
|
|
let pendingFile = null;
|
|
let e2eKeys = new Map(); // conv_id → { publicKey, sharedSecret }
|
|
|
|
// Quick commands definition
|
|
const QUICK_COMMANDS = {
|
|
'/screenshot': { icon: 'screenshot_monitor', label: 'Screenshot', action: 'screenshot' },
|
|
'/restart': { icon: 'restart_alt', label: 'Restart', action: 'restart', confirm: true },
|
|
'/lock': { icon: 'lock', label: 'Lock screen', action: 'lock' },
|
|
'/shutdown': { icon: 'power_settings_new', label: 'Shutdown', action: 'shutdown', confirm: true },
|
|
'/deploy': { icon: 'cloud_upload', label: 'Deploy script', action: 'deploy', hasArg: true },
|
|
'/info': { icon: 'info', label: 'Device info', action: 'info' },
|
|
};
|
|
|
|
// Common emoji set (lightweight)
|
|
const EMOJI_SET = ['👍', '👎', '❤️', '😂', '😮', '😢', '🔥', '🎉', '✅', '❌',
|
|
'👏', '🤔', '💯', '⭐', '🚀', '🐛', '🔧', '📎', '🔒', '⚡'];
|
|
|
|
// ==================== DOM References ====================
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const $$ = (sel) => document.querySelectorAll(sel);
|
|
|
|
const dom = {};
|
|
|
|
function initDom() {
|
|
dom.contactsList = $('#chat-contacts-list');
|
|
dom.groupsList = $('#chat-groups-list');
|
|
dom.emptyState = $('#chat-empty-state');
|
|
dom.conversation = $('#chat-conversation');
|
|
dom.convName = $('#chat-conv-name');
|
|
dom.convStatus = $('#chat-conv-status');
|
|
dom.convAvatar = $('#chat-conv-avatar');
|
|
dom.messagesInner = $('#chat-messages-inner');
|
|
dom.messagesWrap = $('#chat-messages');
|
|
dom.input = $('#chat-input');
|
|
dom.sendBtn = $('#chat-send-btn');
|
|
dom.typing = $('#chat-typing');
|
|
dom.typingText = $('#chat-typing-text');
|
|
dom.e2eBanner = $('#chat-e2e-banner');
|
|
dom.e2eText = $('#chat-e2e-text');
|
|
dom.fileInput = $('#chat-file-input');
|
|
dom.filePreview = $('#chat-file-preview');
|
|
dom.fileName = $('#chat-file-name');
|
|
dom.fileSize = $('#chat-file-size');
|
|
dom.searchBar = $('#chat-search-bar');
|
|
dom.searchInput = $('#chat-search-input');
|
|
dom.searchResults = $('#chat-search-results');
|
|
dom.emojiPicker = $('#chat-emoji-picker');
|
|
dom.presenceSelect = $('#chat-presence-select');
|
|
dom.presenceDot = $('#chat-my-presence-dot');
|
|
dom.groupModal = $('#chat-group-modal');
|
|
dom.groupNameInput = $('#chat-group-name-input');
|
|
dom.memberSelect = $('#chat-member-select');
|
|
}
|
|
|
|
// ==================== WebSocket ====================
|
|
function connect() {
|
|
if (ws && ws.readyState <= WebSocket.OPEN) return;
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
// Connect as operator — deviceId = 'panel' for standalone chat
|
|
ws = new WebSocket(`${proto}://${location.host}/ws/chat-operator/panel`);
|
|
|
|
ws.onopen = () => {
|
|
reconnectDelay = 2000;
|
|
ws.send(JSON.stringify({ type: 'hello', device_id: 'panel', role: 'operator' }));
|
|
ws.send(JSON.stringify({ type: 'get_contacts', device_id: 'panel' }));
|
|
|
|
// Send presence
|
|
sendPresence(myPresence);
|
|
};
|
|
|
|
ws.onmessage = (evt) => {
|
|
let frame;
|
|
try { frame = JSON.parse(evt.data); } catch { return; }
|
|
handleFrame(frame);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
scheduleReconnect();
|
|
};
|
|
|
|
ws.onerror = () => {};
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer) return;
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT);
|
|
connect();
|
|
}, reconnectDelay);
|
|
}
|
|
|
|
function send(data) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
|
|
// ==================== Frame Handler ====================
|
|
function handleFrame(frame) {
|
|
switch (frame.type) {
|
|
case 'welcome':
|
|
updateConnectionStatus(true);
|
|
break;
|
|
|
|
case 'contacts':
|
|
contacts = frame.contacts || [];
|
|
renderContacts();
|
|
break;
|
|
|
|
case 'groups':
|
|
groups = frame.groups || [];
|
|
renderGroups();
|
|
break;
|
|
|
|
case 'history': {
|
|
const convId = frame.conversation_id || 'default';
|
|
if (!conversations.has(convId)) {
|
|
conversations.set(convId, { messages: [], unread: 0 });
|
|
}
|
|
const conv = conversations.get(convId);
|
|
conv.messages = (frame.messages || []).map(normalizeMessage);
|
|
if (convId === activeConvId) renderMessages();
|
|
break;
|
|
}
|
|
|
|
case 'message': {
|
|
const msg = normalizeMessage(frame);
|
|
const convId = msg.conversation_id || 'default';
|
|
if (!conversations.has(convId)) {
|
|
conversations.set(convId, { messages: [], unread: 0 });
|
|
}
|
|
const conv = conversations.get(convId);
|
|
conv.messages.push(msg);
|
|
if (conv.messages.length > 1000) conv.messages.splice(0, conv.messages.length - 1000);
|
|
|
|
if (convId === activeConvId) {
|
|
appendMessageDom(msg);
|
|
scrollToBottom();
|
|
// Auto send read receipt
|
|
send({ type: 'read_receipt', conversation_id: convId, message_ids: [msg.id] });
|
|
} else {
|
|
conv.unread++;
|
|
updateUnreadBadge(convId, conv.unread);
|
|
showNotification(msg);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'typing': {
|
|
const convId = frame.conversation_id || 'default';
|
|
if (convId !== activeConvId) return;
|
|
const name = frame.from_name || frame.from || frame.operator || '';
|
|
showTyping(name);
|
|
break;
|
|
}
|
|
|
|
case 'read_receipt':
|
|
if (frame.conversation_id === activeConvId) {
|
|
markMessagesRead(frame.message_ids || []);
|
|
}
|
|
break;
|
|
|
|
case 'presence': {
|
|
const deviceId = frame.device_id;
|
|
const c = contacts.find(c => c.id === deviceId);
|
|
if (c) {
|
|
c.online = frame.online;
|
|
c.status = frame.status || (frame.online ? 'online' : 'offline');
|
|
renderContacts();
|
|
if (activeConvId === deviceId) updateConvHeader();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'key_exchange':
|
|
handleKeyExchange(frame);
|
|
break;
|
|
|
|
case 'file_share':
|
|
handleFileShare(frame);
|
|
break;
|
|
|
|
case 'status':
|
|
updateConnectionStatus(true);
|
|
break;
|
|
|
|
case 'system_message': {
|
|
const sysMsg = {
|
|
id: frame.id || Date.now(),
|
|
from: 'system',
|
|
text: frame.text || '',
|
|
timestamp: frame.timestamp || Date.now(),
|
|
system: true,
|
|
conversation_id: frame.conversation_id || activeConvId || 'default',
|
|
};
|
|
const convId = sysMsg.conversation_id;
|
|
if (!conversations.has(convId)) conversations.set(convId, { messages: [], unread: 0 });
|
|
conversations.get(convId).messages.push(sysMsg);
|
|
if (convId === activeConvId) {
|
|
appendMessageDom(sysMsg);
|
|
scrollToBottom();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'reaction': {
|
|
const convId = frame.conversation_id;
|
|
if (convId !== activeConvId) return;
|
|
const msgEl = document.querySelector(`.chat-msg[data-id="${frame.message_id}"]`);
|
|
if (msgEl) addReactionBadge(msgEl, frame.emoji, frame.from);
|
|
break;
|
|
}
|
|
|
|
case 'command_result': {
|
|
const text = `Command result (${frame.action}): ${frame.result || frame.error || 'done'}`;
|
|
const sysMsg = { id: Date.now(), from: 'system', text, timestamp: Date.now(), system: true, conversation_id: frame.conversation_id || activeConvId };
|
|
if (sysMsg.conversation_id && conversations.has(sysMsg.conversation_id)) {
|
|
conversations.get(sysMsg.conversation_id).messages.push(sysMsg);
|
|
if (sysMsg.conversation_id === activeConvId) {
|
|
appendMessageDom(sysMsg);
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== Key Exchange (E2E) ====================
|
|
async function initE2E(convId) {
|
|
if (typeof ChatCrypto === 'undefined') return;
|
|
try {
|
|
await ChatCrypto.init(convId);
|
|
const pubKey = await ChatCrypto.getPublicKey();
|
|
send({ type: 'key_exchange', conversation_id: convId, public_key: pubKey });
|
|
showE2EBanner('establishing');
|
|
} catch (e) {
|
|
console.warn('[Chat] E2E init failed:', e.message);
|
|
}
|
|
}
|
|
|
|
async function handleKeyExchange(frame) {
|
|
if (typeof ChatCrypto === 'undefined') return;
|
|
try {
|
|
await ChatCrypto.receivePublicKey(frame.conversation_id || activeConvId, frame.public_key);
|
|
showE2EBanner('established');
|
|
} catch (e) {
|
|
console.warn('[Chat] Key exchange failed:', e.message);
|
|
}
|
|
}
|
|
|
|
function showE2EBanner(state) {
|
|
if (!dom.e2eBanner) return;
|
|
dom.e2eBanner.style.display = 'flex';
|
|
if (state === 'established') {
|
|
dom.e2eText.textContent = _('chat.e2e_established');
|
|
dom.e2eBanner.classList.add('established');
|
|
setTimeout(() => { dom.e2eBanner.style.display = 'none'; }, 5000);
|
|
} else {
|
|
dom.e2eText.textContent = _('chat.e2e_establishing');
|
|
dom.e2eBanner.classList.remove('established');
|
|
}
|
|
}
|
|
|
|
// ==================== Contacts & Groups ====================
|
|
function renderContacts() {
|
|
if (!dom.contactsList) return;
|
|
if (contacts.length === 0) {
|
|
dom.contactsList.innerHTML = `<div class="chat-list-empty"><span class="material-icons">forum</span><p>${_('chat.no_contacts')}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
// Sort: online first, then alphabetical
|
|
const sorted = [...contacts].sort((a, b) => {
|
|
if (a.online && !b.online) return -1;
|
|
if (!a.online && b.online) return 1;
|
|
return (a.name || a.id).localeCompare(b.name || b.id);
|
|
});
|
|
|
|
dom.contactsList.innerHTML = sorted.map(c => {
|
|
const conv = conversations.get(c.id);
|
|
const unread = conv ? conv.unread : 0;
|
|
const name = Utils.escapeHtml(c.name || c.id);
|
|
const presenceClass = c.online ? (c.status || 'online') : 'offline';
|
|
const initial = (c.name || c.id).charAt(0).toUpperCase();
|
|
const color = Utils.sanitizeColor(c.avatar_color || '#4f6ef7');
|
|
const roleClass = c.role ? String(c.role).replace(/[^a-z0-9_-]/gi, '') : '';
|
|
const roleLabel = c.role ? Utils.escapeHtml(c.role) : '';
|
|
const lastMsg = conv && conv.messages.length > 0 ? conv.messages[conv.messages.length - 1] : null;
|
|
const preview = lastMsg ? Utils.escapeHtml(lastMsg.text || '').slice(0, 40) : '';
|
|
|
|
return `
|
|
<div class="chat-list-item ${activeConvId === c.id ? 'active' : ''}" data-id="${Utils.escapeHtml(c.id)}" data-type="contact">
|
|
<div class="chat-avatar" style="background-color: ${color}">
|
|
${initial}
|
|
<span class="chat-presence-indicator ${presenceClass}"></span>
|
|
</div>
|
|
<div class="chat-list-item-body">
|
|
<div class="chat-list-item-header">
|
|
<span class="chat-list-name">${name}</span>
|
|
${lastMsg ? `<span class="chat-list-time">${formatTime(lastMsg.timestamp)}</span>` : ''}
|
|
</div>
|
|
<div class="chat-list-preview">
|
|
${roleLabel ? `<span class="chat-role-badge ${roleClass}">${roleLabel}</span>` : ''}
|
|
${preview}
|
|
</div>
|
|
</div>
|
|
${unread > 0 ? `<span class="chat-unread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Attach click handlers
|
|
dom.contactsList.querySelectorAll('.chat-list-item').forEach(el => {
|
|
el.addEventListener('click', () => openConversation(el.dataset.id, 'contact'));
|
|
});
|
|
}
|
|
|
|
function renderGroups() {
|
|
if (!dom.groupsList) return;
|
|
if (groups.length === 0) {
|
|
dom.groupsList.innerHTML = `<div class="chat-list-empty"><span class="material-icons">group</span><p>${_('chat.no_groups')}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
dom.groupsList.innerHTML = groups.map(g => {
|
|
const conv = conversations.get(`group_${g.id}`);
|
|
const unread = conv ? conv.unread : 0;
|
|
const name = Utils.escapeHtml(g.name);
|
|
|
|
return `
|
|
<div class="chat-list-item ${activeConvId === 'group_' + g.id ? 'active' : ''}" data-id="group_${Utils.escapeHtml(g.id)}" data-type="group">
|
|
<div class="chat-avatar group-avatar">
|
|
<span class="material-icons">groups</span>
|
|
</div>
|
|
<div class="chat-list-item-body">
|
|
<span class="chat-list-name">${name}</span>
|
|
<span class="chat-list-members">${(g.members || []).length} ${_('chat.members')}</span>
|
|
</div>
|
|
${unread > 0 ? `<span class="chat-unread-badge">${unread}</span>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
dom.groupsList.querySelectorAll('.chat-list-item').forEach(el => {
|
|
el.addEventListener('click', () => openConversation(el.dataset.id, 'group'));
|
|
});
|
|
}
|
|
|
|
// ==================== Conversation ====================
|
|
function openConversation(convId, type) {
|
|
activeConvId = convId;
|
|
activeConvType = type || 'contact';
|
|
|
|
// Create conversation if needed
|
|
if (!conversations.has(convId)) {
|
|
conversations.set(convId, { messages: [], unread: 0 });
|
|
}
|
|
|
|
// Clear unread
|
|
const conv = conversations.get(convId);
|
|
conv.unread = 0;
|
|
updateUnreadBadge(convId, 0);
|
|
|
|
// Update UI
|
|
dom.emptyState.style.display = 'none';
|
|
dom.conversation.style.display = 'flex';
|
|
|
|
updateConvHeader();
|
|
renderMessages();
|
|
scrollToBottom();
|
|
|
|
// Mark active in list
|
|
$$('.chat-list-item').forEach(el => el.classList.toggle('active', el.dataset.id === convId));
|
|
|
|
// Load history from server
|
|
send({ type: 'get_history', device_id: 'panel', conversation_id: convId });
|
|
|
|
// Init E2E for this conversation
|
|
initE2E(convId);
|
|
|
|
// Focus input
|
|
if (dom.input) dom.input.focus();
|
|
}
|
|
|
|
function updateConvHeader() {
|
|
if (!activeConvId) return;
|
|
|
|
if (activeConvType === 'group') {
|
|
const g = groups.find(g => 'group_' + g.id === activeConvId);
|
|
if (g) {
|
|
dom.convName.textContent = g.name;
|
|
dom.convStatus.textContent = `${(g.members || []).length} ${_('chat.members')}`;
|
|
dom.convAvatar.innerHTML = '<span class="material-icons">groups</span>';
|
|
dom.convAvatar.className = 'chat-conv-avatar group-avatar';
|
|
}
|
|
} else {
|
|
const c = contacts.find(c => c.id === activeConvId);
|
|
if (c) {
|
|
const name = c.name || c.id;
|
|
dom.convName.textContent = name;
|
|
const presenceKey = c.online ? (c.status || 'online') : 'offline';
|
|
dom.convStatus.textContent = _(`chat.presence_${presenceKey}`);
|
|
dom.convStatus.className = `chat-conv-status presence-${presenceKey}`;
|
|
dom.convAvatar.textContent = name.charAt(0).toUpperCase();
|
|
dom.convAvatar.style.backgroundColor = c.avatar_color || '#4f6ef7';
|
|
dom.convAvatar.className = 'chat-conv-avatar';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== Messages ====================
|
|
function normalizeMessage(msg) {
|
|
return {
|
|
id: msg.id || Date.now(),
|
|
from: msg.from || '',
|
|
from_name: msg.from_name || msg.operator || msg.from || '',
|
|
text: msg.text || '',
|
|
timestamp: msg.timestamp || Date.now(),
|
|
conversation_id: msg.conversation_id || '',
|
|
system: !!msg.system,
|
|
file: msg.file || null,
|
|
reactions: msg.reactions || {},
|
|
read_by: msg.read_by || [],
|
|
};
|
|
}
|
|
|
|
function renderMessages() {
|
|
if (!dom.messagesInner) return;
|
|
const conv = conversations.get(activeConvId);
|
|
if (!conv || conv.messages.length === 0) {
|
|
dom.messagesInner.innerHTML = `<div class="chat-messages-empty"><p>${_('chat.no_messages')}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
let lastDate = '';
|
|
|
|
for (const msg of conv.messages) {
|
|
// Date separator
|
|
const date = formatDate(msg.timestamp);
|
|
if (date !== lastDate) {
|
|
html += `<div class="chat-date-separator"><span>${date}</span></div>`;
|
|
lastDate = date;
|
|
}
|
|
html += renderMessageHtml(msg);
|
|
}
|
|
|
|
dom.messagesInner.innerHTML = html;
|
|
attachMessageHandlers();
|
|
scrollToBottom();
|
|
}
|
|
|
|
function renderMessageHtml(msg) {
|
|
const msgId = Utils.escapeHtml(String(msg.id ?? ''));
|
|
|
|
if (msg.system) {
|
|
return `<div class="chat-msg system" data-id="${msgId}">
|
|
<span class="material-icons">info</span>
|
|
<span>${Utils.escapeHtml(msg.text)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
const isMe = msg.from === 'operator' || msg.from === 'panel';
|
|
const name = Utils.escapeHtml(msg.from_name || msg.from);
|
|
const time = formatTime(msg.timestamp);
|
|
const readIcon = msg.read_by && msg.read_by.length > 0 ? 'done_all' : 'done';
|
|
|
|
let content = '';
|
|
if (msg.file) {
|
|
content = `<div class="chat-file-msg">
|
|
<span class="material-icons">description</span>
|
|
<a href="${Utils.escapeHtml(msg.file.url)}" target="_blank" rel="noopener">${Utils.escapeHtml(msg.file.name)}</a>
|
|
<span class="chat-file-size-label">${formatFileSize(msg.file.size)}</span>
|
|
</div>`;
|
|
}
|
|
content += msg.text ? `<div class="chat-msg-text">${linkify(Utils.escapeHtml(msg.text))}</div>` : '';
|
|
|
|
// Reactions
|
|
let reactionsHtml = '';
|
|
if (msg.reactions && Object.keys(msg.reactions).length > 0) {
|
|
reactionsHtml = '<div class="chat-reactions">';
|
|
for (const [emoji, users] of Object.entries(msg.reactions)) {
|
|
const safeEmoji = Utils.escapeHtml(emoji);
|
|
const safeTitle = Utils.escapeHtml((users || []).join(', '));
|
|
reactionsHtml += `<span class="chat-reaction" data-emoji="${safeEmoji}" title="${safeTitle}">${safeEmoji} ${users.length}</span>`;
|
|
}
|
|
reactionsHtml += '</div>';
|
|
}
|
|
|
|
return `
|
|
<div class="chat-msg ${isMe ? 'mine' : 'theirs'}" data-id="${msgId}">
|
|
${!isMe ? `<div class="chat-msg-sender">${name}</div>` : ''}
|
|
<div class="chat-msg-bubble">
|
|
${content}
|
|
<div class="chat-msg-meta">
|
|
<span class="chat-msg-time">${time}</span>
|
|
${isMe ? `<span class="material-icons chat-msg-read">${readIcon}</span>` : ''}
|
|
</div>
|
|
<button class="chat-msg-react-btn" title="${_('chat.react')}">
|
|
<span class="material-icons">add_reaction</span>
|
|
</button>
|
|
</div>
|
|
${reactionsHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function appendMessageDom(msg) {
|
|
if (!dom.messagesInner) return;
|
|
// Remove empty state
|
|
const empty = dom.messagesInner.querySelector('.chat-messages-empty');
|
|
if (empty) empty.remove();
|
|
|
|
const html = renderMessageHtml(msg);
|
|
dom.messagesInner.insertAdjacentHTML('beforeend', html);
|
|
|
|
// Attach handlers to new message
|
|
const lastMsg = dom.messagesInner.lastElementChild;
|
|
if (lastMsg) attachSingleMessageHandlers(lastMsg);
|
|
}
|
|
|
|
function attachMessageHandlers() {
|
|
dom.messagesInner.querySelectorAll('.chat-msg').forEach(el => attachSingleMessageHandlers(el));
|
|
}
|
|
|
|
function attachSingleMessageHandlers(el) {
|
|
const reactBtn = el.querySelector('.chat-msg-react-btn');
|
|
if (reactBtn) {
|
|
reactBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
showReactionPicker(el, e.currentTarget);
|
|
});
|
|
}
|
|
el.querySelectorAll('.chat-reaction').forEach(badge => {
|
|
badge.addEventListener('click', () => {
|
|
const emoji = badge.dataset.emoji;
|
|
const msgId = el.dataset.id;
|
|
toggleReaction(msgId, emoji);
|
|
});
|
|
});
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
if (dom.messagesWrap) {
|
|
requestAnimationFrame(() => {
|
|
dom.messagesWrap.scrollTop = dom.messagesWrap.scrollHeight;
|
|
});
|
|
}
|
|
}
|
|
|
|
function markMessagesRead(ids) {
|
|
ids.forEach(id => {
|
|
const el = document.querySelector(`.chat-msg[data-id="${id}"] .chat-msg-read`);
|
|
if (el) el.textContent = 'done_all';
|
|
});
|
|
}
|
|
|
|
// ==================== Sending ====================
|
|
function sendMessage() {
|
|
const text = (dom.input.value || '').trim();
|
|
if (!text && !pendingFile) return;
|
|
|
|
// Check for quick commands
|
|
if (text.startsWith('/')) {
|
|
const handled = handleQuickCommand(text);
|
|
if (handled) {
|
|
dom.input.value = '';
|
|
autoResizeInput();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build message
|
|
const msg = {
|
|
type: 'message',
|
|
text: text,
|
|
conversation_id: activeConvId,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
// If file is pending, upload first
|
|
if (pendingFile) {
|
|
uploadAndSendFile(pendingFile, text);
|
|
pendingFile = null;
|
|
dom.filePreview.style.display = 'none';
|
|
dom.input.value = '';
|
|
autoResizeInput();
|
|
return;
|
|
}
|
|
|
|
send(msg);
|
|
dom.input.value = '';
|
|
autoResizeInput();
|
|
}
|
|
|
|
async function uploadAndSendFile(file, caption) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const resp = await fetch('/api/chat/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: { 'X-CSRF-Token': (typeof BetterDesk !== 'undefined' && BetterDesk.csrfToken) || '' },
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.success) throw new Error(data.error);
|
|
|
|
// Send file_share frame
|
|
send({
|
|
type: 'file_share',
|
|
conversation_id: activeConvId,
|
|
file_id: data.file_id,
|
|
file_name_encrypted: file.name,
|
|
file_size: file.size,
|
|
file_url: data.url,
|
|
});
|
|
|
|
// Send caption as message
|
|
if (caption) {
|
|
send({
|
|
type: 'message',
|
|
text: caption,
|
|
conversation_id: activeConvId,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
if (typeof Notifications !== 'undefined') Notifications.success(file.name, _('chat.file_share'));
|
|
} catch (e) {
|
|
if (typeof Notifications !== 'undefined') Notifications.error(e.message);
|
|
}
|
|
}
|
|
|
|
// ==================== Typing ====================
|
|
function sendTyping() {
|
|
const now = Date.now();
|
|
if (now - lastTypingSent < 2000) return;
|
|
lastTypingSent = now;
|
|
send({ type: 'typing', conversation_id: activeConvId });
|
|
}
|
|
|
|
function showTyping(name) {
|
|
if (!dom.typing) return;
|
|
dom.typingText.textContent = `${name} ${_('chat.typing_indicator')}`;
|
|
dom.typing.style.display = 'flex';
|
|
|
|
// Clear after 3s
|
|
const key = activeConvId || 'default';
|
|
if (typingTimers.has(key)) clearTimeout(typingTimers.get(key));
|
|
typingTimers.set(key, setTimeout(() => {
|
|
dom.typing.style.display = 'none';
|
|
}, 3000));
|
|
}
|
|
|
|
// ==================== Quick Commands ====================
|
|
function handleQuickCommand(text) {
|
|
const parts = text.split(/\s+/);
|
|
const cmd = parts[0].toLowerCase();
|
|
const def = QUICK_COMMANDS[cmd];
|
|
if (!def) return false;
|
|
|
|
if (def.confirm) {
|
|
if (!confirm(`Execute command: ${def.label} on ${activeConvId}?`)) return true;
|
|
}
|
|
|
|
send({
|
|
type: 'quick_command',
|
|
conversation_id: activeConvId,
|
|
device_id: activeConvId,
|
|
action: def.action,
|
|
args: parts.slice(1).join(' '),
|
|
});
|
|
|
|
// Show system message locally
|
|
const sysMsg = {
|
|
id: Date.now(), from: 'system', system: true,
|
|
text: `Command sent: ${cmd} ${parts.slice(1).join(' ')}`,
|
|
timestamp: Date.now(), conversation_id: activeConvId,
|
|
};
|
|
const conv = conversations.get(activeConvId);
|
|
if (conv) {
|
|
conv.messages.push(sysMsg);
|
|
appendMessageDom(sysMsg);
|
|
scrollToBottom();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ==================== Reactions ====================
|
|
function showReactionPicker(msgEl, anchorEl) {
|
|
if (!dom.emojiPicker) return;
|
|
dom.emojiPicker.innerHTML = EMOJI_SET.map(e =>
|
|
`<button class="emoji-btn" data-emoji="${e}">${e}</button>`
|
|
).join('');
|
|
dom.emojiPicker.style.display = 'flex';
|
|
|
|
// Position near the message
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
dom.emojiPicker.style.top = (rect.top - 50) + 'px';
|
|
dom.emojiPicker.style.left = rect.left + 'px';
|
|
|
|
dom.emojiPicker.querySelectorAll('.emoji-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
toggleReaction(msgEl.dataset.id, btn.dataset.emoji);
|
|
dom.emojiPicker.style.display = 'none';
|
|
});
|
|
});
|
|
|
|
// Close on outside click
|
|
setTimeout(() => {
|
|
document.addEventListener('click', function closePicker() {
|
|
dom.emojiPicker.style.display = 'none';
|
|
document.removeEventListener('click', closePicker);
|
|
}, { once: true });
|
|
}, 10);
|
|
}
|
|
|
|
function toggleReaction(messageId, emoji) {
|
|
send({
|
|
type: 'reaction',
|
|
conversation_id: activeConvId,
|
|
message_id: messageId,
|
|
emoji: emoji,
|
|
});
|
|
}
|
|
|
|
function addReactionBadge(msgEl, emoji, from) {
|
|
let reactionsDiv = msgEl.querySelector('.chat-reactions');
|
|
if (!reactionsDiv) {
|
|
reactionsDiv = document.createElement('div');
|
|
reactionsDiv.className = 'chat-reactions';
|
|
msgEl.appendChild(reactionsDiv);
|
|
}
|
|
|
|
let badge = reactionsDiv.querySelector(`[data-emoji="${emoji}"]`);
|
|
if (badge) {
|
|
const count = parseInt(badge.textContent.replace(emoji, '').trim()) || 1;
|
|
badge.textContent = `${emoji} ${count + 1}`;
|
|
} else {
|
|
badge = document.createElement('span');
|
|
badge.className = 'chat-reaction';
|
|
badge.dataset.emoji = emoji;
|
|
badge.textContent = `${emoji} 1`;
|
|
badge.addEventListener('click', () => toggleReaction(msgEl.dataset.id, emoji));
|
|
reactionsDiv.appendChild(badge);
|
|
}
|
|
}
|
|
|
|
// ==================== File Sharing ====================
|
|
function handleFileShare(frame) {
|
|
const convId = frame.conversation_id || activeConvId || 'default';
|
|
if (!conversations.has(convId)) conversations.set(convId, { messages: [], unread: 0 });
|
|
|
|
const msg = {
|
|
id: frame.file_id || Date.now(),
|
|
from: frame.from,
|
|
from_name: frame.from_name || frame.from,
|
|
text: '',
|
|
timestamp: frame.timestamp || Date.now(),
|
|
conversation_id: convId,
|
|
file: {
|
|
id: frame.file_id,
|
|
name: frame.file_name_encrypted || 'file',
|
|
size: frame.file_size || 0,
|
|
url: frame.file_url || `/api/chat/files/${frame.file_id}`,
|
|
},
|
|
};
|
|
|
|
conversations.get(convId).messages.push(msg);
|
|
if (convId === activeConvId) {
|
|
appendMessageDom(msg);
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
// ==================== Search ====================
|
|
let searchDebounce = null;
|
|
|
|
async function searchMessages(query) {
|
|
if (!query || query.length < 2) {
|
|
dom.searchResults.style.display = 'none';
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(`/api/chat/search?q=${encodeURIComponent(query)}&conversation_id=${encodeURIComponent(activeConvId || '')}`);
|
|
const data = await resp.json();
|
|
const msgs = data.messages || [];
|
|
|
|
dom.searchResults.style.display = 'block';
|
|
if (msgs.length === 0) {
|
|
dom.searchResults.innerHTML = `<div class="chat-search-no-results">${_('chat.no_results')}</div>`;
|
|
return;
|
|
}
|
|
|
|
dom.searchResults.innerHTML = msgs.map(m => `
|
|
<div class="chat-search-result" data-conv="${Utils.escapeHtml(m.conversation_id)}" data-id="${Utils.escapeHtml(String(m.id ?? ''))}">
|
|
<span class="chat-search-from">${Utils.escapeHtml(m.from_name || m.from_id)}</span>
|
|
<span class="chat-search-text">${Utils.escapeHtml((m.text || '').slice(0, 80))}</span>
|
|
<span class="chat-search-time">${formatTime(m.created_at || m.timestamp)}</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
dom.searchResults.querySelectorAll('.chat-search-result').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
openConversation(el.dataset.conv, 'contact');
|
|
dom.searchResults.style.display = 'none';
|
|
dom.searchBar.style.display = 'none';
|
|
});
|
|
});
|
|
} catch (e) {
|
|
console.warn('[Chat] Search failed:', e);
|
|
}
|
|
}
|
|
|
|
// ==================== Push Notifications ====================
|
|
function showNotification(msg) {
|
|
if (!('Notification' in window)) return;
|
|
if (Notification.permission === 'default') {
|
|
Notification.requestPermission();
|
|
return;
|
|
}
|
|
if (Notification.permission !== 'granted') return;
|
|
if (document.hasFocus()) return; // Don't notify if page is focused
|
|
|
|
const name = msg.from_name || msg.from || 'Unknown';
|
|
const body = msg.text ? msg.text.slice(0, 100) : _('chat.file_share');
|
|
|
|
const notification = new Notification(`${name} — ${_('chat.new_message')}`, {
|
|
body,
|
|
icon: '/branding/favicon.svg',
|
|
tag: `chat-${msg.conversation_id}`,
|
|
requireInteraction: false,
|
|
});
|
|
|
|
notification.onclick = () => {
|
|
window.focus();
|
|
openConversation(msg.conversation_id, 'contact');
|
|
notification.close();
|
|
};
|
|
|
|
setTimeout(() => notification.close(), 8000);
|
|
}
|
|
|
|
// ==================== Presence ====================
|
|
function sendPresence(status) {
|
|
myPresence = status;
|
|
send({ type: 'presence_update', online: status !== 'offline', status: status });
|
|
if (dom.presenceDot) {
|
|
dom.presenceDot.className = `chat-presence-dot presence-${status}`;
|
|
}
|
|
}
|
|
|
|
// ==================== Group Creation ====================
|
|
function showCreateGroupModal() {
|
|
dom.groupModal.style.display = 'flex';
|
|
dom.groupNameInput.value = '';
|
|
|
|
// Populate member checkboxes
|
|
dom.memberSelect.innerHTML = contacts.map(c => `
|
|
<label class="chat-member-option">
|
|
<input type="checkbox" value="${Utils.escapeHtml(c.id)}">
|
|
<span>${Utils.escapeHtml(c.name || c.id)}</span>
|
|
</label>
|
|
`).join('');
|
|
|
|
dom.groupNameInput.focus();
|
|
}
|
|
|
|
function createGroup() {
|
|
const name = (dom.groupNameInput.value || '').trim();
|
|
if (!name) return;
|
|
|
|
const memberIds = [];
|
|
dom.memberSelect.querySelectorAll('input:checked').forEach(cb => memberIds.push(cb.value));
|
|
if (memberIds.length === 0) return;
|
|
|
|
send({ type: 'create_group', name, member_ids: memberIds });
|
|
dom.groupModal.style.display = 'none';
|
|
}
|
|
|
|
// ==================== Helpers ====================
|
|
function formatTime(ts) {
|
|
if (!ts) return '';
|
|
const d = new Date(typeof ts === 'number' ? ts : Date.parse(ts));
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function formatDate(ts) {
|
|
if (!ts) return '';
|
|
const d = new Date(typeof ts === 'number' ? ts : Date.parse(ts));
|
|
const today = new Date();
|
|
if (d.toDateString() === today.toDateString()) return _('chat.today') || 'Today';
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
if (d.toDateString() === yesterday.toDateString()) return _('chat.yesterday') || 'Yesterday';
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function linkify(text) {
|
|
return text.replace(/(https?:\/\/[^\s<>"']+)/gi, (rawUrl) => {
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return Utils.escapeHtml(rawUrl);
|
|
}
|
|
const safeHref = Utils.escapeHtml(parsed.href);
|
|
const safeLabel = Utils.escapeHtml(rawUrl);
|
|
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
|
|
} catch (_) {
|
|
return Utils.escapeHtml(rawUrl);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateUnreadBadge(convId, count) {
|
|
const el = document.querySelector(`.chat-list-item[data-id="${convId}"] .chat-unread-badge`);
|
|
if (el) {
|
|
if (count > 0) {
|
|
el.textContent = count > 99 ? '99+' : count;
|
|
el.style.display = '';
|
|
} else {
|
|
el.style.display = 'none';
|
|
}
|
|
}
|
|
renderContacts();
|
|
renderGroups();
|
|
}
|
|
|
|
function updateConnectionStatus(connected) {
|
|
// Could add a status indicator to the header
|
|
}
|
|
|
|
function autoResizeInput() {
|
|
if (!dom.input) return;
|
|
dom.input.style.height = 'auto';
|
|
dom.input.style.height = Math.min(dom.input.scrollHeight, 120) + 'px';
|
|
}
|
|
|
|
// ==================== Event Binding ====================
|
|
function bindEvents() {
|
|
// Send message
|
|
dom.sendBtn.addEventListener('click', sendMessage);
|
|
dom.input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
} else {
|
|
sendTyping();
|
|
}
|
|
});
|
|
dom.input.addEventListener('input', autoResizeInput);
|
|
|
|
// Tab switching
|
|
$$('.chat-tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
$$('.chat-tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const target = tab.dataset.tab;
|
|
$$('[data-tab-content]').forEach(panel => {
|
|
panel.style.display = panel.dataset.tabContent === target ? '' : 'none';
|
|
});
|
|
});
|
|
});
|
|
|
|
// Back button (mobile)
|
|
$('#chat-back-btn').addEventListener('click', () => {
|
|
dom.conversation.style.display = 'none';
|
|
dom.emptyState.style.display = 'flex';
|
|
activeConvId = null;
|
|
});
|
|
|
|
// Search toggle
|
|
$('#chat-search-toggle').addEventListener('click', () => {
|
|
const visible = dom.searchBar.style.display !== 'none';
|
|
dom.searchBar.style.display = visible ? 'none' : 'flex';
|
|
dom.searchResults.style.display = 'none';
|
|
if (!visible) dom.searchInput.focus();
|
|
});
|
|
$('#chat-search-close').addEventListener('click', () => {
|
|
dom.searchBar.style.display = 'none';
|
|
dom.searchResults.style.display = 'none';
|
|
dom.searchInput.value = '';
|
|
});
|
|
dom.searchInput.addEventListener('input', () => {
|
|
clearTimeout(searchDebounce);
|
|
searchDebounce = setTimeout(() => searchMessages(dom.searchInput.value), 300);
|
|
});
|
|
|
|
// File attach
|
|
$('#chat-attach-btn').addEventListener('click', () => dom.fileInput.click());
|
|
dom.fileInput.addEventListener('change', () => {
|
|
const file = dom.fileInput.files[0];
|
|
if (!file) return;
|
|
if (file.size > 50 * 1024 * 1024) {
|
|
if (typeof Notifications !== 'undefined') Notifications.error(_('chat.file_too_large'));
|
|
return;
|
|
}
|
|
pendingFile = file;
|
|
dom.fileName.textContent = file.name;
|
|
dom.fileSize.textContent = formatFileSize(file.size);
|
|
dom.filePreview.style.display = 'flex';
|
|
});
|
|
$('#chat-file-remove').addEventListener('click', () => {
|
|
pendingFile = null;
|
|
dom.filePreview.style.display = 'none';
|
|
dom.fileInput.value = '';
|
|
});
|
|
|
|
// Emoji picker
|
|
$('#chat-emoji-btn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (dom.emojiPicker.style.display === 'flex') {
|
|
dom.emojiPicker.style.display = 'none';
|
|
return;
|
|
}
|
|
dom.emojiPicker.innerHTML = EMOJI_SET.map(emoji =>
|
|
`<button class="emoji-btn" data-emoji="${emoji}">${emoji}</button>`
|
|
).join('');
|
|
dom.emojiPicker.style.display = 'flex';
|
|
|
|
// Position above emoji button
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
dom.emojiPicker.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
|
dom.emojiPicker.style.left = rect.left + 'px';
|
|
|
|
dom.emojiPicker.querySelectorAll('.emoji-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
dom.input.value += btn.dataset.emoji;
|
|
dom.emojiPicker.style.display = 'none';
|
|
dom.input.focus();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Presence selector
|
|
dom.presenceSelect.addEventListener('change', () => {
|
|
sendPresence(dom.presenceSelect.value);
|
|
});
|
|
|
|
// New group
|
|
$('#chat-new-group-btn').addEventListener('click', showCreateGroupModal);
|
|
$('#chat-group-modal-close').addEventListener('click', () => dom.groupModal.style.display = 'none');
|
|
$('#chat-group-cancel').addEventListener('click', () => dom.groupModal.style.display = 'none');
|
|
$('#chat-group-create').addEventListener('click', createGroup);
|
|
|
|
// Request notification permission
|
|
if ('Notification' in window && Notification.permission === 'default') {
|
|
Notification.requestPermission();
|
|
}
|
|
}
|
|
|
|
// ==================== Init ====================
|
|
function init() {
|
|
initDom();
|
|
bindEvents();
|
|
connect();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|