/** * Conversations UI — NordaBiznes messaging * Vanilla JS, no build step. Loaded after globals are set by Jinja2 template. * * Globals expected: * window.__CONVERSATIONS__ — Array of conversation objects * window.__CURRENT_USER__ — {id, name, email} * window.__USERS__ — Array of all portal users * window.__CSRF_TOKEN__ — CSRF token for POST/PATCH/DELETE */ (function () { 'use strict'; // ============================================================ // 1. STATE // ============================================================ var state = { currentConversationId: null, conversations: window.__CONVERSATIONS__ || [], messages: {}, // keyed by conversation_id → [] hasMore: {}, // keyed by conversation_id → bool replyToMessage: null, editingMessageId: null, typingUsers: {}, // keyed by conversation_id → {user_id: {name, timeout}} attachedFiles: [], conversationDetails: {},// keyed by conversation_id → detail object quill: null, newMessageQuill: null, selectedRecipients: [], sse: null, heartbeatInterval: null, presenceInterval: null, typingTimeout: null, reconnectDelay: 1000, pinnedMessageIds: [], // IDs of pinned messages in current conversation searchHighlight: null, // Current search query to highlight in chat isMobile: window.innerWidth <= 768, }; // ============================================================ // 2. API HELPER // ============================================================ async function api(url, method, body) { method = method || 'GET'; var opts = { method: method, headers: { 'X-CSRFToken': window.__CSRF_TOKEN__ }, }; if (body instanceof FormData) { opts.body = body; } else if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } var res = await fetch(url, opts); if (!res.ok) { var errBody; try { errBody = await res.json(); } catch (_) { errBody = {}; } var err = new Error(errBody.error || res.status); err.status = res.status; throw err; } return res.json(); } // ============================================================ // 14. TIME FORMATTING // ============================================================ function isToday(d) { var now = new Date(); return d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear(); } function isYesterday(d) { var y = new Date(); y.setDate(y.getDate() - 1); return d.getDate() === y.getDate() && d.getMonth() === y.getMonth() && d.getFullYear() === y.getFullYear(); } function formatTime(dateStr) { if (!dateStr) return ''; var d = new Date(dateStr); var now = new Date(); var diff = (now - d) / 1000; if (diff < 60) return 'teraz'; if (diff < 3600) return Math.floor(diff / 60) + ' min'; if (isToday(d)) return d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' }); if (isYesterday(d)) return 'wczoraj'; return d.toLocaleDateString('pl', { day: 'numeric', month: 'short' }); } function formatMessageTime(dateStr) { if (!dateStr) return ''; var d = new Date(dateStr); return d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' }); } function formatDateSeparator(dateStr) { var d = new Date(dateStr); if (isToday(d)) return 'Dzisiaj'; if (isYesterday(d)) return 'Wczoraj'; return d.toLocaleDateString('pl', { day: 'numeric', month: 'long', year: 'numeric' }); } function formatPresence(lastSeen) { if (!lastSeen) return ''; var d = new Date(lastSeen); return 'ostatnio: ' + d.toLocaleDateString('pl', { day: '2-digit', month: '2-digit' }) + ' o ' + d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' }); } // ============================================================ // UTIL: strip HTML // ============================================================ function stripHtml(html) { var tmp = document.createElement('div'); tmp.innerHTML = html || ''; return tmp.textContent || tmp.innerText || ''; } // ============================================================ // UTIL: avatar color from name hash // ============================================================ var AVATAR_COLORS = ['green', 'blue', 'purple', 'orange', 'teal']; function avatarColor(name) { var hash = 0; var str = name || ''; for (var i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; } function initials(name) { if (!name) return '?'; var parts = name.trim().split(/\s+/); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); return parts[0].substring(0, 2).toUpperCase(); } // ============================================================ // UTIL: createElement shorthand // ============================================================ function el(tag, className, text) { var e = document.createElement(tag); if (className) e.className = className; if (text !== undefined) e.textContent = text; return e; } // ============================================================ // 3. CONVERSATION LIST // ============================================================ var ConversationList = { renderList: function () { var container = document.getElementById('conversationList'); if (!container) return; container.innerHTML = ''; if (!state.conversations.length) { var empty = el('div', 'conversation-list-empty'); empty.style.padding = '24px 16px'; empty.style.textAlign = 'center'; empty.style.color = 'var(--conv-text-muted)'; empty.style.fontSize = '14px'; empty.textContent = 'Brak rozmów. Rozpocznij nową!'; container.appendChild(empty); return; } state.conversations.forEach(function (conv) { container.appendChild(ConversationList.renderItem(conv)); }); }, renderItem: function (conv) { var item = el('div', 'conversation-item'); item.dataset.id = conv.id; if (conv.id === state.currentConversationId) item.classList.add('active'); if (conv.unread_count > 0) item.classList.add('unread'); // Avatar var avatar = el('div', 'conv-avatar'); if (conv.is_group) { avatar.classList.add('group-icon'); avatar.innerHTML = ''; } else if (conv.avatar_url) { avatar.classList.add('has-photo'); var img = document.createElement('img'); img.src = conv.avatar_url; img.alt = conv.display_name; avatar.appendChild(img); } else { avatar.classList.add(avatarColor(conv.display_name)); avatar.textContent = initials(conv.display_name); } // Online dot (for 1:1 conversations) if (!conv.is_group && conv.is_online) { var dot = el('div', 'online-dot'); avatar.appendChild(dot); } // Content var content = el('div', 'conv-content'); var topRow = el('div', 'conv-top-row'); var name = el('span', 'conv-name', conv.display_name || conv.name || 'Bez nazwy'); var time = el('span', 'conv-time'); if (conv.last_message && conv.last_message.created_at) { time.textContent = formatTime(conv.last_message.created_at); } else if (conv.updated_at) { time.textContent = formatTime(conv.updated_at); } topRow.appendChild(name); if (conv.is_muted) { var muted = el('span', 'muted-icon', '🔇'); topRow.appendChild(muted); } topRow.appendChild(time); var bottomRow = el('div', 'conv-bottom-row'); var preview = el('span', 'conv-preview'); if (conv.last_message) { var previewText = ''; if (conv.is_group && conv.last_message.sender_name) { previewText = conv.last_message.sender_name + ': '; } previewText += conv.last_message.content_preview || ''; preview.textContent = previewText; } bottomRow.appendChild(preview); if (conv.unread_count > 0) { var badge = el('span', 'unread-badge', String(conv.unread_count)); bottomRow.appendChild(badge); } content.appendChild(topRow); content.appendChild(bottomRow); item.appendChild(avatar); item.appendChild(content); item.addEventListener('click', function () { ConversationList.selectConversation(conv.id); }); return item; }, selectConversation: function (id) { state.currentConversationId = id; // Update active class document.querySelectorAll('.conversation-item').forEach(function (el) { el.classList.toggle('active', parseInt(el.dataset.id) === id); }); // Mobile: show chat panel var container = document.getElementById('conversationsApp'); if (container) container.classList.add('show-chat'); // Show chat UI elements var chatEmpty = document.getElementById('chatEmpty'); var chatHeader = document.getElementById('chatHeader'); var chatMessages = document.getElementById('chatMessages'); var chatInputArea = document.getElementById('chatInputArea'); if (chatEmpty) chatEmpty.style.display = 'none'; if (chatHeader) chatHeader.style.display = ''; if (chatMessages) chatMessages.style.display = ''; if (chatInputArea) chatInputArea.style.display = ''; // Set header info from list data var conv = state.conversations.find(function (c) { return c.id === id; }); if (conv) { var headerName = document.getElementById('headerName'); if (headerName) headerName.textContent = conv.display_name || conv.name || 'Bez nazwy'; ChatView.updateHeaderAvatar(conv); } // Load conversation details + messages ChatView.loadConversationDetails(id); ChatView.loadMessages(id); // Mark read api('/api/conversations/' + id + '/read', 'POST').catch(function () {}); // Update unread count in list if (conv) { conv.unread_count = 0; var item = document.querySelector('.conversation-item[data-id="' + id + '"]'); if (item) { item.classList.remove('unread'); var badge = item.querySelector('.unread-badge'); if (badge) badge.remove(); } } // Load presence Presence.fetchForConversation(id); // Clear reply state state.replyToMessage = null; state.editingMessageId = null; var replyPreview = document.getElementById('replyPreview'); if (replyPreview) replyPreview.style.display = 'none'; }, updateConversation: function (id, data) { var conv = state.conversations.find(function (c) { return c.id === id; }); if (!conv) return; Object.assign(conv, data); // Move to top state.conversations.sort(function (a, b) { var aTime = a.last_message ? a.last_message.created_at : a.updated_at; var bTime = b.last_message ? b.last_message.created_at : b.updated_at; return new Date(bTime || 0) - new Date(aTime || 0); }); ConversationList.renderList(); }, searchFilter: function (query) { query = (query || '').toLowerCase().trim(); document.querySelectorAll('.conversation-item').forEach(function (item) { var nameEl = item.querySelector('.conv-name'); var previewEl = item.querySelector('.conv-preview'); var name = (nameEl ? nameEl.textContent : '').toLowerCase(); var preview = (previewEl ? previewEl.textContent : '').toLowerCase(); var match = !query || name.indexOf(query) !== -1 || preview.indexOf(query) !== -1; item.style.display = match ? '' : 'none'; }); }, }; // ============================================================ // 4. CHAT VIEW // ============================================================ var ChatView = { updateHeaderAvatar: function (conv) { var headerAvatar = document.getElementById('headerAvatar'); if (!headerAvatar) return; headerAvatar.innerHTML = ''; headerAvatar.className = 'conv-avatar'; if (conv.is_group) { headerAvatar.classList.add('group-icon'); headerAvatar.innerHTML = ''; } else if (conv.avatar_url) { headerAvatar.classList.add('has-photo'); var img = document.createElement('img'); img.src = conv.avatar_url; img.alt = conv.display_name || ''; headerAvatar.appendChild(img); } else { var displayName = conv.display_name || conv.name || ''; headerAvatar.classList.add(avatarColor(displayName)); headerAvatar.textContent = initials(displayName); } }, loadConversationDetails: async function (conversationId) { try { var details = await api('/api/conversations/' + conversationId); state.conversationDetails[conversationId] = details; // Update header subtitle var subtitle = document.getElementById('headerSubtitle'); if (subtitle) { if (details.is_group) { subtitle.textContent = details.members.length + ' uczestników'; } else { var other = details.members.find(function (m) { return m.user_id !== window.__CURRENT_USER__.id; }); if (other && other.is_online) { subtitle.innerHTML = ' online'; subtitle.classList.add('is-online'); } else if (other && (other.last_active_at || other.last_read_at)) { subtitle.textContent = formatPresence(other.last_active_at || other.last_read_at); } else { subtitle.textContent = ''; } } } // Pinned bar — load actual pin IDs Pins.updateBar(details.pins_count || 0); try { var pinsData = await api('/api/conversations/' + conversationId + '/pins'); var pinsList = Array.isArray(pinsData) ? pinsData : (pinsData.pins || []); state.pinnedMessageIds = pinsList.map(function (p) { return p.message_id; }); Pins.updateBar(pinsList.length); } catch (_) { state.pinnedMessageIds = []; } // Re-render messages with full details (read status, pins) if (state.messages[conversationId]) { ChatView.renderMessages(state.messages[conversationId]); } } catch (e) { // silently ignore detail load errors } }, loadMessages: async function (conversationId, beforeId) { var url = '/api/conversations/' + conversationId + '/messages'; if (beforeId) url += '?before_id=' + beforeId; try { var data = await api(url); // Messages come newest-first from API, reverse for display var msgs = (data.messages || []).reverse(); if (beforeId) { // Prepend older messages state.messages[conversationId] = msgs.concat(state.messages[conversationId] || []); } else { state.messages[conversationId] = msgs; } state.hasMore[conversationId] = data.has_more; ChatView.renderMessages(state.messages[conversationId]); if (!beforeId) { ChatView.scrollToBottom(false); } } catch (e) { var container = document.getElementById('chatMessages'); if (container) { container.innerHTML = '
Nie udało się załadować wiadomości
'; } } }, renderMessages: function (messages) { var container = document.getElementById('chatMessages'); if (!container) return; container.innerHTML = ''; if (!messages || !messages.length) { container.innerHTML = '
Brak wiadomości. Napisz pierwszą!
'; return; } var lastDate = null; messages.forEach(function (msg) { // Date separator if (msg.created_at) { var msgDate = new Date(msg.created_at).toDateString(); if (msgDate !== lastDate) { var sep = el('div', 'date-separator'); sep.textContent = formatDateSeparator(msg.created_at); container.appendChild(sep); lastDate = msgDate; } } container.appendChild(ChatView.renderMessage(msg)); }); // Typing indicator placement var typingEl = document.getElementById('typingIndicator'); if (typingEl) { // Typing indicator is outside chat-messages in template } }, renderMessage: function (msg) { var isMine = msg.sender && msg.sender.id === window.__CURRENT_USER__.id; var row = el('div', 'message-row ' + (isMine ? 'mine' : 'theirs')); row.dataset.messageId = msg.id; var bubble = el('div', 'message-bubble'); // Deleted message if (msg.is_deleted) { var deleted = el('span', 'message-deleted', 'Wiadomość usunięta'); bubble.appendChild(deleted); row.appendChild(bubble); return row; } // Sender name (groups, other people's messages) if (!isMine && msg.sender) { var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; }); if (conv && conv.is_group) { var senderName = el('div', 'message-subject', msg.sender.name); bubble.appendChild(senderName); } } // Reply quote if (msg.reply_to) { var quote = el('div', 'message-reply-quote'); if (msg.reply_to.sender_name) { var replyAuthor = el('span', 'reply-author', msg.reply_to.sender_name); quote.appendChild(replyAuthor); } var replyText = document.createTextNode(msg.reply_to.content_preview || ''); quote.appendChild(replyText); quote.addEventListener('click', function () { ChatView.scrollToMessage(msg.reply_to.id); }); bubble.appendChild(quote); } // Content var content = el('div', 'message-content'); content.innerHTML = msg.content || ''; // Highlight search term if active if (state.searchHighlight) { ChatView.highlightText(content, state.searchHighlight); } bubble.appendChild(content); // Link preview if (msg.link_preview) { bubble.appendChild(ChatView.renderLinkPreview(msg.link_preview)); } // Attachments if (msg.attachments && msg.attachments.length) { msg.attachments.forEach(function (att) { var attEl = el('div', 'message-attachment'); attEl.style.marginTop = '6px'; var link = el('a', '', att.filename); link.href = '/api/messages/attachment/' + att.stored_filename; link.target = '_blank'; link.style.color = 'inherit'; link.style.textDecoration = 'underline'; // File size if (att.file_size) { var sizeStr = att.file_size < 1024 ? att.file_size + ' B' : att.file_size < 1048576 ? Math.round(att.file_size / 1024) + ' KB' : (att.file_size / 1048576).toFixed(1) + ' MB'; link.textContent = att.filename + ' (' + sizeStr + ')'; } // Image preview if (att.mime_type && att.mime_type.startsWith('image/')) { var img = document.createElement('img'); img.src = '/api/messages/attachment/' + att.stored_filename; img.alt = att.filename; img.style.maxWidth = '240px'; img.style.maxHeight = '180px'; img.style.borderRadius = '8px'; img.style.display = 'block'; img.style.marginTop = '4px'; img.style.cursor = 'pointer'; img.addEventListener('click', function () { window.open(img.src, '_blank'); }); attEl.appendChild(img); } attEl.appendChild(link); bubble.appendChild(attEl); }); } // Time + edited + read check var timeRow = el('div', 'message-time'); timeRow.textContent = formatMessageTime(msg.created_at); if (msg.edited_at) { var editedLabel = el('span', 'message-edited', '(edytowano)'); timeRow.appendChild(editedLabel); } if (isMine) { timeRow.appendChild(ChatView.renderReadCheck(msg)); } bubble.appendChild(timeRow); // Reactions if (msg.reactions && msg.reactions.length) { bubble.appendChild(Reactions.renderPills(msg)); } // Pinned indicator if (state.pinnedMessageIds.indexOf(msg.id) >= 0) { var pinIndicator = el('div', 'pin-indicator'); pinIndicator.innerHTML = '📌'; pinIndicator.title = 'Przypięta — kliknij aby odpiąć'; pinIndicator.addEventListener('click', function (e) { e.stopPropagation(); api('/api/messages/' + msg.id + '/pin', 'DELETE') .then(function () { state.pinnedMessageIds = state.pinnedMessageIds.filter(function (id) { return id !== msg.id; }); Pins.updateBar(state.pinnedMessageIds.length); pinIndicator.remove(); }) .catch(function () {}); }); bubble.appendChild(pinIndicator); } row.appendChild(bubble); // Context menu triggers MessageActions.attachTriggers(row, msg); return row; }, renderReadCheck: function (msg) { var check = el('span', 'read-status'); // Check if other members have read this message var details = state.conversationDetails[state.currentConversationId]; var isRead = false; var readAt = null; if (details && details.members) { var msgTime = new Date(msg.created_at); var otherMembers = details.members.filter(function (m) { return m.user_id !== window.__CURRENT_USER__.id; }); isRead = otherMembers.length > 0 && otherMembers.every(function (m) { return m.last_read_at && new Date(m.last_read_at) >= msgTime; }); if (isRead) { // Find earliest read time among others var readTimes = otherMembers .filter(function (m) { return m.last_read_at; }) .map(function (m) { return new Date(m.last_read_at); }); if (readTimes.length) { readAt = new Date(Math.min.apply(null, readTimes)); } } } if (details && details.members) { var otherMembers = details.members.filter(function (m) { return m.user_id !== window.__CURRENT_USER__.id; }); var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; }); var isGroup = conv && conv.is_group; if (isGroup) { // Group: show per-member read status // Sort: read (earliest first), then unread (alphabetically) otherMembers.sort(function (a, b) { var aRead = a.last_read_at && new Date(a.last_read_at) >= msgTime; var bRead = b.last_read_at && new Date(b.last_read_at) >= msgTime; if (aRead && !bRead) return -1; if (!aRead && bRead) return 1; if (aRead && bRead) return new Date(a.last_read_at) - new Date(b.last_read_at); return (a.name || '').localeCompare(b.name || '', 'pl'); }); var lines = []; otherMembers.forEach(function (m) { var name = m.name || 'Użytkownik'; if (m.last_read_at && new Date(m.last_read_at) >= msgTime) { var d = new Date(m.last_read_at); var dateStr = d.toLocaleDateString('pl', {day:'2-digit', month:'2-digit'}) + ' o ' + d.toLocaleTimeString('pl', {hour:'2-digit', minute:'2-digit'}); lines.push('✓ ' + name + ' — ' + dateStr + ''); } else { lines.push('• ' + name + ' — nieprzeczytane'); } }); check.innerHTML = lines.join(''); check.classList.add('group-read-status'); } else { // 1:1: simple read/unread if (isRead && readAt) { check.classList.add('read'); check.innerHTML = '✓ Przeczytane ' + readAt.toLocaleDateString('pl', {day:'2-digit', month:'2-digit'}) + ' o ' + readAt.toLocaleTimeString('pl', {hour:'2-digit', minute:'2-digit'}); } else { check.classList.add('unread'); check.innerHTML = '• Nieprzeczytane'; } } } else if (isRead && readAt) { check.classList.add('read'); check.innerHTML = '✓ Przeczytane ' + readAt.toLocaleDateString('pl', {day:'2-digit', month:'2-digit'}) + ' o ' + readAt.toLocaleTimeString('pl', {hour:'2-digit', minute:'2-digit'}); } else { check.classList.add('unread'); check.innerHTML = '• Nieprzeczytane'; } return check; }, renderLinkPreview: function (lp) { var card = document.createElement('a'); card.className = 'link-preview-card'; card.href = lp.url || '#'; card.target = '_blank'; card.rel = 'noopener noreferrer'; if (lp.image) { var img = document.createElement('img'); img.className = 'lp-image'; img.src = lp.image; img.alt = ''; card.appendChild(img); } var lpContent = el('div', 'lp-content'); var title = el('p', 'lp-title', lp.title || lp.url); lpContent.appendChild(title); if (lp.description) { var desc = el('p', 'lp-description', lp.description); lpContent.appendChild(desc); } card.appendChild(lpContent); return card; }, appendMessage: function (msg) { var convId = msg.conversation_id; if (!state.messages[convId]) state.messages[convId] = []; state.messages[convId].push(msg); if (convId !== state.currentConversationId) return; var container = document.getElementById('chatMessages'); if (!container) return; // Remove "no messages" placeholder if present var placeholder = container.querySelector('div[style*="text-align:center"]'); if (placeholder && container.children.length === 1) { container.innerHTML = ''; } // Date separator if needed var lastRow = container.querySelector('.message-row:last-child'); var lastDate = null; if (lastRow) { var lastMsg = state.messages[convId][state.messages[convId].length - 2]; if (lastMsg && lastMsg.created_at) { lastDate = new Date(lastMsg.created_at).toDateString(); } } if (msg.created_at) { var msgDate = new Date(msg.created_at).toDateString(); if (msgDate !== lastDate) { var sep = el('div', 'date-separator'); sep.textContent = formatDateSeparator(msg.created_at); container.appendChild(sep); } } var rowEl = ChatView.renderMessage(msg); rowEl.classList.add('new-message'); container.appendChild(rowEl); ChatView.scrollToBottom(true); }, scrollToBottom: function (smooth) { var container = document.getElementById('chatMessages'); if (!container) return; if (smooth) { container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); } else { container.scrollTop = container.scrollHeight; } }, scrollToMessage: function (messageId) { var row = document.querySelector('.message-row[data-message-id="' + messageId + '"]'); if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.style.background = 'rgba(46,72,114,0.1)'; setTimeout(function () { row.style.background = ''; }, 2000); } }, highlightText: function (element, query) { // Walk text nodes and wrap matches in var escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); var regex = new RegExp('(' + escapedQuery + ')', 'gi'); var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); var textNodes = []; while (walker.nextNode()) textNodes.push(walker.currentNode); textNodes.forEach(function (node) { if (regex.test(node.nodeValue)) { var span = document.createElement('span'); span.innerHTML = node.nodeValue.replace(regex, '$1'); node.parentNode.replaceChild(span, node); } regex.lastIndex = 0; }); }, clearHighlights: function () { state.searchHighlight = null; document.querySelectorAll('mark.search-highlight').forEach(function (m) { var parent = m.parentNode; parent.replaceChild(document.createTextNode(m.textContent), m); parent.normalize(); }); // Remove clear button var clearBtn = document.getElementById('clearSearchHighlight'); if (clearBtn) clearBtn.remove(); }, }; // ============================================================ // 5. MESSAGE ACTIONS // ============================================================ var _contextMenuMessageId = null; var _contextMenuMessage = null; var _longPressTimer = null; var MessageActions = { attachTriggers: function (row, msg) { // Desktop: show on hover row.addEventListener('mouseenter', function () { if (state.isMobile) return; MessageActions.showContextMenu(row, msg); }); row.addEventListener('mouseleave', function (e) { if (state.isMobile) return; var menu = document.getElementById('contextMenu'); if (menu && menu.contains(e.relatedTarget)) return; MessageActions.hideContextMenu(); }); // Mobile: long press row.addEventListener('touchstart', function (e) { _longPressTimer = setTimeout(function () { MessageActions.showContextMenu(row, msg); }, 500); }, { passive: true }); row.addEventListener('touchend', function () { clearTimeout(_longPressTimer); }); row.addEventListener('touchmove', function () { clearTimeout(_longPressTimer); }); }, showContextMenu: function (row, msg) { var menu = document.getElementById('contextMenu'); if (!menu) return; _contextMenuMessageId = msg.id; _contextMenuMessage = msg; var isMine = msg.sender && msg.sender.id === window.__CURRENT_USER__.id; var canEdit = isMine && msg.created_at && (new Date() - new Date(msg.created_at)) < 24 * 60 * 60 * 1000; // Show/hide owner-only buttons menu.querySelectorAll('.owner-only').forEach(function (btn) { btn.style.display = isMine ? '' : 'none'; }); // Edit: also check 24h var editBtn = menu.querySelector('[data-action="edit"]'); if (editBtn) editBtn.style.display = canEdit ? '' : 'none'; // Don't show menu for deleted messages if (msg.is_deleted) return; menu.style.display = 'flex'; if (!state.isMobile) { // Position near the message row var rowRect = row.getBoundingClientRect(); var bubble = row.querySelector('.message-bubble'); var bubbleRect = bubble ? bubble.getBoundingClientRect() : rowRect; if (isMine) { menu.style.position = 'absolute'; menu.style.top = (rowRect.top + window.scrollY - 4) + 'px'; menu.style.left = (bubbleRect.left - menu.offsetWidth - 4) + 'px'; menu.style.right = 'auto'; menu.style.bottom = 'auto'; } else { menu.style.position = 'absolute'; menu.style.top = (rowRect.top + window.scrollY - 4) + 'px'; menu.style.left = (bubbleRect.right + 4) + 'px'; menu.style.right = 'auto'; menu.style.bottom = 'auto'; } } // Mobile: CSS handles bottom sheet positioning }, hideContextMenu: function () { var menu = document.getElementById('contextMenu'); if (menu) menu.style.display = 'none'; _contextMenuMessageId = null; _contextMenuMessage = null; var picker = document.getElementById('emojiPicker'); if (picker) picker.style.display = 'none'; }, handleAction: function (action) { var msg = _contextMenuMessage; if (!msg) return; switch (action) { case 'reply': MessageActions.startReply(msg); break; case 'react': MessageActions.showEmojiPicker(); return; // Don't hide context menu yet case 'forward': MessageActions.showForwardModal(msg); break; case 'pin': MessageActions.togglePin(msg); break; case 'edit': MessageActions.startEdit(msg); break; case 'delete': MessageActions.confirmDelete(msg); break; } MessageActions.hideContextMenu(); }, startReply: function (msg) { state.replyToMessage = msg; var replyPreview = document.getElementById('replyPreview'); var replyName = document.getElementById('replyPreviewName'); var replyText = document.getElementById('replyPreviewText'); if (replyPreview) replyPreview.style.display = ''; if (replyName) replyName.textContent = msg.sender ? msg.sender.name : ''; if (replyText) replyText.textContent = stripHtml(msg.content).substring(0, 100); // Focus editor if (state.quill) state.quill.focus(); }, showEmojiPicker: function () { var picker = document.getElementById('emojiPicker'); if (!picker) return; var menu = document.getElementById('contextMenu'); if (menu && !state.isMobile) { picker.style.position = 'absolute'; picker.style.left = menu.style.left; picker.style.top = (parseInt(menu.style.top) - 44) + 'px'; picker.style.bottom = 'auto'; picker.style.transform = 'none'; } picker.style.display = 'flex'; }, showForwardModal: function (msg) { // Build a simple conversation picker var modal = document.getElementById('newMessageModal'); if (!modal) return; var modalHeader = modal.querySelector('.modal-header h3'); if (modalHeader) modalHeader.textContent = 'Przekaż do...'; // Show conversations as clickable list var body = modal.querySelector('.modal-body'); if (!body) return; body.innerHTML = ''; var list = el('div', 'forward-list'); list.style.maxHeight = '400px'; list.style.overflowY = 'auto'; state.conversations.forEach(function (conv) { if (conv.id === state.currentConversationId) return; var item = el('div', 'conversation-item'); item.style.cursor = 'pointer'; var avatar = el('div', 'conv-avatar ' + avatarColor(conv.display_name)); avatar.textContent = initials(conv.display_name); var name = el('span', 'conv-name', conv.display_name || conv.name || 'Bez nazwy'); item.appendChild(avatar); item.appendChild(name); item.addEventListener('click', function () { api('/api/messages/' + msg.id + '/forward', 'POST', { conversation_id: conv.id, }).then(function () { modal.style.display = 'none'; // Restore modal header if (modalHeader) modalHeader.textContent = 'Nowa wiadomość'; }).catch(function (err) { alert('Nie udało się przekazać wiadomości: ' + err.message); }); }); list.appendChild(item); }); body.appendChild(list); modal.style.display = 'flex'; // Footer — only cancel var footer = modal.querySelector('.modal-footer'); if (footer) { footer.innerHTML = ''; var cancelBtn = el('button', 'btn-secondary', 'Anuluj'); cancelBtn.addEventListener('click', function () { modal.style.display = 'none'; NewMessageModal.resetModal(); }); footer.appendChild(cancelBtn); } }, togglePin: async function (msg) { try { // Try to pin; if already pinned, the API will handle it await api('/api/messages/' + msg.id + '/pin', 'POST'); } catch (e) { // If conflict (already pinned), try unpin try { await api('/api/messages/' + msg.id + '/pin', 'DELETE'); } catch (_) {} } // Refresh pins ChatView.loadConversationDetails(state.currentConversationId); }, startEdit: function (msg) { state.editingMessageId = msg.id; var row = document.querySelector('.message-row[data-message-id="' + msg.id + '"]'); if (!row) return; var bubble = row.querySelector('.message-bubble'); if (!bubble) return; // Store original content var original = msg.content; // Replace bubble content with an editor bubble.innerHTML = ''; var editArea = document.createElement('textarea'); editArea.style.width = '100%'; editArea.style.minHeight = '40px'; editArea.style.border = '1px solid var(--conv-border)'; editArea.style.borderRadius = '6px'; editArea.style.padding = '8px'; editArea.style.fontFamily = 'inherit'; editArea.style.fontSize = '14px'; editArea.style.resize = 'vertical'; editArea.style.background = 'var(--conv-surface)'; editArea.style.color = 'var(--conv-text-primary)'; editArea.value = stripHtml(original); var actions = el('div', ''); actions.style.display = 'flex'; actions.style.gap = '8px'; actions.style.marginTop = '6px'; var saveBtn = el('button', 'btn-primary', 'Zapisz'); saveBtn.style.fontSize = '12px'; saveBtn.style.padding = '4px 12px'; saveBtn.style.border = 'none'; saveBtn.style.borderRadius = '6px'; saveBtn.style.background = 'var(--conv-accent)'; saveBtn.style.color = '#fff'; saveBtn.style.cursor = 'pointer'; var cancelBtn = el('button', 'btn-secondary', 'Anuluj'); cancelBtn.style.fontSize = '12px'; cancelBtn.style.padding = '4px 12px'; cancelBtn.style.border = '1px solid var(--conv-border)'; cancelBtn.style.borderRadius = '6px'; cancelBtn.style.background = 'var(--conv-surface)'; cancelBtn.style.color = 'var(--conv-text-primary)'; cancelBtn.style.cursor = 'pointer'; saveBtn.addEventListener('click', function () { var newContent = editArea.value.trim(); if (!newContent) return; api('/api/messages/' + msg.id, 'PATCH', { content: newContent }) .then(function (updated) { // Update in state var msgs = state.messages[state.currentConversationId] || []; var idx = msgs.findIndex(function (m) { return m.id === msg.id; }); if (idx !== -1) msgs[idx] = updated; state.editingMessageId = null; ChatView.renderMessages(msgs); ChatView.scrollToMessage(msg.id); }) .catch(function (err) { alert('Nie udało się edytować: ' + err.message); }); }); cancelBtn.addEventListener('click', function () { state.editingMessageId = null; ChatView.renderMessages(state.messages[state.currentConversationId] || []); ChatView.scrollToMessage(msg.id); }); actions.appendChild(saveBtn); actions.appendChild(cancelBtn); bubble.appendChild(editArea); bubble.appendChild(actions); editArea.focus(); }, confirmDelete: function (msg) { if (typeof window.nordaConfirm === 'function') { window.nordaConfirm('Czy na pewno chcesz usunąć tę wiadomość?', function () { MessageActions.doDelete(msg); }); } else if (confirm('Czy na pewno chcesz usunąć tę wiadomość?')) { MessageActions.doDelete(msg); } }, doDelete: function (msg) { api('/api/messages/' + msg.id, 'DELETE') .then(function () { // Update in state var msgs = state.messages[state.currentConversationId] || []; var idx = msgs.findIndex(function (m) { return m.id === msg.id; }); if (idx !== -1) { msgs[idx].is_deleted = true; msgs[idx].content = ''; } ChatView.renderMessages(msgs); }) .catch(function (err) { alert('Nie udało się usunąć: ' + err.message); }); }, }; // ============================================================ // 6. REACTIONS // ============================================================ var Reactions = { renderPills: function (msg) { var container = el('div', 'message-reactions'); (msg.reactions || []).forEach(function (r) { var pill = el('span', 'reaction-pill'); var isMine = r.users.some(function (u) { return u.id === window.__CURRENT_USER__.id; }); if (isMine) pill.classList.add('mine'); pill.innerHTML = r.emoji + ' ' + r.count + ''; pill.addEventListener('click', function (e) { e.stopPropagation(); Reactions.toggle(msg.id, r.emoji, isMine); }); container.appendChild(pill); }); return container; }, toggle: async function (messageId, emoji, isCurrentlyMine) { try { if (isCurrentlyMine) { await api('/api/messages/' + messageId + '/reactions/' + encodeURIComponent(emoji), 'DELETE'); } else { await api('/api/messages/' + messageId + '/reactions', 'POST', { emoji: emoji }); } // Refresh messages to update reactions display ChatView.loadMessages(state.currentConversationId); } catch (e) { // silently ignore } }, addFromPicker: async function (messageId, emoji) { try { await api('/api/messages/' + messageId + '/reactions', 'POST', { emoji: emoji }); ChatView.loadMessages(state.currentConversationId); } catch (e) { // silently ignore } }, }; // ============================================================ // 7. COMPOSER // ============================================================ var Composer = { init: function () { var editorEl = document.getElementById('quillEditor'); if (!editorEl || typeof Quill === 'undefined') return; state.quill = new Quill('#quillEditor', { theme: 'snow', placeholder: 'Napisz wiadomość...', modules: { toolbar: [['bold', 'italic'], ['link'], ['clean']], keyboard: { bindings: { enter: { key: 13, handler: function() { Composer.send(); return false; } }, shiftEnter: { key: 13, shiftKey: true, handler: function() { return true; } // Allow newline } } } }, }); // Enter handled by Quill keyboard binding above (no duplicate DOM listener) // Typing indicator state.quill.on('text-change', function () { Composer.sendTyping(); }); // Send button var sendBtn = document.getElementById('sendBtn'); if (sendBtn) { sendBtn.addEventListener('click', function () { Composer.send(); }); } // Attach button var attachBtn = document.getElementById('attachBtn'); var fileInput = document.getElementById('fileInput'); if (attachBtn && fileInput) { attachBtn.addEventListener('click', function () { fileInput.click(); }); fileInput.addEventListener('change', function () { Composer.handleFiles(fileInput.files); fileInput.value = ''; }); } // Reply close var replyClose = document.getElementById('replyPreviewClose'); if (replyClose) { replyClose.addEventListener('click', function () { state.replyToMessage = null; var replyPreview = document.getElementById('replyPreview'); if (replyPreview) replyPreview.style.display = 'none'; }); } }, send: async function () { if (!state.currentConversationId || !state.quill) return; if (state._isSending) return; // Prevent double send var html = state.quill.root.innerHTML; var text = state.quill.getText().trim(); if (!text && !state.attachedFiles.length) return; state._isSending = true; var convId = state.currentConversationId; try { var fd = new FormData(); if (html && text) fd.append('content', html); if (state.replyToMessage) fd.append('reply_to_id', state.replyToMessage.id); state.attachedFiles.forEach(function (file) { fd.append('files', file); }); var result = await api('/api/conversations/' + convId + '/messages', 'POST', fd); // Clear editor state.quill.setText(''); state.attachedFiles = []; state.replyToMessage = null; var replyPreview = document.getElementById('replyPreview'); if (replyPreview) replyPreview.style.display = 'none'; Composer.renderAttachments(); // Append the sent message ChatView.appendMessage(result); // Update conversation in list ConversationList.updateConversation(convId, { last_message: { id: result.id, content_preview: stripHtml(result.content).substring(0, 100), sender_name: window.__CURRENT_USER__.name, created_at: result.created_at, }, updated_at: result.created_at, }); } catch (e) { alert('Nie udało się wysłać wiadomości: ' + e.message); } finally { state._isSending = false; } }, handleFiles: function (files) { if (!files || !files.length) return; for (var i = 0; i < files.length; i++) { state.attachedFiles.push(files[i]); } Composer.renderAttachments(); }, renderAttachments: function () { var container = document.getElementById('attachmentsPreview'); if (!container) return; if (!state.attachedFiles.length) { container.style.display = 'none'; container.innerHTML = ''; return; } container.style.display = 'flex'; container.style.flexWrap = 'wrap'; container.style.gap = '8px'; container.style.padding = '8px 0'; container.innerHTML = ''; state.attachedFiles.forEach(function (file, idx) { var chip = el('div', ''); chip.style.display = 'inline-flex'; chip.style.alignItems = 'center'; chip.style.gap = '6px'; chip.style.padding = '4px 10px'; chip.style.borderRadius = '16px'; chip.style.background = 'var(--conv-surface-secondary)'; chip.style.fontSize = '12px'; chip.style.color = 'var(--conv-text-primary)'; var nameSpan = el('span', '', file.name); var removeBtn = el('button', '', '\u00d7'); removeBtn.style.border = 'none'; removeBtn.style.background = 'transparent'; removeBtn.style.cursor = 'pointer'; removeBtn.style.fontSize = '14px'; removeBtn.style.color = 'var(--conv-text-muted)'; removeBtn.style.padding = '0'; removeBtn.style.lineHeight = '1'; removeBtn.addEventListener('click', function () { state.attachedFiles.splice(idx, 1); Composer.renderAttachments(); }); chip.appendChild(nameSpan); chip.appendChild(removeBtn); container.appendChild(chip); }); }, sendTyping: function () { if (!state.currentConversationId) return; if (state.typingTimeout) clearTimeout(state.typingTimeout); state.typingTimeout = setTimeout(function () { api('/api/conversations/' + state.currentConversationId + '/typing', 'POST') .catch(function () {}); }, 300); // Debounce: only send after 300ms of no typing, then don't send again for 2s clearTimeout(state.typingTimeout); if (!state._lastTypingSent || Date.now() - state._lastTypingSent > 2000) { state._lastTypingSent = Date.now(); api('/api/conversations/' + state.currentConversationId + '/typing', 'POST') .catch(function () {}); } }, }; // ============================================================ // 8. SSE CLIENT // ============================================================ var SSEClient = { connect: function () { // SSE disabled: requires async gunicorn workers (gevent/eventlet). // Using polling fallback until async workers are configured. console.info('SSE disabled — using polling fallback (5s interval)'); SSEClient.startPollingFallback(); return; /* SSE code — enable when gunicorn has async workers: if (state.sse) { state.sse.close(); } state.sse = new EventSource('/api/messages/stream'); */ state.sse.addEventListener('connected', function () { state.reconnectDelay = 1000; }); state.sse.addEventListener('new_message', function (e) { try { var msg = JSON.parse(e.data); SSEClient.handleNewMessage(msg); } catch (_) {} }); state.sse.addEventListener('message_read', function (e) { try { var data = JSON.parse(e.data); SSEClient.handleMessageRead(data); } catch (_) {} }); state.sse.addEventListener('typing', function (e) { try { var data = JSON.parse(e.data); SSEClient.handleTyping(data); } catch (_) {} }); state.sse.addEventListener('reaction', function (e) { try { var data = JSON.parse(e.data); SSEClient.handleReaction(data); } catch (_) {} }); state.sse.addEventListener('message_edited', function (e) { try { var msg = JSON.parse(e.data); SSEClient.handleMessageEdited(msg); } catch (_) {} }); state.sse.addEventListener('message_deleted', function (e) { try { var data = JSON.parse(e.data); SSEClient.handleMessageDeleted(data); } catch (_) {} }); state.sse.addEventListener('message_pinned', function (e) { try { var data = JSON.parse(e.data); if (data.conversation_id === state.currentConversationId) { ChatView.loadConversationDetails(state.currentConversationId); } } catch (_) {} }); state.sse.addEventListener('message_unpinned', function (e) { try { var data = JSON.parse(e.data); if (data.conversation_id === state.currentConversationId) { ChatView.loadConversationDetails(state.currentConversationId); } } catch (_) {} }); state.sse.addEventListener('presence', function (e) { try { var data = JSON.parse(e.data); SSEClient.handlePresence(data); } catch (_) {} }); state.sse.onerror = function () { state.sse.close(); // Exponential backoff reconnect setTimeout(function () { SSEClient.connect(); }, state.reconnectDelay); state.reconnectDelay = Math.min(state.reconnectDelay * 2, 30000); }; }, handleNewMessage: function (msg) { var convId = msg.conversation_id; // Append to current view if active if (convId === state.currentConversationId) { ChatView.appendMessage(msg); // Mark read api('/api/conversations/' + convId + '/read', 'POST').catch(function () {}); } // Update conversation list var conv = state.conversations.find(function (c) { return c.id === convId; }); if (conv) { var senderName = msg.sender ? msg.sender.name : ''; ConversationList.updateConversation(convId, { last_message: { id: msg.id, content_preview: stripHtml(msg.content).substring(0, 100), sender_name: senderName, created_at: msg.created_at, }, updated_at: msg.created_at, unread_count: convId === state.currentConversationId ? 0 : (conv.unread_count || 0) + 1, }); } else { // New conversation — reload list api('/api/conversations').then(function (convs) { state.conversations = convs; ConversationList.renderList(); }).catch(function () {}); } }, handleMessageRead: function (data) { // Update read receipts if viewing the same conversation if (data.conversation_id === state.currentConversationId) { var details = state.conversationDetails[state.currentConversationId]; if (details && details.members) { var member = details.members.find(function (m) { return m.user_id === data.user_id; }); if (member) { member.last_read_at = data.last_read_at; } } // Re-render to update check marks ChatView.renderMessages(state.messages[state.currentConversationId] || []); } }, handleTyping: function (data) { var convId = data.conversation_id; if (convId !== state.currentConversationId) return; var typingEl = document.getElementById('typingIndicator'); var typingName = document.getElementById('typingName'); if (!typingEl || !typingName) return; typingName.textContent = data.user_name || ''; typingEl.style.display = ''; // Hide after 3 seconds if (!state.typingUsers[convId]) state.typingUsers[convId] = {}; var userId = data.user_id; if (state.typingUsers[convId][userId]) { clearTimeout(state.typingUsers[convId][userId].timeout); } state.typingUsers[convId][userId] = { name: data.user_name, timeout: setTimeout(function () { delete state.typingUsers[convId][userId]; if (Object.keys(state.typingUsers[convId]).length === 0) { typingEl.style.display = 'none'; } else { // Show remaining typers var names = Object.values(state.typingUsers[convId]).map(function (t) { return t.name; }); typingName.textContent = names.join(', '); } }, 3000), }; }, handleReaction: function (data) { var convId = data.conversation_id; if (convId !== state.currentConversationId) return; // Reload messages to update reactions ChatView.loadMessages(convId); }, handleMessageEdited: function (msg) { var convId = msg.conversation_id; if (convId !== state.currentConversationId) return; var msgs = state.messages[convId] || []; var idx = msgs.findIndex(function (m) { return m.id === msg.id; }); if (idx !== -1) { msgs[idx] = msg; ChatView.renderMessages(msgs); } }, handleMessageDeleted: function (data) { var convId = data.conversation_id; if (convId !== state.currentConversationId) return; var msgs = state.messages[convId] || []; var idx = msgs.findIndex(function (m) { return m.id === data.id; }); if (idx !== -1) { msgs[idx].is_deleted = true; msgs[idx].content = ''; ChatView.renderMessages(msgs); } }, handlePresence: function (data) { // Update online dot in header if relevant if (!state.currentConversationId) return; var details = state.conversationDetails[state.currentConversationId]; if (!details || !details.members) return; var member = details.members.find(function (m) { return m.user_id === data.user_id; }); if (member) { member.is_online = data.is_online; if (data.last_seen) member.last_read_at = data.last_seen; // Update header subtitle for 1:1 if (!details.is_group) { var subtitle = document.getElementById('headerSubtitle'); if (subtitle) { var other = details.members.find(function (m) { return m.user_id !== window.__CURRENT_USER__.id; }); if (other && other.is_online) { subtitle.innerHTML = ' online'; subtitle.classList.add('is-online'); } else if (other && (other.last_active_at || other.last_read_at)) { subtitle.textContent = formatPresence(other.last_active_at || other.last_read_at); } } } } }, startPollingFallback: function () { // Poll for new messages every 5 seconds when SSE is unavailable if (state.pollingInterval) return; state.pollingInterval = setInterval(function () { if (!state.currentConversationId) return; var convId = state.currentConversationId; var msgs = state.messages[convId]; if (!msgs || !msgs.length) return; // Find the newest message ID we already have var newestId = 0; msgs.forEach(function (m) { if (m.id > newestId) newestId = m.id; }); // Fetch only the latest page — API returns newest first api('/api/conversations/' + convId + '/messages') .then(function (data) { if (!data.messages || !data.messages.length) return; // Find messages newer than what we have var newMsgs = data.messages.filter(function (msg) { return msg.id > newestId; }); if (newMsgs.length > 0) { // Sort oldest first for appending newMsgs.sort(function (a, b) { return a.id - b.id; }); newMsgs.forEach(function (msg) { ChatView.appendMessage(msg); state.messages[convId].push(msg); }); // Update conversation list with the NEWEST message var latestMsg = newMsgs[newMsgs.length - 1]; ConversationList.updateConversation(convId, { last_message: { id: latestMsg.id, content_preview: stripHtml(latestMsg.content || '').substring(0, 100), sender_name: latestMsg.sender ? latestMsg.sender.name : '', created_at: latestMsg.created_at, }, updated_at: latestMsg.created_at, }); } }) .catch(function () {}); }, 5000); }, startHeartbeat: function () { state.heartbeatInterval = setInterval(function () { api('/api/messages/heartbeat', 'POST').catch(function () {}); }, 30000); }, }; // ============================================================ // 9. PRESENCE // ============================================================ var Presence = { fetchForConversation: async function (convId) { var details = state.conversationDetails[convId]; if (!details || !details.members) return; var ids = details.members.map(function (m) { return m.user_id; }).join(','); if (!ids) return; try { var data = await api('/api/users/presence?ids=' + ids); if (data && data.users) { data.users.forEach(function (u) { var member = details.members.find(function (m) { return m.user_id === u.user_id; }); if (member) { member.is_online = u.is_online; if (u.last_seen) member.last_seen = u.last_seen; } }); } // Update header for 1:1 if (!details.is_group) { var subtitle = document.getElementById('headerSubtitle'); if (subtitle) { var other = details.members.find(function (m) { return m.user_id !== window.__CURRENT_USER__.id; }); if (other && other.is_online) { subtitle.innerHTML = ' online'; subtitle.classList.add('is-online'); } else if (other && other.last_seen) { subtitle.textContent = formatPresence(other.last_seen); } } } } catch (e) { // silently ignore } }, startPolling: function () { state.presenceInterval = setInterval(function () { if (state.currentConversationId) { Presence.fetchForConversation(state.currentConversationId); } }, 60000); }, }; // ============================================================ // 10. NEW MESSAGE MODAL // ============================================================ var NewMessageModal = { init: function () { var newBtn = document.getElementById('newMessageBtn'); var modal = document.getElementById('newMessageModal'); var closeBtn = document.getElementById('closeNewMessage'); var cancelBtn = document.getElementById('cancelNewMessage'); var sendBtn = document.getElementById('sendNewMessage'); var searchInput = document.getElementById('recipientSearch'); if (!newBtn || !modal) return; newBtn.addEventListener('click', function () { NewMessageModal.resetModal(); modal.style.display = 'flex'; if (searchInput) searchInput.focus(); // Init new message Quill if not done if (!state.newMessageQuill && document.getElementById('newMessageEditor')) { state.newMessageQuill = new Quill('#newMessageEditor', { theme: 'snow', placeholder: 'Napisz wiadomość...', modules: { toolbar: [['bold', 'italic'], ['link'], ['clean']], }, }); } }); if (closeBtn) closeBtn.addEventListener('click', function () { modal.style.display = 'none'; }); if (cancelBtn) cancelBtn.addEventListener('click', function () { modal.style.display = 'none'; }); if (sendBtn) { sendBtn.addEventListener('click', function () { NewMessageModal.send(); }); } if (searchInput) { var debounceTimer = null; searchInput.addEventListener('input', function () { clearTimeout(debounceTimer); debounceTimer = setTimeout(function () { NewMessageModal.filterRecipients(searchInput.value); }, 200); }); } }, resetModal: function () { state.selectedRecipients = []; var selectedContainer = document.getElementById('selectedRecipients'); if (selectedContainer) selectedContainer.innerHTML = ''; var suggestions = document.getElementById('recipientSuggestions'); if (suggestions) suggestions.innerHTML = ''; var searchInput = document.getElementById('recipientSearch'); if (searchInput) searchInput.value = ''; if (state.newMessageQuill) state.newMessageQuill.setText(''); // Restore modal content structure var modal = document.getElementById('newMessageModal'); if (!modal) return; var header = modal.querySelector('.modal-header h3'); if (header) header.textContent = 'Nowa wiadomość'; var body = modal.querySelector('.modal-body'); if (body) { body.innerHTML = ''; var recipientInput = el('div', 'recipient-input'); var label = el('label', '', 'Do:'); var input = document.createElement('input'); input.type = 'text'; input.id = 'recipientSearch'; input.placeholder = 'Wpisz imię lub nazwisko...'; var suggestionsDiv = el('div', 'recipient-suggestions'); suggestionsDiv.id = 'recipientSuggestions'; var selectedDiv = el('div', 'selected-recipients'); selectedDiv.id = 'selectedRecipients'; recipientInput.appendChild(label); recipientInput.appendChild(input); recipientInput.appendChild(suggestionsDiv); recipientInput.appendChild(selectedDiv); body.appendChild(recipientInput); var editorDiv = document.createElement('div'); editorDiv.id = 'newMessageEditor'; body.appendChild(editorDiv); // Re-bind search input var debounceTimer = null; input.addEventListener('input', function () { clearTimeout(debounceTimer); debounceTimer = setTimeout(function () { NewMessageModal.filterRecipients(input.value); }, 200); }); // Re-init Quill state.newMessageQuill = new Quill('#newMessageEditor', { theme: 'snow', placeholder: 'Napisz wiadomość...', modules: { toolbar: [['bold', 'italic'], ['link'], ['clean']], }, }); } var footer = modal.querySelector('.modal-footer'); if (footer) { footer.innerHTML = ''; var cancelBtn = el('button', 'btn-secondary', 'Anuluj'); cancelBtn.id = 'cancelNewMessage'; cancelBtn.addEventListener('click', function () { modal.style.display = 'none'; }); var sendBtn = el('button', 'btn-primary', 'Wyślij'); sendBtn.id = 'sendNewMessage'; sendBtn.addEventListener('click', function () { NewMessageModal.send(); }); footer.appendChild(cancelBtn); footer.appendChild(sendBtn); } }, filterRecipients: function (query) { var suggestions = document.getElementById('recipientSuggestions'); if (!suggestions) return; suggestions.innerHTML = ''; query = (query || '').toLowerCase().trim(); if (!query) return; var users = window.__USERS__ || []; var selectedIds = state.selectedRecipients.map(function (r) { return r.id; }); var matches = users.filter(function (u) { if (u.id === window.__CURRENT_USER__.id) return false; if (selectedIds.indexOf(u.id) !== -1) return false; var name = (u.name || '').toLowerCase(); var email = (u.email || '').toLowerCase(); return name.indexOf(query) !== -1 || email.indexOf(query) !== -1; }).slice(0, 8); matches.forEach(function (u) { var item = el('div', 'suggestion-item'); item.style.padding = '8px 12px'; item.style.cursor = 'pointer'; item.style.display = 'flex'; item.style.alignItems = 'center'; item.style.gap = '8px'; item.style.borderBottom = '1px solid var(--conv-border)'; var avatar = el('div', 'conv-avatar ' + avatarColor(u.name)); avatar.style.width = '30px'; avatar.style.height = '30px'; avatar.style.minWidth = '30px'; avatar.style.fontSize = '11px'; avatar.textContent = initials(u.name); var info = el('div', ''); var nameEl = el('div', '', u.name || u.email); nameEl.style.fontSize = '14px'; nameEl.style.fontWeight = '500'; if (u.company_name) { var companyEl = el('div', '', u.company_name); companyEl.style.fontSize = '12px'; companyEl.style.color = 'var(--conv-text-muted)'; info.appendChild(nameEl); info.appendChild(companyEl); } else { info.appendChild(nameEl); } item.appendChild(avatar); item.appendChild(info); item.addEventListener('click', function () { NewMessageModal.selectRecipient(u); }); item.addEventListener('mouseenter', function () { item.style.background = 'var(--conv-surface-secondary)'; }); item.addEventListener('mouseleave', function () { item.style.background = ''; }); suggestions.appendChild(item); }); }, selectRecipient: function (user) { state.selectedRecipients.push(user); var container = document.getElementById('selectedRecipients'); if (container) { var pill = el('span', ''); pill.style.display = 'inline-flex'; pill.style.alignItems = 'center'; pill.style.gap = '4px'; pill.style.padding = '4px 10px'; pill.style.borderRadius = '16px'; pill.style.background = 'var(--conv-primary-light)'; pill.style.fontSize = '13px'; pill.style.color = 'var(--conv-text-primary)'; pill.style.margin = '2px'; pill.textContent = user.name || user.email; var removeBtn = el('button', '', '\u00d7'); removeBtn.style.border = 'none'; removeBtn.style.background = 'transparent'; removeBtn.style.cursor = 'pointer'; removeBtn.style.fontSize = '14px'; removeBtn.style.color = 'var(--conv-text-muted)'; removeBtn.style.padding = '0'; removeBtn.style.lineHeight = '1'; removeBtn.addEventListener('click', function () { state.selectedRecipients = state.selectedRecipients.filter(function (r) { return r.id !== user.id; }); pill.remove(); }); pill.appendChild(removeBtn); container.appendChild(pill); } // Clear search var searchInput = document.getElementById('recipientSearch'); if (searchInput) searchInput.value = ''; var suggestions = document.getElementById('recipientSuggestions'); if (suggestions) suggestions.innerHTML = ''; }, send: async function () { if (!state.selectedRecipients.length) { alert('Wybierz co najmniej jednego odbiorcę'); return; } if (state._isCreating) return; // Prevent double send state._isCreating = true; var messageContent = ''; if (state.newMessageQuill) { var text = state.newMessageQuill.getText().trim(); if (text) { messageContent = state.newMessageQuill.root.innerHTML; } } var memberIds = state.selectedRecipients.map(function (r) { return r.id; }); var name = null; if (memberIds.length > 1) { // Group conversation — create a name var names = state.selectedRecipients.map(function (r) { return r.name || r.email; }); names.push(window.__CURRENT_USER__.name); name = names.join(', '); } try { var result = await api('/api/conversations', 'POST', { member_ids: memberIds, name: name, message: messageContent, }); // Close modal var modal = document.getElementById('newMessageModal'); if (modal) modal.style.display = 'none'; // Add to conversations list if new var existing = state.conversations.find(function (c) { return c.id === result.id; }); if (!existing) { state.conversations.unshift(result); } else { Object.assign(existing, result); } ConversationList.renderList(); // Select the conversation ConversationList.selectConversation(result.id); } catch (e) { alert('Nie udało się utworzyć konwersacji: ' + e.message); } finally { state._isCreating = false; } }, }; // ============================================================ // 11. SEARCH // ============================================================ var Search = { init: function () { var searchInput = document.getElementById('searchInput'); if (!searchInput) return; var nameTimer = null; var contentTimer = null; searchInput.addEventListener('input', function () { clearTimeout(nameTimer); clearTimeout(contentTimer); var q = searchInput.value.trim(); // Instant: filter conversation names (client-side) nameTimer = setTimeout(function () { ConversationList.searchFilter(q); if (q.length < 3) { Search.clearResults(); } }, 150); // Delayed: search message content (server-side, after 600ms) if (q.length >= 3) { contentTimer = setTimeout(function () { Search.searchContent(q); }, 600); } else { Search.clearResults(); } }); }, searchContent: async function (query) { try { var data = await api('/api/messages/search?q=' + encodeURIComponent(query)); var results = data.results || []; Search.showResults(results, query); } catch (_) {} }, showResults: function (results, query) { Search.clearResults(); var container = document.getElementById('conversationList'); if (!container) return; var section = el('div', 'search-results-section'); section.id = 'searchResultsSection'; var header = el('div', 'search-results-header'); header.textContent = results.length ? 'Znalezione w wiadomościach (' + results.length + '):' : 'Brak wyników w treści wiadomości'; section.appendChild(header); results.forEach(function (r) { var item = el('div', 'search-result-item'); var nameRow = el('div', 'search-result-name'); nameRow.textContent = r.conversation_name + (r.sender_name ? ' — ' + r.sender_name : ''); item.appendChild(nameRow); var previewRow = el('div', 'search-result-preview'); // Highlight query in preview var escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); previewRow.innerHTML = r.preview.replace( new RegExp('(' + escapedQuery + ')', 'gi'), '$1' ); item.appendChild(previewRow); var dateRow = el('div', 'search-result-date'); dateRow.textContent = formatTime(r.created_at); item.appendChild(dateRow); item.addEventListener('click', function () { // Set highlight before loading conversation state.searchHighlight = query; Search.clearResults(); var input = document.getElementById('searchInput'); if (input) input.value = ''; ConversationList.searchFilter(''); ConversationList.selectConversation(r.conversation_id); // Scroll to message and show clear button after load setTimeout(function () { ChatView.scrollToMessage(r.message_id); Search.showClearHighlightBtn(); }, 600); }); section.appendChild(item); }); container.appendChild(section); }, clearResults: function () { var existing = document.getElementById('searchResultsSection'); if (existing) existing.remove(); }, showClearHighlightBtn: function () { if (document.getElementById('clearSearchHighlight')) return; var chatHeader = document.getElementById('chatHeader'); if (!chatHeader) return; var bar = el('div', 'search-highlight-bar'); bar.id = 'clearSearchHighlight'; bar.innerHTML = '🔍 Wyniki dla: ' + state.searchHighlight + ''; var clearBtn = el('button', 'search-highlight-clear', '✕ Wyczyść'); clearBtn.addEventListener('click', function () { ChatView.clearHighlights(); }); bar.appendChild(clearBtn); chatHeader.parentNode.insertBefore(bar, chatHeader.nextSibling); }, }; // ============================================================ // 12. PINS // ============================================================ var Pins = { updateBar: function (count) { var bar = document.getElementById('pinnedBar'); var countEl = document.getElementById('pinnedCount'); if (!bar) return; if (count > 0) { bar.style.display = ''; if (countEl) countEl.textContent = count; } else { bar.style.display = 'none'; } }, init: function () { var toggleBtn = document.getElementById('togglePins'); var pinnedBtn = document.getElementById('pinnedBtn'); if (toggleBtn) { toggleBtn.addEventListener('click', function () { Pins.loadAndShow(); }); } if (pinnedBtn) { pinnedBtn.addEventListener('click', function () { Pins.loadAndShow(); }); } }, loadAndShow: async function () { if (!state.currentConversationId) return; try { var data = await api('/api/conversations/' + state.currentConversationId + '/pins'); // API returns array directly, not {pins: [...]} var pins = Array.isArray(data) ? data : (data.pins || []); if (!pins.length) { Pins.updateBar(0); return; } // Show pins in a modal overlay var overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; overlay.style.background = 'rgba(0,0,0,0.3)'; overlay.style.zIndex = '500'; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; var panel = el('div', ''); panel.style.background = 'var(--conv-surface)'; panel.style.borderRadius = 'var(--conv-radius-lg)'; panel.style.padding = '16px'; panel.style.maxWidth = '400px'; panel.style.width = '90%'; panel.style.maxHeight = '60vh'; panel.style.overflowY = 'auto'; panel.style.boxShadow = 'var(--conv-shadow-lg)'; var title = el('h3', '', 'Przypięte wiadomości'); title.style.margin = '0 0 12px'; title.style.fontSize = '16px'; panel.appendChild(title); pins.forEach(function (pin) { var item = el('div', ''); item.style.padding = '10px 10px 10px 12px'; item.style.borderBottom = '1px solid var(--conv-border)'; item.style.display = 'flex'; item.style.alignItems = 'center'; item.style.gap = '8px'; var textWrap = el('div', ''); textWrap.style.flex = '1'; textWrap.style.cursor = 'pointer'; textWrap.style.fontSize = '13px'; var preview = pin.content_preview || stripHtml(pin.content || '').substring(0, 120); var sender = pin.sender_name ? pin.sender_name + ': ' : ''; textWrap.textContent = sender + (preview || '(pusta wiadomość)'); textWrap.addEventListener('click', function () { overlay.remove(); ChatView.scrollToMessage(pin.message_id || pin.id); }); var unpinBtn = el('button', '', '📌'); unpinBtn.title = 'Odepnij'; unpinBtn.style.border = 'none'; unpinBtn.style.background = 'transparent'; unpinBtn.style.cursor = 'pointer'; unpinBtn.style.fontSize = '16px'; unpinBtn.style.padding = '4px'; unpinBtn.style.borderRadius = '4px'; unpinBtn.style.flexShrink = '0'; unpinBtn.style.opacity = '0.6'; unpinBtn.addEventListener('mouseenter', function () { unpinBtn.style.opacity = '1'; unpinBtn.style.background = 'var(--conv-surface-secondary)'; }); unpinBtn.addEventListener('mouseleave', function () { unpinBtn.style.opacity = '0.6'; unpinBtn.style.background = 'transparent'; }); unpinBtn.addEventListener('click', function (e) { e.stopPropagation(); api('/api/messages/' + pin.message_id + '/pin', 'DELETE') .then(function () { item.remove(); // Update pin count var remaining = panel.querySelectorAll('div[style*="flex"]').length; Pins.updateBar(remaining); if (remaining === 0) { overlay.remove(); } }) .catch(function () {}); }); item.appendChild(textWrap); item.appendChild(unpinBtn); panel.appendChild(item); }); var closeBtn = el('button', '', 'Zamknij'); closeBtn.style.marginTop = '12px'; closeBtn.style.width = '100%'; closeBtn.style.padding = '8px'; closeBtn.style.border = '1px solid var(--conv-border)'; closeBtn.style.borderRadius = '8px'; closeBtn.style.background = 'var(--conv-surface-secondary)'; closeBtn.style.cursor = 'pointer'; closeBtn.style.fontSize = '14px'; closeBtn.addEventListener('click', function () { overlay.remove(); }); panel.appendChild(closeBtn); overlay.appendChild(panel); overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } catch (e) { // silently ignore } }, }; // ============================================================ // SCROLL-UP LOADING // ============================================================ function initScrollLoad() { var chatMessages = document.getElementById('chatMessages'); if (!chatMessages) return; chatMessages.addEventListener('scroll', function () { if (chatMessages.scrollTop < 100 && state.currentConversationId) { var msgs = state.messages[state.currentConversationId] || []; var hasMore = state.hasMore[state.currentConversationId]; if (hasMore && msgs.length > 0) { var oldestId = msgs[0].id; // Prevent multiple loads state.hasMore[state.currentConversationId] = false; var scrollHeightBefore = chatMessages.scrollHeight; ChatView.loadMessages(state.currentConversationId, oldestId).then(function () { // Maintain scroll position var scrollHeightAfter = chatMessages.scrollHeight; chatMessages.scrollTop = scrollHeightAfter - scrollHeightBefore; }); } } }); } // ============================================================ // MOBILE: Back button // ============================================================ function initBackButton() { var backBtn = document.getElementById('backBtn'); if (!backBtn) return; backBtn.addEventListener('click', function () { var container = document.getElementById('conversationsApp'); if (container) container.classList.remove('show-chat'); state.currentConversationId = null; }); } // ============================================================ // CONTEXT MENU & EMOJI PICKER — event delegation // ============================================================ function initContextMenu() { var menu = document.getElementById('contextMenu'); if (menu) { menu.addEventListener('click', function (e) { var btn = e.target.closest('button[data-action]'); if (btn) { MessageActions.handleAction(btn.dataset.action); } }); // Keep menu visible on hover menu.addEventListener('mouseenter', function () {}); menu.addEventListener('mouseleave', function () { MessageActions.hideContextMenu(); }); } var picker = document.getElementById('emojiPicker'); if (picker) { picker.addEventListener('click', function (e) { var btn = e.target.closest('button[data-emoji]'); if (btn && _contextMenuMessageId) { Reactions.addFromPicker(_contextMenuMessageId, btn.dataset.emoji); MessageActions.hideContextMenu(); } }); } // Hide on click outside document.addEventListener('click', function (e) { if (menu && !menu.contains(e.target) && picker && !picker.contains(e.target) && !e.target.closest('.message-row')) { MessageActions.hideContextMenu(); } }); } // ============================================================ // MOBILE DETECTION on resize // ============================================================ function initResizeListener() { window.addEventListener('resize', function () { state.isMobile = window.innerWidth <= 768; }); } // ============================================================ // INITIALIZE ON DOM READY // ============================================================ function init() { ConversationList.renderList(); Composer.init(); NewMessageModal.init(); Search.init(); Pins.init(); initContextMenu(); initScrollLoad(); initBackButton(); initResizeListener(); // SSE + presence SSEClient.connect(); SSEClient.startHeartbeat(); Presence.startPolling(); // If URL has ?conv=, auto-select var urlParams = new URLSearchParams(window.location.search); var convParam = urlParams.get('conv'); if (convParam) { var convId = parseInt(convParam); if (convId && state.conversations.some(function (c) { return c.id === convId; })) { ConversationList.selectConversation(convId); } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();