Files
ACORE/static/assets/js/chat.js
T

1343 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Переклади для JS приходять із JavaScriptCatalog (/jsi18n/). Якщо каталог не
// підвантажився — не падаємо, показуємо базові (російські) рядки.
if (typeof window.gettext !== 'function') { window.gettext = function (s) { return s; }; }
var CHAT_QUICK_EMOJIS = [
['👍', 'thumbsup'], ['❤️', 'heart'], ['😂', 'joy'],
['😮', 'open_mouth'], ['😢', 'cry'], ['🙏', 'pray'],
];
var chatConfig = null;
var chatColleagues = [];
var chatChannels = [];
var chatDmChannels = [];
var chatDmLabels = {};
var chatTeamsById = {};
var chatUserNames = {};
var chatUserIsBot = {}; // userId -> чи це бот (Mattermost is_bot)
var chatPostsById = {};
var chatCurrentChannelId = null;
var chatCurrentThreadRootId = null;
var chatSocket = null;
var chatReconnectTimer = null;
var chatRenderedPostIds = {};
var chatThreadRenderedPostIds = {};
var chatComposerFileIds = [];
var chatThreadComposerFileIds = [];
var chatUploadedFileNames = {};
var chatFileBlobCache = {};
var chatDmPickerSelected = {};
var chatReactionPickerEl = null;
var chatCurrentTeamId = null; // обрана «кімната» (команда Mattermost)
var chatDmOtherUserId = {}; // channelId -> id співрозмовника (для аватара ЛС)
var chatUnread = {}; // channelId -> к-сть непрочитаних
var chatLastActivity = {}; // channelId -> час останнього поста (мс)
var chatShowArchived = false; // показувати закриті/архівні розмови
var chatPrefDirectShow = {}; // otherUserId -> "true"/"false" (видимість ЛС)
var chatPrefGroupShow = {}; // channelId -> "true"/"false" (видимість групового)
function chatIsArchived(ch) {
return !!(ch && ch.delete_at && ch.delete_at > 0);
}
// Співрозмовник у ЛС (навіть до resolve — з імені каналу user1__user2).
function chatDmOther(ch) {
if (!ch || ch.type !== 'D') return null;
if (chatDmOtherUserId[ch.id]) return chatDmOtherUserId[ch.id];
if (!ch.name || !chatConfig) return null;
return ch.name.split('__').filter(function (id) { return id !== chatConfig.currentUserId; })[0] || null;
}
// «Закрита» розмова = архівний канал АБО ЛС/груповий, який користувач сховав у
// Mattermost (preference *_show = "false"). Саме тому вони не видно в клієнті MM.
function chatIsClosed(ch) {
if (chatIsArchived(ch)) return true;
if (ch.type === 'D') {
var other = chatDmOther(ch);
return !!other && chatPrefDirectShow[other] === 'false';
}
if (ch.type === 'G') return chatPrefGroupShow[ch.id] === 'false';
return false;
}
// Налаштування видимості ЛС/групових із Mattermost.
function chatLoadPreferences() {
return chatApiFetch('/users/me/preferences').then(function (prefs) {
(prefs || []).forEach(function (p) {
if (p.category === 'direct_channel_show') chatPrefDirectShow[p.name] = p.value;
else if (p.category === 'group_channel_show') chatPrefGroupShow[p.name] = p.value;
});
}).catch(function () { /* без префів показуємо все */ });
}
function chatToggleArchived(show) {
chatShowArchived = !!show;
chatRenderChannelList();
chatRenderDmList();
}
function chatPanelOpen(id) {
var panel = document.getElementById(id);
var overlay = document.getElementById(id + '-overlay');
if (overlay) overlay.style.display = 'block';
if (panel) panel.style.transform = 'translateX(0)';
document.body.style.overflow = 'hidden';
}
function chatPanelClose(id) {
var panel = document.getElementById(id);
var overlay = document.getElementById(id + '-overlay');
if (overlay) overlay.style.display = 'none';
if (panel) panel.style.transform = 'translateX(100%)';
document.body.style.overflow = '';
}
function chatInit() {
var configEl = document.getElementById('chat-config-data');
if (!configEl) return;
if (chatSocket) {
chatSocket.onclose = null;
chatSocket.close();
chatSocket = null;
}
if (chatReconnectTimer) {
clearTimeout(chatReconnectTimer);
chatReconnectTimer = null;
}
chatConfig = JSON.parse(configEl.textContent);
var colleaguesEl = document.getElementById('chat-colleagues-data');
chatColleagues = colleaguesEl ? JSON.parse(colleaguesEl.textContent) : [];
chatChannels = [];
chatDmChannels = [];
chatDmLabels = {};
chatTeamsById = {};
chatUserNames = {};
chatUserIsBot = {};
chatPostsById = {};
chatCurrentChannelId = null;
chatCurrentThreadRootId = null;
chatComposerFileIds = [];
chatThreadComposerFileIds = [];
chatUploadedFileNames = {};
chatFileBlobCache = {};
chatDmPickerSelected = {};
chatCurrentTeamId = null;
chatDmOtherUserId = {};
chatUnread = {};
chatLastActivity = {};
chatShowArchived = false;
chatPrefDirectShow = {};
chatPrefGroupShow = {};
chatWsFailures = 0;
chatLoadTeams().then(chatLoadPreferences).then(chatLoadChannels).then(chatOpenPendingDm);
chatConnectSocket();
// Дозвіл на браузерні сповіщення про нові повідомлення (не обов'язково).
try {
if (window.Notification && Notification.permission === 'default') Notification.requestPermission();
} catch (e) { /* ignore */ }
}
// Якщо на чат перейшли з картки співробітника (/chat/?dm=<mattermost_id>) —
// відкриваємо/створюємо ЛС із цим користувачем.
function chatOpenPendingDm() {
var dm = null;
try { dm = new URLSearchParams(window.location.search).get('dm'); } catch (e) { /* ignore */ }
if (dm) chatOpenDmWithUser(dm);
}
function chatOpenDmWithUser(userId) {
if (!userId || !chatConfig || userId === chatConfig.currentUserId) return;
var existing = chatDmChannels.filter(function (c) {
return c.type === 'D' && chatDmOtherUserId[c.id] === userId;
})[0];
if (existing) { chatSelectChannel(existing.id); return; }
// POST /channels/direct ідемпотентний: повертає наявний ЛС або створює новий.
chatApiFetch('/channels/direct', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify([chatConfig.currentUserId, userId]),
}).then(function (channel) {
if (!chatDmChannels.some(function (c) { return c.id === channel.id; })) {
chatDmChannels.unshift(channel);
}
chatDmOtherUserId[channel.id] = userId;
chatResolveDmLabel(channel);
chatRenderDmList();
chatSelectChannel(channel.id);
}).catch(function (e) { console.error(e); });
}
function chatCsrfToken() {
// CSRF_COOKIE_HTTPONLY=True — cookie из JS не видна; как у HTMX берём из body hx-headers.
var body = document.body;
if (body) {
var hx = body.getAttribute('hx-headers');
if (hx) {
try {
var parsed = JSON.parse(hx);
if (parsed['X-CSRFToken']) return parsed['X-CSRFToken'];
} catch (e) { /* ignore */ }
}
}
var input = document.querySelector('input[name=csrfmiddlewaretoken]');
if (input && input.value) return input.value;
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]*)/);
return m ? decodeURIComponent(m[1]) : '';
}
function chatApiFetch(path, options) {
options = options || {};
var headers = Object.assign({
'Authorization': 'Bearer ' + chatConfig.token,
'X-CSRFToken': chatCsrfToken(),
}, options.headers || {});
// FormData: не фиксируем Content-Type — браузер сам поставит boundary
if (typeof FormData !== 'undefined' && options.body instanceof FormData) {
delete headers['Content-Type'];
}
options.headers = headers;
return fetch(chatConfig.apiBase + path, options).then(function (res) {
if (!res.ok) {
var err = new Error('Mattermost API ' + path + ' -> ' + res.status);
err.status = res.status;
throw err;
}
var ct = res.headers.get('content-type') || '';
if (ct.indexOf('application/json') >= 0 || ct.indexOf('+json') >= 0) {
return res.json();
}
return res.text().then(function (t) {
try { return JSON.parse(t); } catch (e) { return t; }
});
});
}
function chatApiFetchBlob(path) {
return fetch(chatConfig.apiBase + path, {
headers: {
'Authorization': 'Bearer ' + chatConfig.token,
'X-CSRFToken': chatCsrfToken(),
},
}).then(function (res) {
if (!res.ok) throw new Error('Mattermost API ' + path + ' -> ' + res.status);
return res.blob();
});
}
var chatAvatarCache = {}; // userId -> objectURL профільного фото (юзери і боти)
// Підвантажує аватар користувача/бота з Mattermost (/users/{id}/image) і
// накладає його на елемент-кружок. Поки фото не завантажилось (або якщо
// сталася помилка) лишаються ініціали, вже намальовані на елементі.
function chatApplyAvatar(el, userId) {
if (!el || !userId) return;
el.style.position = 'relative';
el.style.overflow = 'hidden';
var img = document.createElement('img');
img.alt = '';
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity 150ms;';
img.onload = function () { img.style.opacity = '1'; };
el.appendChild(img);
if (chatAvatarCache[userId]) { img.src = chatAvatarCache[userId]; return; }
chatApiFetchBlob('/users/' + userId + '/image').then(function (blob) {
var url = URL.createObjectURL(blob);
chatAvatarCache[userId] = url;
img.src = url;
}).catch(function () { /* лишаємо ініціали */ });
}
function chatResolveUserIds(ids) {
var missing = ids.filter(function (id) { return id && !chatUserNames[id]; });
missing = missing.filter(function (id, i) { return missing.indexOf(id) === i; });
if (!missing.length) return Promise.resolve();
return chatApiFetch('/users/ids', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(missing),
}).then(function (users) {
users.forEach(function (u) {
chatUserNames[u.id] = (u.first_name || u.last_name)
? (u.first_name + ' ' + u.last_name).trim()
: u.username;
chatUserIsBot[u.id] = !!u.is_bot;
});
}).catch(function (e) { console.error(e); });
}
function chatResolveUsernames(posts) {
var ids = posts.map(function (p) { return p.user_id; });
return chatResolveUserIds(ids);
}
function chatLoadTeams() {
return chatApiFetch('/users/me/teams').then(function (teams) {
teams.forEach(function (team) { chatTeamsById[team.id] = team; });
var sorted = teams.slice().sort(function (a, b) { return a.display_name.localeCompare(b.display_name); });
if (!chatCurrentTeamId && sorted.length) chatCurrentTeamId = sorted[0].id;
chatRenderTeamSwitcher();
}).catch(function (e) { console.error(e); });
}
// Верхній перемикач «кімнат» — команди Mattermost (Офіс / Аптеки / Склад).
function chatRenderTeamSwitcher() {
var el = document.getElementById('chat-team-switcher');
if (!el) return;
var teams = Object.keys(chatTeamsById).map(function (id) { return chatTeamsById[id]; })
.sort(function (a, b) { return a.display_name.localeCompare(b.display_name); });
if (teams.length <= 1) { el.innerHTML = ''; return; } // перемикати нема що
el.innerHTML = '';
var group = document.createElement('div');
group.style.cssText = 'display:inline-flex; gap:0.25rem; background:var(--color-muted); border-radius:var(--radius); padding:0.25rem;';
teams.forEach(function (t) {
var active = t.id === chatCurrentTeamId;
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = t.display_name;
btn.style.cssText = 'padding:0.3125rem 0.875rem; font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; border-radius:calc(var(--radius) - 2px); transition:all 150ms;' +
(active ? ' background:var(--color-card); color:var(--color-card-foreground); box-shadow:0 1px 2px rgb(0 0 0/0.08);'
: ' background:transparent; color:var(--color-muted-foreground);');
btn.onclick = function () { chatSelectTeam(t.id); };
group.appendChild(btn);
});
el.appendChild(group);
}
function chatSelectTeam(teamId) {
chatCloseSearch(); // результати пошуку прив'язані до команди
chatCurrentTeamId = teamId;
chatRenderTeamSwitcher();
chatRenderChannelList();
}
function chatChannelActivity(ch) {
// Реальний час останнього поста (chatLastActivity) надійніший за last_post_at,
// який Mattermost у списку каналів часто віддає нульовим для ЛС.
return chatLastActivity[ch.id] || ch.last_post_at || 0;
}
function chatByRecent(a, b) {
var aArch = chatIsClosed(a);
var bArch = chatIsClosed(b);
if (aArch !== bArch) return aArch ? 1 : -1; // закриті/архівні — завжди вниз
var diff = chatChannelActivity(b) - chatChannelActivity(a);
if (diff !== 0) return diff; // найсвіжіші зверху
return (a.display_name || '').localeCompare(b.display_name || '');
}
function chatLoadChannels() {
// include_deleted=true — щоб мати закриті/архівні розмови (ховаємо їх за галочкою).
return chatApiFetch('/users/me/channels?include_deleted=true').then(function (channels) {
chatChannels = channels
.filter(function (ch) { return ch.type === 'O' || ch.type === 'P'; })
.sort(chatByRecent);
chatDmChannels = channels
.filter(function (ch) { return ch.type === 'D' || ch.type === 'G'; })
.sort(chatByRecent);
chatRenderChannelList();
chatRenderDmList();
chatDmChannels.forEach(chatResolveDmLabel);
chatAutoSelectRecent();
chatLoadLastActivity(); // уточнюємо порядок за реальним часом останніх постів
}).catch(function (e) {
console.error(e);
var detail = (e && e.status) ? (' (' + e.status + ')') : '';
var msg = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-destructive);">'
+ gettext('Не удалось загрузить каналы') + detail + '</div>';
var el = document.getElementById('chat-channel-list');
if (el) el.innerHTML = msg;
var dm = document.getElementById('chat-dm-list');
if (dm) dm.innerHTML = msg;
});
}
function chatAutoSelectRecent() {
// Відкриваємо найсвіжішу розмову за замовчуванням, щоб одразу бачити останні
// повідомлення (а не порожній екран). Лише якщо користувач ще нічого не обрав.
if (chatCurrentChannelId) return;
var candidates = chatChannels.filter(function (ch) {
return (!chatCurrentTeamId || ch.team_id === chatCurrentTeamId) && !chatIsClosed(ch);
}).concat(chatDmChannels.filter(function (ch) { return !chatIsClosed(ch); }));
if (!candidates.length) return;
var best = candidates.reduce(function (a, b) {
return chatChannelActivity(b) > chatChannelActivity(a) ? b : a;
});
if (best) chatSelectChannel(best.id);
}
// Тягне час останнього поста для кожного каналу/ЛС (list-endpoint last_post_at
// ненадійний) і після цього пересортовує списки за реальною активністю.
function chatLoadLastActivity() {
var all = chatChannels.concat(chatDmChannels);
if (!all.length) return;
var pending = all.length;
var done = function () { if (--pending === 0) chatResortAndRender(); };
all.forEach(function (ch) {
chatApiFetch('/channels/' + ch.id + '/posts?per_page=1').then(function (data) {
var id = (data.order || [])[0];
var post = id ? data.posts[id] : null;
if (post && post.create_at) chatLastActivity[ch.id] = post.create_at;
}).catch(function () { /* ignore */ }).then(done);
});
}
function chatResortAndRender() {
chatChannels.sort(chatByRecent);
chatDmChannels.sort(chatByRecent);
chatRenderChannelList();
chatRenderDmList();
if (!chatCurrentChannelId) chatAutoSelectRecent();
}
function chatResolveDmLabel(channel) {
var promise;
var otherId = null;
if (channel.type === 'D') {
otherId = channel.name.split('__').filter(function (id) { return id !== chatConfig.currentUserId; })[0];
chatDmOtherUserId[channel.id] = otherId;
promise = chatResolveUserIds([otherId]).then(function () {
return chatUserNames[otherId] || otherId;
});
} else {
promise = chatApiFetch('/channels/' + channel.id + '/members').then(function (members) {
var ids = members.map(function (m) { return m.user_id; }).filter(function (id) { return id !== chatConfig.currentUserId; });
return chatResolveUserIds(ids).then(function () {
return ids.map(function (id) { return chatUserNames[id] || id; }).join(', ');
});
});
}
return promise.then(function (label) {
chatDmLabels[channel.id] = label;
var labelEl = document.getElementById('chat-dm-label-' + channel.id);
if (labelEl) labelEl.textContent = label;
var avEl = document.getElementById('chat-dm-avatar-' + channel.id);
if (avEl) {
avEl.textContent = (label || '').slice(0, 2).toUpperCase();
if (otherId) chatApplyAvatar(avEl, otherId); // фото — лише для особистих (не групових)
}
if (chatCurrentChannelId === channel.id) chatUpdateHeader(channel);
// Позначка «бот» у списку ЛС, якщо співрозмовник — бот (стало відомо після resolve).
if (otherId && chatUserIsBot[otherId] && !document.getElementById('chat-dm-bot-' + channel.id)) {
var row = document.getElementById('chat-channel-row-' + channel.id);
if (row) {
var b = chatBotBadge();
b.id = 'chat-dm-bot-' + channel.id;
b.style.flexShrink = '0';
row.appendChild(b);
}
}
}).catch(function (e) { console.error(e); });
}
function chatRenderChannelList() {
var el = document.getElementById('chat-channel-list');
if (!el) return;
var list = chatChannels.filter(function (ch) {
return (!chatCurrentTeamId || ch.team_id === chatCurrentTeamId)
&& (chatShowArchived || !chatIsClosed(ch));
});
if (!list.length) {
el.innerHTML = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">' + gettext('Нет каналов') + '</div>';
return;
}
el.innerHTML = '';
list.forEach(function (ch) {
// Команда обрана перемикачем зверху — назву команди у рядок не дублюємо.
var row = chatBuildChannelRow(ch.id, '#' + ch.display_name, '');
el.appendChild(row);
});
}
function chatRenderDmList() {
var el = document.getElementById('chat-dm-list');
if (!el) return;
var list = chatDmChannels.filter(function (ch) {
return chatShowArchived || !chatIsClosed(ch);
});
if (!list.length) {
el.innerHTML = '<div style="padding:0.75rem; text-align:center; font-size:0.75rem; color:var(--color-muted-foreground);">' + gettext('Нет сообщений') + '</div>';
return;
}
el.innerHTML = '';
list.forEach(function (ch) {
var otherId = chatDmOtherUserId[ch.id];
var isBot = ch.type === 'D' && otherId && chatUserIsBot[otherId];
var row = chatBuildChannelRow(ch.id, chatDmLabels[ch.id] || '…',
ch.type === 'G' ? gettext('Групповой разговор') : '', {avatar: true, bot: isBot});
el.appendChild(row);
// Якщо співрозмовник ЛС уже відомий — одразу показати його аватар.
if (ch.type === 'D' && otherId) {
chatApplyAvatar(document.getElementById('chat-dm-avatar-' + ch.id), otherId);
}
});
}
function chatBuildChannelRow(channelId, label, subLabel, opts) {
opts = opts || {};
var row = document.createElement('div');
row.id = 'chat-channel-row-' + channelId;
row.style.cssText = 'display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.75rem; border-bottom:1px solid var(--color-border); cursor:pointer;' +
(channelId === chatCurrentChannelId ? ' background:hsl(0 72% 44% / 0.06);' : '');
row.onmouseover = function () { if (channelId !== chatCurrentChannelId) row.style.background = 'var(--color-muted)'; };
row.onmouseout = function () { if (channelId !== chatCurrentChannelId) row.style.background = ''; };
row.onclick = function () { chatSelectChannel(channelId); };
if (opts.avatar) {
// Кружок-аватар співрозмовника ЛС; фото підвантажиться в chatResolveDmLabel.
var av = document.createElement('div');
av.id = 'chat-dm-avatar-' + channelId;
av.style.cssText = 'width:1.75rem; height:1.75rem; border-radius:50%; background:hsl(205 80% 55% / 0.15); color:hsl(205 80% 45%); display:flex; align-items:center; justify-content:center; font-size:0.5625rem; font-weight:700; flex-shrink:0;';
av.textContent = (label && label !== '…') ? label.slice(0, 2).toUpperCase() : '';
row.appendChild(av);
}
var textCol = document.createElement('div');
textCol.style.cssText = 'min-width:0; flex:1;';
var name = document.createElement('div');
name.id = 'chat-dm-label-' + channelId;
name.style.cssText = 'font-size:0.8125rem; font-weight:500; color:var(--color-foreground); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
name.textContent = label;
textCol.appendChild(name);
if (subLabel) {
var sub = document.createElement('div');
sub.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground);';
sub.textContent = subLabel;
textCol.appendChild(sub);
}
row.appendChild(textCol);
if (opts.bot) {
var botMark = chatBotBadge();
botMark.id = 'chat-dm-bot-' + channelId;
botMark.style.flexShrink = '0';
row.appendChild(botMark);
}
var unread = chatUnread[channelId] || 0;
if (unread > 0) {
name.style.fontWeight = '700';
name.style.color = 'var(--color-foreground)';
var badge = document.createElement('span');
badge.id = 'chat-unread-' + channelId;
badge.textContent = unread > 99 ? '99+' : String(unread);
badge.style.cssText = 'flex-shrink:0; min-width:1.125rem; height:1.125rem; padding:0 0.3125rem; border-radius:999px; background:var(--color-primary); color:#fff; font-size:0.625rem; font-weight:700; display:flex; align-items:center; justify-content:center;';
row.appendChild(badge);
}
return row;
}
function chatFindChannel(channelId) {
return chatChannels.filter(function (c) { return c.id === channelId; })[0]
|| chatDmChannels.filter(function (c) { return c.id === channelId; })[0];
}
function chatUpdateHeader(ch) {
var header = document.getElementById('chat-header');
if (!header || !ch) return;
header.innerHTML = '';
var title = document.createElement('span');
title.style.cssText = 'font-size:0.875rem; font-weight:600; color:var(--color-foreground);';
title.textContent = (ch.type === 'O' || ch.type === 'P') ? '#' + ch.display_name : (chatDmLabels[ch.id] || '…');
header.appendChild(title);
}
function chatSelectChannel(channelId) {
chatCloseSearch(); // вихід із режиму пошуку при відкритті каналу
if (chatUnread[channelId]) { // відкрили канал — скидаємо лічильник непрочитаних
delete chatUnread[channelId];
var oldBadge = document.getElementById('chat-unread-' + channelId);
if (oldBadge) oldBadge.remove();
var oldName = document.getElementById('chat-dm-label-' + channelId);
if (oldName) oldName.style.fontWeight = '500';
chatUpdateTitle();
}
var prevRow = chatCurrentChannelId ? document.getElementById('chat-channel-row-' + chatCurrentChannelId) : null;
if (prevRow) prevRow.style.background = '';
chatCurrentChannelId = channelId;
chatPanelClose('chat-thread-panel');
chatCurrentThreadRootId = null;
var newRow = document.getElementById('chat-channel-row-' + channelId);
if (newRow) newRow.style.background = 'hsl(0 72% 44% / 0.06)';
chatUpdateHeader(chatFindChannel(channelId));
var input = document.getElementById('chat-composer-input');
var send = document.getElementById('chat-composer-send');
var attach = document.getElementById('chat-composer-attach');
if (input) input.disabled = false;
if (send) send.disabled = false;
if (attach) attach.disabled = false;
chatComposerFileIds = [];
chatRenderComposerFileChips('chat-composer-files', chatComposerFileIds);
var messagesEl = document.getElementById('chat-messages');
if (messagesEl) messagesEl.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">' + gettext('Загрузка…') + '</div>';
chatRenderedPostIds = {};
chatApiFetch('/channels/' + channelId + '/posts?per_page=30').then(function (data) {
var ids = (data.order || []).slice().reverse();
var posts = ids.map(function (id) { return data.posts[id]; }).filter(function (p) { return !p.root_id; });
return chatResolveUsernames(posts).then(function () {
if (chatCurrentChannelId !== channelId) return;
chatRenderMessages(posts, true);
});
}).catch(function (e) {
console.error(e);
if (messagesEl) messagesEl.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-destructive);">' + gettext('Не удалось загрузить сообщения') + '</div>';
});
}
function chatAppendPosts(containerEl, posts, renderedIds, context) {
if (!containerEl) return;
posts.forEach(function (post) { chatPostsById[post.id] = post; });
posts = posts.filter(function (post) {
if (renderedIds[post.id]) return false;
renderedIds[post.id] = true;
return true;
});
posts.forEach(function (post) {
containerEl.appendChild(chatBuildMessageEl(post, context));
});
containerEl.scrollTop = containerEl.scrollHeight;
}
function chatRenderMessages(posts, replace) {
var el = document.getElementById('chat-messages');
if (!el) return;
if (replace) el.innerHTML = '';
chatAppendPosts(el, posts, chatRenderedPostIds, 'feed');
}
function chatRenderThreadMessages(posts, replace) {
var el = document.getElementById('chat-thread-messages');
if (!el) return;
if (replace) el.innerHTML = '';
chatAppendPosts(el, posts, chatThreadRenderedPostIds, 'thread');
}
// ── Пошук повідомлень (Mattermost /teams/{id}/posts/search) ──────────────────
function chatCloseSearch() {
var resultsEl = document.getElementById('chat-search-results');
var messagesEl = document.getElementById('chat-messages');
if (resultsEl) { resultsEl.style.display = 'none'; resultsEl.innerHTML = ''; }
if (messagesEl) messagesEl.style.display = 'flex';
var input = document.getElementById('chat-search-input');
if (input) input.value = '';
}
function chatSearchChannelLabel(channelId) {
var ch = chatFindChannel(channelId);
if (!ch) return '';
if (ch.type === 'O' || ch.type === 'P') return '#' + ch.display_name;
return chatDmLabels[ch.id] || gettext('Личное');
}
function chatSearchMessages(terms) {
terms = (terms || '').trim();
var resultsEl = document.getElementById('chat-search-results');
var messagesEl = document.getElementById('chat-messages');
if (!resultsEl) return;
if (!terms) { chatCloseSearch(); return; }
// Пошук по УСІХ командах (Mattermost search — per-team), результати зливаємо.
// ЛС/групові не прив'язані до команди й потрапляють у видачу будь-якої з них.
var teamIds = Object.keys(chatTeamsById);
if (!teamIds.length && chatCurrentTeamId) teamIds = [chatCurrentTeamId];
if (!teamIds.length) return;
resultsEl.style.display = 'flex';
if (messagesEl) messagesEl.style.display = 'none';
resultsEl.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground); padding:1rem;">' + gettext('Поиск…') + '</div>';
var body = JSON.stringify({terms: terms, is_or_search: false, per_page: 30});
var merged = {}; // id -> post (дедуплікація між командами)
Promise.all(teamIds.map(function (tid) {
return chatApiFetch('/teams/' + tid + '/posts/search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: body,
}).then(function (data) {
(data.order || []).forEach(function (id) {
if (data.posts && data.posts[id]) merged[id] = data.posts[id];
});
}).catch(function () { /* збій однієї команди не валить весь пошук */ });
})).then(function () {
var posts = Object.keys(merged).map(function (id) { return merged[id]; });
return chatResolveUsernames(posts).then(function () {
chatRenderSearchResults(posts);
});
}).catch(function (e) {
console.error(e);
resultsEl.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-destructive); padding:1rem;">' + gettext('Не удалось выполнить поиск') + '</div>';
});
}
function chatRenderSearchResults(posts) {
var el = document.getElementById('chat-search-results');
if (!el) return;
el.innerHTML = '';
var head = document.createElement('div');
head.style.cssText = 'display:flex; align-items:center; justify-content:space-between; margin-bottom:0.25rem;';
var title = document.createElement('span');
title.style.cssText = 'font-size:0.75rem; color:var(--color-muted-foreground);';
title.textContent = gettext('Найдено:') + ' ' + posts.length;
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = gettext('Закрыть поиск');
closeBtn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:0.75rem; color:var(--color-primary);';
closeBtn.onclick = chatCloseSearch;
head.appendChild(title);
head.appendChild(closeBtn);
el.appendChild(head);
if (!posts.length) {
var none = document.createElement('div');
none.style.cssText = 'text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground); padding:1rem;';
none.textContent = gettext('Ничего не найдено');
el.appendChild(none);
return;
}
posts.sort(function (a, b) { return (b.create_at || 0) - (a.create_at || 0); });
posts.forEach(function (post) {
var row = document.createElement('div');
row.style.cssText = 'padding:0.625rem 0.75rem; border:1px solid var(--color-border); border-radius:0.5rem; cursor:pointer;';
row.onmouseover = function () { row.style.background = 'var(--color-muted)'; };
row.onmouseout = function () { row.style.background = ''; };
row.onclick = function () { chatSelectChannel(post.channel_id); };
var meta = document.createElement('div');
meta.style.cssText = 'display:flex; justify-content:space-between; gap:0.5rem; margin-bottom:0.25rem;';
var who = document.createElement('span');
who.style.cssText = 'font-size:0.75rem; font-weight:600; color:var(--color-foreground); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
who.textContent = (chatUserNames[post.user_id] || '') + ' · ' + chatSearchChannelLabel(post.channel_id);
var when = document.createElement('span');
when.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground); flex-shrink:0;';
when.textContent = post.create_at ? new Date(post.create_at).toLocaleString() : '';
meta.appendChild(who);
meta.appendChild(when);
var msg = document.createElement('div');
msg.style.cssText = 'font-size:0.8125rem; color:var(--color-foreground); white-space:pre-wrap; word-break:break-word;';
msg.textContent = post.message || '';
row.appendChild(meta);
row.appendChild(msg);
el.appendChild(row);
});
}
function chatPostIsBot(post) {
if (chatUserIsBot[post.user_id]) return true;
var p = post.props || {};
return p.from_bot === 'true' || p.from_webhook === 'true';
}
function chatBotBadge() {
var b = document.createElement('span');
b.textContent = gettext('бот');
b.style.cssText = 'font-size:0.5625rem; font-weight:700; letter-spacing:0.03em; text-transform:uppercase; color:var(--color-muted-foreground); background:var(--color-muted); border:1px solid var(--color-border); border-radius:0.25rem; padding:0.0625rem 0.25rem; line-height:1.2;';
return b;
}
function chatBuildMessageEl(post, context) {
var isMine = post.user_id === chatConfig.currentUserId;
var wrap = document.createElement('div');
wrap.id = 'chat-message-' + context + '-' + post.id;
wrap.style.cssText = 'display:flex; gap:0.625rem; align-items:flex-start;' + (isMine ? ' flex-direction:row-reverse;' : '');
var avatar = document.createElement('div');
avatar.style.cssText = 'width:2rem; height:2rem; border-radius:50%; background:hsl(205 80% 55% / 0.15); color:hsl(205 80% 45%); display:flex; align-items:center; justify-content:center; font-size:0.625rem; font-weight:700; flex-shrink:0;';
var senderName = chatUserNames[post.user_id] || post.user_id;
avatar.textContent = senderName.slice(0, 2).toUpperCase();
chatApplyAvatar(avatar, post.user_id);
wrap.appendChild(avatar);
var body = document.createElement('div');
body.style.cssText = (isMine ? 'align-items:flex-end;' : 'align-items:flex-start;') + ' display:flex; flex-direction:column; max-width:28rem;';
var meta = document.createElement('div');
meta.style.cssText = 'display:flex; align-items:baseline; gap:0.5rem; margin-bottom:0.25rem;' + (isMine ? ' flex-direction:row-reverse;' : '');
var nameEl = document.createElement('span');
nameEl.style.cssText = 'font-size:0.8125rem; font-weight:600; color:var(--color-foreground);';
nameEl.textContent = isMine ? gettext('Вы') : senderName;
var timeEl = document.createElement('span');
timeEl.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground);';
timeEl.textContent = post.create_at ? new Date(post.create_at).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) : '';
meta.appendChild(nameEl);
if (!isMine && chatPostIsBot(post)) meta.appendChild(chatBotBadge());
meta.appendChild(timeEl);
body.appendChild(meta);
if (post.message) {
var bubble = document.createElement('div');
bubble.style.cssText = isMine
? 'background:var(--color-primary); color:white; border-radius:0.75rem 0 0.75rem 0.75rem; padding:0.625rem 0.875rem; font-size:0.8125rem; white-space:pre-wrap; word-break:break-word;'
: 'background:var(--color-muted); border-radius:0 0.75rem 0.75rem 0.75rem; padding:0.625rem 0.875rem; font-size:0.8125rem; color:var(--color-foreground); white-space:pre-wrap; word-break:break-word;';
bubble.textContent = post.message;
body.appendChild(bubble);
}
var files = post.metadata && post.metadata.files;
if (files && files.length) {
var attachments = document.createElement('div');
attachments.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.375rem; margin-top:0.375rem;';
chatRenderAttachments(attachments, files);
body.appendChild(attachments);
}
var reactions = document.createElement('div');
reactions.id = 'chat-reactions-' + context + '-' + post.id;
reactions.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.25rem; margin-top:0.375rem;';
chatRenderReactions(reactions, post);
body.appendChild(reactions);
var actions = document.createElement('div');
actions.style.cssText = 'display:flex; gap:0.625rem; margin-top:0.25rem;';
var reactBtn = document.createElement('button');
reactBtn.type = 'button';
reactBtn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:0.6875rem; color:var(--color-muted-foreground); padding:0;';
reactBtn.textContent = '🙂 ' + gettext('Реакция');
reactBtn.onclick = function (e) { chatToggleReactionPicker(e, post, context); };
actions.appendChild(reactBtn);
if (context === 'feed') {
var replyBtn = document.createElement('button');
replyBtn.type = 'button';
replyBtn.id = 'chat-reply-count-' + post.id;
replyBtn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:0.6875rem; color:var(--color-primary); padding:0;';
replyBtn.textContent = post.reply_count ? ('💬 ' + gettext('Ответов:') + ' ' + post.reply_count) : ('↩ ' + gettext('Ответить'));
replyBtn.onclick = function () { chatOpenThread(post.id); };
actions.appendChild(replyBtn);
}
body.appendChild(actions);
wrap.appendChild(body);
return wrap;
}
function chatRenderAttachments(container, files) {
files.forEach(function (file) {
if (file.mime_type && file.mime_type.indexOf('image/') === 0) {
var img = document.createElement('img');
img.style.cssText = 'max-width:10rem; max-height:8rem; border-radius:0.5rem; cursor:pointer; display:block;';
img.alt = file.name;
chatFetchFileBlobUrl(file.id, 'thumbnail').then(function (url) { img.src = url; });
img.onclick = function () {
chatFetchFileBlobUrl(file.id, '').then(function (url) { window.open(url, '_blank'); });
};
container.appendChild(img);
} else {
var chip = document.createElement('a');
chip.style.cssText = 'display:inline-flex; align-items:center; gap:0.375rem; background:var(--color-muted); border-radius:0.5rem; padding:0.375rem 0.625rem; font-size:0.75rem; color:var(--color-foreground); text-decoration:none; cursor:pointer;';
chip.textContent = '📎 ' + file.name;
chip.onclick = function (e) {
e.preventDefault();
chatFetchFileBlobUrl(file.id, '').then(function (url) {
var a = document.createElement('a');
a.href = url;
a.download = file.name;
a.click();
});
};
container.appendChild(chip);
}
});
}
function chatFetchFileBlobUrl(fileId, variant) {
var key = fileId + ':' + variant;
if (chatFileBlobCache[key]) return Promise.resolve(chatFileBlobCache[key]);
var path = '/files/' + fileId + (variant ? '/' + variant : '');
return chatApiFetchBlob(path).then(function (blob) {
var url = URL.createObjectURL(blob);
chatFileBlobCache[key] = url;
return url;
});
}
function chatRenderReactions(container, post) {
container.innerHTML = '';
var reactions = (post.metadata && post.metadata.reactions) || [];
if (!reactions.length) return;
var byEmoji = {};
reactions.forEach(function (r) {
if (!byEmoji[r.emoji_name]) byEmoji[r.emoji_name] = [];
byEmoji[r.emoji_name].push(r);
});
Object.keys(byEmoji).forEach(function (emojiName) {
var list = byEmoji[emojiName];
var mine = list.some(function (r) { return r.user_id === chatConfig.currentUserId; });
var quick = CHAT_QUICK_EMOJIS.filter(function (q) { return q[1] === emojiName; })[0];
var pill = document.createElement('button');
pill.type = 'button';
pill.style.cssText = 'display:inline-flex; align-items:center; gap:0.25rem; border-radius:999px; padding:0.0625rem 0.5rem; font-size:0.75rem; cursor:pointer; border:1px solid ' +
(mine ? 'var(--color-primary)' : 'var(--color-border)') + '; background:' + (mine ? 'hsl(0 72% 44% / 0.08)' : 'var(--color-muted)') + '; color:var(--color-foreground);';
pill.textContent = (quick ? quick[0] : ':' + emojiName + ':') + ' ' + list.length;
pill.onclick = function () { chatToggleReaction(post, emojiName); };
container.appendChild(pill);
});
}
function chatToggleReactionPicker(event, post, context) {
event.stopPropagation();
if (chatReactionPickerEl) {
chatReactionPickerEl.remove();
chatReactionPickerEl = null;
return;
}
var picker = document.createElement('div');
picker.id = 'chat-reaction-picker';
picker.style.cssText = 'position:fixed; z-index:2000; top:0; left:0; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.625rem; box-shadow:0 4px 16px rgba(0,0,0,0.15); padding:0.375rem; display:flex; gap:0.25rem;';
CHAT_QUICK_EMOJIS.forEach(function (pair) {
var btn = document.createElement('button');
btn.type = 'button';
btn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:1.125rem; padding:0.125rem;';
btn.textContent = pair[0];
btn.onclick = function () {
chatToggleReaction(post, pair[1]);
picker.remove();
chatReactionPickerEl = null;
};
picker.appendChild(btn);
});
document.body.appendChild(picker);
chatReactionPickerEl = picker;
var rect = event.currentTarget.getBoundingClientRect();
var pickerRect = picker.getBoundingClientRect();
var left = Math.min(rect.left, window.innerWidth - pickerRect.width - 8);
var top = rect.bottom + 4;
if (top + pickerRect.height > window.innerHeight) top = rect.top - pickerRect.height - 4;
picker.style.left = Math.max(8, left) + 'px';
picker.style.top = Math.max(8, top) + 'px';
setTimeout(function () {
document.addEventListener('click', function closePicker() {
if (chatReactionPickerEl) { chatReactionPickerEl.remove(); chatReactionPickerEl = null; }
document.removeEventListener('click', closePicker);
}, {once: true});
}, 0);
}
function chatToggleReaction(post, emojiName) {
var cached = chatPostsById[post.id] || post;
if (!cached.metadata) cached.metadata = {};
if (!cached.metadata.reactions) cached.metadata.reactions = [];
var existing = cached.metadata.reactions.filter(function (r) {
return r.user_id === chatConfig.currentUserId && r.emoji_name === emojiName;
})[0];
var request;
if (existing) {
cached.metadata.reactions = cached.metadata.reactions.filter(function (r) { return r !== existing; });
request = chatApiFetch('/users/' + chatConfig.currentUserId + '/posts/' + post.id + '/reactions/' + emojiName, {method: 'DELETE'});
} else {
cached.metadata.reactions.push({user_id: chatConfig.currentUserId, post_id: post.id, emoji_name: emojiName});
request = chatApiFetch('/reactions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_id: chatConfig.currentUserId, post_id: post.id, emoji_name: emojiName}),
});
}
chatRerenderReactions(post.id);
request.catch(function (e) { console.error(e); });
}
function chatRerenderReactions(postId) {
var post = chatPostsById[postId];
if (!post) return;
['feed', 'thread'].forEach(function (context) {
var el = document.getElementById('chat-reactions-' + context + '-' + postId);
if (el) chatRenderReactions(el, post);
});
}
function chatOpenThread(postId) {
chatCurrentThreadRootId = postId;
chatThreadRenderedPostIds = {};
chatThreadComposerFileIds = [];
chatRenderComposerFileChips('chat-thread-composer-files', chatThreadComposerFileIds);
document.getElementById('chat-thread-composer-input').value = '';
var el = document.getElementById('chat-thread-messages');
if (el) el.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">' + gettext('Загрузка…') + '</div>';
chatApiFetch('/posts/' + postId + '/thread?perPage=60').then(function (data) {
var ids = (data.order || []).slice().reverse();
var posts = ids.map(function (id) { return data.posts[id]; });
return chatResolveUsernames(posts).then(function () {
if (chatCurrentThreadRootId !== postId) return;
chatRenderThreadMessages(posts, true);
chatPanelOpen('chat-thread-panel');
});
}).catch(function (e) {
console.error(e);
if (el) el.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-destructive);">' + gettext('Не удалось загрузить обсуждение') + '</div>';
});
}
function chatThreadSendReply() {
var input = document.getElementById('chat-thread-composer-input');
var rootId = chatCurrentThreadRootId;
if (!input || !rootId) return;
var message = input.value.trim();
var fileIds = chatThreadComposerFileIds.slice();
if (!message && !fileIds.length) return;
var rootPost = chatPostsById[rootId];
if (!rootPost) return;
input.disabled = true;
chatApiFetch('/posts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({channel_id: rootPost.channel_id, message: message, root_id: rootId, file_ids: fileIds}),
}).then(function (post) {
input.value = '';
chatThreadComposerFileIds = [];
chatRenderComposerFileChips('chat-thread-composer-files', chatThreadComposerFileIds);
if (chatCurrentThreadRootId === rootId) chatRenderThreadMessages([post], false);
chatBumpReplyCount(rootId);
}).catch(function (e) {
console.error(e);
}).finally(function () {
input.disabled = false;
input.focus();
});
}
function chatBumpReplyCount(rootId) {
var root = chatPostsById[rootId];
if (!root) return;
root.reply_count = (root.reply_count || 0) + 1;
var btn = document.getElementById('chat-reply-count-' + rootId);
if (btn) btn.textContent = '💬 ' + gettext('Ответов:') + ' ' + root.reply_count;
}
function chatSendMessage() {
var input = document.getElementById('chat-composer-input');
if (!input || !chatCurrentChannelId) return;
var message = input.value.trim();
var fileIds = chatComposerFileIds.slice();
if (!message && !fileIds.length) return;
var channelId = chatCurrentChannelId;
input.disabled = true;
chatApiFetch('/posts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({channel_id: channelId, message: message, file_ids: fileIds}),
}).then(function (post) {
input.value = '';
chatComposerFileIds = [];
chatRenderComposerFileChips('chat-composer-files', chatComposerFileIds);
if (chatCurrentChannelId === channelId) chatRenderMessages([post], false);
}).catch(function (e) {
console.error(e);
}).finally(function () {
input.disabled = false;
input.focus();
});
}
function chatUploadFiles(fileList, channelId) {
var uploads = Array.prototype.map.call(fileList, function (file) {
var formData = new FormData();
formData.append('files', file);
formData.append('channel_id', channelId);
return chatApiFetch('/files', {method: 'POST', body: formData}).then(function (data) {
return data.file_infos || [];
});
});
return Promise.all(uploads).then(function (results) {
var infos = [].concat.apply([], results);
infos.forEach(function (info) { chatUploadedFileNames[info.id] = info.name; });
return infos.map(function (info) { return info.id; });
});
}
function chatComposerFilesSelected(inputEl) {
if (!chatCurrentChannelId || !inputEl.files.length) return;
chatUploadFiles(inputEl.files, chatCurrentChannelId).then(function (ids) {
chatComposerFileIds = chatComposerFileIds.concat(ids);
chatRenderComposerFileChips('chat-composer-files', chatComposerFileIds);
}).catch(function (e) { console.error(e); });
inputEl.value = '';
}
function chatThreadComposerFilesSelected(inputEl) {
var rootPost = chatPostsById[chatCurrentThreadRootId];
if (!rootPost || !inputEl.files.length) return;
chatUploadFiles(inputEl.files, rootPost.channel_id).then(function (ids) {
chatThreadComposerFileIds = chatThreadComposerFileIds.concat(ids);
chatRenderComposerFileChips('chat-thread-composer-files', chatThreadComposerFileIds);
}).catch(function (e) { console.error(e); });
inputEl.value = '';
}
function chatRenderComposerFileChips(containerId, fileIds) {
var container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
container.style.display = fileIds.length ? 'flex' : 'none';
fileIds.forEach(function (fileId) {
var chip = document.createElement('span');
chip.style.cssText = 'display:inline-flex; align-items:center; gap:0.375rem; background:var(--color-muted); border-radius:0.5rem; padding:0.25rem 0.5rem; font-size:0.75rem; color:var(--color-foreground); margin:0.375rem 0;';
chip.textContent = '📎 ' + (chatUploadedFileNames[fileId] || fileId);
var remove = document.createElement('button');
remove.type = 'button';
remove.style.cssText = 'background:none; border:none; cursor:pointer; color:var(--color-muted-foreground); font-size:0.75rem; padding:0 0 0 0.25rem;';
remove.textContent = '✕';
remove.onclick = function () {
var idx = fileIds.indexOf(fileId);
if (idx !== -1) fileIds.splice(idx, 1);
chatRenderComposerFileChips(containerId, fileIds);
};
chip.appendChild(remove);
container.appendChild(chip);
});
}
function chatOpenNewDmPicker() {
chatDmPickerSelected = {};
var search = document.getElementById('chat-dm-picker-search');
if (search) search.value = '';
chatRenderDmPickerList();
chatPanelOpen('chat-dm-picker-panel');
}
function chatRenderDmPickerList() {
var container = document.getElementById('chat-dm-picker-list');
if (!container) return;
var search = (document.getElementById('chat-dm-picker-search').value || '').toLowerCase();
var filtered = chatColleagues.filter(function (c) { return c.name.toLowerCase().indexOf(search) !== -1; });
container.innerHTML = '';
if (!filtered.length) {
container.innerHTML = '<div style="padding:0.5rem; text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">' + gettext('Никого не найдено') + '</div>';
return;
}
filtered.forEach(function (c) {
var row = document.createElement('label');
row.style.cssText = 'display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.625rem; border-radius:0.5rem; cursor:pointer; font-size:0.8125rem; color:var(--color-foreground);';
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !!chatDmPickerSelected[c.mattermostId];
checkbox.onchange = function () {
if (checkbox.checked) {
if (Object.keys(chatDmPickerSelected).length >= 7) {
checkbox.checked = false;
return;
}
chatDmPickerSelected[c.mattermostId] = c.name;
} else {
delete chatDmPickerSelected[c.mattermostId];
}
};
row.appendChild(checkbox);
var av = document.createElement('div');
av.style.cssText = 'width:1.75rem; height:1.75rem; border-radius:50%; background:hsl(205 80% 55% / 0.15); color:hsl(205 80% 45%); display:flex; align-items:center; justify-content:center; font-size:0.5625rem; font-weight:700; flex-shrink:0;';
av.textContent = c.name.slice(0, 2).toUpperCase();
chatApplyAvatar(av, c.mattermostId);
row.appendChild(av);
var name = document.createElement('span');
name.textContent = c.name;
row.appendChild(name);
container.appendChild(row);
});
}
function chatStartNewDm() {
var selectedIds = Object.keys(chatDmPickerSelected);
if (!selectedIds.length) return;
var path = selectedIds.length === 1 ? '/channels/direct' : '/channels/group';
var body = selectedIds.length === 1
? [chatConfig.currentUserId, selectedIds[0]]
: [chatConfig.currentUserId].concat(selectedIds);
chatApiFetch(path, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(function (channel) {
if (!chatDmChannels.some(function (c) { return c.id === channel.id; })) {
chatDmChannels.push(channel);
}
chatDmLabels[channel.id] = selectedIds.length === 1
? chatDmPickerSelected[selectedIds[0]]
: selectedIds.map(function (id) { return chatDmPickerSelected[id]; }).join(', ');
chatRenderDmList();
chatPanelClose('chat-dm-picker-panel');
chatSelectChannel(channel.id);
}).catch(function (e) { console.error(e); });
}
var chatWsFailures = 0;
var chatWsMaxBackoff = 60000;
function chatConnectSocket() {
if (!chatConfig || !chatConfig.wsUrl) return;
// Live WS идёт на public_url Mattermost (не через Django). Без AllowCorsFrom /
// разрешения Origin на chat.* будет 403 — не долбим реконнект бесконечно.
if (chatWsFailures >= 8) return;
var socket;
try {
socket = new WebSocket(chatConfig.wsUrl);
} catch (e) {
console.warn('chat websocket create failed', e);
chatWsFailures += 1;
return;
}
chatSocket = socket;
socket.onopen = function () {
chatWsFailures = 0;
socket.send(JSON.stringify({seq: 1, action: 'authentication_challenge', data: {token: chatConfig.token}}));
};
socket.onmessage = function (evt) {
var msg;
try { msg = JSON.parse(evt.data); } catch (e) { return; }
if (msg.event === 'posted') return chatHandleWsPosted(msg);
if (msg.event === 'reaction_added' || msg.event === 'reaction_removed') return chatHandleWsReaction(msg);
};
socket.onclose = function () {
if (!document.getElementById('chat-app')) return;
chatWsFailures += 1;
var delay = Math.min(chatWsMaxBackoff, 3000 * Math.pow(2, Math.min(chatWsFailures - 1, 4)));
chatReconnectTimer = setTimeout(chatConnectSocket, delay);
};
socket.onerror = function () {
try { socket.close(); } catch (e) { /* ignore */ }
};
}
function chatUpdateTitle() {
var total = 0;
for (var k in chatUnread) { if (chatUnread.hasOwnProperty(k)) total += (chatUnread[k] || 0); }
var base = (document.title || '').replace(/^\(\d+\)\s*/, '');
document.title = total > 0 ? '(' + total + ') ' + base : base;
}
function chatNotifyNewMessage(post) {
try {
if (window.Notification && Notification.permission === 'granted') {
var who = chatUserNames[post.user_id] || gettext('Новое сообщение');
new Notification(who, {body: (post.message || '').slice(0, 140)});
}
} catch (e) { /* ignore */ }
}
function chatHandleWsPosted(msg) {
if (!msg.data || !msg.data.post) return;
var post;
try { post = JSON.parse(msg.data.post); } catch (e) { return; }
// Повідомлення в іншому каналі: піднімаємо його нагору за активністю і, якщо
// воно не від нас, рахуємо непрочитане + показуємо сповіщення.
if (post.channel_id !== chatCurrentChannelId) {
chatLastActivity[post.channel_id] = post.create_at || Date.now();
if (post.user_id !== chatConfig.currentUserId) {
chatUnread[post.channel_id] = (chatUnread[post.channel_id] || 0) + 1;
chatUpdateTitle();
chatResolveUsernames([post]).then(function () { chatNotifyNewMessage(post); });
}
chatResortAndRender();
return;
}
if (!post.root_id) {
chatResolveUsernames([post]).then(function () { chatRenderMessages([post], false); });
return;
}
chatResolveUsernames([post]).then(function () {
chatPostsById[post.id] = post;
if (chatCurrentThreadRootId === post.root_id) {
chatRenderThreadMessages([post], false);
} else {
chatBumpReplyCount(post.root_id);
}
});
}
function chatHandleWsReaction(msg) {
if (!msg.data || !msg.data.reaction) return;
var reaction;
try { reaction = JSON.parse(msg.data.reaction); } catch (e) { return; }
var post = chatPostsById[reaction.post_id];
if (!post) return;
if (!post.metadata) post.metadata = {};
if (!post.metadata.reactions) post.metadata.reactions = [];
var exists = post.metadata.reactions.some(function (r) {
return r.user_id === reaction.user_id && r.emoji_name === reaction.emoji_name;
});
if (msg.event === 'reaction_added' && !exists) {
post.metadata.reactions.push(reaction);
} else if (msg.event === 'reaction_removed') {
post.metadata.reactions = post.metadata.reactions.filter(function (r) {
return !(r.user_id === reaction.user_id && r.emoji_name === reaction.emoji_name);
});
}
chatRerenderReactions(reaction.post_id);
}
document.addEventListener('keydown', function (e) {
if (!e.target || e.key !== 'Enter' || e.shiftKey) return;
if (e.target.id === 'chat-composer-input') {
e.preventDefault();
chatSendMessage();
} else if (e.target.id === 'chat-thread-composer-input') {
e.preventDefault();
chatThreadSendReply();
}
});