diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js index a8681ae6a..787361173 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js @@ -570,572 +570,3 @@ } } })(); - -(function () { - if (globalThis.__unityRealtimeWidgetInitialized) { - return; - } - - globalThis.__unityRealtimeWidgetInitialized = true; - - whenReady(init); - - function whenReady(callback) { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', callback); - } else { - callback(); - } - } - - function init() { - if (/\/(account\/login|login|splash)(?:\/|$)/i.test(window.location.pathname)) { - return; - } - - if (window.location.pathname === '/') { - return; - } - - if (abp.features && typeof abp.features.isEnabled === 'function' - && !abp.features.isEnabled('Unity.Notifications.DirectMessaging')) { - return; - } - - if (typeof signalR === 'undefined') { - return; - } - - if (!window.abp?.currentUser?.isAuthenticated) { - return; - } - - const l = window.abp?.localization?.getResource?.('Notifications') - || function (key) { return key; }; - - const myUserId = abp.currentUser.id || null; - - let unreadCount = 0; - let panelOpen = false; - let peers = []; - let activeMode = 'individual'; - let currentTenant = null; - const histories = {}; - const MAX_HISTORY_ITEMS = 100; - const STATUS_GREEN_MS = 10 * 60 * 1000; - const STATUS_ORANGE_MS = 30 * 60 * 1000; - const widget = buildWidget(); - - const connection = new signalR.HubConnectionBuilder() - .withUrl('/signalr/notifications') - .withAutomaticReconnect() - .build(); - - connection.on('directMessageReceived', function (eventData) { - const scope = eventData?.scope || 'user'; - const sender = eventData?.senderName || eventData?.senderId || 'unknown'; - const senderId = eventData?.senderId; - const message = eventData?.message || ''; - const targetId = scope === 'tenant' - ? eventData?.tenantId || currentTenant?.id - : senderId; - - if (!targetId) { - return; - } - - const mode = scope === 'tenant' ? 'tenant' : 'individual'; - addMessage(mode, targetId, sender, senderId, message, eventData?.timestamp); - - if (scope === 'user') { - activeMode = 'individual'; - ensurePeerOption(senderId, sender); - } else { - activeMode = 'tenant'; - } - - widget.modeTabs.forEach(tab => tab.classList.toggle('active', tab.dataset.mode === activeMode)); - renderTargetOptions(); - widget.target.value = targetId; - renderConversation(); - - if (eventData.source === 'UnityMessagingController' && senderId && senderId !== myUserId) { - showIncomingToast(sender, message); - } - - }); - - connection.on('tenantPresenceUpdated', function (result) { - applyPeers(result); - }); - - const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; - const ACTIVITY_HEARTBEAT_THROTTLE_MS = 60 * 1000; - const ACTIVITY_EVENTS = ['click', 'keydown', 'mousemove', 'scroll']; - let lastHeartbeatSentAt = 0; - - connection.start().then(function () { - sendHeartbeat(true); - refreshPeers(); - refreshTenant(); - refreshUnreadMessages(); - setInterval(function () { sendHeartbeat(true); }, HEARTBEAT_INTERVAL_MS); - - ACTIVITY_EVENTS.forEach(function (eventName) { - document.addEventListener(eventName, function () { sendHeartbeat(false); }, { passive: true }); - }); - }).catch(function () { - // Keep normal page flow even when realtime connection is unavailable. - }); - - function refreshUnreadMessages() { - if (connection.state !== signalR.HubConnectionState.Connected) { - return; - } - - connection.invoke('GetUnreadMessagesAsync').then(function (messages) { - (Array.isArray(messages) ? messages : []).forEach(function (eventData) { - const scope = eventData.scope === 'tenant' ? 'tenant' : 'user'; - const senderId = eventData.senderId || null; - const targetId = scope === 'tenant' ? eventData.targetId : senderId; - - if (!targetId) { - return; - } - - addMessage( - scope === 'tenant' ? 'tenant' : 'individual', - targetId, - eventData.senderName || senderId || 'unknown', - senderId, - eventData.message || '', - eventData.timestamp - ); - - if (scope === 'user') { - ensurePeerOption(senderId, eventData.senderName || senderId); - } - - if (eventData.source === 'UnityMessagingController' && senderId && senderId !== myUserId) { - showIncomingToast(eventData.senderName || senderId, eventData.message || ''); - } - }); - }).catch(function () { - // Unread history is a convenience; realtime delivery remains available. - }); - } - - function sendHeartbeat(force) { - if (connection.state !== signalR.HubConnectionState.Connected) { - return; - } - - const now = Date.now(); - if (!force && now - lastHeartbeatSentAt < ACTIVITY_HEARTBEAT_THROTTLE_MS) { - return; - } - - lastHeartbeatSentAt = now; - connection.invoke('HeartbeatAsync').catch(function () { - // Ignore heartbeat failures; presence will simply age out. - }); - } - - function refreshPeers() { - if (connection.state !== signalR.HubConnectionState.Connected) { - return; - } - - connection.invoke('GetTenantUsersAsync').then(function (result) { - applyPeers(result); - }).catch(function () { - // Peer list is a convenience for the compose "To" selector. - }); - } - - function applyPeers(result) { - peers = (Array.isArray(result) ? result : []).filter(function (p) { - return p?.userId && p.userId !== myUserId; - }); - renderTargetOptions(); - } - - function refreshTenant() { - if (connection.state !== signalR.HubConnectionState.Connected) { - return; - } - - connection.invoke('GetCurrentTenantAsync').then(function (result) { - currentTenant = result || null; - renderTargetOptions(); - }).catch(function () { - currentTenant = null; - }); - } - - function renderTargetOptions() { - const currentValue = widget.target.value; - - if (activeMode === 'tenant') { - widget.targetControl.style.display = 'none'; - widget.target.innerHTML = currentTenant - ? `` - : ``; - widget.target.disabled = !currentTenant; - if (currentTenant) { - widget.target.value = currentTenant.id; - } - updateTargetStatusDot(); - renderConversation(); - return; - } - - widget.targetControl.style.display = ''; - - if (peers.length === 0) { - widget.target.innerHTML = ``; - widget.target.disabled = true; - updateTargetStatusDot(); - return; - } - - widget.target.disabled = false; - widget.target.innerHTML = `` + peers - .map(function (p) { - const suffix = p.isOnline ? '' : ` (${l('RealtimeWidget:StatusOffline')})`; - return ``; - }) - .join(''); - - if (currentValue && peers.some(function (p) { return p.userId === currentValue; })) { - widget.target.value = currentValue; - } - - updateTargetStatusDot(); - renderConversation(); - } - - function getPeerStatus(peer) { - if (!peer?.isOnline) { - return { className: 'offline', label: l('RealtimeWidget:StatusOffline') }; - } - - const lastActivity = peer.lastActivityUtc ? new Date(peer.lastActivityUtc) : null; - const ageMs = lastActivity && !Number.isNaN(lastActivity.getTime()) - ? Date.now() - lastActivity.getTime() - : Number.POSITIVE_INFINITY; - - if (ageMs <= STATUS_GREEN_MS) { - return { className: 'green', label: l('RealtimeOps:StatusActive') }; - } - - if (ageMs <= STATUS_ORANGE_MS) { - return { className: 'orange', label: l('RealtimeOps:StatusIdle') }; - } - - return { className: 'red', label: l('RealtimeOps:StatusAway') }; - } - - function updateTargetStatusDot() { - if (activeMode === 'tenant') { - widget.targetStatus.className = 'rt-status-dot rt-widget-target-status rt-status-hidden'; - return; - } - - const peer = peers.find(function (p) { return p.userId === widget.target.value; }); - const status = getPeerStatus(peer); - widget.targetStatus.className = `rt-status-dot rt-widget-target-status rt-status-${status.className}`; - widget.targetStatus.title = status.label; - } - - widget.composeSend.addEventListener('click', sendComposeMessage); - widget.target.addEventListener('change', function () { - updateTargetStatusDot(); - renderConversation(); - }); - widget.modeTabs.forEach(function (tab) { - tab.addEventListener('click', function () { - activeMode = tab.dataset.mode; - widget.modeTabs.forEach(item => item.classList.toggle('active', item === tab)); - renderTargetOptions(); - }); - }); - widget.composeInput.addEventListener('keydown', function (e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendComposeMessage(); - } - }); - - function sendComposeMessage() { - const targetId = widget.target.value; - const message = widget.composeInput.value.trim(); - - if (!targetId || !message) { - return; - } - - if (connection.state !== signalR.HubConnectionState.Connected) { - return; - } - - const sendTask = activeMode === 'tenant' - ? connection.invoke('SendTenantMessageAsync', message) - : connection.invoke('SendPeerMessageAsync', targetId, message); - - sendTask.then(function () { - if (activeMode !== 'tenant') { - addMessage(activeMode, targetId, l('RealtimeWidget:You'), null, message, new Date().toISOString()); - } - widget.composeInput.value = ''; - }).catch(function () { - // Recipient is invalid or outside the tenant; leave the draft in place. - }); - } - - function addMessage(mode, targetId, sender, senderId, message, timestamp) { - const key = `${mode}:${targetId}`; - histories[key] = histories[key] || []; - histories[key].push({ sender, senderId, message, timestamp, mode }); - histories[key] = histories[key].slice(-MAX_HISTORY_ITEMS); - - if (!panelOpen) { - unreadCount += 1; - updateBadge(); - widget.bubble.classList.remove('rt-widget-bounce'); - widget.bubble.offsetWidth; - widget.bubble.classList.add('rt-widget-bounce'); - } - - renderConversation(); - } - - function ensurePeerOption(userId, userName) { - if (!userId) { - return; - } - - const existing = peers.find(peer => peer.userId === userId); - - if (existing) { - existing.isOnline = true; - existing.lastActivityUtc = new Date().toISOString(); - } else { - peers.push({ userId, userName, isOnline: true, connectionCount: 1, lastActivityUtc: new Date().toISOString() }); - } - } - - function renderConversation() { - const targetId = widget.target.value; - const items = targetId ? histories[`${activeMode}:${targetId}`] || [] : []; - widget.list.innerHTML = ''; - - if (items.length === 0) { - const empty = document.createElement('div'); - empty.className = 'rt-widget-empty'; - empty.textContent = l('RealtimeWidget:Empty'); - widget.list.appendChild(empty); - return; - } - - items.forEach(function (entry) { - const item = document.createElement('div'); - item.className = 'rt-widget-message' + (entry.mode === 'tenant' ? ' rt-widget-message-alert' : ''); - - const meta = document.createElement('div'); - meta.className = 'rt-widget-message-meta'; - const senderEl = document.createElement('span'); - senderEl.className = 'rt-widget-message-sender'; - senderEl.textContent = entry.mode === 'tenant' - ? `${entry.sender} (${l('RealtimeWidget:TenantBroadcast')})` - : entry.sender; - const timeEl = document.createElement('span'); - timeEl.className = 'rt-widget-message-time'; - timeEl.textContent = formatTime(entry.timestamp); - meta.appendChild(senderEl); - meta.appendChild(timeEl); - - const textEl = document.createElement('div'); - textEl.className = 'rt-widget-message-text'; - textEl.textContent = entry.message; - item.appendChild(meta); - item.appendChild(textEl); - widget.list.appendChild(item); - }); - - widget.list.scrollTop = widget.list.scrollHeight; - } - - function formatTime(value) { - const date = value ? new Date(value) : new Date(); - return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString(); - } - - function updateBadge() { - if (unreadCount > 0) { - widget.badge.textContent = unreadCount > 9 ? '9+' : String(unreadCount); - widget.badge.classList.remove('rt-widget-hidden'); - } else { - widget.badge.classList.add('rt-widget-hidden'); - } - } - - function showIncomingToast(sender, message) { - if (typeof Swal !== 'undefined') { - Swal.fire({ - toast: true, - position: 'top-end', - icon: 'info', - titleText: String(sender || ''), - text: message, - showConfirmButton: false, - timer: 5000, - timerProgressBar: true - }); - return; - } - - if (window.abp?.notify?.info && typeof window.abp.notify.info === 'function') { - abp.notify.info(`${sender}: ${message}`); - } - } - - function togglePanel() { - panelOpen = !panelOpen; - widget.panel.classList.toggle('rt-widget-panel-open', panelOpen); - widget.bubble.classList.toggle('rt-widget-hidden', panelOpen); - - if (panelOpen) { - unreadCount = 0; - updateBadge(); - connection.invoke('MarkMessagesReadAsync').catch(function () { - // Ignore read-state failures; messages remain available in the activity log. - }); - refreshPeers(); - refreshTenant(); - } - } - - function escapeHtml(value) { - const div = document.createElement('div'); - div.textContent = value || ''; - return div.innerHTML; - } - - function escapeAttribute(value) { - return escapeHtml(value).replaceAll('"', '"'); - } - - function buildWidget() { - const container = document.createElement('div'); - container.className = 'rt-widget-container'; - - const panel = document.createElement('div'); - panel.className = 'rt-widget-panel'; - - const header = document.createElement('div'); - header.className = 'rt-widget-panel-header'; - - const titleEl = document.createElement('span'); - titleEl.textContent = l('RealtimeWidget:Title'); - - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'rt-widget-close'; - closeBtn.setAttribute('aria-label', 'Close'); - closeBtn.innerHTML = '×'; - closeBtn.addEventListener('click', togglePanel); - - header.appendChild(titleEl); - header.appendChild(closeBtn); - - const list = document.createElement('div'); - list.className = 'rt-widget-list'; - - const empty = document.createElement('div'); - empty.className = 'rt-widget-empty'; - empty.textContent = l('RealtimeWidget:Empty'); - list.appendChild(empty); - - const compose = document.createElement('div'); - compose.className = 'rt-widget-compose'; - - const modeTabs = document.createElement('div'); - modeTabs.className = 'rt-widget-mode-tabs'; - - const individualTab = document.createElement('button'); - individualTab.type = 'button'; - individualTab.className = 'rt-widget-mode-tab active'; - individualTab.dataset.mode = 'individual'; - individualTab.textContent = l('RealtimeWidget:Individual'); - - const tenantTab = document.createElement('button'); - tenantTab.type = 'button'; - tenantTab.className = 'rt-widget-mode-tab'; - tenantTab.dataset.mode = 'tenant'; - tenantTab.textContent = l('RealtimeWidget:Tenant'); - - modeTabs.appendChild(individualTab); - modeTabs.appendChild(tenantTab); - - const targetRow = document.createElement('div'); - targetRow.className = 'rt-widget-target-row'; - - const targetControl = document.createElement('div'); - targetControl.className = 'rt-widget-target-control'; - - const targetStatus = document.createElement('span'); - targetStatus.className = 'rt-status-dot rt-widget-target-status rt-status-hidden'; - - const target = document.createElement('select'); - target.className = 'rt-widget-compose-target'; - target.innerHTML = ``; - - targetControl.appendChild(targetStatus); - targetControl.appendChild(target); - targetRow.appendChild(targetControl); - - const composeRow = document.createElement('div'); - composeRow.className = 'rt-widget-compose-row'; - - const composeInput = document.createElement('input'); - composeInput.type = 'text'; - composeInput.className = 'rt-widget-compose-input'; - composeInput.placeholder = l('RealtimeWidget:MessagePlaceholder'); - - const composeSend = document.createElement('button'); - composeSend.type = 'button'; - composeSend.className = 'rt-widget-compose-send'; - composeSend.textContent = l('RealtimeWidget:Send'); - - composeRow.appendChild(composeInput); - composeRow.appendChild(composeSend); - compose.appendChild(modeTabs); - compose.appendChild(targetRow); - compose.appendChild(composeRow); - - panel.appendChild(header); - panel.appendChild(list); - panel.appendChild(compose); - - const bubble = document.createElement('button'); - bubble.type = 'button'; - bubble.className = 'rt-widget-bubble'; - bubble.setAttribute('aria-label', l('RealtimeWidget:Title')); - bubble.innerHTML = ''; - bubble.addEventListener('click', togglePanel); - - const badge = document.createElement('span'); - badge.className = 'rt-widget-badge rt-widget-hidden'; - bubble.appendChild(badge); - - container.appendChild(panel); - container.appendChild(bubble); - document.body.appendChild(container); - - return { container, panel, list, bubble, badge, modeTabs: [individualTab, tenantTab], targetControl, target, targetStatus, composeInput, composeSend }; - } - } -})(); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml index f572a7c90..ba0c57eb4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml @@ -34,6 +34,9 @@ bool isAuthorizedForTenantSwitch = false; if (CurrentUser.IsAuthenticated && CurrentUser.FindClaimValue("has_multiple_tenants") == "true") isAuthorizedForTenantSwitch = true; + + bool isItOperations = CurrentUser.IsInRole("ITOperations"); + bool isDirectMessagingEnabled = isItOperations && await FeatureChecker.IsEnabledAsync("Unity.Notifications.DirectMessaging"); } @@ -53,15 +56,15 @@ { Configuration Management } - @if (CurrentUser.IsInRole("ITOperations")) + @if (isItOperations) { Unity Admin - @if (await FeatureChecker.IsEnabledAsync("Unity.Notifications.DirectMessaging")) - { - Unity Messaging - } Exception Logs } + @if (isDirectMessagingEnabled) + { + Unity Messaging + } Logout