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
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>
3172 lines
132 KiB
JavaScript
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();
|
|
}
|
|
|
|
})();
|