nordabiz/static/js/conversations.js
Maciej Pienczyn 975087c52e
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
fix: linkify URLs in private messages
URLs in message content were rendered as plain text. Added linkifyContent()
that converts URLs to clickable links while preserving existing <a> tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:23:19 +02:00

3246 lines
136 KiB
JavaScript

/**
* 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();
}
// Server stores UTC but isoformat() omits timezone — append Z so JS knows it's UTC
function parseUTC(dateStr) {
if (!dateStr) return null;
if (dateStr.indexOf('Z') === -1 && dateStr.indexOf('+') === -1 && dateStr.indexOf('T') !== -1) {
return new Date(dateStr + 'Z');
}
return new Date(dateStr);
}
function formatTime(dateStr) {
if (!dateStr) return '';
var d = parseUTC(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 = parseUTC(dateStr);
return d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' });
}
function formatDateSeparator(dateStr) {
var d = parseUTC(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 = parseUTC(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: linkify URLs in HTML content
// ============================================================
function linkifyContent(html) {
if (!html) return html;
var tmp = document.createElement('div');
tmp.innerHTML = html;
var walker = document.createTreeWalker(tmp, NodeFilter.SHOW_TEXT, null, false);
var textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
var urlRegex = /(https?:\/\/[^\s<>"']+)/g;
textNodes.forEach(function(node) {
if (node.parentNode && node.parentNode.tagName === 'A') return;
if (urlRegex.test(node.nodeValue)) {
var span = document.createElement('span');
span.innerHTML = node.nodeValue.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener" style="color:inherit;text-decoration:underline;">$1</a>');
node.parentNode.replaceChild(span, node);
}
urlRegex.lastIndex = 0;
});
return tmp.innerHTML;
}
// ============================================================
// 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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
} 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);
GroupManager.updateSettingsButton(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 (parseUTC(bTime) || 0) - (parseUTC(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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
} 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) {
// Make avatar and name clickable to profile
var headerAvatar = document.getElementById('headerAvatar');
var headerName = document.getElementById('headerName');
var profileUrl = '/profil/' + other.user_id;
if (headerAvatar) {
headerAvatar.style.cursor = 'pointer';
headerAvatar.onclick = function() { window.location.href = profileUrl; };
}
if (headerName) {
headerName.style.cursor = 'pointer';
headerName.onclick = function() { window.location.href = profileUrl; };
headerName.title = 'Otwórz profil';
}
}
if (other && other.is_online) {
subtitle.innerHTML = '<span class="online-status-dot"></span> 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 = '<div style="padding:24px;text-align:center;color:var(--conv-text-muted)">Nie udało się załadować wiadomości</div>';
}
}
},
renderMessages: function (messages) {
var container = document.getElementById('chatMessages');
if (!container) return;
container.innerHTML = '';
if (!messages || !messages.length) {
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--conv-text-muted)">Brak wiadomości. Napisz pierwszą!</div>';
return;
}
var lastDate = null;
messages.forEach(function (msg) {
// Date separator
if (msg.created_at) {
var msgDate = parseUTC(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 = linkifyContent(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 = parseUTC(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 && parseUTC(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 parseUTC(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 && parseUTC(a.last_read_at) >= msgTime;
var bRead = b.last_read_at && parseUTC(b.last_read_at) >= msgTime;
if (aRead && !bRead) return -1;
if (!aRead && bRead) return 1;
if (aRead && bRead) return parseUTC(a.last_read_at) - parseUTC(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 && parseUTC(m.last_read_at) >= msgTime) {
var d = parseUTC(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('<span class="read-status-line read">✓ ' + name + ' — ' + dateStr + '</span>');
} else {
lines.push('<span class="read-status-line unread">• ' + name + ' — nieprzeczytane</span>');
}
});
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] = [];
// Dedup: skip if message with this ID already exists
// Dedup: by real ID or by matching optimistic message (same sender + content)
var dominated = state.messages[convId].some(function(m) {
if (m.id === msg.id) return true;
// Match optimistic msg: same sender, same stripped content
if (m._optimistic && msg.sender_id && m.sender_id === msg.sender_id) {
var mc = (m.content || '').replace(/<[^>]*>/g, '').trim();
var nc = (msg.content || '').replace(/<[^>]*>/g, '').trim();
if (mc && nc && mc === nc) {
m.id = msg.id; // Update to real ID
m._optimistic = false;
return true;
}
}
return false;
});
if (dominated) return;
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 = parseUTC(lastMsg.created_at).toDateString();
}
}
if (msg.created_at) {
var msgDate = parseUTC(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 <mark>
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, '<mark class="search-highlight">$1</mark>');
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() - parseUTC(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 + ' <span class="reaction-count">' + r.count + '</span>';
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: {
// Override ALL Enter behavior — send message instead of newline
enter: {
key: 'Enter',
handler: function() {
var html = state.quill.root.innerHTML;
var text = state.quill.getText().trim();
var hasImage = html.toLowerCase().indexOf('<img') !== -1;
var hasContent = text || hasImage || state.quill.getLength() > 1;
if (hasContent) {
state.quill.setContents([]); // Clear everything including images
Composer.sendContent(html, text || '📷');
}
return false;
}
},
// Shift+Enter = newline
linebreak: {
key: 'Enter',
shiftKey: true,
handler: function(range) {
this.quill.insertText(range.index, '\n');
this.quill.setSelection(range.index + 1);
return false;
}
}
}
}
},
});
// Override Quill's image handler — upload pasted/dropped images to server
// This replaces Quill's default clipboard image handling completely
state.quill.getModule('toolbar').addHandler('image', function() {
// Manual image button (if ever added to toolbar)
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = function() { if (input.files[0]) Composer.uploadAndInsertImage(input.files[0]); };
input.click();
});
// Intercept paste — only handle images, let Quill handle text natively
state.quill.root.addEventListener('paste', function(e) {
var clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (var i = 0; i < clipboardData.items.length; i++) {
if (clipboardData.items[i].type.indexOf('image') !== -1) {
e.preventDefault();
e.stopPropagation();
var file = clipboardData.items[i].getAsFile();
if (file) Composer.uploadAndInsertImage(file);
return;
}
}
}
// Text paste — do nothing, Quill handles it natively
}, true);
// 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';
});
}
},
// Queue-based sending: Enter adds to queue, queue processes one at a time
_sendQueue: [],
_queueProcessing: false,
// Upload image and insert into Quill editor
uploadAndInsertImage: function(file) {
var fd = new FormData();
fd.append('image', file, file.name || 'pasted-image.png');
api('/api/messages/upload-image', 'POST', fd)
.then(function(result) {
if (result && result.url) {
var range = state.quill.getSelection(true) || { index: state.quill.getLength() };
state.quill.insertEmbed(range.index, 'image', result.url);
state.quill.setSelection(range.index + 1);
}
})
.catch(function() {
var reader = new FileReader();
reader.onload = function(ev) {
var range = state.quill.getSelection(true) || { index: state.quill.getLength() };
state.quill.insertEmbed(range.index, 'image', ev.target.result);
state.quill.setSelection(range.index + 1);
};
reader.readAsDataURL(file);
});
},
sendContent: function (html, text) {
if (!state.currentConversationId) return;
// Add to queue with current state snapshot
var tempId = 'temp-' + Date.now() + '-' + Math.random();
Composer._sendQueue.push({
convId: state.currentConversationId,
html: html,
text: text,
replyTo: state.replyToMessage,
files: state.attachedFiles.slice(),
tempId: tempId
});
state.attachedFiles = [];
state.replyToMessage = null;
var replyPreview = document.getElementById('replyPreview');
if (replyPreview) replyPreview.style.display = 'none';
Composer.renderAttachments();
// Show optimistic message immediately
ChatView.appendMessage({
id: tempId,
conversation_id: state.currentConversationId,
content: html,
sender_id: window.__CURRENT_USER__ ? window.__CURRENT_USER__.id : null,
sender: window.__CURRENT_USER__ || {},
created_at: new Date().toISOString(),
_optimistic: true
});
// Process queue
Composer._processQueue();
},
_processQueue: async function () {
if (Composer._queueProcessing || !Composer._sendQueue.length) return;
Composer._queueProcessing = true;
while (Composer._sendQueue.length > 0) {
var item = Composer._sendQueue.shift();
await Composer._doSend(item.convId, item.html, item.text, item.replyTo, item.files, item.tempId);
}
Composer._queueProcessing = false;
},
// send: called from button click — reads from Quill
send: async function () {
if (!state.currentConversationId || !state.quill) return;
var html = state.quill.root.innerHTML;
var text = state.quill.getText().trim();
var hasImage = html.toLowerCase().indexOf('<img') !== -1;
if (!text && !hasImage && !state.attachedFiles.length) return;
if (!text && hasImage) text = '📷';
var convId = state.currentConversationId;
var savedReplyTo = state.replyToMessage;
var savedFiles = state.attachedFiles.slice();
state.quill.setContents([]);
state.attachedFiles = [];
state.replyToMessage = null;
var replyPreview = document.getElementById('replyPreview');
if (replyPreview) replyPreview.style.display = 'none';
Composer.renderAttachments();
return Composer._doSend(convId, html, text, savedReplyTo, savedFiles);
},
_doSend: async function (convId, html, text, savedReplyTo, savedFiles, tempId) {
// Optimistic message already shown by sendContent() — just do the API call
try {
var fd = new FormData();
if (html && text) fd.append('content', html);
if (savedReplyTo) fd.append('reply_to_id', savedReplyTo.id);
savedFiles.forEach(function (file) {
fd.append('files', file);
});
var result = await api('/api/conversations/' + convId + '/messages', 'POST', fd);
// Replace optimistic message with real data (match by tempId)
var msgs = state.messages[convId];
if (msgs && tempId) {
for (var i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].id === tempId) {
msgs[i].id = result.id;
msgs[i]._optimistic = false;
msgs[i].content = result.content;
// Update DOM element with server-sanitized content
var domRow = document.querySelector('[data-message-id="' + tempId + '"]');
if (domRow) {
domRow.dataset.messageId = result.id;
var contentEl = domRow.querySelector('.message-content');
if (contentEl) contentEl.innerHTML = result.content || '';
}
break;
}
}
}
// 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) {
console.error('Nie udało się wysłać wiadomości:', e.message);
}
},
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 = '<span class="online-status-dot"></span> 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);
});
// 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 = '<span class="online-status-dot"></span> 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: async function (query) {
var suggestions = document.getElementById('recipientSuggestions');
if (!suggestions) return;
suggestions.innerHTML = '';
query = (query || '').trim();
if (query.length < 2) return;
// Show loading indicator
suggestions.innerHTML = '<div style="padding:10px 12px;color:var(--conv-text-muted);font-size:13px">Szukam...</div>';
try {
var resp = await fetch('/api/users/search?q=' + encodeURIComponent(query), {
headers: { 'X-CSRFToken': window.__CSRF_TOKEN__ },
});
if (!resp.ok) throw new Error('search failed');
var users = await resp.json();
suggestions.innerHTML = '';
var selectedIds = state.selectedRecipients.map(function (r) { return r.id; });
var matches = users.filter(function (u) {
return selectedIds.indexOf(u.id) === -1;
});
if (!matches.length) {
suggestions.innerHTML = '<div style="padding:10px 12px;color:var(--conv-text-muted);font-size:13px">Brak wyników</div>';
return;
}
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);
});
} catch (e) {
suggestions.innerHTML = '<div style="padding:10px 12px;color:var(--conv-text-muted);font-size:13px">Błąd wyszukiwania</div>';
}
},
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;
}
},
};
// ============================================================
// 10b. NEW GROUP MODAL
// ============================================================
var NewGroupModal = {
_quill: null,
_selectedMembers: [],
_debounceTimer: null,
init: function () {
var btn = document.getElementById('newGroupBtn');
var modal = document.getElementById('newGroupModal');
if (!btn || !modal) return;
btn.addEventListener('click', function () {
NewGroupModal.open();
});
var closeBtn = document.getElementById('closeNewGroup');
var cancelBtn = document.getElementById('cancelNewGroup');
if (closeBtn) closeBtn.addEventListener('click', function () { modal.style.display = 'none'; });
if (cancelBtn) cancelBtn.addEventListener('click', function () { modal.style.display = 'none'; });
var sendBtn = document.getElementById('sendNewGroup');
if (sendBtn) sendBtn.addEventListener('click', function () { NewGroupModal.send(); });
var searchInput = document.getElementById('groupRecipientSearch');
if (searchInput) {
searchInput.addEventListener('input', function () {
clearTimeout(NewGroupModal._debounceTimer);
NewGroupModal._debounceTimer = setTimeout(function () {
NewGroupModal.filterRecipients(searchInput.value);
}, 200);
});
}
},
open: function () {
NewGroupModal._selectedMembers = [];
var nameInput = document.getElementById('groupNameInput');
if (nameInput) nameInput.value = '';
var searchInput = document.getElementById('groupRecipientSearch');
if (searchInput) searchInput.value = '';
var suggestions = document.getElementById('groupRecipientSuggestions');
if (suggestions) suggestions.innerHTML = '';
var selected = document.getElementById('groupSelectedRecipients');
if (selected) selected.innerHTML = '';
var editorEl = document.getElementById('groupMessageEditor');
if (editorEl) {
editorEl.innerHTML = '';
NewGroupModal._quill = new Quill('#groupMessageEditor', {
theme: 'snow',
placeholder: 'Pierwsza wiadomość (opcjonalna)...',
modules: {
toolbar: [['bold', 'italic'], ['link'], ['clean']],
},
});
}
var modal = document.getElementById('newGroupModal');
if (modal) modal.style.display = 'flex';
if (nameInput) nameInput.focus();
},
filterRecipients: async function (query) {
var suggestions = document.getElementById('groupRecipientSuggestions');
if (!suggestions) return;
suggestions.innerHTML = '';
query = (query || '').trim();
if (query.length < 2) return;
suggestions.innerHTML = '<div style="padding:10px 12px;color:var(--conv-text-muted);font-size:13px">Szukam...</div>';
try {
var resp = await fetch('/api/users/search?q=' + encodeURIComponent(query), {
headers: { 'X-CSRFToken': window.__CSRF_TOKEN__ },
});
if (!resp.ok) throw new Error('search failed');
var users = await resp.json();
suggestions.innerHTML = '';
var selectedIds = NewGroupModal._selectedMembers.map(function (r) { return r.id; });
var matches = users.filter(function (u) {
return selectedIds.indexOf(u.id) === -1;
});
if (!matches.length) {
suggestions.innerHTML = '<div style="padding:10px 12px;color:var(--conv-text-muted);font-size:13px">Brak wyników</div>';
return;
}
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 () {
NewGroupModal.selectMember(u);
});
item.addEventListener('mouseenter', function () {
item.style.background = 'var(--conv-surface-secondary)';
});
item.addEventListener('mouseleave', function () {
item.style.background = '';
});
suggestions.appendChild(item);
});
} catch (e) {
suggestions.innerHTML = '<div style="padding:10px 12px;color:var(--conv-text-muted);font-size:13px">Błąd wyszukiwania</div>';
}
},
selectMember: function (user) {
NewGroupModal._selectedMembers.push(user);
var container = document.getElementById('groupSelectedRecipients');
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 () {
NewGroupModal._selectedMembers = NewGroupModal._selectedMembers.filter(function (r) {
return r.id !== user.id;
});
pill.remove();
});
pill.appendChild(removeBtn);
container.appendChild(pill);
}
var searchInput = document.getElementById('groupRecipientSearch');
if (searchInput) searchInput.value = '';
var suggestions = document.getElementById('groupRecipientSuggestions');
if (suggestions) suggestions.innerHTML = '';
if (searchInput) searchInput.focus();
},
send: async function () {
var nameInput = document.getElementById('groupNameInput');
var groupName = (nameInput ? nameInput.value : '').trim();
if (NewGroupModal._selectedMembers.length < 2) {
alert('Wybierz co najmniej dwóch członków grupy');
return;
}
if (state._isCreating) return;
state._isCreating = true;
var messageContent = '';
if (NewGroupModal._quill) {
var text = NewGroupModal._quill.getText().trim();
if (text) {
messageContent = NewGroupModal._quill.root.innerHTML;
}
}
var memberIds = NewGroupModal._selectedMembers.map(function (r) { return r.id; });
// Auto-generate name if not provided
if (!groupName) {
var names = NewGroupModal._selectedMembers.map(function (r) { return r.name || r.email; });
names.push(window.__CURRENT_USER__.name);
groupName = names.join(', ');
}
try {
var result = await api('/api/conversations', 'POST', {
member_ids: memberIds,
name: groupName,
message: messageContent,
});
var modal = document.getElementById('newGroupModal');
if (modal) modal.style.display = 'none';
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();
ConversationList.selectConversation(result.id);
} catch (e) {
alert('Nie udało się utworzyć grupy: ' + e.message);
} finally {
state._isCreating = false;
}
},
};
// ============================================================
// 10c. GROUP MANAGEMENT PANEL
// ============================================================
var GroupManager = {
_visible: false,
_debounceTimer: null,
init: function () {
var settingsBtn = document.getElementById('groupSettingsBtn');
var closeBtn = document.getElementById('closeGroupPanel');
var saveNameBtn = document.getElementById('saveGroupName');
var addSearch = document.getElementById('groupAddMemberSearch');
if (settingsBtn) {
settingsBtn.addEventListener('click', function () {
GroupManager.toggle();
});
}
if (closeBtn) {
closeBtn.addEventListener('click', function () {
GroupManager.hide();
});
}
if (saveNameBtn) {
saveNameBtn.addEventListener('click', function () {
GroupManager.saveName();
});
}
if (addSearch) {
addSearch.addEventListener('input', function () {
clearTimeout(GroupManager._debounceTimer);
GroupManager._debounceTimer = setTimeout(function () {
GroupManager.searchNewMember(addSearch.value);
}, 200);
});
}
},
show: async function () {
var panel = document.getElementById('groupPanel');
if (!panel) return;
GroupManager._visible = true;
panel.style.display = 'block';
await GroupManager.loadDetails();
},
hide: function () {
var panel = document.getElementById('groupPanel');
if (!panel) return;
GroupManager._visible = false;
panel.style.display = 'none';
},
toggle: function () {
if (GroupManager._visible) {
GroupManager.hide();
} else {
GroupManager.show();
}
},
updateSettingsButton: function (conv) {
var btn = document.getElementById('groupSettingsBtn');
if (!btn) return;
btn.style.display = conv && conv.is_group ? '' : 'none';
// Hide panel when switching conversations
GroupManager.hide();
},
loadDetails: async function () {
var convId = state.currentConversationId;
if (!convId) {
console.warn('GroupManager.loadDetails: no currentConversationId');
return;
}
// Always fetch fresh data for the management panel
var details;
try {
details = await api('/api/conversations/' + convId);
state.conversationDetails[convId] = details;
} catch (e) {
console.error('GroupManager.loadDetails: API error', e);
return;
}
// Name
var nameInput = document.getElementById('groupEditName');
if (nameInput) nameInput.value = details.name || '';
// Check caller's role
var myMembership = details.members.find(function (m) {
return m.user_id === window.__CURRENT_USER__.id;
});
var myRole = myMembership ? myMembership.role : 'member';
var canManage = myRole === 'owner' || myRole === 'admin';
var isOwner = myRole === 'owner';
// Save name — show for owner/admin
var saveBtn = document.getElementById('saveGroupName');
if (saveBtn) saveBtn.style.display = canManage ? '' : 'none';
if (nameInput) nameInput.readOnly = !canManage;
// Add member section — owner/admin
var addSection = document.getElementById('groupAddMemberSection');
if (addSection) addSection.style.display = canManage ? '' : 'none';
// Members list
var countEl = document.getElementById('groupMemberCount');
if (countEl) countEl.textContent = details.members.length;
var listEl = document.getElementById('groupMembersList');
if (!listEl) return;
listEl.innerHTML = '';
var roleLabels = { owner: 'Właściciel', admin: 'Administrator', member: 'Członek' };
details.members.forEach(function (m) {
var item = el('div', 'group-member-item');
var avatar = el('div', 'conv-avatar ' + avatarColor(m.name));
avatar.style.width = '30px';
avatar.style.height = '30px';
avatar.style.minWidth = '30px';
avatar.style.fontSize = '11px';
avatar.textContent = initials(m.name);
var info = el('div', 'group-member-info');
var nameEl = el('div', 'group-member-name', m.name);
var roleEl = el('span', 'group-member-role', ' \u2014 ' + (roleLabels[m.role] || m.role));
nameEl.appendChild(roleEl);
info.appendChild(nameEl);
if (m.company_name) {
var companyEl = el('div', 'group-member-role', m.company_name);
info.appendChild(companyEl);
}
item.appendChild(avatar);
item.appendChild(info);
// Actions
var isMe = m.user_id === window.__CURRENT_USER__.id;
if (!isMe && m.role !== 'owner') {
var actions = el('div', 'group-member-actions');
// Role toggle — only owner can change roles
if (isOwner) {
if (m.role === 'member') {
var promoteBtn = el('button', 'btn-member-action', 'Nadaj admina');
promoteBtn.addEventListener('click', function () {
GroupManager.changeRole(convId, m.user_id, 'admin');
});
actions.appendChild(promoteBtn);
} else if (m.role === 'admin') {
var demoteBtn = el('button', 'btn-member-action', 'Odbierz admina');
demoteBtn.addEventListener('click', function () {
GroupManager.changeRole(convId, m.user_id, 'member');
});
actions.appendChild(demoteBtn);
}
}
// Remove — owner or admin can remove
if (canManage) {
var removeBtn = el('button', 'btn-member-action danger', 'Usuń');
removeBtn.addEventListener('click', function () {
GroupManager.removeMember(convId, m.user_id, m.name);
});
actions.appendChild(removeBtn);
}
item.appendChild(actions);
}
listEl.appendChild(item);
});
},
saveName: async function () {
var convId = state.currentConversationId;
var nameInput = document.getElementById('groupEditName');
if (!convId || !nameInput) return;
var newName = nameInput.value.trim();
if (!newName) return;
try {
await api('/api/conversations/' + convId, 'PATCH', { name: newName });
// Update local state
var conv = state.conversations.find(function (c) { return c.id === convId; });
if (conv) {
conv.name = newName;
conv.display_name = newName;
}
if (state.conversationDetails[convId]) {
state.conversationDetails[convId].name = newName;
}
ConversationList.renderList();
var headerName = document.getElementById('headerName');
if (headerName) headerName.textContent = newName;
} catch (e) {
alert('Nie udało się zmienić nazwy: ' + e.message);
}
},
removeMember: async function (convId, userId, userName) {
if (!confirm('Czy na pewno chcesz usunąć ' + userName + ' z grupy?')) return;
try {
await api('/api/conversations/' + convId + '/members/' + userId, 'DELETE');
// Refresh details
delete state.conversationDetails[convId];
GroupManager.loadDetails();
// Update subtitle
ChatView.loadConversationDetails(convId);
} catch (e) {
alert('Nie udało się usunąć członka: ' + e.message);
}
},
changeRole: async function (convId, userId, newRole) {
try {
await api('/api/conversations/' + convId + '/members/' + userId, 'PATCH', { role: newRole });
// Refresh
delete state.conversationDetails[convId];
GroupManager.loadDetails();
} catch (e) {
alert('Nie udało się zmienić roli: ' + e.message);
}
},
searchNewMember: async function (query) {
var suggestions = document.getElementById('groupAddMemberSuggestions');
if (!suggestions) return;
suggestions.innerHTML = '';
query = (query || '').trim();
if (query.length < 2) return;
var convId = state.currentConversationId;
var details = state.conversationDetails[convId];
var existingIds = details ? details.members.map(function (m) { return m.user_id; }) : [];
suggestions.innerHTML = '<div style="padding:8px 12px;color:var(--conv-text-muted);font-size:13px">Szukam...</div>';
try {
var resp = await fetch('/api/users/search?q=' + encodeURIComponent(query), {
headers: { 'X-CSRFToken': window.__CSRF_TOKEN__ },
});
if (!resp.ok) throw new Error('search failed');
var users = await resp.json();
suggestions.innerHTML = '';
var matches = users.filter(function (u) {
return existingIds.indexOf(u.id) === -1;
});
if (!matches.length) {
suggestions.innerHTML = '<div style="padding:8px 12px;color:var(--conv-text-muted);font-size:13px">Brak wyników</div>';
return;
}
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 = '28px';
avatar.style.height = '28px';
avatar.style.minWidth = '28px';
avatar.style.fontSize = '10px';
avatar.textContent = initials(u.name);
var nameEl = el('div', '', u.name || u.email);
nameEl.style.fontSize = '13px';
item.appendChild(avatar);
item.appendChild(nameEl);
item.addEventListener('click', function () {
GroupManager.addMember(convId, u.id, u.name);
});
item.addEventListener('mouseenter', function () { item.style.background = 'var(--conv-surface-secondary)'; });
item.addEventListener('mouseleave', function () { item.style.background = ''; });
suggestions.appendChild(item);
});
} catch (e) {
suggestions.innerHTML = '<div style="padding:8px 12px;color:var(--conv-text-muted);font-size:13px">Błąd wyszukiwania</div>';
}
},
addMember: async function (convId, userId, userName) {
try {
await api('/api/conversations/' + convId + '/members', 'POST', { user_id: userId });
// Clear search
var search = document.getElementById('groupAddMemberSearch');
if (search) search.value = '';
var suggestions = document.getElementById('groupAddMemberSuggestions');
if (suggestions) suggestions.innerHTML = '';
// Refresh
delete state.conversationDetails[convId];
GroupManager.loadDetails();
ChatView.loadConversationDetails(convId);
} catch (e) {
alert('Nie udało się dodać członka: ' + e.message);
}
},
};
// ============================================================
// 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'),
'<mark>$1</mark>'
);
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 = '<span>🔍 Wyniki dla: <strong>' + state.searchHighlight + '</strong></span>';
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();
NewGroupModal.init();
GroupManager.init();
Search.init();
Pins.init();
initContextMenu();
initScrollLoad();
initBackButton();
initResizeListener();
// SSE + presence
SSEClient.connect();
SSEClient.startHeartbeat();
Presence.startPolling();
// If URL has ?conv=<id>, 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 URL has ?new_to=<user_id>, open or create conversation with that user
var newToParam = urlParams.get('new_to');
if (newToParam) {
var targetUserId = parseInt(newToParam);
if (targetUserId) {
// Build context message if coming from classified/B2B
var ctxType = urlParams.get('ctx');
var ctxTitle = urlParams.get('ctx_title');
var contextMessage = '';
if (ctxType === 'classified' && ctxTitle) {
contextMessage = 'Hej, piszę w sprawie ogłoszenia na tablicy B2B: „' + decodeURIComponent(ctxTitle) + '"';
}
// Check if conversation with this user already exists (1:1)
var existingConv = state.conversations.find(function (c) {
return !c.is_group && c.members && c.members.some(function (m) {
return m.user_id === targetUserId;
});
});
if (existingConv) {
ConversationList.selectConversation(existingConv.id);
// Pre-fill editor with context message
if (contextMessage) {
setTimeout(function () {
var editor = document.querySelector('#chatInput .ql-editor, #chatInput');
if (editor) {
if (editor.classList && editor.classList.contains('ql-editor')) {
editor.innerHTML = '<p>' + contextMessage + '</p>';
} else if (editor.value !== undefined) {
editor.value = contextMessage;
}
}
}, 800);
}
} else {
// Create conversation via API with optional first message
var payload = { member_ids: [targetUserId] };
if (contextMessage) payload.message = contextMessage;
fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.__CSRF_TOKEN__ },
body: JSON.stringify(payload)
}).then(function (r) { return r.json(); }).then(function (data) {
if (data.id) {
window.location.href = '/wiadomosci?conv=' + data.id;
}
}).catch(function () {});
}
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();