nordabiz/static/js/conversations.js
Maciej Pienczyn f0bdfe013b
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: correct timezone display in messages — parse server UTC dates properly
Server stores timestamps in UTC without timezone suffix. JavaScript
new Date() treated them as local time, showing times 2h behind.
Added parseUTC() helper that appends 'Z' to naive ISO dates so the
browser correctly converts UTC → user's local timezone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:06 +02:00

3172 lines
132 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: 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 = 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 at Quill's clipboard module level
var originalPaste = state.quill.clipboard.onPaste;
state.quill.clipboard.onPaste = 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();
var file = clipboardData.items[i].getAsFile();
if (file) Composer.uploadAndInsertImage(file);
return; // Don't call original paste
}
}
}
// No image — let Quill handle text paste normally
if (originalPaste) originalPaste.call(this, e);
};
// 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 (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();