Чат: сортировка по активности, непрочитанные, кнопка «Написати»
- Порядок каналов/ЛС — по реальному времени последнего поста (тянем 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:
+132
-8
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user