// Переклади для 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); if (panel) panel.style.display = 'flex'; document.body.style.overflow = 'hidden'; } function chatPanelClose(id) { var panel = document.getElementById(id); if (panel) panel.style.display = 'none'; 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=) — // відкриваємо/створюємо ЛС із цим користувачем. 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 = '
' + gettext('Не удалось загрузить каналы') + detail + '
'; 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 = '
' + gettext('Нет каналов') + '
'; 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 = '
' + gettext('Нет сообщений') + '
'; 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 = '
' + gettext('Загрузка…') + '
'; 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 = '
' + gettext('Не удалось загрузить сообщения') + '
'; }); } 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 = '
' + gettext('Поиск…') + '
'; 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 = '
' + gettext('Не удалось выполнить поиск') + '
'; }); } 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 = '
' + gettext('Загрузка…') + '
'; 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 = '
' + gettext('Не удалось загрузить обсуждение') + '
'; }); } 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 = '
' + gettext('Никого не найдено') + '
'; 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(); } });