diff --git a/core/tests.py b/core/tests.py index d53ba86..570e500 100644 --- a/core/tests.py +++ b/core/tests.py @@ -165,6 +165,24 @@ class EmployeeDetailTests(TestCase): response = self.client.get('/login/') self.assertEqual(response.status_code, 200) + def test_chat_button_shown_for_employee_with_mattermost(self): + login_2fa(self.client, self.staff) + target = make_employee(mattermost_user_id='mm-xyz') + html = self.client.get(f'/employees/{target.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertIn('Написати в чат', html) + self.assertIn('/chat/?dm=mm-xyz', html) + + def test_chat_button_hidden_on_own_page(self): + target = make_employee(mattermost_user_id='mm-self') + login_2fa(self.client, target) + html = self.client.get(f'/employees/{target.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertNotIn('Написати в чат', html) + + def test_chat_button_hidden_without_mattermost(self): + login_2fa(self.client, self.staff) + html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertNotIn('Написати в чат', html) + class ITSupportViewTests(TestCase): def setUp(self): diff --git a/static/assets/js/chat.js b/static/assets/js/chat.js index 33b4072..0816674 100644 --- a/static/assets/js/chat.js +++ b/static/assets/js/chat.js @@ -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=) — +// відкриваємо/створюємо ЛС із цим користувачем. +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); }); diff --git a/templates/pages/employee-detail.html b/templates/pages/employee-detail.html index ba094e2..280ba28 100644 --- a/templates/pages/employee-detail.html +++ b/templates/pages/employee-detail.html @@ -12,8 +12,15 @@ До списку співробітників
- + + Написати в чат + + {% endif %} + Призначити задачу