Files
ACORE/static/assets/js/chat.js
T
lokses 97bf274c6b Чат: поиск сообщений по всем командам, а не только по текущей
Mattermost search per-team — опрашиваем все команды пользователя параллельно
и сливаем результаты с дедупликацией по id. ЛС/групповые попадают в выдачу
любой команды. Сбой одной команды не валит весь поиск.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 21:24:35 +03:00

1204 lines
53 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.
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 -> час останнього поста (мс)
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 = {};
chatLoadTeams().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 chatApiFetch(path, options) {
options = options || {};
options.headers = Object.assign({'Authorization': 'Bearer ' + chatConfig.token}, options.headers || {});
return fetch(chatConfig.apiBase + path, options).then(function (res) {
if (!res.ok) throw new Error('Mattermost API ' + path + ' -> ' + res.status);
return res.json();
});
}
function chatApiFetchBlob(path) {
return fetch(chatConfig.apiBase + path, {headers: {'Authorization': 'Bearer ' + chatConfig.token}}).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 = !!(a.delete_at && a.delete_at > 0);
var bArch = !!(b.delete_at && b.delete_at > 0);
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() {
return chatApiFetch('/users/me/channels').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 el = document.getElementById('chat-channel-list');
if (el) el.innerHTML = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-destructive);">Не вдалося завантажити канали</div>';
});
}
function chatAutoSelectRecent() {
// Відкриваємо найсвіжішу розмову за замовчуванням, щоб одразу бачити останні
// повідомлення (а не порожній екран). Лише якщо користувач ще нічого не обрав.
if (chatCurrentChannelId) return;
var candidates = chatChannels.filter(function (ch) {
return (!chatCurrentTeamId || ch.team_id === chatCurrentTeamId)
&& !(ch.delete_at && ch.delete_at > 0);
}).concat(chatDmChannels.filter(function (ch) { return !(ch.delete_at && ch.delete_at > 0); }));
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);
}).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;
});
if (!list.length) {
el.innerHTML = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">Немає каналів</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;
if (!chatDmChannels.length) {
el.innerHTML = '<div style="padding:0.75rem; text-align:center; font-size:0.75rem; color:var(--color-muted-foreground);">Немає повідомлень</div>';
return;
}
el.innerHTML = '';
chatDmChannels.forEach(function (ch) {
var row = chatBuildChannelRow(ch.id, chatDmLabels[ch.id] || '…',
ch.type === 'G' ? 'Групова розмова' : '', {avatar: true});
el.appendChild(row);
// Якщо співрозмовник ЛС уже відомий — одразу показати його аватар.
if (ch.type === 'D' && chatDmOtherUserId[ch.id]) {
chatApplyAvatar(document.getElementById('chat-dm-avatar-' + ch.id), chatDmOtherUserId[ch.id]);
}
});
}
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);
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);">Завантаження…</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);">Не вдалося завантажити повідомлення</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] || 'Особисте';
}
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;">Пошук…</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;">Не вдалося виконати пошук</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 = 'Знайдено: ' + posts.length;
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = 'Закрити пошук';
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 = 'Нічого не знайдено';
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 = 'бот';
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 ? 'Ви' : 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 = '🙂 Реакція';
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 ? ('💬 ' + post.reply_count + ' відповідей') : '↩ Відповісти';
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);">Завантаження…</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);">Не вдалося завантажити тред</div>';
});
}
function chatThreadSendReply() {
var input = document.getElementById('chat-thread-composer-input');
var rootId = chatCurrentThreadRootId;
if (!input || !rootId) return;
var message = input.value.trim();
if (!message) 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: chatThreadComposerFileIds}),
}).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 = '💬 ' + root.reply_count + ' відповідей';
}
function chatSendMessage() {
var input = document.getElementById('chat-composer-input');
if (!input || !chatCurrentChannelId) return;
var message = input.value.trim();
if (!message) return;
var channelId = chatCurrentChannelId;
var fileIds = chatComposerFileIds.slice();
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);">Нікого не знайдено</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); });
}
function chatConnectSocket() {
if (!chatConfig) return;
var socket = new WebSocket(chatConfig.wsUrl);
chatSocket = socket;
socket.onopen = function () {
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;
chatReconnectTimer = setTimeout(chatConnectSocket, 3000);
};
socket.onerror = function () {
socket.close();
};
}
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] || 'Нове повідомлення';
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();
}
});