Чат: сортировка по активности, непрочитанные, кнопка «Написати»

- Порядок каналов/ЛС — по реальному времени последнего поста (тянем per-channel,
  т.к. last_post_at из списка каналов ненадёжен), архивные (delete_at>0) — вниз
- Непрочитанные: счётчик по websocket, бейдж на строке + жирное имя, префикс
  «(N)» в тайтле, браузерное уведомление; сброс при открытии канала
- Кнопка «Написати в чат» на карточке сотрудника → /chat/?dm=<mm_id>,
  чат открывает/создаёт ЛС с этим человеком (идемпотентный /channels/direct)
- Тесты кнопки чата в EmployeeDetailTests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:08:53 +03:00
parent ea2cc9daca
commit 85bd89f762
3 changed files with 158 additions and 9 deletions
+132 -8
View File
@@ -25,6 +25,8 @@ var chatDmPickerSelected = {};
var chatReactionPickerEl = null;
var chatCurrentTeamId = null; // обрана «кімната» (команда Mattermost)
var chatDmOtherUserId = {}; // channelId -> id співрозмовника (для аватара ЛС)
var chatUnread = {}; // channelId -> к-сть непрочитаних
var chatLastActivity = {}; // channelId -> час останнього поста (мс)
function chatPanelOpen(id) {
var panel = document.getElementById(id);
@@ -75,9 +77,47 @@ function chatInit() {
chatDmPickerSelected = {};
chatCurrentTeamId = null;
chatDmOtherUserId = {};
chatUnread = {};
chatLastActivity = {};
chatLoadTeams().then(chatLoadChannels);
chatLoadTeams().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 chatApiFetch(path, options) {
@@ -182,10 +222,19 @@ function chatSelectTeam(teamId) {
chatRenderChannelList();
}
function chatChannelActivity(ch) {
// Реальний час останнього поста (chatLastActivity) надійніший за last_post_at,
// який Mattermost у списку каналів часто віддає нульовим для ЛС.
return chatLastActivity[ch.id] || ch.last_post_at || 0;
}
function chatByRecent(a, b) {
// Спершу найсвіжіші за останнім повідомленням, потім за назвою.
var diff = (b.last_post_at || 0) - (a.last_post_at || 0);
return diff !== 0 ? diff : (a.display_name || '').localeCompare(b.display_name || '');
var aArch = !!(a.delete_at && a.delete_at > 0);
var bArch = !!(b.delete_at && b.delete_at > 0);
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() {
@@ -200,6 +249,7 @@ function chatLoadChannels() {
chatRenderDmList();
chatDmChannels.forEach(chatResolveDmLabel);
chatAutoSelectRecent();
chatLoadLastActivity(); // уточнюємо порядок за реальним часом останніх постів
}).catch(function (e) {
console.error(e);
var el = document.getElementById('chat-channel-list');
@@ -212,15 +262,40 @@ function chatAutoSelectRecent() {
// повідомлення (а не порожній екран). Лише якщо користувач ще нічого не обрав.
if (chatCurrentChannelId) return;
var candidates = chatChannels.filter(function (ch) {
return !chatCurrentTeamId || ch.team_id === chatCurrentTeamId;
}).concat(chatDmChannels);
return (!chatCurrentTeamId || ch.team_id === chatCurrentTeamId)
&& !(ch.delete_at && ch.delete_at > 0);
}).concat(chatDmChannels.filter(function (ch) { return !(ch.delete_at && ch.delete_at > 0); }));
if (!candidates.length) return;
var best = candidates.reduce(function (a, b) {
return (b.last_post_at || 0) > (a.last_post_at || 0) ? b : a;
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;
@@ -329,6 +404,18 @@ function chatBuildChannelRow(channelId, label, subLabel, opts) {
}
row.appendChild(textCol);
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;
}
@@ -349,6 +436,16 @@ function chatUpdateHeader(ch) {
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 = '';
@@ -991,12 +1088,39 @@ function chatConnectSocket() {
};
}
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] || 'Нове повідомлення';
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) 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); });