Files
ACORE/static/assets/js/chat.js
T
lokses 8a4577f165 Чат: аватар собеседника в списке ЛС + переключатель комнат
- Аватар співрозмовника напроти особистого повідомлення (лівий список):
  chatBuildChannelRow отримав опцію avatar; id співрозмовника ЛС резолвиться
  в chatResolveDmLabel і підвантажує фото (chatApplyAvatar). Групові ЛС —
  без фото, з ініціалами.
- Верхній перемикач «кімнат» — команди Mattermost (Офіс / Аптеки / Склад):
  chatRenderTeamSwitcher/chatSelectTeam; chatRenderChannelList фільтрує канали
  за обраною командою; за замовчуванням — перша команда. ЛС показуються завжди.

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

924 lines
39 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;
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) {
chatCurrentTeamId = teamId;
chatRenderTeamSwitcher();
chatRenderChannelList();
}
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;
var otherId = null;
if (channel.type === 'D') {
otherId = channel.name.split('__').filter(function (id) { return id !== chatConfig.currentUserId; })[0];
chatDmOtherUserId[channel.id] = otherId;
promise = chatResolveUserIds([otherId]).then(function () {
return chatUserNames[otherId] || otherId;
});
} else {
promise = chatApiFetch('/channels/' + channel.id + '/members').then(function (members) {
var ids = members.map(function (m) { return m.user_id; }).filter(function (id) { return id !== chatConfig.currentUserId; });
return chatResolveUserIds(ids).then(function () {
return ids.map(function (id) { return chatUserNames[id] || id; }).join(', ');
});
});
}
return promise.then(function (label) {
chatDmLabels[channel.id] = label;
var labelEl = document.getElementById('chat-dm-label-' + channel.id);
if (labelEl) labelEl.textContent = label;
var avEl = document.getElementById('chat-dm-avatar-' + channel.id);
if (avEl) {
avEl.textContent = (label || '').slice(0, 2).toUpperCase();
if (otherId) chatApplyAvatar(avEl, otherId); // фото — лише для особистих (не групових)
}
if (chatCurrentChannelId === channel.id) chatUpdateHeader(channel);
}).catch(function (e) { console.error(e); });
}
function chatRenderChannelList() {
var el = document.getElementById('chat-channel-list');
if (!el) return;
var list = chatChannels.filter(function (ch) {
return !chatCurrentTeamId || ch.team_id === chatCurrentTeamId;
});
if (!list.length) {
el.innerHTML = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">Немає каналів</div>';
return;
}
el.innerHTML = '';
list.forEach(function (ch) {
// Команда обрана перемикачем зверху — назву команди у рядок не дублюємо.
var row = chatBuildChannelRow(ch.id, '#' + ch.display_name, '');
el.appendChild(row);
});
}
function chatRenderDmList() {
var el = document.getElementById('chat-dm-list');
if (!el) return;
if (!chatDmChannels.length) {
el.innerHTML = '<div style="padding:0.75rem; text-align:center; font-size:0.75rem; color:var(--color-muted-foreground);">Немає повідомлень</div>';
return;
}
el.innerHTML = '';
chatDmChannels.forEach(function (ch) {
var row = chatBuildChannelRow(ch.id, chatDmLabels[ch.id] || '…',
ch.type === 'G' ? 'Групова розмова' : '', {avatar: true});
el.appendChild(row);
// Якщо співрозмовник ЛС уже відомий — одразу показати його аватар.
if (ch.type === 'D' && chatDmOtherUserId[ch.id]) {
chatApplyAvatar(document.getElementById('chat-dm-avatar-' + ch.id), chatDmOtherUserId[ch.id]);
}
});
}
function chatBuildChannelRow(channelId, label, subLabel, opts) {
opts = opts || {};
var row = document.createElement('div');
row.id = 'chat-channel-row-' + channelId;
row.style.cssText = 'display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.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); };
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) {
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();
}
});