Chat notifs (#11832)

* first part of chat notifs

* moved default notif to manage page

* spec

* notifs

* delete settings on room delete
This commit is contained in:
Barış Soner Uşaklı
2023-07-21 15:31:34 -04:00
committed by GitHub
parent f377650161
commit 61f036ce1d
15 changed files with 249 additions and 46 deletions

View File

@@ -97,14 +97,14 @@
"nodebb-plugin-emoji": "5.1.3",
"nodebb-plugin-emoji-android": "4.0.0",
"nodebb-plugin-markdown": "12.1.7",
"nodebb-plugin-mentions": "4.3.2",
"nodebb-plugin-mentions": "4.3.3",
"nodebb-plugin-ntfy": "1.1.0",
"nodebb-plugin-spam-be-gone": "2.1.1",
"nodebb-rewards-essentials": "0.2.3",
"nodebb-theme-harmony": "1.1.13",
"nodebb-theme-harmony": "1.1.14",
"nodebb-theme-lavender": "7.1.3",
"nodebb-theme-peace": "2.1.3",
"nodebb-theme-persona": "13.2.6",
"nodebb-theme-persona": "13.2.7",
"nodebb-widget-essentials": "7.0.13",
"nodemailer": "6.9.4",
"nprogress": "0.2.0",

View File

@@ -36,6 +36,12 @@
"chat.public.groups-help": "To create a chat room that is visible to all users select registered-users from the group list.",
"chat.manage-room": "Manage Chat Room",
"chat.add-user": "Add User",
"chat.notification-settings": "Notification Settings",
"chat.default-notification-setting": "Default Notification Setting",
"chat.notification-setting-room-default": "Room Default",
"chat.notification-setting-none": "No notifications",
"chat.notification-setting-at-mention-only": "@mention only",
"chat.notification-setting-all-messages": "All messages",
"chat.select-groups": "Select Groups",
"chat.add-user-help": "Search for users here. When selected, the user will be added to the chat. The new user will not be able to see chat messages written before they were added to the conversation. Only room owners (<i class=\"fa fa-star text-warning\"></i>) may remove users from chat rooms.",
"chat.confirm-chat-with-dnd-user": "This user has set their status to DnD(Do not disturb). Do you still want to chat with them?",

View File

@@ -22,6 +22,8 @@ RoomObject:
timestamp:
type: number
description: Timestamp of when room was created
notificationSetting:
type: number
MessageObject:
type: object
properties:

View File

@@ -39,6 +39,12 @@ get:
timestamp:
type: number
description: Timestamp of when room was created
notificationSetting:
type: number
notificationOptions:
type: array
notificationOptionsIcon:
type: string
messages:
type: array
items:
@@ -318,6 +324,8 @@ get:
type: string
chatWithMessage:
type: string
notificationSetting:
type: number
publicRooms:
type: array
items:

View File

@@ -99,6 +99,8 @@ define('forum/chats', [
});
userList.init(roomId, mainWrapper);
Chats.addPublicRoomSortHandler();
Chats.addTooltipHandler();
Chats.addNotificationSettingHandler();
};
Chats.addPublicRoomSortHandler = function () {
@@ -122,6 +124,37 @@ define('forum/chats', [
}
};
Chats.addTooltipHandler = function () {
$('[data-manual-tooltip]').tooltip({
trigger: 'manual',
animation: false,
placement: 'bottom',
}).on('mouseenter', function (ev) {
const target = $(ev.target);
const isDropdown = target.hasClass('dropdown-menu') || !!target.parents('.dropdown-menu').length;
if (!isDropdown) {
$(this).tooltip('show');
}
}).on('click mouseleave', function () {
$(this).tooltip('hide');
});
};
Chats.addNotificationSettingHandler = function () {
const notifSettingEl = $('[component="chat/notification/setting"]');
notifSettingEl.find('[data-value]').on('click', async function () {
notifSettingEl.find('i.fa-check').addClass('hidden');
const $this = $(this);
$this.find('i.fa-check').removeClass('hidden');
$('[component="chat/notification/setting/icon"]').attr('class', `fa ${$this.attr('data-icon')}`);
await socket.emit('modules.chats.setNotificationSetting', {
roomId: ajaxify.data.roomId,
value: $this.attr('data-value'),
});
});
};
Chats.addUploadHandler = function (options) {
uploadHelpers.init({
dragDropAreaEl: options.dragDropAreaEl,

View File

@@ -23,7 +23,7 @@ define('forum/chats/manage', [
const html = await app.parseAndTranslate('modals/manage-room', {
groups,
user: app.user,
group: ajaxify.data,
room: ajaxify.data,
});
modal = bootbox.dialog({
title: '[[modules:chat.manage-room]]',
@@ -67,14 +67,28 @@ define('forum/chats/manage', [
});
});
modal.find('[component="chat/manage/save/groups"]').on('click', (ev) => {
const btn = $(ev.target);
modal.find('[component="chat/manage/save"]').on('click', () => {
const notifSettingEl = modal.find('[component="chat/room/notification/setting"]');
api.put(`/chats/${roomId}`, {
groups: modal.find('[component="chat/room/groups"]').val(),
notificationSetting: notifSettingEl.val(),
}).then((payload) => {
ajaxify.data.groups = payload.groups;
btn.addClass('btn-success');
setTimeout(() => btn.removeClass('btn-success'), 1000);
ajaxify.data.notificationSetting = payload.notificationSetting;
const roomDefaultOption = payload.notificationOptions[0];
$('[component="chat/notification/setting"] [data-icon]').first().attr(
'data-icon', roomDefaultOption.icon
);
$('[component="chat/notification/setting/sub-label"]').translateText(
roomDefaultOption.subLabel
);
if (roomDefaultOption.selected) {
$('[component="chat/notification/setting/icon"]').attr(
'class', `fa ${roomDefaultOption.icon}`
);
}
modal.modal('hide');
}).catch(alerts.error);
});
});

View File

@@ -40,11 +40,13 @@ chatsAPI.create = async function (caller, data) {
if (!data) {
throw new Error('[[error:invalid-data]]');
}
const isPublic = data.type === 'public';
const isAdmin = await user.isAdministrator(caller.uid);
if (isPublic && !isAdmin) {
throw new Error('[[error:no-privileges]]');
}
if (!data.uids || !Array.isArray(data.uids)) {
throw new Error(`[[error:wrong-parameter-type, uids, ${typeof data.uids}, Array]]`);
}
@@ -55,6 +57,11 @@ chatsAPI.create = async function (caller, data) {
if (isPublic && (!Array.isArray(data.groups) || !data.groups.length)) {
throw new Error('[[error:no-groups-selected]]');
}
data.notificationSetting = isPublic ?
messaging.notificationSettings.ATMENTION :
messaging.notificationSettings.ALLMESSAGES;
await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid)));
const roomId = await messaging.newRoom(caller.uid, data);
@@ -108,7 +115,6 @@ chatsAPI.update = async (caller, data) => {
});
}
}
if (data.hasOwnProperty('groups')) {
const [roomData, isAdmin] = await Promise.all([
messaging.getRoomData(data.roomId),
user.isAdministrator(caller.uid),
@@ -116,10 +122,14 @@ chatsAPI.update = async (caller, data) => {
if (!roomData) {
throw new Error('[[error:invalid-data]]');
}
if (data.hasOwnProperty('groups')) {
if (roomData.public && isAdmin) {
await db.setObjectField(`chat:room:${data.roomId}`, 'groups', JSON.stringify(data.groups));
}
}
if (data.hasOwnProperty('notificationSetting') && isAdmin) {
await db.setObjectField(`chat:room:${data.roomId}`, 'notificationSetting', data.notificationSetting);
}
return messaging.loadRoom(caller.uid, {
roomId: data.roomId,
});

View File

@@ -25,6 +25,11 @@ require('./rooms')(Messaging);
require('./unread')(Messaging);
require('./notifications')(Messaging);
Messaging.notificationSettings = Object.create(null);
Messaging.notificationSettings.NONE = 1;
Messaging.notificationSettings.ATMENTION = 2;
Messaging.notificationSettings.ALLMESSAGES = 3;
Messaging.messageExists = async mid => db.exists(`message:${mid}`);
Messaging.getMessages = async (params) => {

View File

@@ -12,6 +12,23 @@ const meta = require('../meta');
module.exports = function (Messaging) {
// Only used to notify a user of a new chat message
Messaging.notifyQueue = {};
Messaging.setUserNotificationSetting = async (uid, roomId, value) => {
if (parseInt(value, 10) === -1) {
// go back to default
return await db.deleteObjectField(`chat:room:${roomId}:notification:settings`, uid);
}
await db.setObjectField(`chat:room:${roomId}:notification:settings`, uid, parseInt(value, 10));
};
Messaging.getUidsNotificationSetting = async (uids, roomId) => {
const [settings, roomData] = await Promise.all([
db.getObjectFields(`chat:room:${roomId}:notification:settings`, uids),
Messaging.getRoomData(roomId, ['notificationSetting']),
]);
return uids.map(uid => parseInt(settings[uid] || roomData.notificationSetting, 10));
};
Messaging.notifyUsersInRoom = async (fromUid, roomId, messageObj) => {
const isPublic = parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1;
@@ -34,13 +51,15 @@ module.exports = function (Messaging) {
// delivers unread public msg to all online users on the chats page
io.in(`chat_room_public_${roomId}`).emit('event:chats.public.unread', unreadData);
}
if (messageObj.system || isPublic) {
if (messageObj.system) {
return;
}
// push unread count only for private rooms
if (!isPublic) {
const uids = await Messaging.getAllUidsInRoomFromSet(`chat:room:${roomId}:uids:online`);
Messaging.pushUnreadCount(uids, unreadData);
}
// Delayed notifications
let queueObj = Messaging.notifyQueue[`${fromUid}:${roomId}`];
@@ -65,6 +84,29 @@ module.exports = function (Messaging) {
};
async function sendNotification(fromUid, roomId, messageObj) {
fromUid = parseInt(fromUid, 10);
const [settings, roomData] = await Promise.all([
db.getObject(`chat:room:${roomId}:notification:settings`),
Messaging.getRoomData(roomId, ['notificationSetting']),
]);
const roomDefault = roomData.notificationSetting;
const uidsToNotify = [];
const { ALLMESSAGES } = Messaging.notificationSettings;
await batch.processSortedSet(`chat:room:${roomId}:uids:online`, async (uids) => {
uids = uids.filter(
uid => (parseInt((settings && settings[uid]) || roomDefault, 10) === ALLMESSAGES) &&
fromUid !== parseInt(uid, 10)
);
const hasRead = await Messaging.hasRead(uids, roomId);
uidsToNotify.push(...uids.filter((uid, index) => !hasRead[index]));
}, {
reverse: true,
batch: 500,
interval: 100,
});
if (uidsToNotify.length) {
const { displayname } = messageObj.fromUser;
const isGroupChat = await Messaging.isGroupChat(roomId);
const notification = await notifications.create({
@@ -76,16 +118,7 @@ module.exports = function (Messaging) {
from: fromUid,
path: `/chats/${messageObj.roomId}`,
});
await batch.processSortedSet(`chat:room:${roomId}:uids:online`, async (uids) => {
const hasRead = await Messaging.hasRead(uids, roomId);
uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromUid, 10) !== parseInt(uid, 10));
notifications.push(notification, uids);
}, {
reverse: true,
batch: 500,
interval: 1000,
});
await notifications.push(notification, uidsToNotify);
}
}
};

View File

@@ -54,6 +54,15 @@ module.exports = function (Messaging) {
data.groupChat = parseInt(data.groupChat, 10) === 1;
}
if (!fields.length || fields.includes('notificationSetting')) {
data.notificationSetting = data.notificationSetting ||
(
data.public ?
Messaging.notificationSettings.ATMENTION :
Messaging.notificationSettings.ALLMESSAGES
);
}
if (data.hasOwnProperty('groups') || !fields.length || fields.includes('groups')) {
try {
data.groups = JSON.parse(data.groups || '[]');
@@ -76,6 +85,7 @@ module.exports = function (Messaging) {
const room = {
roomId: roomId,
timestamp: now,
notificationSetting: data.notificationSetting,
};
if (data.hasOwnProperty('roomName') && data.roomName) {
@@ -145,10 +155,14 @@ module.exports = function (Messaging) {
...roomIds.map(id => `chat:room:${id}:uids`),
...roomIds.map(id => `chat:room:${id}:owners`),
...roomIds.map(id => `chat:room:${id}:uids:online`),
...roomIds.map(id => `chat:room:${id}:notification:settings`),
]),
db.sortedSetRemove('chat:rooms', roomIds),
db.sortedSetRemove('chat:rooms:public', roomIds),
db.sortedSetRemove('chat:rooms:public:order', roomIds),
db.sortedSetRemove([
'chat:rooms',
'chat:rooms:public',
'chat:rooms:public:order',
'chat:rooms:public:lastpost',
], roomIds),
]);
cache.del([
'chat:rooms:public:all',
@@ -448,7 +462,36 @@ module.exports = function (Messaging) {
await db.sortedSetAdd(`chat:room:${roomId}:uids:online`, Date.now(), uid);
}
const [canReply, users, messages, settings, isOwner, onlineUids] = await Promise.all([
async function getNotificationOptions() {
const userSetting = await db.getObjectField(`chat:room:${roomId}:notification:settings`, uid);
const roomDefault = room.notificationSetting;
const currentSetting = userSetting || roomDefault;
const labels = {
[Messaging.notificationSettings.NONE]: { label: '[[modules:chat.notification-setting-none]]', icon: 'fa-ban' },
[Messaging.notificationSettings.ATMENTION]: { label: '[[modules:chat.notification-setting-at-mention-only]]', icon: 'fa-at' },
[Messaging.notificationSettings.ALLMESSAGES]: { label: '[[modules:chat.notification-setting-all-messages]]', icon: 'fa-comment-o' },
};
const options = [
{
label: '[[modules:chat.notification-setting-room-default]]',
subLabel: labels[roomDefault].label || '',
icon: labels[roomDefault].icon,
value: -1,
selected: userSetting === null,
},
];
Object.keys(labels).forEach((key) => {
options.push({
label: labels[key].label,
icon: labels[key].icon,
value: key,
selected: parseInt(userSetting, 10) === parseInt(key, 10),
});
});
return { options, selectedIcon: labels[currentSetting].icon };
}
const [canReply, users, messages, settings, isOwner, onlineUids, notifOptions] = await Promise.all([
Messaging.canReply(roomId, uid),
Messaging.getUsersInRoomFromSet(`chat:room:${roomId}:uids:online`, roomId, 0, 39, true),
Messaging.getMessages({
@@ -460,6 +503,7 @@ module.exports = function (Messaging) {
user.getSettings(uid),
Messaging.isRoomOwner(uid, roomId),
io.getUidsInRoom(`chat_room_${roomId}`),
getNotificationOptions(),
]);
users.forEach((user) => {
@@ -481,6 +525,8 @@ module.exports = function (Messaging) {
room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2;
room.isAdminOrGlobalMod = isAdmin || isGlobalMod;
room.isAdmin = isAdmin;
room.notificationOptions = notifOptions.options;
room.notificationOptionsIcon = notifOptions.selectedIcon;
const payload = await plugins.hooks.fire('filter:messaging.loadRoom', { uid, data, room });
return payload.room;

View File

@@ -33,6 +33,25 @@ module.exports = function (Messaging) {
};
Messaging.hasRead = async (uids, roomId) => {
if (!uids.length) {
return [];
}
const roomData = await Messaging.getRoomData(roomId);
if (!roomData) {
return uids.map(() => false);
}
if (roomData.public) {
const [userTimestamps, mids] = await Promise.all([
db.getObjectsFields(uids.map(uid => `uid:${uid}:chat:rooms:read`), [roomId]),
db.getSortedSetRevRangeWithScores(`chat:room:${roomId}:mids`, 0, 0),
]);
const lastMsgTimestamp = mids[0] ? mids[0].score : 0;
return uids.map(
(uid, index) => !userTimestamps[index] ||
!userTimestamps[index][roomId] ||
parseInt(userTimestamps[index][roomId], 10) > lastMsgTimestamp
);
}
const isMembers = await db.isMemberOfSortedSets(
uids.map(uid => `uid:${uid}:chat:rooms:unread`),
roomId

View File

@@ -159,7 +159,7 @@ Notifications.push = async function (notification, uids) {
winston.error(err.stack);
}
});
}, 1000);
}, 500);
};
async function pushToUids(uids, notification) {

View File

@@ -200,4 +200,17 @@ SocketModules.chats.toggleOwner = async (socket, data) => {
await Messaging.toggleOwner(data.uid, data.roomId);
};
SocketModules.chats.setNotificationSetting = async (socket, data) => {
if (!data || !utils.isNumber(data.value) || !data.roomId) {
throw new Error('[[error:invalid-data]]');
}
const inRoom = await Messaging.isUserInRoom(socket.uid, data.roomId);
if (!inRoom) {
throw new Error('[[error:no-privileges]]');
}
await Messaging.setUserNotificationSetting(socket.uid, data.roomId, data.value);
};
require('../promisify')(SocketModules);

View File

@@ -1,10 +1,11 @@
<div class="mb-3">
<div class="mb-3">
<label class="form-label">[[modules:chat.room-name-optional]]</label>
<input component="chat/room/name" class="form-control"/>
<label class="form-label text-nowrap">[[modules:chat.room-name-optional]]</label>
<input component="chat/room/name" class="form-control" />
</div>
<div class="mb-3">
<div class="dropdown mb-3">
<div class="dropdown">
<label class="form-label">[[modules:chat.add-user]]</label>
<input component="chat/search" class="form-control" type="text" placeholder="[[global:user-search-prompt]]" data-bs-toggle="dropdown"/>
<ul component="chat/search/list" class="dropdown-menu p-1 overflow-auto" style="max-height: 400px;">
@@ -15,7 +16,7 @@
{{{ end }}}
</ul>
</div>
<ul component="chat/room/users" class="list-group">
<ul component="chat/room/users" class="list-group mt-2">
{{{ each selectedUsers }}}
<li class="list-group-item d-flex gap-2 align-items-center justify-content-between" component="chat/user" data-uid="{./uid}">
<a href="#" class="text-reset text-decoration-none">{buildAvatar(@value, "24px", true)} {./username}</a>

View File

@@ -1,4 +1,4 @@
<div class="mb-3">
<div class="">
<label class="form-label">[[modules:chat.add-user]]</label>
<input component="chat/manage/user/add/search" class="form-control" type="text" placeholder="[[global:user-search-prompt]]" />
<p class="text-danger"></p>
@@ -12,16 +12,29 @@
<li class="list-group-item"><i class="fa fa-spinner fa-spin"></i> [[modules:chat.retrieving-users]]</li>
</ul>
{{{ if (user.isAdmin && group.public ) }}}
{{{ if user.isAdmin }}}
<hr/>
<div class="d-flex gap-2 mb-3 align-items-center justify-content-between">
<label class="form-label text-nowrap mb-0">[[modules:chat.default-notification-setting]]</label>
<select component="chat/room/notification/setting" class="form-select" style="width: 200px;">
<option value="1" {{{ if (room.notificationSetting == "1") }}}selected{{{ end }}}>[[modules:chat.notification-setting-none]]</option>
<option value="2" {{{ if (room.notificationSetting == "2") }}}selected{{{ end }}}>[[modules:chat.notification-setting-at-mention-only]]</option>
<option value="3" {{{ if (room.notificationSetting == "3") }}}selected{{{ end }}}>[[modules:chat.notification-setting-all-messages]]</option>
</select>
</div>
{{{ if room.public }}}
<label class="form-label">[[modules:chat.select-groups]]</label>
<select component="chat/room/groups" class="form-select mb-1" multiple size="10">
<select component="chat/room/groups" class="form-select mb-3" multiple size="10">
{{{ each groups }}}
<option value="{./displayName}" {{{ if ./selected }}}selected{{{ end }}}>{./displayName}</option>
{{{ end }}}
</select>
{{{ end }}}
<div class="d-flex justify-content-end">
<button component="chat/manage/save/groups" class="btn btn-sm btn-primary">[[global:save]]</button>
<button component="chat/manage/save" class="btn btn-sm btn-primary">[[global:save]]</button>
</div>
{{{ end }}}
</div>