Chat notifs (#11832)

* first part of chat notifs

* moved default notif to manage page

* spec

* notifs

* delete settings on room delete
isekai-main
Barış Soner Uşaklı 2 years ago committed by GitHub
parent f377650161
commit 61f036ce1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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",

@ -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?",

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

@ -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:

@ -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,

@ -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);
});
});

@ -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,18 +115,21 @@ chatsAPI.update = async (caller, data) => {
});
}
}
const [roomData, isAdmin] = await Promise.all([
messaging.getRoomData(data.roomId),
user.isAdministrator(caller.uid),
]);
if (!roomData) {
throw new Error('[[error:invalid-data]]');
}
if (data.hasOwnProperty('groups')) {
const [roomData, isAdmin] = await Promise.all([
messaging.getRoomData(data.roomId),
user.isAdministrator(caller.uid),
]);
if (!roomData) {
throw new Error('[[error:invalid-data]]');
}
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,
});

@ -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) => {

@ -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
const uids = await Messaging.getAllUidsInRoomFromSet(`chat:room:${roomId}:uids:online`);
Messaging.pushUnreadCount(uids, unreadData);
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,27 +84,41 @@ module.exports = function (Messaging) {
};
async function sendNotification(fromUid, roomId, messageObj) {
const { displayname } = messageObj.fromUser;
const isGroupChat = await Messaging.isGroupChat(roomId);
const notification = await notifications.create({
type: isGroupChat ? 'new-group-chat' : 'new-chat',
subject: `[[email:notif.chat.subject, ${displayname}]]`,
bodyShort: `[[notifications:new_message_from, ${displayname}]]`,
bodyLong: messageObj.content,
nid: `chat_${fromUid}_${roomId}`,
from: fromUid,
path: `/chats/${messageObj.roomId}`,
});
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);
uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromUid, 10) !== parseInt(uid, 10));
notifications.push(notification, uids);
uidsToNotify.push(...uids.filter((uid, index) => !hasRead[index]));
}, {
reverse: true,
batch: 500,
interval: 1000,
interval: 100,
});
if (uidsToNotify.length) {
const { displayname } = messageObj.fromUser;
const isGroupChat = await Messaging.isGroupChat(roomId);
const notification = await notifications.create({
type: isGroupChat ? 'new-group-chat' : 'new-chat',
subject: `[[email:notif.chat.subject, ${displayname}]]`,
bodyShort: `[[notifications:new_message_from, ${displayname}]]`,
bodyLong: messageObj.content,
nid: `chat_${fromUid}_${roomId}`,
from: fromUid,
path: `/chats/${messageObj.roomId}`,
});
await notifications.push(notification, uidsToNotify);
}
}
};

@ -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;

@ -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

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

@ -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);

@ -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>

@ -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>
Loading…
Cancel
Save