Инфраструктура: Celery, общие утилиты, виджеты, шелл портала

- acore: Celery-приложение (брокер из env, eager без брокера), настройки
  CELERY_*, регистрация всех новых приложений в INSTALLED_APPS, маршруты
- core/utils: render_page (HTMX-фрагмент/полный layout), is_owner/
  is_owner_or_staff; templatetags common_tags (initials, clean_excerpt,
  is_it_staff); UpdateLastSeenMiddleware
- static: people-picker (дерево отделов с аватарками), searchable-select
  (одиночный выбор с поиском), directory-quick-add, it-manager, pagination;
  тема Tailwind v4 пересобрана, лого
- templates: новый шелл index.html (мобильный сайдбар, темы, фон,
  user-dropdown) и login с брендингом

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 17:31:01 +03:00
parent c3f56f8a3e
commit 78475b58e9
22 changed files with 2996 additions and 22 deletions
+827
View File
@@ -0,0 +1,827 @@
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();
});
}
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();
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 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();
}
});