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 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 співрозмовника (для аватара ЛС) 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 = {}; chatPostsById = {}; chatCurrentChannelId = null; chatCurrentThreadRootId = null; chatComposerFileIds = []; chatThreadComposerFileIds = []; chatUploadedFileNames = {}; chatFileBlobCache = {}; chatDmPickerSelected = {}; chatCurrentTeamId = null; chatDmOtherUserId = {}; chatLoadTeams().then(chatLoadChannels); chatConnectSocket(); } 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; }); }).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 chatByRecent(a, b) { // Спершу найсвіжіші за останнім повідомленням, потім за назвою. var diff = (b.last_post_at || 0) - (a.last_post_at || 0); return diff !== 0 ? diff : (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(); }).catch(function (e) { console.error(e); var el = document.getElementById('chat-channel-list'); if (el) el.innerHTML = '
Не вдалося завантажити канали
'; }); } function chatAutoSelectRecent() { // Відкриваємо найсвіжішу розмову за замовчуванням, щоб одразу бачити останні // повідомлення (а не порожній екран). Лише якщо користувач ще нічого не обрав. if (chatCurrentChannelId) return; var candidates = chatChannels.filter(function (ch) { return !chatCurrentTeamId || ch.team_id === chatCurrentTeamId; }).concat(chatDmChannels); if (!candidates.length) return; var best = candidates.reduce(function (a, b) { return (b.last_post_at || 0) > (a.last_post_at || 0) ? b : a; }); if (best) chatSelectChannel(best.id); } 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 = '
Немає каналів
'; 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 = '
Немає повідомлень
'; 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); 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(); // вихід із режиму пошуку при відкритті каналу 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 = '
Завантаження…
'; 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 = '
Не вдалося завантажити повідомлення
'; }); } 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; } if (!chatCurrentTeamId) return; resultsEl.style.display = 'flex'; if (messagesEl) messagesEl.style.display = 'none'; resultsEl.innerHTML = '
Пошук…
'; chatApiFetch('/teams/' + chatCurrentTeamId + '/posts/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({terms: terms, is_or_search: false, per_page: 30}), }).then(function (data) { var ids = data.order || []; var posts = ids.map(function (id) { return data.posts[id]; }).filter(Boolean); return chatResolveUsernames(posts).then(function () { chatRenderSearchResults(posts); }); }).catch(function (e) { console.error(e); resultsEl.innerHTML = '
Не вдалося виконати пошук
'; }); } 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 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); 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 = '
Завантаження…
'; 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 = '
Не вдалося завантажити тред
'; }); } 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 = '
Нікого не знайдено
'; 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 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) 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(); } });