diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade index f44673ae4..140335178 100644 --- a/client/components/activities/activities.jade +++ b/client/components/activities/activities.jade @@ -199,4 +199,5 @@ template(name="activity") else if(currentData.timeValue) | {{_ activity.activityType currentData.timeValue}} - div(title=activity.createdAt).activity-meta {{ moment activity.createdAt }} + if($neq mode 'none') + div(title=activity.createdAt).activity-meta {{ moment activity.createdAt }} diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 9f15e5068..8613d656c 100644 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -45,15 +45,16 @@ BlazeComponent.extendComponent({ match: /\B@([\w.-]*)$/, search(term, callback) { const currentBoard = Utils.getCurrentBoard(); + const searchTerm = term.toLowerCase(); callback( _.union( currentBoard .activeMembers() .map(member => { const user = ReactiveCache.getUser(member.userId); - const username = user.username; - const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname : ""; - return username.includes(term) || fullName.includes(term) ? user : null; + const username = user.username.toLowerCase(); + const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : ""; + return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null; }) .filter(Boolean), [...specialHandles]) ); diff --git a/client/components/notifications/notification.css b/client/components/notifications/notification.css index 397061fc8..2de0dd05f 100644 --- a/client/components/notifications/notification.css +++ b/client/components/notifications/notification.css @@ -12,8 +12,8 @@ display: none; } #notifications-drawer .notification .read-status { - width: 4vw; - padding: 0 1.3vw 0 0; + width: 2vw; + padding: 0 0.5vw 0 0; } #notifications-drawer .notification .read-status input { width: 3vw; @@ -27,6 +27,9 @@ display: block; color: #bbb; } +#notifications-drawer .notification .read-status .activity-type.hidden { + display: none; +} #notifications-drawer .notification .details .activity a.member { margin: 0px 0px 0px 0px; padding: 0px; @@ -53,6 +56,13 @@ color: #999; font-style: italic; } +#notifications-drawer .notification .details .notification-date { + margin-top: 4px; +} +#notifications-drawer .notification .details .notification-date small { + font-size: 0.85em; + color: #999; +} #notifications-drawer .notification .remove a:hover { color: #eb4646 !important; } diff --git a/client/components/notifications/notification.jade b/client/components/notifications/notification.jade index 0ee76306f..8b9c854bb 100644 --- a/client/components/notifications/notification.jade +++ b/client/components/notifications/notification.jade @@ -5,6 +5,8 @@ template(name='notification') +notificationIcon(activityData) .details +activity(activity=activityData mode='none') + .notification-date + small.quiet {{activityDate}} if read .remove a(title="{{_ 'delete'}}") 🗑️ diff --git a/client/components/notifications/notification.js b/client/components/notifications/notification.js index 220aa8c74..821402f66 100644 --- a/client/components/notifications/notification.js +++ b/client/components/notifications/notification.js @@ -3,10 +3,14 @@ import { ReactiveCache } from '/imports/reactiveCache'; Template.notification.events({ 'click .read-status .materialCheckBox'() { const update = {}; - update[`profile.notifications.${this.index}.read`] = this.read - ? null - : Date.now(); - Users.update(Meteor.userId(), { $set: update }); + const newReadValue = this.read ? null : Date.now(); + update[`profile.notifications.${this.index}.read`] = newReadValue; + + Users.update(Meteor.userId(), { $set: update }, (error, result) => { + if (error) { + console.error('Error updating notification:', error); + } + }); }, 'click .remove a'() { ReactiveCache.getCurrentUser().removeNotification(this.activityData._id); @@ -27,4 +31,16 @@ Template.notification.helpers({ const activity = ReactiveCache.getActivity(activityId); return activity && activity.userId; }, + activityDate() { + const activity = this.activityData; + if (!activity || !activity.createdAt) return ''; + + const user = ReactiveCache.getCurrentUser(); + if (!user) return ''; + + const dateFormat = user.getDateFormat ? user.getDateFormat() : 'L'; + const timeFormat = user.getTimeFormat ? user.getTimeFormat() : 'LT'; + + return moment(activity.createdAt).format(`${dateFormat} ${timeFormat}`); + }, }); diff --git a/client/components/notifications/notificationIcon.jade b/client/components/notifications/notificationIcon.jade index 9b4c629ee..a3ce75f7c 100644 --- a/client/components/notifications/notificationIcon.jade +++ b/client/components/notifications/notificationIcon.jade @@ -21,7 +21,7 @@ template(name='notificationIcon') else if($in activityType 'checkedItem' 'uncheckedItem' 'addChecklistItem' 'removedChecklistItem') span.activity-type(title="checklist item") ☑️ else if($in activityType 'addComment') - span.activity-type(title="comment") 💬 + span.activity-type.hidden(title="comment") else if($in activityType 'createCustomField' 'setCustomField' 'unsetCustomField') span.activity-type(title="custom field") 🧩 else if($in activityType 'addedLabel' 'removedLabel') diff --git a/client/components/notifications/notificationsDrawer.css b/client/components/notifications/notificationsDrawer.css index 758018404..fac7b9574 100644 --- a/client/components/notifications/notificationsDrawer.css +++ b/client/components/notifications/notificationsDrawer.css @@ -23,12 +23,66 @@ section#notifications-drawer .header { border-bottom: 1px solid #dbdbdb; z-index: 2; } -section#notifications-drawer .header .toggle-read { +section#notifications-drawer .header .notification-menu-toggle { position: absolute; left: 16px; - top: calc(50% - 8px); + top: calc(50% - 12px); + font-size: 20px; + cursor: pointer; + color: #333; + line-height: 24px; +} +section#notifications-drawer .header .notification-menu-toggle:hover { color: #2980b9; } +section#notifications-drawer .header .notification-menu { + position: absolute; + left: 16px; + top: 44px; + background: white; + border: 1px solid #dbdbdb; + border-radius: 3px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + min-width: 220px; + z-index: 100; + display: none; +} +section#notifications-drawer .header .notification-menu.is-open { + display: block; +} +section#notifications-drawer .header .notification-menu .menu-section { + padding: 4px 0; +} +section#notifications-drawer .header .notification-menu .menu-divider { + border-top: 1px solid #dbdbdb; + margin: 4px 0; +} +section#notifications-drawer .header .notification-menu .menu-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + color: #333; + white-space: nowrap; +} +section#notifications-drawer .header .notification-menu .menu-item:hover { + background-color: #f5f5f5; +} +section#notifications-drawer .header .notification-menu .menu-item.selected { + background-color: #e8f4f8; +} +section#notifications-drawer .header .notification-menu .menu-item .check-icon { + width: 20px; + min-width: 20px; + margin-right: 8px; + text-align: center; + color: #2980b9; + font-weight: bold; +} +section#notifications-drawer .header .notification-menu .menu-item .menu-icon { + margin-right: 8px; + font-size: 16px; +} section#notifications-drawer .header h5 { text-align: center; margin: 0; @@ -42,19 +96,7 @@ section#notifications-drawer .header .close { line-height: 24px; opacity: 1; } -section#notifications-drawer .all-read, -section#notifications-drawer .remove-read { - color: #2980b9; - background-color: #fafafa; - margin: 8px 16px 12px; - display: inline-block; -} -section#notifications-drawer .remove-read { - float: right; -} -section#notifications-drawer .remove-read:hover { - color: #eb4646 !important; -} + section#notifications-drawer ul.notifications { display: block; padding: 0px 16px 0px 16px; diff --git a/client/components/notifications/notificationsDrawer.jade b/client/components/notifications/notificationsDrawer.jade index 66c53b849..17200d43d 100644 --- a/client/components/notifications/notificationsDrawer.jade +++ b/client/components/notifications/notificationsDrawer.jade @@ -1,20 +1,42 @@ template(name='notificationsDrawer') section#notifications-drawer(class="{{#if $.Session.get 'showReadNotifications'}}show-read{{/if}}") .header - if $.Session.get 'showReadNotifications' - a.toggle-read {{_ 'filter-by-unread'}} - else - a.toggle-read {{_ 'view-all'}} + a.notification-menu-toggle ☰ + .notification-menu(class="{{#if $.Session.get 'showNotificationMenu'}}is-open{{/if}}") + .menu-section + a.menu-item(class="{{#unless $.Session.get 'showReadNotifications'}}selected{{/unless}}") + span.check-icon {{#unless $.Session.get 'showReadNotifications'}}✓{{/unless}} + span.menu-icon 📭 + span {{_ 'filter-by-unread'}} + a.menu-item(class="{{#if $.Session.get 'showReadNotifications'}}selected{{/if}}") + span.check-icon {{#if $.Session.get 'showReadNotifications'}}✓{{/if}} + span.menu-icon 📋 + span {{_ 'view-all'}} + .menu-divider + .menu-section + if($gt unreadNotifications 0) + a.menu-item.mark-all-read + span.check-icon + span.menu-icon ✅ + span {{_ 'mark-all-as-read'}} + if ($and ($.Session.get 'showReadNotifications') ($gt readNotifications 0)) + a.menu-item.mark-all-unread + span.check-icon + span.menu-icon 📬 + span {{_ 'mark-all-as-unread'}} + if ($and ($.Session.get 'showReadNotifications') ($gt readNotifications 0)) + a.menu-item.delete-read + span.check-icon + span.menu-icon 🗑️ + span {{_ 'remove-all-read'}} + a.menu-item.delete-all + span.check-icon + span.menu-icon 🗑️ + span {{_ 'delete-all-notifications'}} h5 {{_ 'notifications'}} if($gt unreadNotifications 0) |(#{unreadNotifications}) a.close ❌ ul.notifications - each transformedProfile.notifications + each notifications +notification(activityData=activityObj index=dbIndex read=read) - if($gt unreadNotifications 0) - a.all-read {{_ 'mark-all-as-read'}} - if ($and ($.Session.get 'showReadNotifications') ($gt readNotifications 0)) - a.remove-read - | 🗑️ - | {{_ 'remove-all-read'}} diff --git a/client/components/notifications/notificationsDrawer.js b/client/components/notifications/notificationsDrawer.js index add14b129..06d31e041 100644 --- a/client/components/notifications/notificationsDrawer.js +++ b/client/components/notifications/notificationsDrawer.js @@ -14,41 +14,83 @@ Template.notificationsDrawer.onCreated(function() { }); Template.notificationsDrawer.helpers({ + notifications() { + const user = ReactiveCache.getCurrentUser(); + return user ? user.notifications() : []; + }, transformedProfile() { return ReactiveCache.getCurrentUser(); }, readNotifications() { - const readNotifications = _.filter( - ReactiveCache.getCurrentUser().profile.notifications, - v => !!v.read, - ); + const user = ReactiveCache.getCurrentUser(); + const list = user ? user.notifications() : []; + const readNotifications = _.filter(list, v => !!v.read); return readNotifications.length; }, }); Template.notificationsDrawer.events({ - 'click .all-read'() { - const notifications = ReactiveCache.getCurrentUser().profile.notifications; - for (const index in notifications) { - if (notifications.hasOwnProperty(index) && !notifications[index].read) { - const update = {}; - update[`profile.notifications.${index}.read`] = Date.now(); - Users.update(Meteor.userId(), { $set: update }); + 'click .notification-menu-toggle'(event) { + event.stopPropagation(); + Session.set('showNotificationMenu', !Session.get('showNotificationMenu')); + }, + 'click .notification-menu .menu-item'(event) { + const target = event.currentTarget; + + if (target.classList.contains('mark-all-read')) { + const notifications = ReactiveCache.getCurrentUser().profile.notifications; + for (const index in notifications) { + if (notifications.hasOwnProperty(index) && !notifications[index].read) { + const update = {}; + update[`profile.notifications.${index}.read`] = Date.now(); + Users.update(Meteor.userId(), { $set: update }); + } } + Session.set('showNotificationMenu', false); + } else if (target.classList.contains('mark-all-unread')) { + const notifications = ReactiveCache.getCurrentUser().profile.notifications; + for (const index in notifications) { + if (notifications.hasOwnProperty(index) && notifications[index].read) { + const update = {}; + update[`profile.notifications.${index}.read`] = null; + Users.update(Meteor.userId(), { $set: update }); + } + } + Session.set('showNotificationMenu', false); + } else if (target.classList.contains('delete-read')) { + const user = ReactiveCache.getCurrentUser(); + for (const notification of user.profile.notifications) { + if (notification.read) { + user.removeNotification(notification.activity); + } + } + Session.set('showNotificationMenu', false); + } else if (target.classList.contains('delete-all')) { + if (confirm(TAPi18n.__('delete-all-notifications-confirm'))) { + const user = ReactiveCache.getCurrentUser(); + const notificationsCopy = [...user.profile.notifications]; + for (const notification of notificationsCopy) { + user.removeNotification(notification.activity); + } + } + Session.set('showNotificationMenu', false); + } else if (target.classList.contains('selected')) { + // Already selected, do nothing + Session.set('showNotificationMenu', false); + } else { + // Toggle view + Session.set('showReadNotifications', !Session.get('showReadNotifications')); + Session.set('showNotificationMenu', false); } }, 'click .close'() { + Session.set('showNotificationMenu', false); toggleNotificationsDrawer(); }, - 'click .toggle-read'() { - Session.set('showReadNotifications', !Session.get('showReadNotifications')); - }, - 'click .remove-read'() { - const user = ReactiveCache.getCurrentUser(); - for (const notification of user.profile.notifications) { - if (notification.read) { - user.removeNotification(notification.activity); - } + 'click'(event) { + // Close menu when clicking outside + if (!event.target.closest('.notification-menu') && !event.target.closest('.notification-menu-toggle')) { + Session.set('showNotificationMenu', false); } }, }); diff --git a/client/lib/popup.js b/client/lib/popup.js index c0bfb779f..4a8b481ac 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -299,7 +299,7 @@ escapeActions.forEach(actionName => { () => Popup[actionName](), () => Popup.isOpen(), { - noClickEscapeOn: '.js-pop-over,.js-open-card-title-popup,.js-open-inlined-form', + noClickEscapeOn: '.js-pop-over,.js-open-card-title-popup,.js-open-inlined-form,.textcomplete-dropdown', enabledOnClick: actionName === 'close', }, ); diff --git a/client/lib/textComplete.js b/client/lib/textComplete.js index e97d38534..fe1864e3c 100644 --- a/client/lib/textComplete.js +++ b/client/lib/textComplete.js @@ -32,6 +32,9 @@ $.fn.escapeableTextComplete = function(strategies, options, ...otherArgs) { 'textComplete:show'() { dropdownMenuIsOpened = true; }, + 'textComplete:select'() { + EscapeActions.preventNextClick(); + }, 'textComplete:hide'() { Tracker.afterFlush(() => { // XXX Hack. We unfortunately need to set a setTimeout here to make the diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index 74bb4c655..42be4c4b6 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -783,6 +783,8 @@ "delete-board-confirm-popup": "All lists, cards, labels, and activities will be deleted and you won't be able to recover the board contents. There is no undo.", "boardDeletePopup-title": "Delete Board?", "delete-board": "Delete Board", + "delete-all-notifications": "Delete All Notifications", + "delete-all-notifications-confirm": "Are you sure you want to delete all notifications? This action cannot be undone.", "delete-duplicate-lists": "Delete Duplicate Lists", "delete-duplicate-lists-confirm": "Are you sure? This will delete all duplicate lists that have the same name and contain no cards.", "default-subtasks-board": "Subtasks for __board__ board", @@ -997,6 +999,7 @@ "view-all": "View All", "filter-by-unread": "Filter by Unread", "mark-all-as-read": "Mark all as read", + "mark-all-as-unread": "Mark all as unread", "remove-all-read": "Remove all read", "allow-rename": "Allow Rename", "allowRenamePopup-title": "Allow Rename", diff --git a/models/activities.js b/models/activities.js index 87d3d5f8e..077893437 100644 --- a/models/activities.js +++ b/models/activities.js @@ -212,14 +212,13 @@ if (Meteor.isServer) { }); const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.-]+))/gi; // including space in username let currentMention; + while ((currentMention = mentionRegex.exec(comment)) !== null) { /*eslint no-unused-vars: ["error", { "varsIgnorePattern": "[iI]gnored" }]*/ const [ignored, quoteduser, simple] = currentMention; const username = quoteduser || simple; - if (username === params.user) { - // ignore commenter mention himself? - continue; - } + // Removed the check that prevented self-mentions from creating notifications + // Users can now mention themselves in comments to create notifications if (activity.boardId && username === 'board_members') { // mentions all board members @@ -335,8 +334,9 @@ if (Meteor.isServer) { ); } Notifications.getUsers(watchers).forEach((user) => { - // don't notify a user of their own behavior - if (user._id !== userId) { + // Don't notify a user of their own behavior, EXCEPT for self-mentions + const isSelfMention = (user._id === userId && title === 'act-atUserComment'); + if (user._id !== userId || isSelfMention) { Notifications.notify(user, title, description, params); } }); diff --git a/models/users.js b/models/users.js index e022c36de..e784ae699 100644 --- a/models/users.js +++ b/models/users.js @@ -713,7 +713,7 @@ Users.attachSchema( ); // Security helpers for user updates -export const USER_UPDATE_ALLOWED_EXACT = ['username', 'profile']; +export const USER_UPDATE_ALLOWED_EXACT = ['username', 'profile', 'modifiedAt']; export const USER_UPDATE_ALLOWED_PREFIXES = ['profile.']; export const USER_UPDATE_FORBIDDEN_PREFIXES = [ 'services', @@ -729,24 +729,33 @@ export const USER_UPDATE_FORBIDDEN_PREFIXES = [ ]; export function isUserUpdateAllowed(fields) { - return fields.every((f) => + const result = fields.every((f) => USER_UPDATE_ALLOWED_EXACT.includes(f) || USER_UPDATE_ALLOWED_PREFIXES.some((p) => f.startsWith(p)) ); + return result; } export function hasForbiddenUserUpdateField(fields) { - return fields.some((f) => USER_UPDATE_FORBIDDEN_PREFIXES.some((p) => f === p || f.startsWith(p + '.'))); + const result = fields.some((f) => USER_UPDATE_FORBIDDEN_PREFIXES.some((p) => f === p || f.startsWith(p + '.'))); + return result; } Users.allow({ update(userId, doc, fields /*, modifier */) { // Only the owner can update, and only for allowed fields - if (!userId || doc._id !== userId) return false; - if (!Array.isArray(fields) || fields.length === 0) return false; + if (!userId || doc._id !== userId) { + return false; + } + if (!Array.isArray(fields) || fields.length === 0) { + return false; + } // Disallow if any forbidden field present - if (hasForbiddenUserUpdateField(fields)) return false; + if (hasForbiddenUserUpdateField(fields)) { + return false; + } // Allow only username and profile.* - return isUserUpdateAllowed(fields); + const allowed = isUserUpdateAllowed(fields); + return allowed; }, remove(userId, doc) { // Disable direct client-side user removal for security @@ -760,7 +769,8 @@ Users.allow({ // Deny any attempts to touch forbidden fields from client updates Users.deny({ update(userId, doc, fields /*, modifier */) { - return hasForbiddenUserUpdateField(fields); + const denied = hasForbiddenUserUpdateField(fields); + return denied; }, fetch: [], }); @@ -1770,6 +1780,7 @@ Users.mutations({ $addToSet: { 'profile.notifications': { activity: activityId, + read: null, }, }, };