Files
ACORE/static/assets/js/chat.js
T
lokses 479fe3e0d7 Чат: аватары-фото сотрудников и ботов
- chatApplyAvatar(el, userId): подгружает фото из Mattermost
  /users/{id}/image (работает и для сотрудников, и для ботов) и накладывает
  на кружок-аватар; до загрузки/при ошибке остаются инициалы. Кэш по userId.
- Применено в сообщениях (chatBuildMessageEl) и в пикере коллег
  (chatRenderDmPickerList)
- Переиспользует существующий chatApiFetchBlob (Bearer-токен)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:10:09 +03:00

856 lines
35 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 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;
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 = {};
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; });
}).catch(function (e) { console.error(e); });
}
function chatLoadChannels() {
return chatApiFetch('/users/me/channels').then(function (channels) {
chatChannels = channels
.filter(function (ch) { return ch.type === 'O' || ch.type === 'P'; })
.sort(function (a, b) { return a.display_name.localeCompare(b.display_name); });
chatDmChannels = channels.filter(function (ch) { return ch.type === 'D' || ch.type === 'G'; });
chatRenderChannelList();
chatRenderDmList();
chatDmChannels.forEach(chatResolveDmLabel);
}).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 chatResolveDmLabel(channel) {
var promise;
if (channel.type === 'D') {
var otherId = channel.name.split('__').filter(function (id) { return id !== chatConfig.currentUserId; })[0];
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;
if (chatCurrentChannelId === channel.id) chatUpdateHeader(channel);
}).catch(function (e) { console.error(e); });
}
function chatRenderChannelList() {
var el = document.getElementById('chat-channel-list');
if (!el) return;
if (!chatChannels.length) {
el.innerHTML = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">Немає каналів</div>';
return;
}
el.innerHTML = '';
chatChannels.forEach(function (ch) {
var team = chatTeamsById[ch.team_id];
var row = chatBuildChannelRow(ch.id, '#' + ch.display_name, team ? team.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' ? 'Групова розмова' : '');
el.appendChild(row);
});
}
function chatBuildChannelRow(channelId, label, subLabel) {
var row = document.createElement('div');
row.id = 'chat-channel-row-' + channelId;
row.style.cssText = 'padding:0.5rem 0.625rem; 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); };
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);';
name.textContent = label;
row.appendChild(name);
if (subLabel) {
var sub = document.createElement('div');
sub.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground);';
sub.textContent = subLabel;
row.appendChild(sub);
}
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) {
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');
}
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 = '<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 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();
}
});