mirror of
https://github.com/wekan/wekan.git
synced 2026-01-16 22:45:29 +01:00
Fix mentions and notifications drawer.
Thanks to xet7 ! Fixes #6062, fixes #6003, fixes #5996, fixes #5720, fixes #5911, fixes #5792, fixes #5163, fixes #4431, fixes #4126, fixes #3363, fixes #3150
This commit is contained in:
parent
0d5dd3082c
commit
20b5e2ab8f
14 changed files with 225 additions and 72 deletions
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}}") 🗑️
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue