diff --git a/agent/ajax.php b/agent/ajax.php index 012a60e46..89f544cd1 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -382,6 +382,52 @@ if (isset($_GET['get_active_clients'])) { echo json_encode($response); } +/* + * The bell panel in the top bar (js/notifications.js). + * + * notifications_feed - the latest notifications for the panel, and the unread count + * notifications_count - just the count, polled for the badge + * notifications_mark - POST: mark ids read or unread, or all=1 for everything + * + * All of it goes through functions/notifications.php, which only ever reads or + * changes the current user's own rows. Reading one is not audited; marking all + * read is, as the old "Dismiss all" was. + */ +if (isset($_GET['notifications_feed'])) { + $notifications_view = ($_GET['view'] ?? '') === 'all' ? 'all' : 'unread'; + + header('Content-Type: application/json'); + echo json_encode([ + 'unread_count' => notificationUnreadCount(), + 'items' => notificationsFetch($notifications_view, 20), + ], JSON_INVALID_UTF8_SUBSTITUTE); + exit; +} + +if (isset($_GET['notifications_count'])) { + header('Content-Type: application/json'); + echo json_encode(['unread_count' => notificationUnreadCount()]); + exit; +} + +if (isset($_POST['notifications_mark'])) { + + validateCSRFToken(); + + if (!empty($_POST['all'])) { + $notifications_changed = notificationsMarkAllRead(); + if ($notifications_changed) { + logAudit("Notification", "Dismiss", "$session_name marked $notifications_changed notifications as read"); + } + } else { + notificationsMark($_POST['ids'] ?? [], ($_POST['state'] ?? 'read') !== 'unread'); + } + + header('Content-Type: application/json'); + echo json_encode(['unread_count' => notificationUnreadCount()]); + exit; +} + /* * Live results for the search palette in the top bar (js/global_search.js). * diff --git a/agent/global_search.php b/agent/global_search.php index 54fa73b4a..d6049c499 100644 --- a/agent/global_search.php +++ b/agent/global_search.php @@ -112,7 +112,7 @@ function searchResultBadge($badge) {

Names, emails, phone numbers, IP and MAC addresses, serials, ticket, invoice and quote numbers - plus ticket conversations and document text.
- Press / on any page to search without leaving it. + Press Ctrl + K on any page to search without leaving it.

diff --git a/agent/notifications.php b/agent/notifications.php index bf116484a..c1c7b9221 100644 --- a/agent/notifications.php +++ b/agent/notifications.php @@ -1,160 +1,192 @@
-

- Notifications -

+

Notifications

- - Dismissed - - Dismissed - +
- - - -
-
+ +
+
- Notifications"> - - + + +
-
- - +
+ +
+
-
" id="advancedFilter"> -
-
-
- - -
-
-
-
- - -
+
" id="advancedFilter"> +
+
+ + + + +
-
- - "> - - - - - - - - - - - - - + + +
+ + +
No notifications match the current filters
+ + Clear filters + + + +
You're all caught up
+

Nothing new since you last looked.

+ See all notifications + + +
No notifications yet
+ +
+ + + +
-
- - - - - + if ($item_day !== $notification_day) { + $notification_day = $item_day; ?> + - - - - + + + + + - -
- - Timestamp - - - - Type - - - - Notification - - - - Dismissed At - - - 0) { ?> - " - class="btn btn-sm btn-dark mb-2" title="Dismiss All"> - - - -
-
[ + 'Invoice', 'Invoice Paid', 'Invoice Viewed', 'Invoice Overdue', 'Invoice Late Charge', + 'Draft Invoices', 'Recurring Sent', 'Recurring Generated', + 'Quote Viewed', 'Quote Accepted', 'Quote Declined', 'Quote File', + ], + 'module_financial' => ['Expense', 'Expense Created'], + 'module_support' => [ + 'Ticket', 'Ticket SLA', 'Pending Tickets', 'Feedback', 'Recurring Ticket', + 'Domain Expiring', 'Certificate Expiring', 'Asset Warranty Expiring', + ], + 'module_client' => ['Share Viewed'], + ]; +} + +/** + * The WHERE conditions for "notifications this user may see": their own, for a + * client they can access (or no client), and of a type their role can read. + */ +function notificationVisibilitySql() { + global $session_user_id; + + $sql = 'notification_user_id = ' . intval($session_user_id) . clientScopeSql('notification_client_id'); + + foreach (notificationTypeModules() as $module => $types) { + if (lookupUserPermission($module) < 1) { + $sql .= " AND notification_type NOT IN ('" . implode("','", array_map('escapeSql', $types)) . "')"; + } + } + + return $sql; +} + +/** Icon and colour for a notification type, matched on its wording. */ +function notificationTypeStyle($type) { + $t = strtolower((string) $type); + + $rules = [ + // [words in the type, icon, tone] + [['sla'], 'stopwatch', 'danger'], + [['failed', 'fail'], 'exclamation-circle', 'danger'], + [['declined'], 'times-circle', 'danger'], + [['feedback'], 'thumbs-down', 'danger'], + [['paid'], 'check-circle', 'success'], + [['accepted'], 'check-circle', 'success'], + [['overdue', 'late charge'], 'exclamation-triangle', 'warning'], + [['expiring'], 'hourglass-half', 'warning'], + [['viewed'], 'eye', 'info'], + [['quote'], 'comment-dollar', 'primary'], + [['invoice', 'recurring sent', 'recurring generated'], 'file-invoice-dollar', 'primary'], + [['expense'], 'receipt', 'secondary'], + [['ticket'], 'life-ring', 'primary'], + [['mail'], 'envelope', 'danger'], + [['backup'], 'database', 'secondary'], + [['master key'], 'key', 'warning'], + [['update'], 'download', 'info'], + [['share'], 'share-square', 'info'], + [['settings'], 'cog', 'secondary'], + ]; + + foreach ($rules as [$words, $icon, $tone]) { + foreach ($words as $word) { + if (strpos($t, $word) !== false) { + return ['icon' => $icon, 'tone' => $tone]; + } + } + } + + return ['icon' => 'bell', 'tone' => 'secondary']; +} + +/* + * Where clicking a notification goes. Stored actions are a mix of + * "/agent/ticket.php?..." and a bare "ticket.php?..." that only worked from + * pages under /agent/. Only same-site paths are returned - anything with a + * scheme or host comes back as null, which renders as "no link". + */ +function notificationActionUrl($action) { + $action = trim((string) $action); + + if ($action === '' || $action === '#' || strtoupper($action) === 'NULL') { + return null; + } + if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $action) || strpos($action, '//') === 0 || strpos($action, '\\') !== false) { + return null; + } + if ($action[0] !== '/') { + $action = '/agent/' . $action; + } + + return $action; +} + +function notificationItem($row) { + $style = notificationTypeStyle($row['notification_type']); + + // Backups, updates and the like share one type for good news and bad; + // the wording is what says it went wrong + if (preg_match('/\bfail(ed|ure|s)?\b/i', (string) $row['notification'])) { + $style['tone'] = 'danger'; + } + + $timestamp = strtotime($row['notification_timestamp']); + + return [ + 'id' => intval($row['notification_id']), + 'type' => $row['notification_type'], + 'text' => $row['notification'], + 'url' => notificationActionUrl($row['notification_action']), + 'client' => $row['client_name'] ?: null, + 'client_id' => intval($row['notification_client_id']), + 'unread' => $row['notification_dismissed_at'] === null, + 'time' => $timestamp ? date('c', $timestamp) : null, + 'time_full' => $timestamp ? date('D, M j Y g:i A', $timestamp) : '', + 'icon' => $style['icon'], + 'tone' => $style['tone'], + ]; +} + +function notificationUnreadCount() { + global $mysqli; + + $row = mysqli_fetch_row(mysqli_query( + $mysqli, + "SELECT COUNT(*) FROM notifications WHERE " . notificationVisibilitySql() . " AND notification_dismissed_at IS NULL" + )); + + return intval($row[0]); +} + +/** Newest first. $view is 'unread' or 'all'. */ +function notificationsFetch($view = 'unread', $limit = 20) { + global $mysqli; + + $limit = max(1, min(100, intval($limit))); + $unread_only = $view === 'unread' ? 'AND notification_dismissed_at IS NULL' : ''; + + $sql = mysqli_query( + $mysqli, + "SELECT notification_id, notification_type, notification, notification_action, notification_timestamp, + notification_dismissed_at, notification_client_id, client_name + FROM notifications + LEFT JOIN clients ON notification_client_id = client_id + WHERE " . notificationVisibilitySql() . " $unread_only + ORDER BY notification_id DESC + LIMIT $limit" + ); + + $items = []; + while ($row = mysqli_fetch_assoc($sql)) { + $items[] = notificationItem($row); + } + + return $items; +} + +/** + * Mark the given notifications read (or unread again). Only the current user's + * own rows are touched, whatever ids are sent. Returns the number changed. + */ +function notificationsMark($ids, $read = true) { + global $mysqli, $session_user_id; + + $ids = array_values(array_filter(array_map('intval', (array) $ids))); + if (!$ids) { + return 0; + } + + $user_id = intval($session_user_id); + $id_list = implode(',', $ids); + + if ($read) { + mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $user_id + WHERE notification_user_id = $user_id AND notification_dismissed_at IS NULL AND notification_id IN ($id_list)"); + } else { + mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NULL, notification_dismissed_by = NULL + WHERE notification_user_id = $user_id AND notification_dismissed_at IS NOT NULL AND notification_id IN ($id_list)"); + } + + return mysqli_affected_rows($mysqli); +} + +/** Every unread notification of the current user, including any their role hides. */ +function notificationsMarkAllRead() { + global $mysqli, $session_user_id; + + $user_id = intval($session_user_id); + + mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $user_id + WHERE notification_user_id = $user_id AND notification_dismissed_at IS NULL"); + + return mysqli_affected_rows($mysqli); +} diff --git a/functions/search.php b/functions/search.php index 38a7f84e6..dd2d30a88 100644 --- a/functions/search.php +++ b/functions/search.php @@ -4,7 +4,7 @@ * Global search. * * One search behind two surfaces: - * - the live search palette in the top bar (press / or Ctrl+K), which asks + * - the live search palette in the top bar (press Ctrl+K), which asks * agent/ajax.php?global_search as you type * - the full results page, agent/global_search.php, reached by pressing Enter * diff --git a/includes/footer.php b/includes/footer.php index 0cde3a220..7b7eefbdd 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -82,6 +82,7 @@ + diff --git a/includes/inc_empty_state.php b/includes/inc_empty_state.php index 78d60843c..ee8190993 100644 --- a/includes/inc_empty_state.php +++ b/includes/inc_empty_state.php @@ -23,7 +23,8 @@ * with nothing to maintain. */ -if (!isset($num_rows) || intval($num_rows[0]) > 0) { +// A page that draws its own empty state sets $empty_state_skip +if (!isset($num_rows) || intval($num_rows[0]) > 0 || !empty($empty_state_skip)) { return; } diff --git a/includes/top_nav.php b/includes/top_nav.php index 684836006..80587d3c8 100644 --- a/includes/top_nav.php +++ b/includes/top_nav.php @@ -9,18 +9,18 @@ @@ -55,22 +55,20 @@ - - + - ?> - -
' + '
' + '
' + @@ -243,9 +243,8 @@ }); modalEl.addEventListener('hidden.bs.modal', function () { - // Chrome can leave focus on the now-hidden box for a moment, and the - // next / would count as typing in it. Hand focus back to where it was - - // except the top bar box, which would just open the palette again. + // Hand focus back to where it was rather than leave it on the now-hidden + // box - except the top bar box, which would just open the palette again. input.blur(); if (returnFocus && returnFocus !== navbarBox && document.contains(returnFocus) && typeof returnFocus.focus === 'function') { returnFocus.focus({ preventScroll: true }); @@ -370,22 +369,6 @@ } } - function isTyping(target) { - if (!target || !(target instanceof Element) || (modalEl && modalEl.contains(target))) { - return false; - } - if (target.isContentEditable || target.closest('.tox, [contenteditable="true"], [contenteditable=""]')) { - return true; - } - if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { - return true; - } - if (target.tagName === 'INPUT') { - return ['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].indexOf(target.type) === -1; - } - return false; - } - function anotherModalOpen() { return !!document.querySelector('.modal.show:not(#itflowSearch)'); } @@ -404,12 +387,6 @@ e.preventDefault(); open(); } - return; - } - - if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isOpen() && !anotherModalOpen() && !isTyping(e.target)) { - e.preventDefault(); - open(); } }); @@ -614,6 +591,12 @@ navbarBox = box; const form = box.form; + // The shortcut hint in the box: the markup says Ctrl K, a Mac gets Cmd + const hint = form ? form.querySelector('.navbar-search-kbd') : null; + if (hint) { + hint.textContent = MOD_KEY + ' K'; + } + box.addEventListener('mousedown', function (e) { e.preventDefault(); open(box.value || undefined); diff --git a/js/notifications.js b/js/notifications.js new file mode 100644 index 000000000..2009ccc09 --- /dev/null +++ b/js/notifications.js @@ -0,0 +1,520 @@ +/* + * ITFlow - notifications. + * + * The bell in the top bar opens a panel (a Bootstrap dropdown) with the latest + * notifications, Unread and All tabs, grouped by day. Clicking one opens what it + * is about and marks it read; the button on each row flips read / unread; "Mark + * all as read" clears the lot. The unread badge refreshes every minute while the + * tab is visible, and straight away when you come back to it. + * + * The full page, agent/notifications.php, renders the same rows in PHP; the row + * behaviour below is delegated on document so it covers both. + * + * Data comes from agent/ajax.php (functions/notifications.php) and is rendered + * with textContent only - notification text carries client names, email + * subjects and other user-entered values. + * + * Loaded once per page from includes/footer.php. + */ +(function () { + 'use strict'; + + if (window.itflowNotifications || typeof bootstrap === 'undefined') { + return; + } + + const ENDPOINT = '/agent/ajax.php'; + const POLL_MS = 60000; + const TONES = ['primary', 'secondary', 'success', 'danger', 'warning', 'info']; + + const root = document.getElementById('itflowNotify'); + const csrf = root ? root.dataset.csrf : ''; + + let panel = null; + let list = null; + let view = 'unread'; + let loadSeq = 0; + let lastPoll = 0; + let pollingStopped = false; + + /* ---------- small helpers ---------- */ + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text !== undefined && text !== null) { + node.textContent = text; + } + return node; + } + + function icon(name) { + const i = el('i', 'fas fa-fw fa-' + (/^[a-z0-9-]+$/.test(name || '') ? name : 'bell')); + i.setAttribute('aria-hidden', 'true'); + return i; + } + + // Same-site paths only; the server already filters, this is belt and braces + function safeUrl(url) { + return typeof url === 'string' && url.charAt(0) === '/' && url.charAt(1) !== '/' && url.charAt(1) !== '\\' ? url : null; + } + + function sameDay(a, b) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + } + + function relativeTime(date) { + const now = new Date(); + const minutes = Math.round((now - date) / 60000); + if (minutes < 1) { + return 'now'; + } + if (minutes < 60) { + return minutes + 'm'; + } + if (minutes < 24 * 60 && sameDay(date, now)) { + return Math.floor(minutes / 60) + 'h'; + } + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (sameDay(date, yesterday)) { + return 'Yesterday'; + } + if (minutes < 6 * 24 * 60) { + return date.toLocaleDateString(undefined, { weekday: 'short' }); + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric' }); + } + + function dayGroup(date) { + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (sameDay(date, now)) { + return 'Today'; + } + if (sameDay(date, yesterday)) { + return 'Yesterday'; + } + return 'Earlier'; + } + + function refreshTimes(scope) { + (scope || document).querySelectorAll('.itflow-notify-item time[datetime]').forEach(function (node) { + const date = new Date(node.getAttribute('datetime')); + if (!isNaN(date)) { + node.textContent = relativeTime(date); + } + }); + } + + /* ---------- server calls ---------- */ + + function getJson(params) { + return fetch(ENDPOINT + '?' + new URLSearchParams(params).toString(), { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } + }).then(function (res) { + // An expired session is redirected to the login page, which is HTML + if (!res.ok || (res.headers.get('Content-Type') || '').indexOf('application/json') === -1) { + throw new Error(res.ok ? 'session' : 'HTTP ' + res.status); + } + return res.json(); + }); + } + + function markForm(ids, state, all) { + const form = new FormData(); + form.append('notifications_mark', '1'); + form.append('csrf_token', csrf); + if (all) { + form.append('all', '1'); + } else { + form.append('state', state); + ids.forEach(function (id) { + form.append('ids[]', id); + }); + } + return form; + } + + function postMark(ids, state, all) { + return fetch(ENDPOINT, { + method: 'POST', + credentials: 'same-origin', + body: markForm(ids, state, all) + }).then(function (res) { + if (!res.ok) { + throw new Error('HTTP ' + res.status); + } + return res.json(); + }).then(function (data) { + setCount(data.unread_count); + return data; + }); + } + + /* ---------- badge and counts ---------- */ + + function setCount(count) { + count = Math.max(0, parseInt(count, 10) || 0); + const label = count > 99 ? '99+' : String(count); + + document.querySelectorAll('[data-notify-badge]').forEach(function (badge) { + badge.textContent = label; + badge.hidden = count === 0; + }); + document.querySelectorAll('[data-notify-count]').forEach(function (node) { + node.textContent = label; + node.hidden = count === 0; + }); + document.querySelectorAll('[data-notify-all]').forEach(function (button) { + button.disabled = count === 0; + }); + + const toggle = root && root.querySelector('[data-bs-toggle="dropdown"]'); + if (toggle) { + toggle.setAttribute('aria-label', count ? 'Notifications, ' + count + ' unread' : 'Notifications'); + } + } + + function poll(force) { + if (pollingStopped || !root) { + return; + } + const now = Date.now(); + if (!force && now - lastPoll < 10000) { + return; + } + lastPoll = now; + + const before = currentCount(); + getJson({ notifications_count: 1 }) + .then(function (data) { + setCount(data.unread_count); + // Something new arrived while the panel is open: show it + if (panel && root.querySelector('.dropdown-menu.show') && data.unread_count !== before) { + load(); + } + }) + .catch(function (err) { + if (err.message === 'session') { + pollingStopped = true; + } + }); + } + + function currentCount() { + const badge = document.querySelector('[data-notify-badge]'); + if (!badge || badge.hidden) { + return 0; + } + return parseInt(badge.textContent, 10) || 0; + } + + /* ---------- one notification row ---------- */ + + function renderItem(item) { + const row = el('div', 'itflow-notify-item' + (item.unread ? ' is-unread' : '')); + row.dataset.id = item.id; + row.setAttribute('role', 'listitem'); + + const url = safeUrl(item.url); + const link = el('a', 'itflow-notify-link dropdown-item'); + link.href = url || '#'; + if (!url) { + link.dataset.noLink = '1'; + } + + const tone = TONES.indexOf(item.tone) !== -1 ? item.tone : 'secondary'; + const badge = el('span', 'itflow-notify-icon bg-' + tone + '-subtle text-' + tone + '-emphasis'); + badge.append(icon(item.icon)); + + const body = el('span', 'itflow-notify-body'); + const top = el('span', 'itflow-notify-top'); + top.append(el('span', 'itflow-notify-type', item.type)); + if (item.time) { + const time = el('time', 'itflow-notify-time'); + time.setAttribute('datetime', item.time); + time.title = item.time_full || ''; + time.textContent = relativeTime(new Date(item.time)); + top.append(time); + } + body.append(top, el('span', 'itflow-notify-text', item.text)); + if (item.client) { + const client = el('span', 'itflow-notify-client'); + client.append(icon('building'), document.createTextNode(item.client)); + body.append(client); + } + + link.append(badge, body); + + const toggle = el('button', 'btn btn-sm itflow-notify-toggle'); + toggle.type = 'button'; + setToggle(toggle, item.unread); + toggle.append(icon(item.unread ? 'check' : 'undo')); + + row.append(link, toggle); + return row; + } + + function setToggle(button, unread) { + const label = unread ? 'Mark as read' : 'Mark as unread'; + button.title = label; + button.setAttribute('aria-label', label); + const i = button.querySelector('i'); + if (i) { + i.className = 'fas fa-fw fa-' + (unread ? 'check' : 'undo'); + } + } + + function setRowRead(row, read) { + row.classList.toggle('is-unread', !read); + const button = row.querySelector('.itflow-notify-toggle'); + if (button) { + setToggle(button, !read); + } + } + + /* ---------- the panel ---------- */ + + function buildPanel() { + if (panel || !root) { + return; + } + panel = root.querySelector('.itflow-notify-panel'); + + // Static skeleton only - no data goes through this string + panel.innerHTML = + '
' + + 'Notifications' + + '' + + '
' + + '' + + '
' + + 'View all notifications'; + + list = panel.querySelector('.itflow-notify-list'); + + panel.querySelectorAll('[data-notify-view]').forEach(function (tab) { + tab.addEventListener('click', function () { + if (view === tab.dataset.notifyView) { + return; + } + view = tab.dataset.notifyView; + panel.querySelectorAll('[data-notify-view]').forEach(function (t) { + const on = t === tab; + t.classList.toggle('active', on); + t.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + load(); + }); + }); + + setCount(currentCount()); + } + + function message(iconName, title, text, retry) { + const box = el('div', 'itflow-notify-empty'); + const i = icon(iconName); + i.classList.add('fa-2x', 'mb-2'); + box.append(i, el('div', 'fw-semibold', title)); + if (text) { + box.append(el('div', 'small', text)); + } + if (retry) { + const button = el('button', 'btn btn-sm btn-outline-secondary mt-2', 'Try again'); + button.type = 'button'; + button.addEventListener('click', load); + box.append(button); + } + return box; + } + + function load() { + if (!list) { + return; + } + const mine = ++loadSeq; + const spinner = setTimeout(function () { + if (mine === loadSeq && !list.children.length) { + list.replaceChildren(el('div', 'itflow-notify-empty', 'Loading…')); + } + }, 150); + + getJson({ notifications_feed: 1, view: view }) + .then(function (data) { + if (mine !== loadSeq) { + return; + } + setCount(data.unread_count); + render(data.items || []); + }) + .catch(function (err) { + if (mine !== loadSeq) { + return; + } + list.replaceChildren(err.message === 'session' + ? message('lock', 'Your session has ended', 'Reload the page to sign in again.') + : message('exclamation-triangle', 'Couldn’t load notifications', null, true)); + }) + .finally(function () { + clearTimeout(spinner); + }); + } + + function render(items) { + list.replaceChildren(); + + if (!items.length) { + list.append(view === 'unread' + ? message('check-circle', 'You’re all caught up', 'New notifications will show up here.') + : message('bell-slash', 'No notifications yet')); + return; + } + + let group = null; + items.forEach(function (item) { + const label = item.time ? dayGroup(new Date(item.time)) : 'Earlier'; + if (label !== group) { + group = label; + const heading = el('div', 'itflow-notify-group', label); + heading.setAttribute('role', 'presentation'); + list.append(heading); + } + list.append(renderItem(item)); + }); + } + + /* ---------- row behaviour, on the panel and the full page ---------- */ + + document.addEventListener('click', function (e) { + const toggle = e.target.closest('.itflow-notify-item .itflow-notify-toggle'); + if (toggle) { + e.preventDefault(); + const row = toggle.closest('.itflow-notify-item'); + const makeRead = row.classList.contains('is-unread'); + setRowRead(row, makeRead); + postMark([row.dataset.id], makeRead ? 'read' : 'unread').catch(function () { + setRowRead(row, !makeRead); + if (typeof itflowToast === 'function') { + itflowToast('Couldn’t update the notification', 'danger'); + } + }); + return; + } + + const link = e.target.closest('.itflow-notify-item .itflow-notify-link'); + if (!link) { + return; + } + const row = link.closest('.itflow-notify-item'); + const unread = row.classList.contains('is-unread'); + + if (link.dataset.noLink) { + // Nothing to open - clicking it is how you acknowledge it + e.preventDefault(); + if (unread) { + setRowRead(row, true); + postMark([row.dataset.id], 'read').catch(function () { + setRowRead(row, false); + }); + } + return; + } + + if (!unread) { + return; + } + + const newTab = e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1; + setRowRead(row, true); + if (newTab) { + postMark([row.dataset.id], 'read').catch(function () { + setRowRead(row, false); + }); + } else if (navigator.sendBeacon) { + // This page is about to unload; a beacon still gets there + navigator.sendBeacon(ENDPOINT, markForm([row.dataset.id], 'read', false)); + } else { + postMark([row.dataset.id], 'read'); + } + }); + + // Middle click does not fire "click" + document.addEventListener('auxclick', function (e) { + if (e.button !== 1) { + return; + } + const link = e.target.closest('.itflow-notify-item .itflow-notify-link'); + if (link && !link.dataset.noLink) { + const row = link.closest('.itflow-notify-item'); + if (row.classList.contains('is-unread')) { + setRowRead(row, true); + postMark([row.dataset.id], 'read').catch(function () { + setRowRead(row, false); + }); + } + } + }); + + document.addEventListener('click', function (e) { + const button = e.target.closest('[data-notify-all]'); + if (!button || button.disabled) { + return; + } + e.preventDefault(); + button.disabled = true; + postMark([], 'read', true) + .then(function () { + document.querySelectorAll('.itflow-notify-item.is-unread').forEach(function (row) { + setRowRead(row, true); + }); + // The full page lists unread by default; reload so it matches + if (button.dataset.notifyReload) { + window.location.reload(); + } + }) + .catch(function () { + button.disabled = false; + if (typeof itflowToast === 'function') { + itflowToast('Couldn’t mark notifications as read', 'danger'); + } + }); + }); + + /* ---------- wiring ---------- */ + + if (root) { + root.addEventListener('show.bs.dropdown', function () { + buildPanel(); + load(); + }); + + setInterval(function () { + if (document.visibilityState === 'visible') { + poll(true); + } + }, POLL_MS); + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') { + poll(false); + } + }); + } + + refreshTimes(document); + setInterval(function () { + refreshTimes(document); + }, 60000); + + window.itflowNotifications = { refresh: function () { poll(true); } }; +})(); diff --git a/modals/notifications.php b/modals/notifications.php deleted file mode 100644 index 796508b59..000000000 --- a/modals/notifications.php +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - -$num_notifications Notifications Dismissed"; - - redirect(); - -} - // Revoke sharing (sharing itself is done via ajax.php) if (isset($_GET['deactivate_shared_item'])) {