Инфраструктура: 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:
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user