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
Dedup: sendContent passes tempId through queue to _doSend, which replaces optimistic msg by matching tempId (not content). Eliminates false negatives from HTML sanitization differences. Profile: clicking avatar or name in chat header opens /profil/<user_id>. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2528 lines
104 KiB
JavaScript
2528 lines
104 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();
|
|
}
|
|
|
|
function formatTime(dateStr) {
|
|
if (!dateStr) return '';
|
|
var d = new Date(dateStr);
|
|
var now = new Date();
|
|
var diff = (now - d) / 1000;
|
|
if (diff < 60) return 'teraz';
|
|
if (diff < 3600) return Math.floor(diff / 60) + ' min';
|
|
if (isToday(d)) return d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' });
|
|
if (isYesterday(d)) return 'wczoraj';
|
|
return d.toLocaleDateString('pl', { day: 'numeric', month: 'short' });
|
|
}
|
|
|
|
function formatMessageTime(dateStr) {
|
|
if (!dateStr) return '';
|
|
var d = new Date(dateStr);
|
|
return d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function formatDateSeparator(dateStr) {
|
|
var d = new Date(dateStr);
|
|
if (isToday(d)) return 'Dzisiaj';
|
|
if (isYesterday(d)) return 'Wczoraj';
|
|
return d.toLocaleDateString('pl', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function formatPresence(lastSeen) {
|
|
if (!lastSeen) return '';
|
|
var d = new Date(lastSeen);
|
|
return 'ostatnio: ' + d.toLocaleDateString('pl', { day: '2-digit', month: '2-digit' }) +
|
|
' o ' + d.toLocaleTimeString('pl', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
// ============================================================
|
|
// UTIL: strip HTML
|
|
// ============================================================
|
|
|
|
function stripHtml(html) {
|
|
var tmp = document.createElement('div');
|
|
tmp.innerHTML = html || '';
|
|
return tmp.textContent || tmp.innerText || '';
|
|
}
|
|
|
|
// ============================================================
|
|
// UTIL: avatar color from name hash
|
|
// ============================================================
|
|
|
|
var AVATAR_COLORS = ['green', 'blue', 'purple', 'orange', 'teal'];
|
|
|
|
function avatarColor(name) {
|
|
var hash = 0;
|
|
var str = name || '';
|
|
for (var i = 0; i < str.length; i++) {
|
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
|
}
|
|
|
|
function initials(name) {
|
|
if (!name) return '?';
|
|
var parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
return parts[0].substring(0, 2).toUpperCase();
|
|
}
|
|
|
|
// ============================================================
|
|
// UTIL: createElement shorthand
|
|
// ============================================================
|
|
|
|
function el(tag, className, text) {
|
|
var e = document.createElement(tag);
|
|
if (className) e.className = className;
|
|
if (text !== undefined) e.textContent = text;
|
|
return e;
|
|
}
|
|
|
|
// ============================================================
|
|
// 3. CONVERSATION LIST
|
|
// ============================================================
|
|
|
|
var ConversationList = {
|
|
renderList: function () {
|
|
var container = document.getElementById('conversationList');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
if (!state.conversations.length) {
|
|
var empty = el('div', 'conversation-list-empty');
|
|
empty.style.padding = '24px 16px';
|
|
empty.style.textAlign = 'center';
|
|
empty.style.color = 'var(--conv-text-muted)';
|
|
empty.style.fontSize = '14px';
|
|
empty.textContent = 'Brak rozmów. Rozpocznij nową!';
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
state.conversations.forEach(function (conv) {
|
|
container.appendChild(ConversationList.renderItem(conv));
|
|
});
|
|
},
|
|
|
|
renderItem: function (conv) {
|
|
var item = el('div', 'conversation-item');
|
|
item.dataset.id = conv.id;
|
|
if (conv.id === state.currentConversationId) item.classList.add('active');
|
|
if (conv.unread_count > 0) item.classList.add('unread');
|
|
|
|
// Avatar
|
|
var avatar = el('div', 'conv-avatar');
|
|
if (conv.is_group) {
|
|
avatar.classList.add('group-icon');
|
|
avatar.innerHTML = '<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);
|
|
}
|
|
|
|
// Load conversation details + messages
|
|
ChatView.loadConversationDetails(id);
|
|
ChatView.loadMessages(id);
|
|
|
|
// Mark read
|
|
api('/api/conversations/' + id + '/read', 'POST').catch(function () {});
|
|
|
|
// Update unread count in list
|
|
if (conv) {
|
|
conv.unread_count = 0;
|
|
var item = document.querySelector('.conversation-item[data-id="' + id + '"]');
|
|
if (item) {
|
|
item.classList.remove('unread');
|
|
var badge = item.querySelector('.unread-badge');
|
|
if (badge) badge.remove();
|
|
}
|
|
}
|
|
|
|
// Load presence
|
|
Presence.fetchForConversation(id);
|
|
|
|
// Clear reply state
|
|
state.replyToMessage = null;
|
|
state.editingMessageId = null;
|
|
var replyPreview = document.getElementById('replyPreview');
|
|
if (replyPreview) replyPreview.style.display = 'none';
|
|
},
|
|
|
|
updateConversation: function (id, data) {
|
|
var conv = state.conversations.find(function (c) { return c.id === id; });
|
|
if (!conv) return;
|
|
Object.assign(conv, data);
|
|
|
|
// Move to top
|
|
state.conversations.sort(function (a, b) {
|
|
var aTime = a.last_message ? a.last_message.created_at : a.updated_at;
|
|
var bTime = b.last_message ? b.last_message.created_at : b.updated_at;
|
|
return new Date(bTime || 0) - new Date(aTime || 0);
|
|
});
|
|
|
|
ConversationList.renderList();
|
|
},
|
|
|
|
searchFilter: function (query) {
|
|
query = (query || '').toLowerCase().trim();
|
|
document.querySelectorAll('.conversation-item').forEach(function (item) {
|
|
var nameEl = item.querySelector('.conv-name');
|
|
var previewEl = item.querySelector('.conv-preview');
|
|
var name = (nameEl ? nameEl.textContent : '').toLowerCase();
|
|
var preview = (previewEl ? previewEl.textContent : '').toLowerCase();
|
|
var match = !query || name.indexOf(query) !== -1 || preview.indexOf(query) !== -1;
|
|
item.style.display = match ? '' : 'none';
|
|
});
|
|
},
|
|
};
|
|
|
|
// ============================================================
|
|
// 4. CHAT VIEW
|
|
// ============================================================
|
|
|
|
var ChatView = {
|
|
updateHeaderAvatar: function (conv) {
|
|
var headerAvatar = document.getElementById('headerAvatar');
|
|
if (!headerAvatar) return;
|
|
headerAvatar.innerHTML = '';
|
|
headerAvatar.className = 'conv-avatar';
|
|
|
|
if (conv.is_group) {
|
|
headerAvatar.classList.add('group-icon');
|
|
headerAvatar.innerHTML = '<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 = new Date(msg.created_at).toDateString();
|
|
if (msgDate !== lastDate) {
|
|
var sep = el('div', 'date-separator');
|
|
sep.textContent = formatDateSeparator(msg.created_at);
|
|
container.appendChild(sep);
|
|
lastDate = msgDate;
|
|
}
|
|
}
|
|
container.appendChild(ChatView.renderMessage(msg));
|
|
});
|
|
|
|
// Typing indicator placement
|
|
var typingEl = document.getElementById('typingIndicator');
|
|
if (typingEl) {
|
|
// Typing indicator is outside chat-messages in template
|
|
}
|
|
},
|
|
|
|
renderMessage: function (msg) {
|
|
var isMine = msg.sender && msg.sender.id === window.__CURRENT_USER__.id;
|
|
var row = el('div', 'message-row ' + (isMine ? 'mine' : 'theirs'));
|
|
row.dataset.messageId = msg.id;
|
|
|
|
var bubble = el('div', 'message-bubble');
|
|
|
|
// Deleted message
|
|
if (msg.is_deleted) {
|
|
var deleted = el('span', 'message-deleted', 'Wiadomość usunięta');
|
|
bubble.appendChild(deleted);
|
|
row.appendChild(bubble);
|
|
return row;
|
|
}
|
|
|
|
// Sender name (groups, other people's messages)
|
|
if (!isMine && msg.sender) {
|
|
var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; });
|
|
if (conv && conv.is_group) {
|
|
var senderName = el('div', 'message-subject', msg.sender.name);
|
|
bubble.appendChild(senderName);
|
|
}
|
|
}
|
|
|
|
// Reply quote
|
|
if (msg.reply_to) {
|
|
var quote = el('div', 'message-reply-quote');
|
|
if (msg.reply_to.sender_name) {
|
|
var replyAuthor = el('span', 'reply-author', msg.reply_to.sender_name);
|
|
quote.appendChild(replyAuthor);
|
|
}
|
|
var replyText = document.createTextNode(msg.reply_to.content_preview || '');
|
|
quote.appendChild(replyText);
|
|
quote.addEventListener('click', function () {
|
|
ChatView.scrollToMessage(msg.reply_to.id);
|
|
});
|
|
bubble.appendChild(quote);
|
|
}
|
|
|
|
// Content
|
|
var content = el('div', 'message-content');
|
|
content.innerHTML = msg.content || '';
|
|
|
|
// Highlight search term if active
|
|
if (state.searchHighlight) {
|
|
ChatView.highlightText(content, state.searchHighlight);
|
|
}
|
|
|
|
bubble.appendChild(content);
|
|
|
|
// Link preview
|
|
if (msg.link_preview) {
|
|
bubble.appendChild(ChatView.renderLinkPreview(msg.link_preview));
|
|
}
|
|
|
|
// Attachments
|
|
if (msg.attachments && msg.attachments.length) {
|
|
msg.attachments.forEach(function (att) {
|
|
var attEl = el('div', 'message-attachment');
|
|
attEl.style.marginTop = '6px';
|
|
var link = el('a', '', att.filename);
|
|
link.href = '/api/messages/attachment/' + att.stored_filename;
|
|
link.target = '_blank';
|
|
link.style.color = 'inherit';
|
|
link.style.textDecoration = 'underline';
|
|
// File size
|
|
if (att.file_size) {
|
|
var sizeStr = att.file_size < 1024 ? att.file_size + ' B'
|
|
: att.file_size < 1048576 ? Math.round(att.file_size / 1024) + ' KB'
|
|
: (att.file_size / 1048576).toFixed(1) + ' MB';
|
|
link.textContent = att.filename + ' (' + sizeStr + ')';
|
|
}
|
|
|
|
// Image preview
|
|
if (att.mime_type && att.mime_type.startsWith('image/')) {
|
|
var img = document.createElement('img');
|
|
img.src = '/api/messages/attachment/' + att.stored_filename;
|
|
img.alt = att.filename;
|
|
img.style.maxWidth = '240px';
|
|
img.style.maxHeight = '180px';
|
|
img.style.borderRadius = '8px';
|
|
img.style.display = 'block';
|
|
img.style.marginTop = '4px';
|
|
img.style.cursor = 'pointer';
|
|
img.addEventListener('click', function () { window.open(img.src, '_blank'); });
|
|
attEl.appendChild(img);
|
|
}
|
|
attEl.appendChild(link);
|
|
bubble.appendChild(attEl);
|
|
});
|
|
}
|
|
|
|
// Time + edited + read check
|
|
var timeRow = el('div', 'message-time');
|
|
timeRow.textContent = formatMessageTime(msg.created_at);
|
|
if (msg.edited_at) {
|
|
var editedLabel = el('span', 'message-edited', '(edytowano)');
|
|
timeRow.appendChild(editedLabel);
|
|
}
|
|
if (isMine) {
|
|
timeRow.appendChild(ChatView.renderReadCheck(msg));
|
|
}
|
|
bubble.appendChild(timeRow);
|
|
|
|
// Reactions
|
|
if (msg.reactions && msg.reactions.length) {
|
|
bubble.appendChild(Reactions.renderPills(msg));
|
|
}
|
|
|
|
// Pinned indicator
|
|
if (state.pinnedMessageIds.indexOf(msg.id) >= 0) {
|
|
var pinIndicator = el('div', 'pin-indicator');
|
|
pinIndicator.innerHTML = '📌';
|
|
pinIndicator.title = 'Przypięta — kliknij aby odpiąć';
|
|
pinIndicator.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
api('/api/messages/' + msg.id + '/pin', 'DELETE')
|
|
.then(function () {
|
|
state.pinnedMessageIds = state.pinnedMessageIds.filter(function (id) { return id !== msg.id; });
|
|
Pins.updateBar(state.pinnedMessageIds.length);
|
|
pinIndicator.remove();
|
|
})
|
|
.catch(function () {});
|
|
});
|
|
bubble.appendChild(pinIndicator);
|
|
}
|
|
|
|
row.appendChild(bubble);
|
|
|
|
// Context menu triggers
|
|
MessageActions.attachTriggers(row, msg);
|
|
|
|
return row;
|
|
},
|
|
|
|
renderReadCheck: function (msg) {
|
|
var check = el('span', 'read-status');
|
|
|
|
// Check if other members have read this message
|
|
var details = state.conversationDetails[state.currentConversationId];
|
|
var isRead = false;
|
|
var readAt = null;
|
|
|
|
if (details && details.members) {
|
|
var msgTime = new Date(msg.created_at);
|
|
var otherMembers = details.members.filter(function (m) {
|
|
return m.user_id !== window.__CURRENT_USER__.id;
|
|
});
|
|
isRead = otherMembers.length > 0 && otherMembers.every(function (m) {
|
|
return m.last_read_at && new Date(m.last_read_at) >= msgTime;
|
|
});
|
|
if (isRead) {
|
|
// Find earliest read time among others
|
|
var readTimes = otherMembers
|
|
.filter(function (m) { return m.last_read_at; })
|
|
.map(function (m) { return new Date(m.last_read_at); });
|
|
if (readTimes.length) {
|
|
readAt = new Date(Math.min.apply(null, readTimes));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (details && details.members) {
|
|
var otherMembers = details.members.filter(function (m) {
|
|
return m.user_id !== window.__CURRENT_USER__.id;
|
|
});
|
|
var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; });
|
|
var isGroup = conv && conv.is_group;
|
|
|
|
if (isGroup) {
|
|
// Group: show per-member read status
|
|
// Sort: read (earliest first), then unread (alphabetically)
|
|
otherMembers.sort(function (a, b) {
|
|
var aRead = a.last_read_at && new Date(a.last_read_at) >= msgTime;
|
|
var bRead = b.last_read_at && new Date(b.last_read_at) >= msgTime;
|
|
if (aRead && !bRead) return -1;
|
|
if (!aRead && bRead) return 1;
|
|
if (aRead && bRead) return new Date(a.last_read_at) - new Date(b.last_read_at);
|
|
return (a.name || '').localeCompare(b.name || '', 'pl');
|
|
});
|
|
var lines = [];
|
|
otherMembers.forEach(function (m) {
|
|
var name = m.name || 'Użytkownik';
|
|
if (m.last_read_at && new Date(m.last_read_at) >= msgTime) {
|
|
var d = new Date(m.last_read_at);
|
|
var dateStr = d.toLocaleDateString('pl', {day:'2-digit', month:'2-digit'}) + ' o ' + d.toLocaleTimeString('pl', {hour:'2-digit', minute:'2-digit'});
|
|
lines.push('<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 = new Date(lastMsg.created_at).toDateString();
|
|
}
|
|
}
|
|
if (msg.created_at) {
|
|
var msgDate = new Date(msg.created_at).toDateString();
|
|
if (msgDate !== lastDate) {
|
|
var sep = el('div', 'date-separator');
|
|
sep.textContent = formatDateSeparator(msg.created_at);
|
|
container.appendChild(sep);
|
|
}
|
|
}
|
|
|
|
var rowEl = ChatView.renderMessage(msg);
|
|
rowEl.classList.add('new-message');
|
|
container.appendChild(rowEl);
|
|
ChatView.scrollToBottom(true);
|
|
},
|
|
|
|
scrollToBottom: function (smooth) {
|
|
var container = document.getElementById('chatMessages');
|
|
if (!container) return;
|
|
if (smooth) {
|
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
|
} else {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
},
|
|
|
|
scrollToMessage: function (messageId) {
|
|
var row = document.querySelector('.message-row[data-message-id="' + messageId + '"]');
|
|
if (row) {
|
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
row.style.background = 'rgba(46,72,114,0.1)';
|
|
setTimeout(function () { row.style.background = ''; }, 2000);
|
|
}
|
|
},
|
|
|
|
highlightText: function (element, query) {
|
|
// Walk text nodes and wrap matches in <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() - new Date(msg.created_at)) < 24 * 60 * 60 * 1000;
|
|
|
|
// Show/hide owner-only buttons
|
|
menu.querySelectorAll('.owner-only').forEach(function (btn) {
|
|
btn.style.display = isMine ? '' : 'none';
|
|
});
|
|
// Edit: also check 24h
|
|
var editBtn = menu.querySelector('[data-action="edit"]');
|
|
if (editBtn) editBtn.style.display = canEdit ? '' : 'none';
|
|
|
|
// Don't show menu for deleted messages
|
|
if (msg.is_deleted) return;
|
|
|
|
menu.style.display = 'flex';
|
|
|
|
if (!state.isMobile) {
|
|
// Position near the message row
|
|
var rowRect = row.getBoundingClientRect();
|
|
var bubble = row.querySelector('.message-bubble');
|
|
var bubbleRect = bubble ? bubble.getBoundingClientRect() : rowRect;
|
|
|
|
if (isMine) {
|
|
menu.style.position = 'absolute';
|
|
menu.style.top = (rowRect.top + window.scrollY - 4) + 'px';
|
|
menu.style.left = (bubbleRect.left - menu.offsetWidth - 4) + 'px';
|
|
menu.style.right = 'auto';
|
|
menu.style.bottom = 'auto';
|
|
} else {
|
|
menu.style.position = 'absolute';
|
|
menu.style.top = (rowRect.top + window.scrollY - 4) + 'px';
|
|
menu.style.left = (bubbleRect.right + 4) + 'px';
|
|
menu.style.right = 'auto';
|
|
menu.style.bottom = 'auto';
|
|
}
|
|
}
|
|
// Mobile: CSS handles bottom sheet positioning
|
|
},
|
|
|
|
hideContextMenu: function () {
|
|
var menu = document.getElementById('contextMenu');
|
|
if (menu) menu.style.display = 'none';
|
|
_contextMenuMessageId = null;
|
|
_contextMenuMessage = null;
|
|
|
|
var picker = document.getElementById('emojiPicker');
|
|
if (picker) picker.style.display = 'none';
|
|
},
|
|
|
|
handleAction: function (action) {
|
|
var msg = _contextMenuMessage;
|
|
if (!msg) return;
|
|
|
|
switch (action) {
|
|
case 'reply':
|
|
MessageActions.startReply(msg);
|
|
break;
|
|
case 'react':
|
|
MessageActions.showEmojiPicker();
|
|
return; // Don't hide context menu yet
|
|
case 'forward':
|
|
MessageActions.showForwardModal(msg);
|
|
break;
|
|
case 'pin':
|
|
MessageActions.togglePin(msg);
|
|
break;
|
|
case 'edit':
|
|
MessageActions.startEdit(msg);
|
|
break;
|
|
case 'delete':
|
|
MessageActions.confirmDelete(msg);
|
|
break;
|
|
}
|
|
MessageActions.hideContextMenu();
|
|
},
|
|
|
|
startReply: function (msg) {
|
|
state.replyToMessage = msg;
|
|
var replyPreview = document.getElementById('replyPreview');
|
|
var replyName = document.getElementById('replyPreviewName');
|
|
var replyText = document.getElementById('replyPreviewText');
|
|
if (replyPreview) replyPreview.style.display = '';
|
|
if (replyName) replyName.textContent = msg.sender ? msg.sender.name : '';
|
|
if (replyText) replyText.textContent = stripHtml(msg.content).substring(0, 100);
|
|
|
|
// Focus editor
|
|
if (state.quill) state.quill.focus();
|
|
},
|
|
|
|
showEmojiPicker: function () {
|
|
var picker = document.getElementById('emojiPicker');
|
|
if (!picker) return;
|
|
|
|
var menu = document.getElementById('contextMenu');
|
|
if (menu && !state.isMobile) {
|
|
picker.style.position = 'absolute';
|
|
picker.style.left = menu.style.left;
|
|
picker.style.top = (parseInt(menu.style.top) - 44) + 'px';
|
|
picker.style.bottom = 'auto';
|
|
picker.style.transform = 'none';
|
|
}
|
|
picker.style.display = 'flex';
|
|
},
|
|
|
|
showForwardModal: function (msg) {
|
|
// Build a simple conversation picker
|
|
var modal = document.getElementById('newMessageModal');
|
|
if (!modal) return;
|
|
|
|
var modalHeader = modal.querySelector('.modal-header h3');
|
|
if (modalHeader) modalHeader.textContent = 'Przekaż do...';
|
|
|
|
// Show conversations as clickable list
|
|
var body = modal.querySelector('.modal-body');
|
|
if (!body) return;
|
|
body.innerHTML = '';
|
|
|
|
var list = el('div', 'forward-list');
|
|
list.style.maxHeight = '400px';
|
|
list.style.overflowY = 'auto';
|
|
|
|
state.conversations.forEach(function (conv) {
|
|
if (conv.id === state.currentConversationId) return;
|
|
var item = el('div', 'conversation-item');
|
|
item.style.cursor = 'pointer';
|
|
|
|
var avatar = el('div', 'conv-avatar ' + avatarColor(conv.display_name));
|
|
avatar.textContent = initials(conv.display_name);
|
|
var name = el('span', 'conv-name', conv.display_name || conv.name || 'Bez nazwy');
|
|
|
|
item.appendChild(avatar);
|
|
item.appendChild(name);
|
|
|
|
item.addEventListener('click', function () {
|
|
api('/api/messages/' + msg.id + '/forward', 'POST', {
|
|
conversation_id: conv.id,
|
|
}).then(function () {
|
|
modal.style.display = 'none';
|
|
// Restore modal header
|
|
if (modalHeader) modalHeader.textContent = 'Nowa wiadomość';
|
|
}).catch(function (err) {
|
|
alert('Nie udało się przekazać wiadomości: ' + err.message);
|
|
});
|
|
});
|
|
|
|
list.appendChild(item);
|
|
});
|
|
body.appendChild(list);
|
|
|
|
modal.style.display = 'flex';
|
|
|
|
// Footer — only cancel
|
|
var footer = modal.querySelector('.modal-footer');
|
|
if (footer) {
|
|
footer.innerHTML = '';
|
|
var cancelBtn = el('button', 'btn-secondary', 'Anuluj');
|
|
cancelBtn.addEventListener('click', function () {
|
|
modal.style.display = 'none';
|
|
NewMessageModal.resetModal();
|
|
});
|
|
footer.appendChild(cancelBtn);
|
|
}
|
|
},
|
|
|
|
togglePin: async function (msg) {
|
|
try {
|
|
// Try to pin; if already pinned, the API will handle it
|
|
await api('/api/messages/' + msg.id + '/pin', 'POST');
|
|
} catch (e) {
|
|
// If conflict (already pinned), try unpin
|
|
try {
|
|
await api('/api/messages/' + msg.id + '/pin', 'DELETE');
|
|
} catch (_) {}
|
|
}
|
|
// Refresh pins
|
|
ChatView.loadConversationDetails(state.currentConversationId);
|
|
},
|
|
|
|
startEdit: function (msg) {
|
|
state.editingMessageId = msg.id;
|
|
var row = document.querySelector('.message-row[data-message-id="' + msg.id + '"]');
|
|
if (!row) return;
|
|
|
|
var bubble = row.querySelector('.message-bubble');
|
|
if (!bubble) return;
|
|
|
|
// Store original content
|
|
var original = msg.content;
|
|
|
|
// Replace bubble content with an editor
|
|
bubble.innerHTML = '';
|
|
var editArea = document.createElement('textarea');
|
|
editArea.style.width = '100%';
|
|
editArea.style.minHeight = '40px';
|
|
editArea.style.border = '1px solid var(--conv-border)';
|
|
editArea.style.borderRadius = '6px';
|
|
editArea.style.padding = '8px';
|
|
editArea.style.fontFamily = 'inherit';
|
|
editArea.style.fontSize = '14px';
|
|
editArea.style.resize = 'vertical';
|
|
editArea.style.background = 'var(--conv-surface)';
|
|
editArea.style.color = 'var(--conv-text-primary)';
|
|
editArea.value = stripHtml(original);
|
|
|
|
var actions = el('div', '');
|
|
actions.style.display = 'flex';
|
|
actions.style.gap = '8px';
|
|
actions.style.marginTop = '6px';
|
|
|
|
var saveBtn = el('button', 'btn-primary', 'Zapisz');
|
|
saveBtn.style.fontSize = '12px';
|
|
saveBtn.style.padding = '4px 12px';
|
|
saveBtn.style.border = 'none';
|
|
saveBtn.style.borderRadius = '6px';
|
|
saveBtn.style.background = 'var(--conv-accent)';
|
|
saveBtn.style.color = '#fff';
|
|
saveBtn.style.cursor = 'pointer';
|
|
|
|
var cancelBtn = el('button', 'btn-secondary', 'Anuluj');
|
|
cancelBtn.style.fontSize = '12px';
|
|
cancelBtn.style.padding = '4px 12px';
|
|
cancelBtn.style.border = '1px solid var(--conv-border)';
|
|
cancelBtn.style.borderRadius = '6px';
|
|
cancelBtn.style.background = 'var(--conv-surface)';
|
|
cancelBtn.style.color = 'var(--conv-text-primary)';
|
|
cancelBtn.style.cursor = 'pointer';
|
|
|
|
saveBtn.addEventListener('click', function () {
|
|
var newContent = editArea.value.trim();
|
|
if (!newContent) return;
|
|
api('/api/messages/' + msg.id, 'PATCH', { content: newContent })
|
|
.then(function (updated) {
|
|
// Update in state
|
|
var msgs = state.messages[state.currentConversationId] || [];
|
|
var idx = msgs.findIndex(function (m) { return m.id === msg.id; });
|
|
if (idx !== -1) msgs[idx] = updated;
|
|
state.editingMessageId = null;
|
|
ChatView.renderMessages(msgs);
|
|
ChatView.scrollToMessage(msg.id);
|
|
})
|
|
.catch(function (err) {
|
|
alert('Nie udało się edytować: ' + err.message);
|
|
});
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', function () {
|
|
state.editingMessageId = null;
|
|
ChatView.renderMessages(state.messages[state.currentConversationId] || []);
|
|
ChatView.scrollToMessage(msg.id);
|
|
});
|
|
|
|
actions.appendChild(saveBtn);
|
|
actions.appendChild(cancelBtn);
|
|
|
|
bubble.appendChild(editArea);
|
|
bubble.appendChild(actions);
|
|
editArea.focus();
|
|
},
|
|
|
|
confirmDelete: function (msg) {
|
|
if (typeof window.nordaConfirm === 'function') {
|
|
window.nordaConfirm('Czy na pewno chcesz usunąć tę wiadomość?', function () {
|
|
MessageActions.doDelete(msg);
|
|
});
|
|
} else if (confirm('Czy na pewno chcesz usunąć tę wiadomość?')) {
|
|
MessageActions.doDelete(msg);
|
|
}
|
|
},
|
|
|
|
doDelete: function (msg) {
|
|
api('/api/messages/' + msg.id, 'DELETE')
|
|
.then(function () {
|
|
// Update in state
|
|
var msgs = state.messages[state.currentConversationId] || [];
|
|
var idx = msgs.findIndex(function (m) { return m.id === msg.id; });
|
|
if (idx !== -1) {
|
|
msgs[idx].is_deleted = true;
|
|
msgs[idx].content = '';
|
|
}
|
|
ChatView.renderMessages(msgs);
|
|
})
|
|
.catch(function (err) {
|
|
alert('Nie udało się usunąć: ' + err.message);
|
|
});
|
|
},
|
|
};
|
|
|
|
// ============================================================
|
|
// 6. REACTIONS
|
|
// ============================================================
|
|
|
|
var Reactions = {
|
|
renderPills: function (msg) {
|
|
var container = el('div', 'message-reactions');
|
|
(msg.reactions || []).forEach(function (r) {
|
|
var pill = el('span', 'reaction-pill');
|
|
var isMine = r.users.some(function (u) { return u.id === window.__CURRENT_USER__.id; });
|
|
if (isMine) pill.classList.add('mine');
|
|
pill.innerHTML = r.emoji + ' <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();
|
|
if (text) {
|
|
state.quill.setText('');
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
// 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,
|
|
|
|
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();
|
|
|
|
if (!text && !state.attachedFiles.length) return;
|
|
|
|
var convId = state.currentConversationId;
|
|
var savedReplyTo = state.replyToMessage;
|
|
var savedFiles = state.attachedFiles.slice();
|
|
|
|
state.quill.setText('');
|
|
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;
|
|
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: function (query) {
|
|
var suggestions = document.getElementById('recipientSuggestions');
|
|
if (!suggestions) return;
|
|
suggestions.innerHTML = '';
|
|
|
|
query = (query || '').toLowerCase().trim();
|
|
if (!query) return;
|
|
|
|
var users = window.__USERS__ || [];
|
|
var selectedIds = state.selectedRecipients.map(function (r) { return r.id; });
|
|
|
|
var matches = users.filter(function (u) {
|
|
if (u.id === window.__CURRENT_USER__.id) return false;
|
|
if (selectedIds.indexOf(u.id) !== -1) return false;
|
|
var name = (u.name || '').toLowerCase();
|
|
var email = (u.email || '').toLowerCase();
|
|
return name.indexOf(query) !== -1 || email.indexOf(query) !== -1;
|
|
}).slice(0, 8);
|
|
|
|
matches.forEach(function (u) {
|
|
var item = el('div', 'suggestion-item');
|
|
item.style.padding = '8px 12px';
|
|
item.style.cursor = 'pointer';
|
|
item.style.display = 'flex';
|
|
item.style.alignItems = 'center';
|
|
item.style.gap = '8px';
|
|
item.style.borderBottom = '1px solid var(--conv-border)';
|
|
|
|
var avatar = el('div', 'conv-avatar ' + avatarColor(u.name));
|
|
avatar.style.width = '30px';
|
|
avatar.style.height = '30px';
|
|
avatar.style.minWidth = '30px';
|
|
avatar.style.fontSize = '11px';
|
|
avatar.textContent = initials(u.name);
|
|
|
|
var info = el('div', '');
|
|
var nameEl = el('div', '', u.name || u.email);
|
|
nameEl.style.fontSize = '14px';
|
|
nameEl.style.fontWeight = '500';
|
|
if (u.company_name) {
|
|
var companyEl = el('div', '', u.company_name);
|
|
companyEl.style.fontSize = '12px';
|
|
companyEl.style.color = 'var(--conv-text-muted)';
|
|
info.appendChild(nameEl);
|
|
info.appendChild(companyEl);
|
|
} else {
|
|
info.appendChild(nameEl);
|
|
}
|
|
|
|
item.appendChild(avatar);
|
|
item.appendChild(info);
|
|
|
|
item.addEventListener('click', function () {
|
|
NewMessageModal.selectRecipient(u);
|
|
});
|
|
item.addEventListener('mouseenter', function () {
|
|
item.style.background = 'var(--conv-surface-secondary)';
|
|
});
|
|
item.addEventListener('mouseleave', function () {
|
|
item.style.background = '';
|
|
});
|
|
|
|
suggestions.appendChild(item);
|
|
});
|
|
},
|
|
|
|
selectRecipient: function (user) {
|
|
state.selectedRecipients.push(user);
|
|
|
|
var container = document.getElementById('selectedRecipients');
|
|
if (container) {
|
|
var pill = el('span', '');
|
|
pill.style.display = 'inline-flex';
|
|
pill.style.alignItems = 'center';
|
|
pill.style.gap = '4px';
|
|
pill.style.padding = '4px 10px';
|
|
pill.style.borderRadius = '16px';
|
|
pill.style.background = 'var(--conv-primary-light)';
|
|
pill.style.fontSize = '13px';
|
|
pill.style.color = 'var(--conv-text-primary)';
|
|
pill.style.margin = '2px';
|
|
|
|
pill.textContent = user.name || user.email;
|
|
var removeBtn = el('button', '', '\u00d7');
|
|
removeBtn.style.border = 'none';
|
|
removeBtn.style.background = 'transparent';
|
|
removeBtn.style.cursor = 'pointer';
|
|
removeBtn.style.fontSize = '14px';
|
|
removeBtn.style.color = 'var(--conv-text-muted)';
|
|
removeBtn.style.padding = '0';
|
|
removeBtn.style.lineHeight = '1';
|
|
removeBtn.addEventListener('click', function () {
|
|
state.selectedRecipients = state.selectedRecipients.filter(function (r) {
|
|
return r.id !== user.id;
|
|
});
|
|
pill.remove();
|
|
});
|
|
pill.appendChild(removeBtn);
|
|
container.appendChild(pill);
|
|
}
|
|
|
|
// Clear search
|
|
var searchInput = document.getElementById('recipientSearch');
|
|
if (searchInput) searchInput.value = '';
|
|
var suggestions = document.getElementById('recipientSuggestions');
|
|
if (suggestions) suggestions.innerHTML = '';
|
|
},
|
|
|
|
send: async function () {
|
|
if (!state.selectedRecipients.length) {
|
|
alert('Wybierz co najmniej jednego odbiorcę');
|
|
return;
|
|
}
|
|
if (state._isCreating) return; // Prevent double send
|
|
state._isCreating = true;
|
|
|
|
var messageContent = '';
|
|
if (state.newMessageQuill) {
|
|
var text = state.newMessageQuill.getText().trim();
|
|
if (text) {
|
|
messageContent = state.newMessageQuill.root.innerHTML;
|
|
}
|
|
}
|
|
|
|
var memberIds = state.selectedRecipients.map(function (r) { return r.id; });
|
|
var name = null;
|
|
if (memberIds.length > 1) {
|
|
// Group conversation — create a name
|
|
var names = state.selectedRecipients.map(function (r) { return r.name || r.email; });
|
|
names.push(window.__CURRENT_USER__.name);
|
|
name = names.join(', ');
|
|
}
|
|
|
|
try {
|
|
var result = await api('/api/conversations', 'POST', {
|
|
member_ids: memberIds,
|
|
name: name,
|
|
message: messageContent,
|
|
});
|
|
|
|
// Close modal
|
|
var modal = document.getElementById('newMessageModal');
|
|
if (modal) modal.style.display = 'none';
|
|
|
|
// Add to conversations list if new
|
|
var existing = state.conversations.find(function (c) { return c.id === result.id; });
|
|
if (!existing) {
|
|
state.conversations.unshift(result);
|
|
} else {
|
|
Object.assign(existing, result);
|
|
}
|
|
ConversationList.renderList();
|
|
|
|
// Select the conversation
|
|
ConversationList.selectConversation(result.id);
|
|
} catch (e) {
|
|
alert('Nie udało się utworzyć konwersacji: ' + e.message);
|
|
} finally {
|
|
state._isCreating = false;
|
|
}
|
|
},
|
|
};
|
|
|
|
// ============================================================
|
|
// 11. SEARCH
|
|
// ============================================================
|
|
|
|
var Search = {
|
|
init: function () {
|
|
var searchInput = document.getElementById('searchInput');
|
|
if (!searchInput) return;
|
|
|
|
var nameTimer = null;
|
|
var contentTimer = null;
|
|
searchInput.addEventListener('input', function () {
|
|
clearTimeout(nameTimer);
|
|
clearTimeout(contentTimer);
|
|
var q = searchInput.value.trim();
|
|
|
|
// Instant: filter conversation names (client-side)
|
|
nameTimer = setTimeout(function () {
|
|
ConversationList.searchFilter(q);
|
|
if (q.length < 3) {
|
|
Search.clearResults();
|
|
}
|
|
}, 150);
|
|
|
|
// Delayed: search message content (server-side, after 600ms)
|
|
if (q.length >= 3) {
|
|
contentTimer = setTimeout(function () {
|
|
Search.searchContent(q);
|
|
}, 600);
|
|
} else {
|
|
Search.clearResults();
|
|
}
|
|
});
|
|
},
|
|
|
|
searchContent: async function (query) {
|
|
try {
|
|
var data = await api('/api/messages/search?q=' + encodeURIComponent(query));
|
|
var results = data.results || [];
|
|
Search.showResults(results, query);
|
|
} catch (_) {}
|
|
},
|
|
|
|
showResults: function (results, query) {
|
|
Search.clearResults();
|
|
var container = document.getElementById('conversationList');
|
|
if (!container) return;
|
|
|
|
var section = el('div', 'search-results-section');
|
|
section.id = 'searchResultsSection';
|
|
|
|
var header = el('div', 'search-results-header');
|
|
header.textContent = results.length
|
|
? 'Znalezione w wiadomościach (' + results.length + '):'
|
|
: 'Brak wyników w treści wiadomości';
|
|
section.appendChild(header);
|
|
|
|
results.forEach(function (r) {
|
|
var item = el('div', 'search-result-item');
|
|
|
|
var nameRow = el('div', 'search-result-name');
|
|
nameRow.textContent = r.conversation_name + (r.sender_name ? ' — ' + r.sender_name : '');
|
|
item.appendChild(nameRow);
|
|
|
|
var previewRow = el('div', 'search-result-preview');
|
|
// Highlight query in preview
|
|
var escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
previewRow.innerHTML = r.preview.replace(
|
|
new RegExp('(' + escapedQuery + ')', 'gi'),
|
|
'<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();
|
|
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();
|
|
}
|
|
|
|
})();
|