bc04162a31
Иначе Django отдавал 403 на /users/ids, posts, channels/direct и т.п. Co-authored-by: Cursor <cursoragent@cursor.com>
1342 lines
59 KiB
JavaScript
1342 lines
59 KiB
JavaScript
// Переклади для JS приходять із JavaScriptCatalog (/jsi18n/). Якщо каталог не
|
||
// підвантажився — не падаємо, показуємо базові (російські) рядки.
|
||
if (typeof window.gettext !== 'function') { window.gettext = function (s) { return s; }; }
|
||
|
||
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 chatUserIsBot = {}; // userId -> чи це бот (Mattermost is_bot)
|
||
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 співрозмовника (для аватара ЛС)
|
||
var chatUnread = {}; // channelId -> к-сть непрочитаних
|
||
var chatLastActivity = {}; // channelId -> час останнього поста (мс)
|
||
var chatShowArchived = false; // показувати закриті/архівні розмови
|
||
var chatPrefDirectShow = {}; // otherUserId -> "true"/"false" (видимість ЛС)
|
||
var chatPrefGroupShow = {}; // channelId -> "true"/"false" (видимість групового)
|
||
|
||
function chatIsArchived(ch) {
|
||
return !!(ch && ch.delete_at && ch.delete_at > 0);
|
||
}
|
||
|
||
// Співрозмовник у ЛС (навіть до resolve — з імені каналу user1__user2).
|
||
function chatDmOther(ch) {
|
||
if (!ch || ch.type !== 'D') return null;
|
||
if (chatDmOtherUserId[ch.id]) return chatDmOtherUserId[ch.id];
|
||
if (!ch.name || !chatConfig) return null;
|
||
return ch.name.split('__').filter(function (id) { return id !== chatConfig.currentUserId; })[0] || null;
|
||
}
|
||
|
||
// «Закрита» розмова = архівний канал АБО ЛС/груповий, який користувач сховав у
|
||
// Mattermost (preference *_show = "false"). Саме тому вони не видно в клієнті MM.
|
||
function chatIsClosed(ch) {
|
||
if (chatIsArchived(ch)) return true;
|
||
if (ch.type === 'D') {
|
||
var other = chatDmOther(ch);
|
||
return !!other && chatPrefDirectShow[other] === 'false';
|
||
}
|
||
if (ch.type === 'G') return chatPrefGroupShow[ch.id] === 'false';
|
||
return false;
|
||
}
|
||
|
||
// Налаштування видимості ЛС/групових із Mattermost.
|
||
function chatLoadPreferences() {
|
||
return chatApiFetch('/users/me/preferences').then(function (prefs) {
|
||
(prefs || []).forEach(function (p) {
|
||
if (p.category === 'direct_channel_show') chatPrefDirectShow[p.name] = p.value;
|
||
else if (p.category === 'group_channel_show') chatPrefGroupShow[p.name] = p.value;
|
||
});
|
||
}).catch(function () { /* без префів показуємо все */ });
|
||
}
|
||
|
||
function chatToggleArchived(show) {
|
||
chatShowArchived = !!show;
|
||
chatRenderChannelList();
|
||
chatRenderDmList();
|
||
}
|
||
|
||
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 = {};
|
||
chatUserIsBot = {};
|
||
chatPostsById = {};
|
||
chatCurrentChannelId = null;
|
||
chatCurrentThreadRootId = null;
|
||
chatComposerFileIds = [];
|
||
chatThreadComposerFileIds = [];
|
||
chatUploadedFileNames = {};
|
||
chatFileBlobCache = {};
|
||
chatDmPickerSelected = {};
|
||
chatCurrentTeamId = null;
|
||
chatDmOtherUserId = {};
|
||
chatUnread = {};
|
||
chatLastActivity = {};
|
||
chatShowArchived = false;
|
||
chatPrefDirectShow = {};
|
||
chatPrefGroupShow = {};
|
||
chatWsFailures = 0;
|
||
|
||
chatLoadTeams().then(chatLoadPreferences).then(chatLoadChannels).then(chatOpenPendingDm);
|
||
chatConnectSocket();
|
||
|
||
// Дозвіл на браузерні сповіщення про нові повідомлення (не обов'язково).
|
||
try {
|
||
if (window.Notification && Notification.permission === 'default') Notification.requestPermission();
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// Якщо на чат перейшли з картки співробітника (/chat/?dm=<mattermost_id>) —
|
||
// відкриваємо/створюємо ЛС із цим користувачем.
|
||
function chatOpenPendingDm() {
|
||
var dm = null;
|
||
try { dm = new URLSearchParams(window.location.search).get('dm'); } catch (e) { /* ignore */ }
|
||
if (dm) chatOpenDmWithUser(dm);
|
||
}
|
||
|
||
function chatOpenDmWithUser(userId) {
|
||
if (!userId || !chatConfig || userId === chatConfig.currentUserId) return;
|
||
var existing = chatDmChannels.filter(function (c) {
|
||
return c.type === 'D' && chatDmOtherUserId[c.id] === userId;
|
||
})[0];
|
||
if (existing) { chatSelectChannel(existing.id); return; }
|
||
|
||
// POST /channels/direct ідемпотентний: повертає наявний ЛС або створює новий.
|
||
chatApiFetch('/channels/direct', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify([chatConfig.currentUserId, userId]),
|
||
}).then(function (channel) {
|
||
if (!chatDmChannels.some(function (c) { return c.id === channel.id; })) {
|
||
chatDmChannels.unshift(channel);
|
||
}
|
||
chatDmOtherUserId[channel.id] = userId;
|
||
chatResolveDmLabel(channel);
|
||
chatRenderDmList();
|
||
chatSelectChannel(channel.id);
|
||
}).catch(function (e) { console.error(e); });
|
||
}
|
||
|
||
function chatCsrfToken() {
|
||
// CSRF_COOKIE_HTTPONLY=True — cookie из JS не видна; как у HTMX берём из body hx-headers.
|
||
var body = document.body;
|
||
if (body) {
|
||
var hx = body.getAttribute('hx-headers');
|
||
if (hx) {
|
||
try {
|
||
var parsed = JSON.parse(hx);
|
||
if (parsed['X-CSRFToken']) return parsed['X-CSRFToken'];
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
var input = document.querySelector('input[name=csrfmiddlewaretoken]');
|
||
if (input && input.value) return input.value;
|
||
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]*)/);
|
||
return m ? decodeURIComponent(m[1]) : '';
|
||
}
|
||
|
||
function chatApiFetch(path, options) {
|
||
options = options || {};
|
||
var headers = Object.assign({
|
||
'Authorization': 'Bearer ' + chatConfig.token,
|
||
'X-CSRFToken': chatCsrfToken(),
|
||
}, options.headers || {});
|
||
// FormData: не фиксируем Content-Type — браузер сам поставит boundary
|
||
if (typeof FormData !== 'undefined' && options.body instanceof FormData) {
|
||
delete headers['Content-Type'];
|
||
}
|
||
options.headers = headers;
|
||
return fetch(chatConfig.apiBase + path, options).then(function (res) {
|
||
if (!res.ok) {
|
||
var err = new Error('Mattermost API ' + path + ' -> ' + res.status);
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
var ct = res.headers.get('content-type') || '';
|
||
if (ct.indexOf('application/json') >= 0 || ct.indexOf('+json') >= 0) {
|
||
return res.json();
|
||
}
|
||
return res.text().then(function (t) {
|
||
try { return JSON.parse(t); } catch (e) { return t; }
|
||
});
|
||
});
|
||
}
|
||
|
||
function chatApiFetchBlob(path) {
|
||
return fetch(chatConfig.apiBase + path, {
|
||
headers: {
|
||
'Authorization': 'Bearer ' + chatConfig.token,
|
||
'X-CSRFToken': chatCsrfToken(),
|
||
},
|
||
}).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;
|
||
chatUserIsBot[u.id] = !!u.is_bot;
|
||
});
|
||
}).catch(function (e) { console.error(e); });
|
||
}
|
||
|
||
function chatResolveUsernames(posts) {
|
||
var ids = posts.map(function (p) { return p.user_id; });
|
||
return chatResolveUserIds(ids);
|
||
}
|
||
|
||
function chatLoadTeams() {
|
||
return chatApiFetch('/users/me/teams').then(function (teams) {
|
||
teams.forEach(function (team) { chatTeamsById[team.id] = team; });
|
||
var sorted = teams.slice().sort(function (a, b) { return a.display_name.localeCompare(b.display_name); });
|
||
if (!chatCurrentTeamId && sorted.length) chatCurrentTeamId = sorted[0].id;
|
||
chatRenderTeamSwitcher();
|
||
}).catch(function (e) { console.error(e); });
|
||
}
|
||
|
||
// Верхній перемикач «кімнат» — команди Mattermost (Офіс / Аптеки / Склад).
|
||
function chatRenderTeamSwitcher() {
|
||
var el = document.getElementById('chat-team-switcher');
|
||
if (!el) return;
|
||
var teams = Object.keys(chatTeamsById).map(function (id) { return chatTeamsById[id]; })
|
||
.sort(function (a, b) { return a.display_name.localeCompare(b.display_name); });
|
||
if (teams.length <= 1) { el.innerHTML = ''; return; } // перемикати нема що
|
||
|
||
el.innerHTML = '';
|
||
var group = document.createElement('div');
|
||
group.style.cssText = 'display:inline-flex; gap:0.25rem; background:var(--color-muted); border-radius:var(--radius); padding:0.25rem;';
|
||
teams.forEach(function (t) {
|
||
var active = t.id === chatCurrentTeamId;
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.textContent = t.display_name;
|
||
btn.style.cssText = 'padding:0.3125rem 0.875rem; font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; border-radius:calc(var(--radius) - 2px); transition:all 150ms;' +
|
||
(active ? ' background:var(--color-card); color:var(--color-card-foreground); box-shadow:0 1px 2px rgb(0 0 0/0.08);'
|
||
: ' background:transparent; color:var(--color-muted-foreground);');
|
||
btn.onclick = function () { chatSelectTeam(t.id); };
|
||
group.appendChild(btn);
|
||
});
|
||
el.appendChild(group);
|
||
}
|
||
|
||
function chatSelectTeam(teamId) {
|
||
chatCloseSearch(); // результати пошуку прив'язані до команди
|
||
chatCurrentTeamId = teamId;
|
||
chatRenderTeamSwitcher();
|
||
chatRenderChannelList();
|
||
}
|
||
|
||
function chatChannelActivity(ch) {
|
||
// Реальний час останнього поста (chatLastActivity) надійніший за last_post_at,
|
||
// який Mattermost у списку каналів часто віддає нульовим для ЛС.
|
||
return chatLastActivity[ch.id] || ch.last_post_at || 0;
|
||
}
|
||
|
||
function chatByRecent(a, b) {
|
||
var aArch = chatIsClosed(a);
|
||
var bArch = chatIsClosed(b);
|
||
if (aArch !== bArch) return aArch ? 1 : -1; // закриті/архівні — завжди вниз
|
||
var diff = chatChannelActivity(b) - chatChannelActivity(a);
|
||
if (diff !== 0) return diff; // найсвіжіші зверху
|
||
return (a.display_name || '').localeCompare(b.display_name || '');
|
||
}
|
||
|
||
function chatLoadChannels() {
|
||
// include_deleted=true — щоб мати закриті/архівні розмови (ховаємо їх за галочкою).
|
||
return chatApiFetch('/users/me/channels?include_deleted=true').then(function (channels) {
|
||
chatChannels = channels
|
||
.filter(function (ch) { return ch.type === 'O' || ch.type === 'P'; })
|
||
.sort(chatByRecent);
|
||
chatDmChannels = channels
|
||
.filter(function (ch) { return ch.type === 'D' || ch.type === 'G'; })
|
||
.sort(chatByRecent);
|
||
chatRenderChannelList();
|
||
chatRenderDmList();
|
||
chatDmChannels.forEach(chatResolveDmLabel);
|
||
chatAutoSelectRecent();
|
||
chatLoadLastActivity(); // уточнюємо порядок за реальним часом останніх постів
|
||
}).catch(function (e) {
|
||
console.error(e);
|
||
var detail = (e && e.status) ? (' (' + e.status + ')') : '';
|
||
var msg = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-destructive);">'
|
||
+ gettext('Не удалось загрузить каналы') + detail + '</div>';
|
||
var el = document.getElementById('chat-channel-list');
|
||
if (el) el.innerHTML = msg;
|
||
var dm = document.getElementById('chat-dm-list');
|
||
if (dm) dm.innerHTML = msg;
|
||
});
|
||
}
|
||
|
||
function chatAutoSelectRecent() {
|
||
// Відкриваємо найсвіжішу розмову за замовчуванням, щоб одразу бачити останні
|
||
// повідомлення (а не порожній екран). Лише якщо користувач ще нічого не обрав.
|
||
if (chatCurrentChannelId) return;
|
||
var candidates = chatChannels.filter(function (ch) {
|
||
return (!chatCurrentTeamId || ch.team_id === chatCurrentTeamId) && !chatIsClosed(ch);
|
||
}).concat(chatDmChannels.filter(function (ch) { return !chatIsClosed(ch); }));
|
||
if (!candidates.length) return;
|
||
var best = candidates.reduce(function (a, b) {
|
||
return chatChannelActivity(b) > chatChannelActivity(a) ? b : a;
|
||
});
|
||
if (best) chatSelectChannel(best.id);
|
||
}
|
||
|
||
// Тягне час останнього поста для кожного каналу/ЛС (list-endpoint last_post_at
|
||
// ненадійний) і після цього пересортовує списки за реальною активністю.
|
||
function chatLoadLastActivity() {
|
||
var all = chatChannels.concat(chatDmChannels);
|
||
if (!all.length) return;
|
||
var pending = all.length;
|
||
var done = function () { if (--pending === 0) chatResortAndRender(); };
|
||
all.forEach(function (ch) {
|
||
chatApiFetch('/channels/' + ch.id + '/posts?per_page=1').then(function (data) {
|
||
var id = (data.order || [])[0];
|
||
var post = id ? data.posts[id] : null;
|
||
if (post && post.create_at) chatLastActivity[ch.id] = post.create_at;
|
||
}).catch(function () { /* ignore */ }).then(done);
|
||
});
|
||
}
|
||
|
||
function chatResortAndRender() {
|
||
chatChannels.sort(chatByRecent);
|
||
chatDmChannels.sort(chatByRecent);
|
||
chatRenderChannelList();
|
||
chatRenderDmList();
|
||
if (!chatCurrentChannelId) chatAutoSelectRecent();
|
||
}
|
||
|
||
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);
|
||
// Позначка «бот» у списку ЛС, якщо співрозмовник — бот (стало відомо після resolve).
|
||
if (otherId && chatUserIsBot[otherId] && !document.getElementById('chat-dm-bot-' + channel.id)) {
|
||
var row = document.getElementById('chat-channel-row-' + channel.id);
|
||
if (row) {
|
||
var b = chatBotBadge();
|
||
b.id = 'chat-dm-bot-' + channel.id;
|
||
b.style.flexShrink = '0';
|
||
row.appendChild(b);
|
||
}
|
||
}
|
||
}).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)
|
||
&& (chatShowArchived || !chatIsClosed(ch));
|
||
});
|
||
|
||
if (!list.length) {
|
||
el.innerHTML = '<div style="padding:1rem; text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground);">' + gettext('Нет каналов') + '</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;
|
||
|
||
var list = chatDmChannels.filter(function (ch) {
|
||
return chatShowArchived || !chatIsClosed(ch);
|
||
});
|
||
|
||
if (!list.length) {
|
||
el.innerHTML = '<div style="padding:0.75rem; text-align:center; font-size:0.75rem; color:var(--color-muted-foreground);">' + gettext('Нет сообщений') + '</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = '';
|
||
list.forEach(function (ch) {
|
||
var otherId = chatDmOtherUserId[ch.id];
|
||
var isBot = ch.type === 'D' && otherId && chatUserIsBot[otherId];
|
||
var row = chatBuildChannelRow(ch.id, chatDmLabels[ch.id] || '…',
|
||
ch.type === 'G' ? gettext('Групповой разговор') : '', {avatar: true, bot: isBot});
|
||
el.appendChild(row);
|
||
// Якщо співрозмовник ЛС уже відомий — одразу показати його аватар.
|
||
if (ch.type === 'D' && otherId) {
|
||
chatApplyAvatar(document.getElementById('chat-dm-avatar-' + ch.id), otherId);
|
||
}
|
||
});
|
||
}
|
||
|
||
function chatBuildChannelRow(channelId, label, subLabel, opts) {
|
||
opts = opts || {};
|
||
var row = document.createElement('div');
|
||
row.id = 'chat-channel-row-' + channelId;
|
||
row.style.cssText = 'display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.75rem; border-bottom:1px solid var(--color-border); cursor:pointer;' +
|
||
(channelId === chatCurrentChannelId ? ' background:hsl(0 72% 44% / 0.06);' : '');
|
||
row.onmouseover = function () { if (channelId !== chatCurrentChannelId) row.style.background = 'var(--color-muted)'; };
|
||
row.onmouseout = function () { if (channelId !== chatCurrentChannelId) row.style.background = ''; };
|
||
row.onclick = function () { chatSelectChannel(channelId); };
|
||
|
||
if (opts.avatar) {
|
||
// Кружок-аватар співрозмовника ЛС; фото підвантажиться в chatResolveDmLabel.
|
||
var av = document.createElement('div');
|
||
av.id = 'chat-dm-avatar-' + channelId;
|
||
av.style.cssText = 'width:1.75rem; height:1.75rem; border-radius:50%; background:hsl(205 80% 55% / 0.15); color:hsl(205 80% 45%); display:flex; align-items:center; justify-content:center; font-size:0.5625rem; font-weight:700; flex-shrink:0;';
|
||
av.textContent = (label && label !== '…') ? label.slice(0, 2).toUpperCase() : '';
|
||
row.appendChild(av);
|
||
}
|
||
|
||
var textCol = document.createElement('div');
|
||
textCol.style.cssText = 'min-width:0; flex:1;';
|
||
|
||
var name = document.createElement('div');
|
||
name.id = 'chat-dm-label-' + channelId;
|
||
name.style.cssText = 'font-size:0.8125rem; font-weight:500; color:var(--color-foreground); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
|
||
name.textContent = label;
|
||
textCol.appendChild(name);
|
||
|
||
if (subLabel) {
|
||
var sub = document.createElement('div');
|
||
sub.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground);';
|
||
sub.textContent = subLabel;
|
||
textCol.appendChild(sub);
|
||
}
|
||
|
||
row.appendChild(textCol);
|
||
|
||
if (opts.bot) {
|
||
var botMark = chatBotBadge();
|
||
botMark.id = 'chat-dm-bot-' + channelId;
|
||
botMark.style.flexShrink = '0';
|
||
row.appendChild(botMark);
|
||
}
|
||
|
||
var unread = chatUnread[channelId] || 0;
|
||
if (unread > 0) {
|
||
name.style.fontWeight = '700';
|
||
name.style.color = 'var(--color-foreground)';
|
||
var badge = document.createElement('span');
|
||
badge.id = 'chat-unread-' + channelId;
|
||
badge.textContent = unread > 99 ? '99+' : String(unread);
|
||
badge.style.cssText = 'flex-shrink:0; min-width:1.125rem; height:1.125rem; padding:0 0.3125rem; border-radius:999px; background:var(--color-primary); color:#fff; font-size:0.625rem; font-weight:700; display:flex; align-items:center; justify-content:center;';
|
||
row.appendChild(badge);
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
function chatFindChannel(channelId) {
|
||
return chatChannels.filter(function (c) { return c.id === channelId; })[0]
|
||
|| chatDmChannels.filter(function (c) { return c.id === channelId; })[0];
|
||
}
|
||
|
||
function chatUpdateHeader(ch) {
|
||
var header = document.getElementById('chat-header');
|
||
if (!header || !ch) return;
|
||
header.innerHTML = '';
|
||
var title = document.createElement('span');
|
||
title.style.cssText = 'font-size:0.875rem; font-weight:600; color:var(--color-foreground);';
|
||
title.textContent = (ch.type === 'O' || ch.type === 'P') ? '#' + ch.display_name : (chatDmLabels[ch.id] || '…');
|
||
header.appendChild(title);
|
||
}
|
||
|
||
function chatSelectChannel(channelId) {
|
||
chatCloseSearch(); // вихід із режиму пошуку при відкритті каналу
|
||
|
||
if (chatUnread[channelId]) { // відкрили канал — скидаємо лічильник непрочитаних
|
||
delete chatUnread[channelId];
|
||
var oldBadge = document.getElementById('chat-unread-' + channelId);
|
||
if (oldBadge) oldBadge.remove();
|
||
var oldName = document.getElementById('chat-dm-label-' + channelId);
|
||
if (oldName) oldName.style.fontWeight = '500';
|
||
chatUpdateTitle();
|
||
}
|
||
|
||
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);">' + gettext('Загрузка…') + '</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);">' + gettext('Не удалось загрузить сообщения') + '</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');
|
||
}
|
||
|
||
// ── Пошук повідомлень (Mattermost /teams/{id}/posts/search) ──────────────────
|
||
|
||
function chatCloseSearch() {
|
||
var resultsEl = document.getElementById('chat-search-results');
|
||
var messagesEl = document.getElementById('chat-messages');
|
||
if (resultsEl) { resultsEl.style.display = 'none'; resultsEl.innerHTML = ''; }
|
||
if (messagesEl) messagesEl.style.display = 'flex';
|
||
var input = document.getElementById('chat-search-input');
|
||
if (input) input.value = '';
|
||
}
|
||
|
||
function chatSearchChannelLabel(channelId) {
|
||
var ch = chatFindChannel(channelId);
|
||
if (!ch) return '';
|
||
if (ch.type === 'O' || ch.type === 'P') return '#' + ch.display_name;
|
||
return chatDmLabels[ch.id] || gettext('Личное');
|
||
}
|
||
|
||
function chatSearchMessages(terms) {
|
||
terms = (terms || '').trim();
|
||
var resultsEl = document.getElementById('chat-search-results');
|
||
var messagesEl = document.getElementById('chat-messages');
|
||
if (!resultsEl) return;
|
||
if (!terms) { chatCloseSearch(); return; }
|
||
|
||
// Пошук по УСІХ командах (Mattermost search — per-team), результати зливаємо.
|
||
// ЛС/групові не прив'язані до команди й потрапляють у видачу будь-якої з них.
|
||
var teamIds = Object.keys(chatTeamsById);
|
||
if (!teamIds.length && chatCurrentTeamId) teamIds = [chatCurrentTeamId];
|
||
if (!teamIds.length) return;
|
||
|
||
resultsEl.style.display = 'flex';
|
||
if (messagesEl) messagesEl.style.display = 'none';
|
||
resultsEl.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground); padding:1rem;">' + gettext('Поиск…') + '</div>';
|
||
|
||
var body = JSON.stringify({terms: terms, is_or_search: false, per_page: 30});
|
||
var merged = {}; // id -> post (дедуплікація між командами)
|
||
Promise.all(teamIds.map(function (tid) {
|
||
return chatApiFetch('/teams/' + tid + '/posts/search', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: body,
|
||
}).then(function (data) {
|
||
(data.order || []).forEach(function (id) {
|
||
if (data.posts && data.posts[id]) merged[id] = data.posts[id];
|
||
});
|
||
}).catch(function () { /* збій однієї команди не валить весь пошук */ });
|
||
})).then(function () {
|
||
var posts = Object.keys(merged).map(function (id) { return merged[id]; });
|
||
return chatResolveUsernames(posts).then(function () {
|
||
chatRenderSearchResults(posts);
|
||
});
|
||
}).catch(function (e) {
|
||
console.error(e);
|
||
resultsEl.innerHTML = '<div style="text-align:center; font-size:0.8125rem; color:var(--color-destructive); padding:1rem;">' + gettext('Не удалось выполнить поиск') + '</div>';
|
||
});
|
||
}
|
||
|
||
function chatRenderSearchResults(posts) {
|
||
var el = document.getElementById('chat-search-results');
|
||
if (!el) return;
|
||
el.innerHTML = '';
|
||
|
||
var head = document.createElement('div');
|
||
head.style.cssText = 'display:flex; align-items:center; justify-content:space-between; margin-bottom:0.25rem;';
|
||
var title = document.createElement('span');
|
||
title.style.cssText = 'font-size:0.75rem; color:var(--color-muted-foreground);';
|
||
title.textContent = gettext('Найдено:') + ' ' + posts.length;
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.textContent = gettext('Закрыть поиск');
|
||
closeBtn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:0.75rem; color:var(--color-primary);';
|
||
closeBtn.onclick = chatCloseSearch;
|
||
head.appendChild(title);
|
||
head.appendChild(closeBtn);
|
||
el.appendChild(head);
|
||
|
||
if (!posts.length) {
|
||
var none = document.createElement('div');
|
||
none.style.cssText = 'text-align:center; font-size:0.8125rem; color:var(--color-muted-foreground); padding:1rem;';
|
||
none.textContent = gettext('Ничего не найдено');
|
||
el.appendChild(none);
|
||
return;
|
||
}
|
||
|
||
posts.sort(function (a, b) { return (b.create_at || 0) - (a.create_at || 0); });
|
||
posts.forEach(function (post) {
|
||
var row = document.createElement('div');
|
||
row.style.cssText = 'padding:0.625rem 0.75rem; border:1px solid var(--color-border); border-radius:0.5rem; cursor:pointer;';
|
||
row.onmouseover = function () { row.style.background = 'var(--color-muted)'; };
|
||
row.onmouseout = function () { row.style.background = ''; };
|
||
row.onclick = function () { chatSelectChannel(post.channel_id); };
|
||
|
||
var meta = document.createElement('div');
|
||
meta.style.cssText = 'display:flex; justify-content:space-between; gap:0.5rem; margin-bottom:0.25rem;';
|
||
var who = document.createElement('span');
|
||
who.style.cssText = 'font-size:0.75rem; font-weight:600; color:var(--color-foreground); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
|
||
who.textContent = (chatUserNames[post.user_id] || '') + ' · ' + chatSearchChannelLabel(post.channel_id);
|
||
var when = document.createElement('span');
|
||
when.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground); flex-shrink:0;';
|
||
when.textContent = post.create_at ? new Date(post.create_at).toLocaleString() : '';
|
||
meta.appendChild(who);
|
||
meta.appendChild(when);
|
||
|
||
var msg = document.createElement('div');
|
||
msg.style.cssText = 'font-size:0.8125rem; color:var(--color-foreground); white-space:pre-wrap; word-break:break-word;';
|
||
msg.textContent = post.message || '';
|
||
|
||
row.appendChild(meta);
|
||
row.appendChild(msg);
|
||
el.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function chatPostIsBot(post) {
|
||
if (chatUserIsBot[post.user_id]) return true;
|
||
var p = post.props || {};
|
||
return p.from_bot === 'true' || p.from_webhook === 'true';
|
||
}
|
||
|
||
function chatBotBadge() {
|
||
var b = document.createElement('span');
|
||
b.textContent = gettext('бот');
|
||
b.style.cssText = 'font-size:0.5625rem; font-weight:700; letter-spacing:0.03em; text-transform:uppercase; color:var(--color-muted-foreground); background:var(--color-muted); border:1px solid var(--color-border); border-radius:0.25rem; padding:0.0625rem 0.25rem; line-height:1.2;';
|
||
return b;
|
||
}
|
||
|
||
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 ? gettext('Вы') : 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);
|
||
if (!isMine && chatPostIsBot(post)) meta.appendChild(chatBotBadge());
|
||
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 = '🙂 ' + gettext('Реакция');
|
||
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 ? ('💬 ' + gettext('Ответов:') + ' ' + post.reply_count) : ('↩ ' + gettext('Ответить'));
|
||
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);">' + gettext('Загрузка…') + '</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);">' + gettext('Не удалось загрузить обсуждение') + '</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 = '💬 ' + gettext('Ответов:') + ' ' + 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);">' + gettext('Никого не найдено') + '</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); });
|
||
}
|
||
|
||
var chatWsFailures = 0;
|
||
var chatWsMaxBackoff = 60000;
|
||
|
||
function chatConnectSocket() {
|
||
if (!chatConfig || !chatConfig.wsUrl) return;
|
||
// Live WS идёт на public_url Mattermost (не через Django). Без AllowCorsFrom /
|
||
// разрешения Origin на chat.* будет 403 — не долбим реконнект бесконечно.
|
||
if (chatWsFailures >= 8) return;
|
||
|
||
var socket;
|
||
try {
|
||
socket = new WebSocket(chatConfig.wsUrl);
|
||
} catch (e) {
|
||
console.warn('chat websocket create failed', e);
|
||
chatWsFailures += 1;
|
||
return;
|
||
}
|
||
chatSocket = socket;
|
||
|
||
socket.onopen = function () {
|
||
chatWsFailures = 0;
|
||
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;
|
||
chatWsFailures += 1;
|
||
var delay = Math.min(chatWsMaxBackoff, 3000 * Math.pow(2, Math.min(chatWsFailures - 1, 4)));
|
||
chatReconnectTimer = setTimeout(chatConnectSocket, delay);
|
||
};
|
||
|
||
socket.onerror = function () {
|
||
try { socket.close(); } catch (e) { /* ignore */ }
|
||
};
|
||
}
|
||
|
||
function chatUpdateTitle() {
|
||
var total = 0;
|
||
for (var k in chatUnread) { if (chatUnread.hasOwnProperty(k)) total += (chatUnread[k] || 0); }
|
||
var base = (document.title || '').replace(/^\(\d+\)\s*/, '');
|
||
document.title = total > 0 ? '(' + total + ') ' + base : base;
|
||
}
|
||
|
||
function chatNotifyNewMessage(post) {
|
||
try {
|
||
if (window.Notification && Notification.permission === 'granted') {
|
||
var who = chatUserNames[post.user_id] || gettext('Новое сообщение');
|
||
new Notification(who, {body: (post.message || '').slice(0, 140)});
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
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) {
|
||
chatLastActivity[post.channel_id] = post.create_at || Date.now();
|
||
if (post.user_id !== chatConfig.currentUserId) {
|
||
chatUnread[post.channel_id] = (chatUnread[post.channel_id] || 0) + 1;
|
||
chatUpdateTitle();
|
||
chatResolveUsernames([post]).then(function () { chatNotifyNewMessage(post); });
|
||
}
|
||
chatResortAndRender();
|
||
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();
|
||
}
|
||
});
|