Chat refactor (#11779)

* first part of chat refactor

remove per user chat zsets & store all mids in chat:room:<roomId>:mids
reverse uids in getUidsInRoom

* feat: create room button

public groups wip

* feat: public rooms

create chats:room zset
chat room deletion

* join socket.io room

* get rid of some calls that load all users in room

* dont load all users when loadRoom is called

* mange room users infinitescroll

dont load all members in api call

* IS for user list

ability to change groups field for public rooms
update groups field if group is renamed

* test: test fixes

* wip

* keep 150 messages

* fix extra awaits

fix dupe code in chat toggleReadState

* unread state for public rooms

* feat: faster push unread

* test: spec

* change base to harmony

* test: lint fixes

* fix language of chat with message

* add 2 methods for perf

messaging.getTeasers and getUsers(roomIds)
instead of loading one by one

* refactor: cleaner conditional

* test fix upgrade script fix

save timestamp of room creation in room object

* set progress.total

* don't check for guests/spiders

* public room unread fix

* add public unread counts

* mark read on send

* ignore instead of throwing

* doggy.gif

* fix: restore delete

* prevent entering chat rooms with

meta.enter

* fix self message causing mark unread

* ability to sort public rooms

* dont init sortable on mobile

* move chat-loaded class to core

* test: fix spec

* add missing keys

* use ajaxify

* refactor: store some refs

* fix: when user is deleted remove from public rooms as well

* feat: change how unread count is calculated

* get rid of cleaned content

get rid of mid

* add help text

* test: fix tests, add back mid

to prevent breaking change

* ability to search members of chat rooms

* remove

* derp

* perf: switch with  partial data

fix tests

* more fixes

if user leaves a group leave public rooms is he is no longer part of any of the groups that have access

fix the cache key used to get all public room ids

dont allow joining chat socket.io room if user is no longer part of group

* fix: lint

* fix: js error when trying to delete room after switching

* add isRoomPublic
isekai-main
Barış Soner Uşaklı 2 years ago committed by GitHub
parent edd8ca997f
commit 9b901783fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -49,8 +49,8 @@ module.exports = function (grunt) {
if (!pluginList.includes('nodebb-plugin-composer-default')) {
pluginList.push('nodebb-plugin-composer-default');
}
if (!pluginList.includes('nodebb-theme-persona')) {
pluginList.push('nodebb-theme-persona');
if (!pluginList.includes('nodebb-theme-harmony')) {
pluginList.push('nodebb-theme-harmony');
}
}

@ -230,6 +230,7 @@
"not-in-room": "User not in room",
"cant-kick-self": "You can't kick yourself from the group",
"no-users-selected": "No user(s) selected",
"no-groups-selected": "No group(s) selected",
"invalid-home-page-route": "Invalid home page route",
"invalid-session": "Invalid Session",

@ -27,15 +27,29 @@
"chat.three_months": "3 Months",
"chat.delete_message_confirm": "Are you sure you wish to delete this message?",
"chat.retrieving-users": "Retrieving users...",
"chat.view-users-list": "View users list",
"chat.public-rooms": "Public Rooms (%1)",
"chat.private-rooms": "Private Rooms (%1)",
"chat.create-room": "Create Chat Room",
"chat.private.option": "Private (Only visible to users added to room)",
"chat.public.option": "Public (Visible to every user in selected groups)",
"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.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?",
"chat.room-name-optional": "Room Name (Optional)",
"chat.rename-room": "Rename Room",
"chat.rename-placeholder": "Enter your room name here",
"chat.rename-help": "The room name set here will be viewable by all participants in the room.",
"chat.leave": "Leave Chat",
"chat.leave": "Leave",
"chat.leave-room": "Leave Room",
"chat.leave-prompt": "Are you sure you wish to leave this chat?",
"chat.leave-help": "Leaving this chat will remove you from future correspondence in this chat. If you are re-added in the future, you will not see any chat history from prior to your re-joining.",
"chat.delete": "Delete",
"chat.delete-room": "Delete Room",
"chat.delete-prompt": "Are you sure you wish to delete this chat room?",
"chat.in-room": "In this room",
"chat.kick": "Kick",
"chat.show-ip": "Show IP",

@ -9,9 +9,19 @@ RoomObject:
description: unique identifier for the chat room
roomName:
type: string
description: the name of the room, if set this is displayed instead of the usernames
groupChat:
type: boolean
description: whether the chat room is a group chat or not
description: whether the chat room is a group chat or not (if more than 2 users it is a group chat)
public:
type: boolean
description: whether the chat room is public or private
userCount:
type: number
description: number of users in this chat room
timestamp:
type: number
description: Timestamp of when room was created
MessageObject:
type: object
properties:
@ -92,8 +102,6 @@ MessageObject:
type: number
newSet:
type: boolean
cleanedContent:
type: string
RoomUserList:
type: object
properties:
@ -132,6 +140,8 @@ RoomUserList:
type: boolean
canKick:
type: boolean
index:
type: number
RoomObjectFull:
# Messaging.loadRoom
allOf:

@ -30,6 +30,13 @@ get:
type: number
roomName:
type: string
public:
type: boolean
userCount:
type: number
timestamp:
type: number
description: Timestamp of when room was created
messages:
type: array
items:
@ -101,8 +108,6 @@ get:
type: boolean
index:
type: number
cleanedContent:
type: string
isOwner:
type: boolean
isOwner:
@ -139,6 +144,8 @@ get:
example: "#f44336"
isOwner:
type: boolean
index:
type: number
canReply:
type: boolean
groupChat:
@ -153,6 +160,8 @@ get:
type: boolean
isAdminOrGlobalMod:
type: boolean
isAdmin:
type: boolean
rooms:
type: array
items:
@ -166,6 +175,162 @@ get:
type: number
roomName:
type: string
public:
type: boolean
userCount:
type: number
timestamp:
type: number
description: Timestamp of when room was created
users:
type: array
items:
type: object
properties:
uid:
type: number
description: A user identifier
username:
type: string
description: A friendly name for a given user account
displayname:
type: string
description: This is either username or fullname depending on forum and user settings
userslug:
type: string
description: An URL-safe variant of the username (i.e. lower-cased, spaces
removed, etc.)
picture:
nullable: true
type: string
status:
type: string
lastonline:
type: number
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
auto-generated icon given to users without
an avatar
icon:bgColor:
type: string
description: A six-character hexadecimal colour code assigned to the user. This
value is used in conjunction with
`icon:text` for the user's auto-generated
icon
example: "#f44336"
lastonlineISO:
type: string
groupChat:
type: boolean
unread:
type: boolean
teaser:
type: object
properties:
fromuid:
type: number
content:
type: string
timestamp:
type: number
timestampISO:
type: string
description: An ISO 8601 formatted date string (complementing `timestamp`)
user:
type: object
properties:
uid:
type: number
description: A user identifier
username:
type: string
description: A friendly name for a given user account
displayname:
type: string
description: This is either username or fullname depending on forum and user settings
userslug:
type: string
description: An URL-safe variant of the username (i.e. lower-cased, spaces
removed, etc.)
picture:
nullable: true
type: string
status:
type: string
lastonline:
type: number
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
auto-generated icon given to users
without an avatar
icon:bgColor:
type: string
description: A six-character hexadecimal colour code assigned to the user. This
value is used in conjunction with
`icon:text` for the user's
auto-generated icon
example: "#f44336"
lastonlineISO:
type: string
nullable: true
lastUser:
type: object
properties:
uid:
type: number
description: A user identifier
username:
type: string
description: A friendly name for a given user account
displayname:
type: string
description: This is either username or fullname depending on forum and user settings
userslug:
type: string
description: An URL-safe variant of the username (i.e. lower-cased, spaces
removed, etc.)
picture:
nullable: true
type: string
status:
type: string
lastonline:
type: number
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
auto-generated icon given to users without
an avatar
icon:bgColor:
type: string
description: A six-character hexadecimal colour code assigned to the user. This
value is used in conjunction with
`icon:text` for the user's auto-generated
icon
example: "#f44336"
lastonlineISO:
type: string
usernames:
type: string
chatWithMessage:
type: string
publicRooms:
type: array
items:
type: object
properties:
owner:
oneOf:
- type: number
- type: string
roomId:
type: number
roomName:
type: string
public:
type: boolean
users:
type: array
items:
@ -300,6 +465,8 @@ get:
type: string
chatWithMessage:
type: string
privateRoomCount:
type: number
nextStart:
type: number
title:
@ -315,4 +482,6 @@ get:
type: boolean
chatWithMessage:
type: string
bodyClasses:
type: array
- $ref: ../../../../components/schemas/CommonProps.yaml#/CommonProps

@ -198,6 +198,8 @@ paths:
$ref: 'write/admin/analytics.yaml'
/admin/analytics/{set}:
$ref: 'write/admin/analytics/set.yaml'
/admin/chats/{roomId}:
$ref: 'write/admin/chats/roomId.yaml'
/admin/tokens:
$ref: 'write/admin/tokens.yaml'
/admin/tokens/{token}:

@ -0,0 +1,26 @@
delete:
tags:
- admin
summary: delete chat room
description: This operation deletes a chat room from the database
parameters:
- in: path
name: roomId
schema:
type: number
description: The roomId to be deleted
example: 1
required: true
responses:
'200':
description: Chat room deleted
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
properties: {}

@ -86,8 +86,6 @@ post:
newSet:
type: boolean
description: Whether the message is considered part of a new "set" of messages. It is used in the frontend UI for explicitly denoting that a time gap existed between messages.
cleanedContent:
type: string
mid:
type: number
put:

@ -3,11 +3,12 @@
define('forum/chats', [
'components',
'translator',
'mousetrap',
'forum/chats/recent',
'forum/chats/search',
'forum/chats/create',
'forum/chats/manage',
'forum/chats/messages',
'forum/chats/user-list',
'composer/autocomplete',
'hooks',
'bootbox',
@ -16,10 +17,10 @@ define('forum/chats', [
'api',
'uploadHelpers',
], function (
components, translator, mousetrap,
recentChats, search, messages,
autocomplete, hooks, bootbox, alerts, chatModule,
api, uploadHelpers
components, mousetrap,
recentChats, create, manage, messages,
userList, autocomplete, hooks, bootbox,
alerts, chatModule, api, uploadHelpers
) {
const Chats = {
initialised: false,
@ -27,13 +28,19 @@ define('forum/chats', [
};
let newMessage = false;
let chatNavWrapper = null;
$(window).on('action:ajaxify.start', function () {
Chats.destroyAutoComplete(ajaxify.data.roomId);
socket.emit('modules.chats.leave', ajaxify.data.roomId);
socket.emit('modules.chats.leavePublic', ajaxify.data.publicRooms.map(r => r.roomId));
});
Chats.init = function () {
$('.chats-full [data-bs-toggle="tooltip"]').tooltip();
socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId));
const env = utils.findBootstrapEnvironment();
chatNavWrapper = $('[component="chat/nav-wrapper"]');
if (!Chats.initialised) {
Chats.addSocketListeners();
@ -49,29 +56,31 @@ define('forum/chats', [
Chats.addHotkeys();
}
$(document).ready(function () {
hooks.fire('action:chat.loaded', $('.chats-full'));
});
Chats.initialised = true;
messages.scrollToBottom($('.expanded-chat ul.chat-content'));
messages.wrapImagesInLinks($('.expanded-chat ul.chat-content'));
search.init();
create.init();
hooks.fire('action:chat.loaded', $('.chats-full'));
};
Chats.addEventListeners = function () {
Chats.addSendHandlers(ajaxify.data.roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]'));
const { roomId } = ajaxify.data;
const mainWrapper = $('[component="chat/main-wrapper"]');
const chatControls = components.get('chat/controls');
Chats.addSendHandlers(roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]'));
Chats.addPopoutHandler();
Chats.addActionHandlers(components.get('chat/messages'), ajaxify.data.roomId);
Chats.addMemberHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="members"]'));
Chats.addRenameHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="rename"]'));
Chats.addLeaveHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="leave"]'));
Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content'));
Chats.addActionHandlers(components.get('chat/messages'), roomId);
Chats.addManageHandler(roomId, chatControls.find('[data-action="members"]'));
Chats.addRenameHandler(roomId, chatControls.find('[data-action="rename"]'));
Chats.addLeaveHandler(roomId, chatControls.find('[data-action="leave"]'));
Chats.addDeleteHandler(roomId, chatControls.find('[data-action="delete"]'));
Chats.addScrollHandler(roomId, ajaxify.data.uid, $('.chat-content'));
Chats.addScrollBottomHandler($('.chat-content'));
Chats.addCharactersLeftHandler($('[component="chat/main-wrapper"]'));
Chats.addTextareaResizeHandler($('[component="chat/main-wrapper"]'));
Chats.addIPHandler($('[component="chat/main-wrapper"]'));
Chats.createAutoComplete(ajaxify.data.roomId, $('[component="chat/input"]'));
Chats.addCharactersLeftHandler(mainWrapper);
Chats.addTextareaResizeHandler(mainWrapper);
Chats.addIPHandler(mainWrapper);
Chats.createAutoComplete(roomId, $('[component="chat/input"]'));
Chats.addUploadHandler({
dragDropAreaEl: $('.chats-full'),
pasteEl: $('[component="chat/input"]'),
@ -83,6 +92,28 @@ define('forum/chats', [
$('[data-action="close"]').on('click', function () {
Chats.switchChat();
});
userList.init(roomId, mainWrapper);
Chats.addPublicRoomSortHandler();
};
Chats.addPublicRoomSortHandler = function () {
if (app.user.isAdmin && !utils.isMobile()) {
app.loadJQueryUI(() => {
const publicRoomList = $('[component="chat/public"]');
publicRoomList.sortable({
handle: '[component="chat/public/room/sort/handle"]',
axis: 'y',
update: async function () {
const data = { roomIds: [], scores: [] };
publicRoomList.find('[data-roomid]').each((idx, el) => {
data.roomIds.push($(el).attr('data-roomid'));
data.scores.push(idx);
});
await socket.emit('modules.chats.sortPublicRooms', data);
},
});
});
}
};
Chats.addUploadHandler = function (options) {
@ -141,7 +172,7 @@ define('forum/chats', [
Chats.addScrollHandler = function (roomId, uid, el) {
let loading = false;
el.off('scroll').on('scroll', function () {
el.off('scroll').on('scroll', utils.debounce(function () {
messages.toggleScrollUpAlert(el);
if (loading) {
return;
@ -176,7 +207,7 @@ define('forum/chats', [
loading = false;
});
}).catch(alerts.error);
});
}, 100));
};
Chats.addScrollBottomHandler = function (chatContent) {
@ -208,7 +239,7 @@ define('forum/chats', [
};
Chats.addActionHandlers = function (element, roomId) {
element.on('click', '[data-action]', function () {
element.on('click', '[data-mid] [data-action]', function () {
const messageId = $(this).parents('[data-mid]').attr('data-mid');
const action = this.getAttribute('data-action');
@ -231,18 +262,16 @@ define('forum/chats', [
Chats.addHotkeys = function () {
mousetrap.bind('ctrl+up', function () {
const activeContact = $('.chats-list .bg-info');
const prev = activeContact.prev();
if (prev.length) {
const activeContact = $('.chats-list .active');
const prev = activeContact.prevAll('[data-roomid]').first();
if (prev.length && prev.attr('data-roomid')) {
Chats.switchChat(prev.attr('data-roomid'));
}
});
mousetrap.bind('ctrl+down', function () {
const activeContact = $('.chats-list .bg-info');
const next = activeContact.next();
if (next.length) {
const activeContact = $('.chats-list .active');
const next = activeContact.nextAll('[data-roomid]').first();
if (next.length && next.attr('data-roomid')) {
Chats.switchChat(next.attr('data-roomid'));
}
});
@ -260,50 +289,8 @@ define('forum/chats', [
});
};
Chats.addMemberHandler = function (roomId, buttonEl) {
let modal;
buttonEl.on('click', function () {
app.parseAndTranslate('modals/manage-room', {}, function (html) {
modal = bootbox.dialog({
title: '[[modules:chat.manage-room]]',
message: html,
});
modal.attr('component', 'chat/manage-modal');
Chats.refreshParticipantsList(roomId, modal);
Chats.addKickHandler(roomId, modal);
const searchInput = modal.find('input');
const errorEl = modal.find('.text-danger');
require(['autocomplete', 'translator'], function (autocomplete, translator) {
autocomplete.user(searchInput, function (event, selected) {
errorEl.text('');
api.post(`/chats/${roomId}/users`, {
uids: [selected.item.user.uid],
}).then((body) => {
Chats.refreshParticipantsList(roomId, modal, body);
searchInput.val('');
}).catch((err) => {
translator.translate(err.message, function (translated) {
errorEl.text(translated);
});
});
});
});
});
});
};
Chats.addKickHandler = function (roomId, modal) {
modal.on('click', '[data-action="kick"]', function () {
const uid = parseInt(this.getAttribute('data-uid'), 10);
api.del(`/chats/${roomId}/users/${uid}`, {}).then((body) => {
Chats.refreshParticipantsList(roomId, modal, body);
}).catch(alerts.error);
});
Chats.addManageHandler = function (roomId, buttonEl) {
manage.init(roomId, buttonEl);
};
Chats.addLeaveHandler = function (roomId, buttonEl) {
@ -330,21 +317,27 @@ define('forum/chats', [
});
};
Chats.refreshParticipantsList = async (roomId, modal, data) => {
const listEl = modal.find('.list-group');
if (!data) {
try {
data = await api.get(`/chats/${roomId}/users`, {});
} catch (err) {
translator.translate('[[error:invalid-data]]', function (translated) {
listEl.find('li').text(translated);
});
}
}
app.parseAndTranslate('partials/chats/manage-room-users', data, function (html) {
listEl.html(html);
Chats.addDeleteHandler = function (roomId, buttonEl) {
buttonEl.on('click', function () {
bootbox.confirm({
size: 'small',
title: '[[modules:chat.delete]]',
message: '<p>[[modules:chat.delete-prompt]]</p>',
callback: function (ok) {
if (ok) {
api.del(`/admin/chats/${roomId}`, {}).then(() => {
// Return user to chats page. If modal, close modal.
const modal = buttonEl.parents('.chat-modal');
if (modal.length) {
chatModule.close(modal);
} else {
Chats.destroyAutoComplete(roomId);
ajaxify.go('chats');
}
}).catch(alerts.error);
}
},
});
});
};
@ -362,18 +355,16 @@ define('forum/chats', [
save: {
label: '[[global:save]]',
className: 'btn-primary',
callback: submit,
callback: function () {
api.put(`/chats/${roomId}`, {
name: modal.find('#roomName').val(),
}).catch(alerts.error);
},
},
},
});
});
});
function submit() {
api.put(`/chats/${roomId}`, {
name: modal.find('#roomName').val(),
}).catch(alerts.error);
}
};
Chats.addSendHandlers = function (roomId, inputEl, sendEl) {
@ -452,37 +443,41 @@ define('forum/chats', [
roomid = '';
}
Chats.destroyAutoComplete(ajaxify.data.roomId);
socket.emit('modules.chats.leave', ajaxify.data.roomId);
const url = 'user/' + ajaxify.data.userslug + '/chats/' + roomid + window.location.search;
if (self.fetch) {
fetch(config.relative_path + '/api/' + url, { credentials: 'include' })
.then(function (response) {
if (response.ok) {
response.json().then(function (payload) {
app.parseAndTranslate('partials/chats/message-window', payload, function (html) {
components.get('chat/main-wrapper').html(html);
html.find('.timeago').timeago();
ajaxify.data = payload;
Chats.setActive();
Chats.addEventListeners();
hooks.fire('action:chat.loaded', $('.chats-full'));
messages.scrollToBottom($('.expanded-chat ul.chat-content'));
if (history.pushState) {
history.pushState({
url: url,
}, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + url);
}
});
});
} else {
console.warn('[search] Received ' + response.status);
}
})
.catch(function (error) {
console.warn('[search] ' + error.message);
});
} else {
ajaxify.go(url);
if (!self.fetch) {
return ajaxify.go(url);
}
const params = new URL(document.location).searchParams;
params.set('switch', 1);
const dataUrl = `${config.relative_path}/api/user/${ajaxify.data.userslug}/chats/${roomid}?${params.toString()}`;
fetch(dataUrl, { credentials: 'include' })
.then(async function (response) {
if (!response.ok) {
return console.warn('[search] Received ' + response.status);
}
const payload = await response.json();
const html = await app.parseAndTranslate('partials/chats/message-window', payload);
const mainWrapper = components.get('chat/main-wrapper');
mainWrapper.html(html);
chatNavWrapper = $('[component="chat/nav-wrapper"]');
html.find('.timeago').timeago();
ajaxify.data = { ...ajaxify.data, ...payload };
$('body').addClass(ajaxify.data.bodyClass);
mainWrapper.find('[data-bs-toggle="tooltip"]').tooltip();
Chats.setActive();
Chats.addEventListeners();
hooks.fire('action:chat.loaded', $('.chats-full'));
messages.scrollToBottom(mainWrapper.find('.expanded-chat ul.chat-content'));
if (history.pushState) {
history.pushState({
url: url,
}, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + url);
}
})
.catch(function (error) {
console.warn('[search] ' + error.message);
});
};
Chats.addGlobalEventListeners = function () {
@ -496,7 +491,11 @@ define('forum/chats', [
Chats.addSocketListeners = function () {
socket.on('event:chats.receive', function (data) {
if (chatModule.isFromBlockedUser(data.fromUid)) {
return;
}
if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) {
data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0;
if (!newMessage) {
newMessage = data.self === 0;
}
@ -504,31 +503,19 @@ define('forum/chats', [
data.message.timestamp = Math.min(Date.now(), data.message.timestamp);
data.message.timestampISO = utils.toISOString(data.message.timestamp);
messages.appendChatMessage($('.expanded-chat .chat-content'), data.message);
} else if (ajaxify.data.template.chats) {
const roomEl = $('[data-roomid=' + data.roomId + ']');
if (roomEl.length > 0) {
roomEl.addClass('unread');
}
});
const markEl = roomEl.find('.mark-read').get(0);
if (markEl) {
markEl.querySelector('.read').classList.add('hidden');
markEl.querySelector('.unread').classList.remove('hidden');
}
} else {
const recentEl = components.get('chat/recent');
app.parseAndTranslate('partials/chats/recent_room', {
rooms: {
roomId: data.roomId,
lastUser: data.message.fromUser,
usernames: data.message.fromUser.username,
unread: true,
},
}, function (html) {
recentEl.prepend(html);
});
}
socket.on('event:chats.public.unread', function (data) {
if (
chatModule.isFromBlockedUser(data.fromUid) ||
chatModule.isLookingAtRoom(data.roomId) ||
app.user.uid === parseInt(data.fromUid, 10)
) {
return;
}
Chats.markChatPageElUnread(data);
Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']'));
});
socket.on('event:user_status_change', function (data) {
@ -539,33 +526,55 @@ define('forum/chats', [
socket.on('event:chats.roomRename', function (data) {
const roomEl = components.get('chat/recent/room', data.roomId);
const titleEl = roomEl.find('[component="chat/title"]');
ajaxify.data.roomName = data.newName;
titleEl.text(data.newName);
if (roomEl.length) {
const titleEl = roomEl.find('[component="chat/room/title"]');
ajaxify.data.roomName = data.newName;
titleEl.text(data.newName);
}
});
socket.on('event:chats.mark', ({ roomId, state }) => {
const roomEls = document.querySelectorAll(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"]`);
roomEls.forEach((roomEl) => {
roomEl.classList[state ? 'add' : 'remove']('unread');
const markEl = roomEl.querySelector('.mark-read');
if (markEl) {
markEl.querySelector('.read').classList[state ? 'add' : 'remove']('hidden');
markEl.querySelector('.unread').classList[state ? 'remove' : 'add']('hidden');
const roomEls = $(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"], [component="chat/public"] [data-roomid="${roomId}"]`);
roomEls.each((idx, el) => {
const roomEl = $(el);
chatModule.markChatElUnread(roomEl, state === 1);
if (state === 0) {
Chats.updatePublicRoomUnreadCount(roomEl, 0);
}
});
});
};
Chats.markChatPageElUnread = function (data) {
if (!ajaxify.data.template.chats) {
return;
}
const roomEl = chatNavWrapper.find('[data-roomid=' + data.roomId + ']');
chatModule.markChatElUnread(roomEl, true);
};
Chats.increasePublicRoomUnreadCount = function (roomEl) {
const unreadCountEl = roomEl.find('[component="chat/public/room/unread/count"]');
const newCount = (parseInt(unreadCountEl.attr('data-count'), 10) || 0) + 1;
Chats.updatePublicRoomUnreadCount(roomEl, newCount);
};
Chats.updatePublicRoomUnreadCount = function (roomEl, count) {
const unreadCountEl = roomEl.find('[component="chat/public/room/unread/count"]');
const countText = count > 50 ? '50+' : count;
unreadCountEl.toggleClass('hidden', count <= 0).text(countText).attr('data-count', count);
};
Chats.setActive = function () {
chatNavWrapper.find('[data-roomid]').removeClass('active');
if (ajaxify.data.roomId) {
const chatEl = document.querySelector(`[component="chat/recent"] [data-roomid="${ajaxify.data.roomId}"]`);
if (chatEl.classList.contains('unread')) {
socket.emit('modules.chats.enter', ajaxify.data.roomId);
const chatEl = chatNavWrapper.find(`[data-roomid="${ajaxify.data.roomId}"]`);
chatEl.addClass('active');
if (chatEl.hasClass('unread')) {
api.del(`/chats/${ajaxify.data.roomId}/state`, {});
chatEl.classList.remove('unread');
chatEl.removeClass('unread');
}
if (!utils.isMobile()) {
@ -573,12 +582,10 @@ define('forum/chats', [
}
messages.updateTextAreaHeight($(`[component="chat/messages"][data-roomid="${ajaxify.data.roomId}"]`));
}
$('.chats-list [data-roomid]').removeClass('active');
$('.chats-list [data-roomid="' + ajaxify.data.roomId + '"]').addClass('active');
components.get('chat/nav-wrapper').attr('data-loaded', ajaxify.data.roomId ? '1' : '0');
chatNavWrapper.attr('data-loaded', ajaxify.data.roomId ? '1' : '0');
};
return Chats;
});

@ -0,0 +1,85 @@
'use strict';
define('forum/chats/create', [
'components', 'api', 'alerts', 'forum/chats/search',
], function (components, api, alerts, search) {
const create = {};
create.init = function () {
components.get('chat/create').on('click', handleCreate);
};
async function handleCreate() {
let groups = [];
if (app.user.isAdmin) {
groups = await socket.emit('groups.getChatGroups', {});
}
const html = await app.parseAndTranslate('modals/create-room', {
user: app.user,
groups: groups,
});
const modal = bootbox.dialog({
title: '[[modules:chat.create-room]]',
message: html,
buttons: {
save: {
label: '[[global:create]]',
className: 'btn-primary',
callback: async function () {
const roomName = modal.find('[component="chat/room/name"]').val();
const uids = modal.find('[component="chat/room/users"] [component="chat/user"]').find('[data-uid]').map(
(i, el) => $(el).attr('data-uid')
).get();
const type = modal.find('[component="chat/room/type"]').val();
const groups = modal.find('[component="chat/room/groups"]').val();
if (type === 'private' && !uids.length) {
alerts.error('[[error:no-users-selected]]');
return false;
}
if (type === 'public' && !groups) {
alerts.error('[[error:no-groups-selected]]');
return false;
}
await createRoom({
roomName: roomName,
uids: uids,
type: type,
groups: groups,
});
},
},
},
});
const chatRoomUsersList = modal.find('[component="chat/room/users"]');
search.init({
onSelect: async function (user) {
const html = await app.parseAndTranslate('modals/create-room', 'selectedUsers', { selectedUsers: [user] });
chatRoomUsersList.append(html);
},
});
chatRoomUsersList.on('click', '[component="chat/room/users/remove"]', function () {
$(this).parents('[data-uid]').remove();
});
modal.find('[component="chat/room/type"]').on('change', function () {
const type = $(this).val();
modal.find('[component="chat/room/public/options"]').toggleClass('hidden', type === 'private');
});
}
async function createRoom(params) {
if (!app.user.uid) {
return alerts.error('[[error:not-logged-in]]');
}
const { roomId } = await api.post(`/chats`, params);
ajaxify.go('chats/' + roomId);
}
return create;
});

@ -0,0 +1,106 @@
'use strict';
define('forum/chats/manage', [
'api', 'alerts', 'translator', 'autocomplete', 'forum/chats/user-list',
], function (api, alerts, translator, autocomplete, userList) {
const manage = {};
manage.init = function (roomId, buttonEl) {
let modal;
buttonEl.on('click', async function () {
let groups = [];
if (app.user.isAdmin) {
groups = await socket.emit('groups.getChatGroups', {});
if (Array.isArray(ajaxify.data.groups)) {
groups.forEach((g) => {
g.selected = ajaxify.data.groups.includes(g.name);
});
}
}
const html = await app.parseAndTranslate('modals/manage-room', {
groups,
user: app.user,
group: ajaxify.data,
});
modal = bootbox.dialog({
title: '[[modules:chat.manage-room]]',
message: html,
});
modal.attr('component', 'chat/manage-modal');
refreshParticipantsList(roomId, modal);
addKickHandler(roomId, modal);
const userListEl = modal.find('[component="chat/manage/user/list"]');
const userListElSearch = modal.find('[component="chat/manage/user/list/search"]');
userList.addSearchHandler(roomId, userListElSearch, async (data) => {
if (userListElSearch.val()) {
userListEl.html(await app.parseAndTranslate('partials/chats/manage-room-users', data));
} else {
refreshParticipantsList(roomId, modal);
}
});
userList.addInfiniteScrollHandler(roomId, userListEl, async (listEl, data) => {
listEl.append(await app.parseAndTranslate('partials/chats/manage-room-users', data));
});
const searchInput = modal.find('[component="chat/manage/user/add/search"]');
const errorEl = modal.find('.text-danger');
autocomplete.user(searchInput, function (event, selected) {
errorEl.text('');
api.post(`/chats/${roomId}/users`, {
uids: [selected.item.user.uid],
}).then((body) => {
refreshParticipantsList(roomId, modal, body);
searchInput.val('');
}).catch((err) => {
translator.translate(err.message, function (translated) {
errorEl.text(translated);
});
});
});
modal.find('[component="chat/manage/save/groups"]').on('click', (ev) => {
const btn = $(ev.target);
api.put(`/chats/${roomId}`, {
groups: modal.find('[component="chat/room/groups"]').val(),
}).then((payload) => {
ajaxify.data.groups = payload.groups;
btn.addClass('btn-success');
setTimeout(() => btn.removeClass('btn-success'), 1000);
}).catch(alerts.error);
});
});
};
function addKickHandler(roomId, modal) {
modal.on('click', '[data-action="kick"]', function () {
const uid = parseInt(this.getAttribute('data-uid'), 10);
api.del(`/chats/${roomId}/users/${uid}`, {}).then((body) => {
refreshParticipantsList(roomId, modal, body);
}).catch(alerts.error);
});
}
async function refreshParticipantsList(roomId, modal, data) {
const listEl = modal.find('[component="chat/manage/user/list"]');
if (!data) {
try {
data = await api.get(`/chats/${roomId}/users`, {});
} catch (err) {
listEl.find('li').text(await translator.translate('[[error:invalid-data]]'));
}
}
listEl.html(await app.parseAndTranslate('partials/chats/manage-room-users', data));
}
return manage;
});

@ -72,8 +72,9 @@ define('forum/chats/messages', [
}
messages.appendChatMessage = function (chatContentEl, data) {
const lastSpeaker = parseInt(chatContentEl.find('.chat-message').last().attr('data-uid'), 10);
const lasttimestamp = parseInt(chatContentEl.find('.chat-message').last().attr('data-timestamp'), 10);
const lastMsgEl = chatContentEl.find('.chat-message').last();
const lastSpeaker = parseInt(lastMsgEl.attr('data-uid'), 10);
const lasttimestamp = parseInt(lastMsgEl.attr('data-timestamp'), 10);
if (!Array.isArray(data)) {
data.newSet = lastSpeaker !== parseInt(data.fromuid, 10) ||
parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3);
@ -91,6 +92,12 @@ define('forum/chats/messages', [
messages.onMessagesAddedToDom(newMessage);
if (isAtBottom) {
messages.scrollToBottom(chatContentEl);
// remove some message elements if there are too many
const chatMsgEls = chatContentEl.find('[data-mid]');
if (chatMsgEls.length > 150) {
const removeCount = chatMsgEls.length - 150;
chatMsgEls.slice(0, removeCount).remove();
}
}
hooks.fire('action:chat.received', {
@ -239,17 +246,23 @@ define('forum/chats/messages', [
}
function onChatMessageDeleted(messageId) {
components.get('chat/message', messageId)
.toggleClass('deleted', true)
.find('[component="chat/message/body"]')
.translateHtml('[[modules:chat.message-deleted]]');
const msgEl = components.get('chat/message', messageId);
const isSelf = parseInt(msgEl.attr('data-uid'), 10) === app.user.uid;
msgEl.toggleClass('deleted', true);
if (!isSelf) {
msgEl.find('[component="chat/message/body"]')
.translateHtml('<p>[[modules:chat.message-deleted]]</p>');
}
}
function onChatMessageRestored(message) {
components.get('chat/message', message.messageId)
.toggleClass('deleted', false)
.find('[component="chat/message/body"]')
.html(message.content);
const msgEl = components.get('chat/message', message.messageId);
const isSelf = parseInt(msgEl.attr('data-uid'), 10) === app.user.uid;
msgEl.toggleClass('deleted', false);
if (!isSelf) {
msgEl.find('[component="chat/message/body"]')
.translateHtml(message.content);
}
}
messages.delete = function (messageId, roomId) {

@ -1,13 +1,13 @@
'use strict';
define('forum/chats/recent', ['alerts', 'api'], function (alerts, api) {
define('forum/chats/recent', ['alerts', 'api', 'chat'], function (alerts, api, chat) {
const recent = {};
recent.init = function () {
require(['forum/chats'], function (Chats) {
$('[component="chat/recent"]')
.on('click', '[component="chat/recent/room"]', function (e) {
$('[component="chat/nav-wrapper"]')
.on('click', '[component="chat/recent/room"], [component="chat/public/room"]', function (e) {
e.stopPropagation();
e.preventDefault();
const roomId = this.getAttribute('data-roomid');
@ -16,21 +16,7 @@ define('forum/chats/recent', ['alerts', 'api'], function (alerts, api) {
.on('click', '.mark-read', function (e) {
e.stopPropagation();
const chatEl = this.closest('[data-roomid]');
const state = !chatEl.classList.contains('unread'); // this is the new state
const roomId = chatEl.getAttribute('data-roomid');
api[state ? 'put' : 'del'](`/chats/${roomId}/state`, {}).catch((err) => {
alerts.error(err);
// Revert on failure
chatEl.classList[state ? 'remove' : 'add']('unread');
this.querySelector('.unread').classList[state ? 'add' : 'remove']('hidden');
this.querySelector('.read').classList[!state ? 'add' : 'remove']('hidden');
});
// Immediate feedback
chatEl.classList[state ? 'add' : 'remove']('unread');
this.querySelector('.unread').classList[!state ? 'add' : 'remove']('hidden');
this.querySelector('.read').classList[state ? 'add' : 'remove']('hidden');
chat.toggleReadState(chatEl);
});
$('[component="chat/recent"]').on('scroll', function () {

@ -5,22 +5,35 @@ define('forum/chats/search', [
'components', 'api', 'alerts',
], function (components, api, alerts) {
const search = {};
let users = [];
search.init = function () {
search.init = function (options) {
options = options || {};
users.length = 0;
components.get('chat/search').on('keyup', utils.debounce(doSearch, 250));
const chatsListEl = $('[component="chat/search/list"]');
chatsListEl.on('click', '[data-uid]', function () {
onUserClick($(this).attr('data-uid'));
if (options.onSelect) {
options.onSelect(
users.find(u => parseInt(u.uid, 10) === parseInt($(this).attr('data-uid'), 10))
);
}
clearInputAndResults(chatsListEl);
});
};
function clearInputAndResults(chatsListEl) {
components.get('chat/search').val('');
removeResults(chatsListEl);
chatsListEl.find('[component="chat/search/no-users"]').addClass('hidden');
chatsListEl.find('[component="chat/search/start-typing"]').removeClass('hidden');
}
function doSearch() {
const chatsListEl = $('[component="chat/search/list"]');
const username = components.get('chat/search').val();
if (!username) {
removeResults(chatsListEl);
chatsListEl.find('[component="chat/search/no-users"]').addClass('hidden');
return chatsListEl.find('[component="chat/search/start-typing"]').removeClass('hidden');
return clearInputAndResults(chatsListEl);
}
chatsListEl.find('[component="chat/search/start-typing"]').addClass('hidden');
api.get('/api/users', {
@ -32,6 +45,7 @@ define('forum/chats/search', [
}
function removeResults(chatsListEl) {
users.length = 0;
chatsListEl.find('[data-uid]').remove();
}
@ -41,35 +55,15 @@ define('forum/chats/search', [
data.users = data.users.filter(function (user) {
return parseInt(user.uid, 10) !== parseInt(app.user.uid, 10);
});
users = data.users;
if (!data.users.length) {
return chatsListEl.find('[component="chat/search/no-users"]').removeClass('hidden');
}
chatsListEl.find('[component="chat/search/no-users"]').addClass('hidden');
const html = await app.parseAndTranslate('chats', 'searchUsers', { searchUsers: data.users });
const html = await app.parseAndTranslate('modals/create-room', 'searchUsers', { searchUsers: data.users });
chatsListEl.append(html);
chatsListEl.parent().toggleClass('show', true);
}
function onUserClick(uid) {
if (!uid) {
return;
}
socket.emit('modules.chats.hasPrivateChat', uid, function (err, roomId) {
if (err) {
return alerts.error(err);
}
if (roomId) {
require(['forum/chats'], function (chats) {
chats.switchChat(roomId);
});
} else {
require(['chat'], function (chat) {
chat.newChat(uid);
});
}
});
}
return search;
});

@ -0,0 +1,48 @@
'use strict';
define('forum/chats/user-list', ['api'], function (api) {
const userList = {};
userList.init = function (roomId, container) {
const userListEl = container.find('[component="chat/user/list"]');
if (!userListEl.length) {
return;
}
container.find('[component="chat/user/list/btn"]').on('click', () => {
userListEl.toggleClass('hidden');
});
userList.addInfiniteScrollHandler(roomId, userListEl, async (listEl, data) => {
listEl.append(await app.parseAndTranslate('partials/chats/user-list', 'users', data));
});
};
userList.addInfiniteScrollHandler = function (roomId, listEl, callback) {
listEl.on('scroll', utils.debounce(async () => {
const bottom = (listEl[0].scrollHeight - listEl.height()) * 0.85;
if (listEl.scrollTop() > bottom) {
const lastIndex = listEl.find('[data-index]').last().attr('data-index');
const data = await api.get(`/chats/${roomId}/users`, {
start: parseInt(lastIndex, 10) + 1,
});
if (data && data.users.length) {
callback(listEl, data);
}
}
}, 200));
};
userList.addSearchHandler = function (roomId, inputEl, callback) {
inputEl.on('keyup', utils.debounce(async () => {
const username = inputEl.val();
const data = await socket.emit('modules.chats.searchMembers', {
username: username,
roomId: roomId,
});
callback(data);
}, 200));
};
return userList;
});

@ -1,6 +1,8 @@
'use strict';
define('forum/header/chat', ['components', 'hooks'], function (components, hooks) {
define('forum/header/chat', [
'components', 'hooks',
], function (components, hooks) {
const chat = {};
chat.prepareDOM = function () {
@ -29,7 +31,20 @@ define('forum/header/chat', ['components', 'hooks'], function (components, hooks
socket.removeListener('event:chats.roomRename', onRoomRename);
socket.on('event:chats.roomRename', onRoomRename);
socket.on('event:unread.updateChatCount', function (count) {
socket.on('event:unread.updateChatCount', async function (data) {
if (data) {
const [chatModule, chatPage] = await app.require(['chat', 'forum/chats']);
if (
chatModule.isFromBlockedUser(data.fromUid) ||
chatModule.isLookingAtRoom(data.roomId) ||
app.user.uid === parseInt(data.fromUid, 10)
) {
return;
}
chatPage.markChatPageElUnread(data);
}
let count = await socket.emit('modules.chats.getUnreadCount', {});
const chatIcon = components.get('chat/icon');
count = Math.max(0, count);
chatIcon.toggleClass('fa-comment', count > 0)
@ -56,10 +71,9 @@ define('forum/header/chat', ['components', 'hooks'], function (components, hooks
requireAndCall('onRoomRename', data);
}
function requireAndCall(method, param) {
require(['chat'], function (chat) {
chat[method](param);
});
async function requireAndCall(method, param) {
const chat = await app.require('chat');
chat[method](param);
}
return chat;

@ -1,13 +1,13 @@
'use strict';
define('autocomplete', ['api', 'alerts'], function (api, alerts) {
const module = {};
const autocomplete = {};
const _default = {
delay: 200,
appendTo: null,
};
module.init = (params) => {
autocomplete.init = (params) => {
const acParams = { ..._default, ...params };
const { input, onSelect } = acParams;
app.loadJQueryUI(function () {
@ -23,14 +23,14 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) {
});
};
module.user = function (input, params, onSelect) {
autocomplete.user = function (input, params, onSelect) {
if (typeof params === 'function') {
onSelect = params;
params = {};
}
params = params || {};
module.init({
autocomplete.init({
input,
onSelect,
source: (request, response) => {
@ -69,8 +69,8 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) {
});
};
module.group = function (input, onSelect) {
module.init({
autocomplete.group = function (input, onSelect) {
autocomplete.init({
input,
onSelect,
source: (request, response) => {
@ -96,8 +96,8 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) {
});
};
module.tag = function (input, onSelect) {
module.init({
autocomplete.tag = function (input, onSelect) {
autocomplete.init({
input,
onSelect,
delay: 100,
@ -129,5 +129,5 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) {
onselect(event, ui);
}
return module;
return autocomplete;
});

@ -88,48 +88,49 @@ define('chat', [
if (err) {
return alerts.error(err);
}
const rooms = data.rooms.filter(function (room) {
return room.teaser;
const rooms = data.rooms.map((room) => {
if (room && room.teaser) {
room.teaser.timeagoLong = $.timeago(new Date(parseInt(room.teaser.timestamp, 10)));
}
return room;
});
for (let i = 0; i < rooms.length; i += 1) {
rooms[i].teaser.timeagoLong = $.timeago(new Date(parseInt(rooms[i].teaser.timestamp, 10)));
}
translator.toggleTimeagoShorthand(async function () {
rooms.forEach((room) => {
if (room && room.teaser) {
room.teaser.timeago = $.timeago(new Date(parseInt(room.teaser.timestamp, 10)));
room.teaser.timeagoShort = room.teaser.timeago;
}
});
translator.toggleTimeagoShorthand(function () {
for (let i = 0; i < rooms.length; i += 1) {
rooms[i].teaser.timeago = $.timeago(new Date(parseInt(rooms[i].teaser.timestamp, 10)));
rooms[i].teaser.timeagoShort = rooms[i].teaser.timeago;
}
translator.toggleTimeagoShorthand();
app.parseAndTranslate('partials/chats/dropdown', { rooms: rooms }, function (html) {
const listEl = chatsListEl.get(0);
chatsListEl.find('*').not('.navigation-link').remove();
chatsListEl.prepend(html);
chatsListEl.off('click').on('click', '[data-roomid]', function (ev) {
if (['.user-link', '.mark-read'].some(className => ev.target.closest(className))) {
return;
}
const roomId = $(this).attr('data-roomid');
if (!ajaxify.currentPage.match(/^chats\//)) {
module.openChat(roomId);
} else {
ajaxify.go('user/' + app.user.userslug + '/chats/' + roomId);
}
});
const html = await app.parseAndTranslate('partials/chats/dropdown', { rooms: rooms });
const listEl = chatsListEl.get(0);
chatsListEl.find('*').not('.navigation-link').remove();
chatsListEl.prepend(html);
chatsListEl.off('click').on('click', '[data-roomid]', function (ev) {
if (['.user-link', '.mark-read'].some(className => ev.target.closest(className))) {
return;
}
const roomId = $(this).attr('data-roomid');
if (!ajaxify.currentPage.match(/^chats\//)) {
module.openChat(roomId);
} else {
ajaxify.go('user/' + app.user.userslug + '/chats/' + roomId);
}
});
listEl.removeEventListener('click', onMarkReadClicked);
listEl.addEventListener('click', onMarkReadClicked);
listEl.removeEventListener('click', onMarkReadClicked);
listEl.addEventListener('click', onMarkReadClicked);
$('[component="chats/mark-all-read"]').off('click').on('click', function () {
socket.emit('modules.chats.markAllRead', function (err) {
if (err) {
return alerts.error(err);
}
$('[component="chats/mark-all-read"]').off('click').on('click', async function () {
await socket.emit('modules.chats.markAllRead');
if (ajaxify.data.template.chats) {
$('[component="chat/nav-wrapper"] [data-roomid]').each((i, el) => {
module.markChatElUnread($(el), false);
});
});
}
});
});
});
@ -143,28 +144,51 @@ define('chat', [
e.stopPropagation();
const chatEl = e.target.closest('[data-roomid]');
const state = !chatEl.classList.contains('unread');
module.toggleReadState(chatEl);
}
module.toggleReadState = function (chatEl) {
const state = !chatEl.classList.contains('unread'); // this is the new state
const roomId = chatEl.getAttribute('data-roomid');
api[state ? 'put' : 'del'](`/chats/${roomId}/state`, {}).catch((err) => {
alerts.error(err);
// Revert on failure
chatEl.classList[state ? 'remove' : 'add']('unread');
chatEl.querySelector('.unread').classList[state ? 'add' : 'remove']('hidden');
chatEl.querySelector('.read').classList[!state ? 'add' : 'remove']('hidden');
module.markChatElUnread($(chatEl), !(state === 1));
});
// Immediate feedback
chatEl.classList[state ? 'add' : 'remove']('unread');
chatEl.querySelector('.unread').classList[!state ? 'add' : 'remove']('hidden');
chatEl.querySelector('.read').classList[state ? 'add' : 'remove']('hidden');
}
module.markChatElUnread($(chatEl), state === 1);
};
module.isFromBlockedUser = function (fromUid) {
return app.user.blocks.includes(parseInt(fromUid, 10));
};
module.isLookingAtRoom = function (roomId) {
return ajaxify.data.template.chats && parseInt(ajaxify.data.roomId, 10) === parseInt(roomId, 10);
};
module.markChatElUnread = function (roomEl, unread) {
if (roomEl.length > 0) {
roomEl.toggleClass('unread', unread);
const markEl = roomEl.find('.mark-read');
if (markEl.length) {
markEl.find('.read').toggleClass('hidden', unread);
markEl.find('.unread').toggleClass('hidden', !unread);
}
}
};
module.onChatMessageReceived = function (data) {
if (!newMessage) {
newMessage = data.self === 0;
if (app.user.blocks.includes(parseInt(data.fromUid, 10))) {
return;
}
if (module.modalExists(data.roomId)) {
data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0;
if (!newMessage) {
newMessage = data.self === 0;
}
data.message.self = data.self;
data.message.timestamp = Math.min(Date.now(), data.message.timetamp);
data.message.timestampISO = utils.toISOString(data.message.timestamp);
@ -324,8 +348,9 @@ define('chat', [
Chats.addActionHandlers(chatModal.find('[component="chat/messages"]'), data.roomId);
Chats.addRenameHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="rename"]'), data.roomName);
Chats.addLeaveHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="leave"]'));
Chats.addDeleteHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="delete"]'));
Chats.addSendHandlers(chatModal.attr('data-roomid'), chatModal.find('.chat-input'), chatModal.find('[data-action="send"]'));
Chats.addMemberHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]'));
Chats.addManageHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]'));
Chats.createAutoComplete(chatModal.attr('data-roomid'), chatModal.find('[component="chat/input"]'));
@ -381,10 +406,11 @@ define('chat', [
if (chatModal.attr('data-mobile')) {
module.disableMobileBehaviour(chatModal);
}
const roomId = chatModal.attr('data-roomid');
require(['forum/chats'], function (chats) {
chats.destroyAutoComplete(chatModal.attr('data-roomid'));
chats.destroyAutoComplete(roomId);
});
socket.emit('modules.chats.leave', roomId);
hooks.fire('action:chat.closed', {
uuid: uuid,
modal: chatModal,
@ -417,8 +443,9 @@ define('chat', [
taskbar.updateActive(uuid);
ChatsMessages.scrollToBottom(chatModal.find('.chat-content'));
module.focusInput(chatModal);
api.del(`/chats/${chatModal.attr('data-roomid')}/state`, {});
const roomId = chatModal.attr('data-roomid');
api.del(`/chats/${roomId}/state`, {});
socket.emit('modules.chats.enter', roomId);
const env = utils.findBootstrapEnvironment();
if (env === 'xs' || env === 'sm') {
module.enableMobileBehaviour(chatModal);

@ -159,9 +159,7 @@ app = window.app || {};
function onConnect() {
if (!reconnecting) {
hooks.fire('action:connected');
}
if (reconnecting) {
} else {
const reconnectEl = $('#reconnect');
const reconnectAlert = $('#reconnect-alert');
@ -188,6 +186,14 @@ app = window.app || {};
app.currentRoom = '';
app.enterRoom(current);
}
if (ajaxify.data.template.chats) {
if (ajaxify.data.roomId) {
socket.emit('modules.chats.enter', ajaxify.data.roomId);
}
if (ajaxify.data.publicRooms) {
socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId));
}
}
}
function onReconnecting() {

@ -2,6 +2,7 @@
const validator = require('validator');
const db = require('../database');
const user = require('../user');
const meta = require('../meta');
const messaging = require('../messaging');
@ -39,12 +40,20 @@ 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]]`);
}
if (!isPublic && !data.uids.length) {
throw new Error('[[error:no-users-selected]]');
}
await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid)));
const roomId = await messaging.newRoom(caller.uid, data.uids);
const roomId = await messaging.newRoom(caller.uid, data);
return await messaging.getRoomData(roomId);
};
@ -78,20 +87,48 @@ chatsAPI.post = async (caller, data) => {
return message;
};
chatsAPI.rename = async (caller, data) => {
if (!data || !data.roomId || !data.name) {
chatsAPI.update = async (caller, data) => {
if (!data || !data.roomId) {
throw new Error('[[error:invalid-data]]');
}
await messaging.renameRoom(caller.uid, data.roomId, data.name);
const uids = await messaging.getUidsInRoom(data.roomId, 0, -1);
const eventData = { roomId: data.roomId, newName: validator.escape(String(data.name)) };
socketHelpers.emitToUids('event:chats.roomRename', eventData, uids);
if (data.hasOwnProperty('name')) {
if (!data.name) {
throw new Error('[[error:invalid-data]]');
}
await messaging.renameRoom(caller.uid, data.roomId, data.name);
const ioRoom = require('../socket.io').in(`chat_room_${data.roomId}`);
if (ioRoom) {
ioRoom.emit('event:chats.roomRename', {
roomId: data.roomId,
newName: validator.escape(String(data.name)),
});
}
}
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));
}
}
return messaging.loadRoom(caller.uid, {
roomId: data.roomId,
});
};
chatsAPI.rename = async (caller, data) => {
if (!data || !data.roomId || !data.name) {
throw new Error('[[error:invalid-data]]');
}
return await chatsAPI.update(caller, data);
};
chatsAPI.mark = async (caller, data) => {
if (!caller.uid || !data || !data.roomId) {
throw new Error('[[error:invalid-data]]');
@ -103,16 +140,19 @@ chatsAPI.mark = async (caller, data) => {
await messaging.markRead(caller.uid, roomId);
socketHelpers.emitToUids('event:chats.markedAsRead', { roomId: roomId }, [caller.uid]);
const uidsInRoom = await messaging.getUidsInRoom(roomId, 0, -1);
if (!uidsInRoom.includes(String(caller.uid))) {
const isUserInRoom = await messaging.isUserInRoom(caller.uid, roomId);
if (!isUserInRoom) {
return;
}
// Mark notification read
const nids = uidsInRoom.filter(uid => parseInt(uid, 10) !== caller.uid)
.map(uid => `chat_${uid}_${roomId}`);
await notifications.markReadMultiple(nids, caller.uid);
let chatNids = await db.getSortedSetScan({
key: `uid:${caller.uid}:notifications:unread`,
match: `chat_*`,
});
chatNids = chatNids.filter(
nid => nid && !nid.startsWith(`chat_${caller.uid}`) && nid.endsWith(`_${roomId}`)
);
await notifications.markReadMultiple(chatNids, caller.uid);
await user.notifications.pushCount(caller.uid);
}
@ -123,16 +163,18 @@ chatsAPI.mark = async (caller, data) => {
};
chatsAPI.users = async (caller, data) => {
const start = data.hasOwnProperty('start') ? data.start : 0;
const stop = start + 39;
const [isOwner, isUserInRoom, users] = await Promise.all([
messaging.isRoomOwner(caller.uid, data.roomId),
messaging.isUserInRoom(caller.uid, data.roomId),
messaging.getUsersInRoom(data.roomId, 0, -1),
messaging.getUsersInRoom(data.roomId, start, stop),
]);
if (!isUserInRoom) {
throw new Error('[[error:no-privileges]]');
}
users.forEach((user) => {
user.canKick = (parseInt(user.uid, 10) !== parseInt(caller.uid, 10)) && isOwner;
user.canKick = isOwner && (parseInt(user.uid, 10) !== parseInt(caller.uid, 10));
});
return { users };
};
@ -145,10 +187,13 @@ chatsAPI.invite = async (caller, data) => {
if (!data || !data.roomId) {
throw new Error('[[error:invalid-data]]');
}
const roomData = await messaging.getRoomData(data.roomId);
if (!roomData) {
throw new Error('[[error:invalid-data]]');
}
const userCount = await messaging.getUserCountInRoom(data.roomId);
const maxUsers = meta.config.maximumUsersInChatRoom;
if (maxUsers && userCount >= maxUsers) {
if (!roomData.public && maxUsers && userCount >= maxUsers) {
throw new Error('[[error:cant-add-more-users-to-chat-room]]');
}

@ -1,5 +1,6 @@
'use strict';
const db = require('../../database');
const messaging = require('../../messaging');
const meta = require('../../meta');
const user = require('../../user');
@ -21,35 +22,45 @@ chatsController.get = async function (req, res, next) {
if (!canChat) {
return next(new Error('[[error:no-privileges]]'));
}
const recentChats = await messaging.getRecentChats(req.uid, uid, 0, 29);
if (!recentChats) {
return next();
const payload = {
title: '[[pages:chats]]',
uid: uid,
userslug: req.params.userslug,
};
const isSwitch = res.locals.isAPI && parseInt(req.query.switch, 10) === 1;
if (!isSwitch) {
const [recentChats, publicRooms, privateRoomCount] = await Promise.all([
messaging.getRecentChats(req.uid, uid, 0, 29),
messaging.getPublicRooms(req.uid, uid),
db.sortedSetCard(`uid:${uid}:chat:rooms`),
]);
if (!recentChats) {
return next();
}
payload.rooms = recentChats.rooms;
payload.nextStart = recentChats.nextStart;
payload.publicRooms = publicRooms;
payload.privateRoomCount = privateRoomCount;
}
if (!req.params.roomid) {
return res.render('chats', {
rooms: recentChats.rooms,
uid: uid,
userslug: req.params.userslug,
nextStart: recentChats.nextStart,
allowed: true,
title: '[[pages:chats]]',
});
return res.render('chats', payload);
}
const room = await messaging.loadRoom(req.uid, { uid: uid, roomId: req.params.roomid });
if (!room) {
return next();
}
room.rooms = recentChats.rooms;
room.nextStart = recentChats.nextStart;
room.title = room.roomName || room.usernames || '[[pages:chats]]';
room.uid = uid;
room.userslug = req.params.userslug;
room.bodyClasses = ['chat-loaded'];
room.canViewInfo = await privileges.global.can('view:users:info', uid);
res.render('chats', room);
res.render('chats', {
...payload,
...room,
});
};
chatsController.redirectToChat = async function (req, res, next) {

@ -93,7 +93,7 @@ async function getPosts(callerUid, userData, setSuffix) {
user.isModerator(callerUid, cids),
privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid),
]);
const cidToIsMod = _.zipObject(cids, isModOfCids);
const isModOfCid = _.zipObject(cids, isModOfCids);
const cidToCanSchedule = _.zipObject(cids, canSchedule);
do {
@ -111,8 +111,12 @@ async function getPosts(callerUid, userData, setSuffix) {
}));
const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false });
postData.push(...p.filter(
p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] ||
(p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || (!p.deleted && !p.topic.deleted))
p => p && p.topic && (
isAdmin ||
isModOfCid[p.topic.cid] ||
(p.topic.scheduled && cidToCanSchedule[p.topic.cid]) ||
(!p.deleted && !p.topic.deleted)
)
));
}
start += count;

@ -2,6 +2,8 @@
const api = require('../../api');
const helpers = require('../helpers');
const messaging = require('../../messaging');
const events = require('../../events');
const Admin = module.exports;
@ -29,6 +31,19 @@ Admin.getAnalyticsData = async (req, res) => {
}));
};
Admin.chats = {};
Admin.chats.deleteRoom = async (req, res) => {
await messaging.deleteRooms([req.params.roomId]);
events.log({
type: 'chat-room-deleted',
uid: req.uid,
ip: req.ip,
});
helpers.formatApiResponse(200, res);
};
Admin.generateToken = async (req, res) => {
const { uid, description } = req.body;
const token = await api.utils.tokens.generate({ uid, description });

@ -39,6 +39,14 @@ Chats.post = async (req, res) => {
helpers.formatApiResponse(200, res, messageObj);
};
Chats.update = async (req, res) => {
const payload = { ...req.body };
payload.roomId = req.params.roomId;
const roomObj = await api.chats.update(req, payload);
helpers.formatApiResponse(200, res, roomObj);
};
Chats.rename = async (req, res) => {
const roomObj = await api.chats.rename(req, {
name: req.body.name,
@ -60,7 +68,8 @@ Chats.mark = async (req, res) => {
Chats.users = async (req, res) => {
const { roomId } = req.params;
const users = await api.chats.users(req, { roomId });
const start = parseInt(req.query.start, 10) || 0;
const users = await api.chats.users(req, { roomId, start });
helpers.formatApiResponse(200, res, users);
};

@ -84,7 +84,9 @@ module.exports = function (module) {
let result = [];
async function doQuery(_key, fields, skip, limit) {
return await module.client.collection('objects').find({ ...query, ...{ _key: _key } }, { projection: fields })
return await module.client.collection('objects').find({
...query, ...{ _key: _key },
}, { projection: fields })
.sort({ score: sort })
.skip(skip)
.limit(limit)

@ -75,6 +75,7 @@ events.types = [
'export:uploads',
'account-locked',
'getUsersCSV',
'chat-room-deleted',
// To add new types from plugins, just Array.push() to this array
];

@ -1,9 +1,12 @@
'use strict';
const _ = require('lodash');
const db = require('../database');
const user = require('../user');
const plugins = require('../plugins');
const cache = require('../cache');
const messaging = require('../messaging');
module.exports = function (Groups) {
Groups.leave = async function (groupNames, uid) {
@ -53,7 +56,10 @@ module.exports = function (Groups) {
await Promise.all(promises);
await clearGroupTitleIfSet(groupsToLeave, uid);
await Promise.all([
clearGroupTitleIfSet(groupsToLeave, uid),
leavePublicRooms(groupsToLeave, uid),
]);
plugins.hooks.fire('action:group.leave', {
groupNames: groupsToLeave,
@ -61,6 +67,20 @@ module.exports = function (Groups) {
});
};
async function leavePublicRooms(groupNames, uid) {
const allRoomIds = await messaging.getPublicRoomIdsFromSet('chat:rooms:public:order');
const allRoomData = await messaging.getRoomsData(allRoomIds);
const roomData = allRoomData.filter(
room => room && room.groups.some(group => groupNames.includes(group))
);
const isMemberOfAny = _.zipObject(
roomData.map(r => r.roomId),
await Promise.all(roomData.map(r => Groups.isMemberOfAny(uid, r.groups)))
);
const roomIds = roomData.filter(r => isMemberOfAny[r.roomId]).map(r => r.roomId);
await messaging.leaveRooms(uid, roomIds);
}
async function clearGroupTitleIfSet(groupNames, uid) {
groupNames = groupNames.filter(groupName => groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName));
if (!groupNames.length) {

@ -97,7 +97,7 @@ module.exports = function (Groups) {
}
Groups.isMemberOfAny = async function (uid, groups) {
if (!groups.length) {
if (!Array.isArray(groups) || !groups.length) {
return false;
}
const isMembers = await Groups.isMemberOfGroups(uid, groups);

@ -189,6 +189,7 @@ module.exports = function (Groups) {
await updateNavigationItems(oldName, newName);
await updateWidgets(oldName, newName);
await updateConfig(oldName, newName);
await updateChatRooms(oldName, newName);
await db.setObject(`group:${oldName}`, { name: newName, slug: slugify(newName) });
await db.deleteObjectField('groupslug:groupname', group.slug);
await db.setObjectField('groupslug:groupname', slugify(newName), newName);
@ -286,4 +287,18 @@ module.exports = function (Groups) {
await meta.configs.set('groupsExemptFromMaintenanceMode', meta.config.groupsExemptFromMaintenanceMode);
}
}
async function updateChatRooms(oldName, newName) {
const messaging = require('../messaging');
const roomIds = await db.getSortedSetRange('chat:rooms:public', 0, -1);
const roomData = await messaging.getRoomsData(roomIds);
const bulkSet = [];
roomData.forEach((room) => {
if (room && room.public && Array.isArray(room.groups) && room.groups.includes(oldName)) {
room.groups.splice(room.groups.indexOf(oldName), 1, newName);
bulkSet.push([`chat:room:${room.roomId}`, { groups: JSON.stringify(room.groups) }]);
}
});
await db.setObjectBulk(bulkSet);
}
};

@ -1,5 +1,7 @@
'use strict';
const _ = require('lodash');
const meta = require('../meta');
const plugins = require('../plugins');
const db = require('../database');
@ -34,13 +36,18 @@ module.exports = function (Messaging) {
};
Messaging.addMessage = async (data) => {
const { uid, roomId } = data;
const roomData = await Messaging.getRoomData(roomId);
if (!roomData) {
throw new Error('[[error:no-room]]');
}
const mid = await db.incrObjectField('global', 'nextMid');
const timestamp = data.timestamp || Date.now();
let message = {
content: String(data.content),
timestamp: timestamp,
fromuid: data.uid,
roomId: data.roomId,
fromuid: uid,
roomId: roomId,
deleted: 0,
system: data.system || 0,
};
@ -51,24 +58,34 @@ module.exports = function (Messaging) {
message = await plugins.hooks.fire('filter:messaging.save', message);
await db.setObject(`message:${mid}`, message);
const isNewSet = await Messaging.isNewSet(data.uid, data.roomId, timestamp);
let uids = await db.getSortedSetRange(`chat:room:${data.roomId}:uids`, 0, -1);
uids = await user.blocks.filterUids(data.uid, uids);
const isNewSet = await Messaging.isNewSet(uid, roomId, timestamp);
await Promise.all([
Messaging.addRoomToUsers(data.roomId, uids, timestamp),
Messaging.addMessageToUsers(data.roomId, uids, mid, timestamp),
Messaging.markUnread(uids.filter(uid => uid !== String(data.uid)), data.roomId),
]);
const tasks = [
Messaging.addMessageToRoom(roomId, mid, timestamp),
Messaging.markRead(uid, roomId),
];
if (roomData.public) {
tasks.push(
db.sortedSetAdd('chat:rooms:public:lastpost', timestamp, roomId)
);
} else {
let uids = await Messaging.getUidsInRoom(roomId, 0, -1);
uids = await user.blocks.filterUids(uid, uids);
tasks.push(
Messaging.addRoomToUsers(roomId, uids, timestamp),
Messaging.markUnread(uids.filter(uid => uid !== String(data.uid)), roomId),
);
}
await Promise.all(tasks);
const messages = await Messaging.getMessagesData([mid], data.uid, data.roomId, true);
const messages = await Messaging.getMessagesData([mid], uid, roomId, true);
if (!messages || !messages[0]) {
return null;
}
messages[0].newSet = isNewSet;
messages[0].mid = mid;
messages[0].roomId = data.roomId;
messages[0].mid = mid; // TODO: messageId is a duplicate
messages[0].roomId = roomId;
plugins.hooks.fire('action:messaging.save', { message: messages[0], data: data });
return messages[0];
};
@ -87,16 +104,11 @@ module.exports = function (Messaging) {
if (!uids.length) {
return;
}
const keys = uids.map(uid => `uid:${uid}:chat:rooms`);
const keys = _.uniq(uids).map(uid => `uid:${uid}:chat:rooms`);
await db.sortedSetsAdd(keys, timestamp, roomId);
};
Messaging.addMessageToUsers = async (roomId, uids, mid, timestamp) => {
if (!uids.length) {
return;
}
const keys = uids.map(uid => `uid:${uid}:chat:room:${roomId}:mids`);
await db.sortedSetsAdd(keys, timestamp, mid);
Messaging.addMessageToRoom = async (roomId, mid, timestamp) => {
await db.sortedSetAdd(`chat:room:${roomId}:mids`, timestamp, mid);
};
};

@ -78,13 +78,11 @@ module.exports = function (Messaging) {
messages = await Promise.all(messages.map(async (message) => {
if (message.system) {
message.content = validator.escape(String(message.content));
message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(message.content));
return message;
}
const result = await Messaging.parse(message.content, message.fromuid, uid, roomId, isNew);
message.content = result;
message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(result));
return message;
}));
@ -108,7 +106,7 @@ module.exports = function (Messaging) {
});
} else if (messages.length === 1) {
// For single messages, we don't know the context, so look up the previous message and compare
const key = `uid:${uid}:chat:room:${roomId}:mids`;
const key = `chat:room:${roomId}:mids`;
const index = await db.sortedSetRank(key, messages[0].messageId);
if (index > 0) {
const mid = await db.getSortedSetRange(key, index - 1, index - 1);

@ -15,19 +15,12 @@ module.exports = function (Messaging) {
await Messaging.setMessageField(mid, 'deleted', state);
const [uids, messages] = await Promise.all([
Messaging.getUidsInRoom(roomId, 0, -1),
Messaging.getMessagesData([mid], uid, roomId, true),
]);
uids.forEach((_uid) => {
if (parseInt(_uid, 10) !== parseInt(uid, 10)) {
if (state === 1) {
sockets.in(`uid_${_uid}`).emit('event:chats.delete', mid);
} else if (state === 0) {
sockets.in(`uid_${_uid}`).emit('event:chats.restore', messages[0]);
}
}
});
const messages = await Messaging.getMessagesData([mid], uid, roomId, true);
const ioRoom = sockets.in(`chat_room_${roomId}`);
if (state === 1 && ioRoom) {
ioRoom.emit('event:chats.delete', mid);
} else if (state === 0 && ioRoom) {
ioRoom.emit('event:chats.restore', messages[0]);
}
}
};

@ -27,15 +27,9 @@ module.exports = function (Messaging) {
await Messaging.setMessageFields(mid, payload);
// Propagate this change to users in the room
const [uids, messages] = await Promise.all([
Messaging.getUidsInRoom(roomId, 0, -1),
Messaging.getMessagesData([mid], uid, roomId, true),
]);
uids.forEach((uid) => {
sockets.in(`uid_${uid}`).emit('event:chats.edit', {
messages: messages,
});
const messages = await Messaging.getMessagesData([mid], uid, roomId, true);
sockets.in(`chat_room_${roomId}`).emit('event:chats.edit', {
messages: messages,
});
};

@ -1,15 +1,17 @@
'use strict';
const _ = require('lodash');
const validator = require('validator');
const nconf = require('nconf');
const db = require('../database');
const user = require('../user');
const groups = require('../groups');
const privileges = require('../privileges');
const plugins = require('../plugins');
const meta = require('../meta');
const utils = require('../utils');
const translator = require('../translator');
const cache = require('../cache');
const relative_path = nconf.get('relative_path');
@ -26,38 +28,50 @@ require('./notifications')(Messaging);
Messaging.messageExists = async mid => db.exists(`message:${mid}`);
Messaging.getMessages = async (params) => {
const { callerUid, uid, roomId } = params;
const isNew = params.isNew || false;
const start = params.hasOwnProperty('start') ? params.start : 0;
const stop = parseInt(start, 10) + ((params.count || 50) - 1);
const indices = {};
const ok = await canGet('filter:messaging.canGetMessages', params.callerUid, params.uid);
const ok = await canGet('filter:messaging.canGetMessages', callerUid, uid);
if (!ok) {
return;
}
const mids = await db.getSortedSetRevRange(`uid:${params.uid}:chat:room:${params.roomId}:mids`, start, stop);
const mids = await getMessageIds(roomId, uid, start, stop);
if (!mids.length) {
return [];
}
const indices = {};
mids.forEach((mid, index) => {
indices[mid] = start + index;
});
mids.reverse();
const messageData = await Messaging.getMessagesData(mids, params.uid, params.roomId, isNew);
messageData.forEach((messageData) => {
messageData.index = indices[messageData.messageId.toString()];
messageData.isOwner = messageData.fromuid === parseInt(params.uid, 10);
if (messageData.deleted && !messageData.isOwner) {
messageData.content = '[[modules:chat.message-deleted]]';
messageData.cleanedContent = messageData.content;
const messageData = await Messaging.getMessagesData(mids, uid, roomId, isNew);
messageData.forEach((msg) => {
msg.index = indices[msg.messageId.toString()];
msg.isOwner = msg.fromuid === parseInt(uid, 10);
if (msg.deleted && !msg.isOwner) {
msg.content = `<p>[[modules:chat.message-deleted]]</p>`;
}
});
return messageData;
};
async function getMessageIds(roomId, uid, start, stop) {
const isPublic = await db.getObjectField(`chat:room:${roomId}`, 'public');
if (parseInt(isPublic, 10) === 1) {
return await db.getSortedSetRevRange(
`chat:room:${roomId}:mids`, start, stop,
);
}
const userjoinTimestamp = await db.sortedSetScore(`chat:room:${roomId}:uids`, uid);
return await db.getSortedSetRevRangeByScore(
`chat:room:${roomId}:mids`, start, stop - start + 1, '+inf', userjoinTimestamp
);
}
async function canGet(hook, callerUid, uid) {
const data = await plugins.hooks.fire(hook, {
callerUid: callerUid,
@ -85,7 +99,7 @@ Messaging.parse = async (message, fromuid, uid, roomId, isNew) => {
};
Messaging.isNewSet = async (uid, roomId, timestamp) => {
const setKey = `uid:${uid}:chat:room:${roomId}:mids`;
const setKey = `chat:room:${roomId}:mids`;
const messages = await db.getSortedSetRevRangeWithScores(setKey, 0, 0);
if (messages && messages.length) {
return parseInt(timestamp, 10) > parseInt(messages[0].score, 10) + Messaging.newMessageCutoff;
@ -93,6 +107,53 @@ Messaging.isNewSet = async (uid, roomId, timestamp) => {
return true;
};
Messaging.getPublicRoomIdsFromSet = async function (set) {
const cacheKey = `${set}:all`;
let allRoomIds = cache.get(cacheKey);
if (allRoomIds === undefined) {
allRoomIds = await db.getSortedSetRange(set, 0, -1);
cache.set(cacheKey, allRoomIds);
}
return allRoomIds.slice();
};
Messaging.getPublicRooms = async (callerUid, uid) => {
const ok = await canGet('filter:messaging.canGetPublicChats', callerUid, uid);
if (!ok) {
return null;
}
const allRoomIds = await Messaging.getPublicRoomIdsFromSet('chat:rooms:public:order');
const allRoomData = await Messaging.getRoomsData(allRoomIds);
const checks = await Promise.all(
allRoomData.map(room => groups.isMemberOfAny(uid, room && room.groups))
);
const roomData = allRoomData.filter((room, idx) => room && checks[idx]);
const roomIds = roomData.map(r => r.roomId);
const userReadTimestamps = await db.getObjectFields(
`uid:${uid}:chat:rooms:read`,
roomIds,
);
const maxUnread = 50;
const unreadCounts = await Promise.all(roomIds.map(async (roomId) => {
const cutoff = userReadTimestamps[roomId] || '-inf';
const unreadMids = await db.getSortedSetRangeByScore(
`chat:room:${roomId}:mids`, 0, maxUnread + 1, cutoff, '+inf'
);
return unreadMids.length;
}));
roomData.forEach((r, idx) => {
const count = unreadCounts[idx];
r.unreadCountText = count > maxUnread ? `${maxUnread}+` : String(count);
r.unreadCount = count;
r.unread = count > 0;
});
return roomData;
};
Messaging.getRecentChats = async (callerUid, uid, start, stop) => {
const ok = await canGet('filter:messaging.canGetRecentChats', callerUid, uid);
if (!ok) {
@ -100,15 +161,29 @@ Messaging.getRecentChats = async (callerUid, uid, start, stop) => {
}
const roomIds = await db.getSortedSetRevRange(`uid:${uid}:chat:rooms`, start, stop);
async function getUsers(roomIds) {
const arrayOfUids = await Promise.all(
roomIds.map(roomId => Messaging.getUidsInRoom(roomId, 0, 9))
);
const uniqUids = _.uniq(_.flatten(arrayOfUids)).filter(
_uid => _uid && parseInt(_uid, 10) !== parseInt(uid, 10)
);
const uidToUser = _.zipObject(
uniqUids,
await user.getUsersFields(uniqUids, [
'uid', 'username', 'userslug', 'picture', 'status', 'lastonline',
])
);
return arrayOfUids.map(uids => uids.map(uid => uidToUser[uid]));
}
const results = await utils.promiseParallel({
roomData: Messaging.getRoomsData(roomIds),
unread: db.isSortedSetMembers(`uid:${uid}:chat:rooms:unread`, roomIds),
users: Promise.all(roomIds.map(async (roomId) => {
let uids = await db.getSortedSetRevRange(`chat:room:${roomId}:uids`, 0, 9);
uids = uids.filter(_uid => _uid && parseInt(_uid, 10) !== parseInt(uid, 10));
return await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']);
})),
teasers: Promise.all(roomIds.map(async roomId => Messaging.getTeaser(uid, roomId))),
users: getUsers(roomIds),
teasers: Messaging.getTeasers(uid, roomIds),
settings: user.getSettings(uid),
});
await Promise.all(results.roomData.map(async (room, index) => {
@ -126,7 +201,7 @@ Messaging.getRecentChats = async (callerUid, uid, start, stop) => {
room.users = room.users.filter(user => user && parseInt(user.uid, 10));
room.lastUser = room.users[0];
room.usernames = Messaging.generateUsernames(room.users, uid);
room.chatWithMessage = await Messaging.generateChatWithMessage(room.users, uid);
room.chatWithMessage = await Messaging.generateChatWithMessage(room.users, uid, results.settings.userLang);
}
}));
@ -153,8 +228,8 @@ Messaging.generateUsernames = function (users, excludeUid) {
return usernames.join(', ');
};
Messaging.generateChatWithMessage = async function (users, excludeUid) {
users = users.filter(u => u && parseInt(u.uid, 10) !== excludeUid);
Messaging.generateChatWithMessage = async function (users, callerUid, userLang) {
users = users.filter(u => u && parseInt(u.uid, 10) !== callerUid);
const usernames = users.map(u => `<a href="${relative_path}/uid/${u.uid}">${u.username}</a>`);
let compiled = '';
if (!users.length) {
@ -172,31 +247,48 @@ Messaging.generateChatWithMessage = async function (users, excludeUid) {
usernames.join(', '),
);
}
return utils.decodeHTMLEntities(await translator.translate(compiled));
return utils.decodeHTMLEntities(await translator.translate(compiled, userLang));
};
Messaging.getTeaser = async (uid, roomId) => {
const mid = await Messaging.getLatestUndeletedMessage(uid, roomId);
if (!mid) {
return null;
}
const teaser = await Messaging.getMessageFields(mid, ['fromuid', 'content', 'timestamp']);
if (!teaser.fromuid) {
return null;
}
const blocked = await user.blocks.is(teaser.fromuid, uid);
if (blocked) {
return null;
}
teaser.user = await user.getUserFields(teaser.fromuid, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']);
if (teaser.content) {
teaser.content = utils.stripHTMLTags(utils.decodeHTMLEntities(teaser.content));
teaser.content = validator.escape(String(teaser.content));
}
const teasers = await Messaging.getTeasers(uid, [roomId]);
return teasers[0];
};
const payload = await plugins.hooks.fire('filter:messaging.getTeaser', { teaser: teaser });
return payload.teaser;
Messaging.getTeasers = async (uid, roomIds) => {
const mids = await Promise.all(
roomIds.map(roomId => Messaging.getLatestUndeletedMessage(uid, roomId))
);
const [teasers, blockedUids] = await Promise.all([
Messaging.getMessagesFields(mids, ['fromuid', 'content', 'timestamp']),
user.blocks.list(uid),
]);
const uids = _.uniq(
teasers.map(t => t && t.fromuid).filter(uid => uid && !blockedUids.includes(uid))
);
const userMap = _.zipObject(
uids,
await user.getUsersFields(uids, [
'uid', 'username', 'userslug', 'picture', 'status', 'lastonline',
])
);
return await Promise.all(roomIds.map(async (roomId, idx) => {
const teaser = teasers[idx];
if (!teaser || !teaser.fromuid) {
return null;
}
if (userMap[teaser.fromuid]) {
teaser.user = userMap[teaser.fromuid];
}
teaser.content = validator.escape(
String(utils.stripHTMLTags(utils.decodeHTMLEntities(teaser.content)))
);
teaser.roomId = roomId;
const payload = await plugins.hooks.fire('filter:messaging.getTeaser', { teaser: teaser });
return payload.teaser;
}));
};
Messaging.getLatestUndeletedMessage = async (uid, roomId) => {
@ -207,7 +299,7 @@ Messaging.getLatestUndeletedMessage = async (uid, roomId) => {
while (!done) {
/* eslint-disable no-await-in-loop */
mids = await db.getSortedSetRevRange(`uid:${uid}:chat:room:${roomId}:mids`, index, index);
mids = await getMessageIds(roomId, uid, index, index);
if (mids.length) {
const states = await Messaging.getMessageFields(mids[0], ['deleted', 'system']);
done = !states.deleted && !states.system;
@ -337,8 +429,16 @@ Messaging.canViewMessage = async (mids, roomId, uid) => {
mids = [mids];
single = true;
}
const isPublic = parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1;
const [midTimestamps, userTimestamp] = await Promise.all([
db.sortedSetScores(`chat:room:${roomId}:mids`, mids),
db.sortedSetScore(`chat:room:${roomId}:uids`, uid),
]);
const canView = midTimestamps.map(
midTimestamp => !!(midTimestamp && userTimestamp && (isPublic || userTimestamp <= midTimestamp))
);
const canView = await db.isSortedSetMembers(`uid:${uid}:chat:room:${roomId}:mids`, mids);
return single ? canView.pop() : canView;
};

@ -2,39 +2,46 @@
const winston = require('winston');
const user = require('../user');
const batch = require('../batch');
const db = require('../database');
const notifications = require('../notifications');
const sockets = require('../socket.io');
const io = require('../socket.io');
const plugins = require('../plugins');
const meta = require('../meta');
module.exports = function (Messaging) {
Messaging.notifyQueue = {}; // Only used to notify a user of a new chat message, see Messaging.notifyUser
// Only used to notify a user of a new chat message
Messaging.notifyQueue = {};
Messaging.notifyUsersInRoom = async (fromUid, roomId, messageObj) => {
let uids = await Messaging.getUidsInRoom(roomId, 0, -1);
uids = await user.blocks.filterUids(fromUid, uids);
const isPublic = parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1;
let data = {
roomId: roomId,
fromUid: fromUid,
message: messageObj,
uids: uids,
public: isPublic,
};
data = await plugins.hooks.fire('filter:messaging.notify', data);
if (!data || !data.uids || !data.uids.length) {
if (!data) {
return;
}
uids = data.uids;
uids.forEach((uid) => {
data.self = parseInt(uid, 10) === parseInt(fromUid, 10) ? 1 : 0;
Messaging.pushUnreadCount(uid);
sockets.in(`uid_${uid}`).emit('event:chats.receive', data);
});
if (messageObj.system) {
// delivers full message to all online users in roomId
io.in(`chat_room_${roomId}`).emit('event:chats.receive', data);
const unreadData = { roomId, fromUid, public: isPublic };
if (isPublic && !messageObj.system) {
// 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) {
return;
}
// push unread count only for private rooms
const uids = await Messaging.getAllUidsInRoom(roomId);
Messaging.pushUnreadCount(uids, unreadData);
// Delayed notifications
let queueObj = Messaging.notifyQueue[`${fromUid}:${roomId}`];
if (queueObj) {
@ -49,35 +56,35 @@ module.exports = function (Messaging) {
queueObj.timeout = setTimeout(async () => {
try {
await sendNotifications(fromUid, uids, roomId, queueObj.message);
await sendNotification(fromUid, roomId, queueObj.message);
delete Messaging.notifyQueue[`${fromUid}:${roomId}`];
} catch (err) {
winston.error(`[messaging/notifications] Unabled to send notification\n${err.stack}`);
}
}, meta.config.notificationSendDelay * 1000);
};
async function sendNotifications(fromuid, uids, roomId, messageObj) {
const hasRead = await Messaging.hasRead(uids, roomId);
uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromuid, 10) !== parseInt(uid, 10));
if (!uids.length) {
delete Messaging.notifyQueue[`${fromuid}:${roomId}`];
return;
}
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,
nid: `chat_${fromUid}_${roomId}`,
from: fromUid,
path: `/chats/${messageObj.roomId}`,
});
delete Messaging.notifyQueue[`${fromuid}:${roomId}`];
notifications.push(notification, uids);
await batch.processSortedSet(`chat:room:${roomId}:uids`, 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);
}, {
batch: 500,
interval: 1000,
});
}
};

@ -1,68 +1,185 @@
'use strict';
const _ = require('lodash');
const validator = require('validator');
const winston = require('winston');
const db = require('../database');
const user = require('../user');
const groups = require('../groups');
const plugins = require('../plugins');
const privileges = require('../privileges');
const meta = require('../meta');
const cacheCreate = require('../cacheCreate');
const cache = cacheCreate({
name: 'chat:room:uids',
max: 500,
ttl: 0,
});
const intFields = [
'roomId', 'timestamp', 'userCount',
];
module.exports = function (Messaging) {
Messaging.getRoomData = async (roomId) => {
const data = await db.getObject(`chat:room:${roomId}`);
Messaging.getRoomData = async (roomId, fields = []) => {
const data = await db.getObject(`chat:room:${roomId}`, fields);
if (!data) {
throw new Error('[[error:no-chat-room]]');
}
modifyRoomData([data]);
modifyRoomData([data], fields);
return data;
};
Messaging.getRoomsData = async (roomIds) => {
const roomData = await db.getObjects(roomIds.map(roomId => `chat:room:${roomId}`));
modifyRoomData(roomData);
Messaging.getRoomsData = async (roomIds, fields = []) => {
const roomData = await db.getObjects(
roomIds.map(roomId => `chat:room:${roomId}`),
fields
);
modifyRoomData(roomData, fields);
return roomData;
};
function modifyRoomData(rooms) {
function modifyRoomData(rooms, fields) {
rooms.forEach((data) => {
if (data) {
data.roomName = data.roomName || '';
data.roomName = validator.escape(String(data.roomName));
db.parseIntFields(data, intFields, fields);
data.roomName = validator.escape(String(data.roomName || ''));
data.public = parseInt(data.public, 10) === 1;
if (data.hasOwnProperty('groupChat')) {
data.groupChat = parseInt(data.groupChat, 10) === 1;
}
if (data.hasOwnProperty('groups')) {
try {
data.groups = JSON.parse(data.groups);
} catch (err) {
winston.error(err.stack);
data.groups = [];
}
}
}
});
}
Messaging.newRoom = async (uid, toUids) => {
Messaging.newRoom = async (uid, data) => {
// backwards compat. remove in 4.x
if (Array.isArray(data)) { // old usage second param used to be toUids
data = { uids: data };
}
const now = Date.now();
const roomId = await db.incrObjectField('global', 'nextChatRoomId');
const room = {
owner: uid,
roomId: roomId,
timestamp: now,
};
if (data.hasOwnProperty('roomName') && data.roomName) {
room.roomName = String(data.roomName);
}
if (Array.isArray(data.groups) && data.groups.length) {
room.groups = JSON.stringify(data.groups);
}
const isPublic = data.type === 'public';
if (isPublic) {
room.public = 1;
}
await Promise.all([
db.setObject(`chat:room:${roomId}`, room),
db.sortedSetAdd('chat:rooms', now, roomId),
db.sortedSetAdd(`chat:room:${roomId}:uids`, now, uid),
]);
await Promise.all([
Messaging.addUsersToRoom(uid, toUids, roomId),
Messaging.addRoomToUsers(roomId, [uid].concat(toUids), now),
Messaging.addUsersToRoom(uid, data.uids, roomId),
isPublic ?
db.sortedSetAddBulk([
['chat:rooms:public', now, roomId],
['chat:rooms:public:order', roomId, roomId],
]) :
Messaging.addRoomToUsers(roomId, [uid].concat(data.uids), now),
]);
// chat owner should also get the user-join system message
await Messaging.addSystemMessage('user-join', uid, roomId);
if (!isPublic) {
// chat owner should also get the user-join system message
await Messaging.addSystemMessage('user-join', uid, roomId);
}
return roomId;
};
Messaging.isUserInRoom = async (uid, roomId) => {
const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid);
const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', { uid: uid, roomId: roomId, inRoom: inRoom });
return data.inRoom;
Messaging.deleteRooms = async (roomIds) => {
if (!roomIds) {
throw new Error('[[error:invalid-data]]');
}
if (!Array.isArray(roomIds)) {
roomIds = [roomIds];
}
await Promise.all(roomIds.map(async (roomId) => {
const uids = await db.getSortedSetMembers(`chat:room:${roomId}:uids`);
const keys = uids
.map(uid => `uid:${uid}:chat:rooms`)
.concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`));
await Promise.all([
db.sortedSetRemove(`chat:room:${roomId}:uids`, uids),
db.sortedSetsRemove(keys, roomId),
]);
}));
await Promise.all([
db.deleteAll(roomIds.map(id => `chat:room:${id}`)),
db.sortedSetRemove('chat:rooms', roomIds),
db.sortedSetRemove('chat:rooms:public', roomIds),
]);
};
Messaging.isUserInRoom = async (uid, roomIds) => {
let single = false;
if (!Array.isArray(roomIds)) {
roomIds = [roomIds];
single = true;
}
const inRooms = await db.isMemberOfSortedSets(
roomIds.map(id => `chat:room:${id}:uids`),
uid
);
const data = await Promise.all(roomIds.map(async (roomId, idx) => {
const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', {
uid: uid,
roomId: roomId,
inRoom: inRooms[idx],
});
return data.inRoom;
}));
return single ? data.pop() : data;
};
Messaging.isUsersInRoom = async (uids, roomId) => {
let single = false;
if (!Array.isArray(uids)) {
uids = [uids];
single = true;
}
const inRooms = await db.isSortedSetMembers(
`chat:room:${roomId}:uids`,
uids,
);
const data = await plugins.hooks.fire('filter:messaging.isUsersInRoom', {
uids: uids,
roomId: roomId,
inRooms: inRooms,
});
return single ? data.inRooms.pop() : data.inRooms;
};
Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}:uids`);
@ -84,7 +201,12 @@ module.exports = function (Messaging) {
return isArray ? result : result[0];
};
Messaging.isRoomPublic = async function (roomId) {
return parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1;
};
Messaging.addUsersToRoom = async function (uid, uids, roomId) {
uids = _.uniq(uids);
const inRoom = await Messaging.isUserInRoom(uid, roomId);
const payload = await plugins.hooks.fire('filter:messaging.addUsersToRoom', { uid, uids, roomId, inRoom });
@ -92,13 +214,17 @@ module.exports = function (Messaging) {
throw new Error('[[error:cant-add-users-to-chat-room]]');
}
const now = Date.now();
const timestamps = payload.uids.map(() => now);
await db.sortedSetAdd(`chat:room:${payload.roomId}:uids`, timestamps, payload.uids);
await updateGroupChatField([payload.roomId]);
await Promise.all(payload.uids.map(uid => Messaging.addSystemMessage('user-join', uid, payload.roomId)));
await addUidsToRoom(payload.uids, roomId);
};
async function addUidsToRoom(uids, roomId) {
const now = Date.now();
const timestamps = uids.map(() => now);
await db.sortedSetAdd(`chat:room:${roomId}:uids`, timestamps, uids);
await updateUserCount([roomId]);
await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-join', uid, roomId)));
}
Messaging.removeUsersFromRoom = async (uid, uids, roomId) => {
const [isOwner, userCount] = await Promise.all([
Messaging.isRoomOwner(uid, roomId),
@ -117,14 +243,16 @@ module.exports = function (Messaging) {
return (await Messaging.getRoomData(roomId)).groupChat;
};
async function updateGroupChatField(roomIds) {
async function updateUserCount(roomIds) {
const userCounts = await db.sortedSetsCard(roomIds.map(roomId => `chat:room:${roomId}:uids`));
const countMap = _.zipObject(roomIds, userCounts);
const groupChats = roomIds.filter((roomId, index) => userCounts[index] > 2);
const privateChats = roomIds.filter((roomId, index) => userCounts[index] <= 2);
await db.setObjectBulk([
...groupChats.map(id => [`chat:room:${id}`, { groupChat: 1 }]),
...privateChats.map(id => [`chat:room:${id}`, { groupChat: 0 }]),
...groupChats.map(id => [`chat:room:${id}`, { groupChat: 1, userCount: countMap[id] }]),
...privateChats.map(id => [`chat:room:${id}`, { groupChat: 0, userCount: countMap[id] }]),
]);
cache.del(roomIds.map(id => `chat:room:${id}:users`));
}
Messaging.leaveRoom = async (uids, roomId) => {
@ -142,7 +270,7 @@ module.exports = function (Messaging) {
await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-leave', uid, roomId)));
await updateOwner(roomId);
await updateGroupChatField([roomId]);
await updateUserCount([roomId]);
};
Messaging.leaveRooms = async (uid, roomIds) => {
@ -162,7 +290,7 @@ module.exports = function (Messaging) {
roomIds.map(roomId => updateOwner(roomId))
.concat(roomIds.map(roomId => Messaging.addSystemMessage('user-leave', uid, roomId)))
);
await updateGroupChatField(roomIds);
await updateUserCount(roomIds);
};
async function updateOwner(roomId) {
@ -171,7 +299,18 @@ module.exports = function (Messaging) {
await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner);
}
Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRevRange(`chat:room:${roomId}:uids`, start, stop);
Messaging.getAllUidsInRoom = async function (roomId) {
const cacheKey = `chat:room:${roomId}:users`;
let uids = cache.get(cacheKey);
if (uids !== undefined) {
return uids;
}
uids = await Messaging.getUidsInRoom(roomId, 0, -1);
cache.set(cacheKey, uids);
return uids;
};
Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRange(`chat:room:${roomId}:uids`, start, stop);
Messaging.getUsersInRoom = async (roomId, start, stop) => {
const uids = await Messaging.getUidsInRoom(roomId, start, stop);
@ -181,6 +320,7 @@ module.exports = function (Messaging) {
]);
return users.map((user, index) => {
user.index = start + index;
user.isOwner = isOwners[index];
return user;
});
@ -221,40 +361,55 @@ module.exports = function (Messaging) {
};
Messaging.loadRoom = async (uid, data) => {
const canChat = await privileges.global.can('chat', uid);
const { roomId } = data;
const [room, inRoom, canChat] = await Promise.all([
Messaging.getRoomData(roomId),
Messaging.isUserInRoom(uid, roomId),
privileges.global.can('chat', uid),
]);
if (!canChat) {
throw new Error('[[error:no-privileges]]');
}
const inRoom = await Messaging.isUserInRoom(uid, data.roomId);
if (!inRoom) {
if (!room ||
(!room.public && !inRoom) ||
(room.public && !(await groups.isMemberOfAny(uid, room.groups)))
) {
return null;
}
const [room, canReply, users, messages, isAdminOrGlobalMod, isOwner] = await Promise.all([
Messaging.getRoomData(data.roomId),
Messaging.canReply(data.roomId, uid),
Messaging.getUsersInRoom(data.roomId, 0, -1),
// add user to public room onload
if (room.public && !inRoom) {
await addUidsToRoom([uid], roomId);
}
const [canReply, users, messages, isAdmin, isGlobalMod, settings, isOwner] = await Promise.all([
Messaging.canReply(roomId, uid),
Messaging.getUsersInRoom(roomId, 0, 39),
Messaging.getMessages({
callerUid: uid,
uid: data.uid || uid,
roomId: data.roomId,
roomId: roomId,
isNew: false,
}),
user.isAdminOrGlobalMod(uid),
Messaging.isRoomOwner(uid, data.roomId),
user.isAdministrator(uid),
user.isGlobalModerator(uid),
user.getSettings(uid),
Messaging.isRoomOwner(uid, roomId),
]);
room.messages = messages;
room.isOwner = isOwner;
room.users = users.filter(user => user && parseInt(user.uid, 10) && parseInt(user.uid, 10) !== parseInt(uid, 10));
room.users = users;
room.canReply = canReply;
room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : users.length > 2;
room.usernames = Messaging.generateUsernames(users, uid);
room.chatWithMessage = await Messaging.generateChatWithMessage(users, uid);
room.chatWithMessage = await Messaging.generateChatWithMessage(users, uid, settings.userLang);
room.maximumUsersInChatRoom = meta.config.maximumUsersInChatRoom;
room.maximumChatMessageLength = meta.config.maximumChatMessageLength;
room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2;
room.isAdminOrGlobalMod = isAdminOrGlobalMod;
room.isAdminOrGlobalMod = isAdmin || isGlobalMod;
room.isAdmin = isAdmin;
const payload = await plugins.hooks.fire('filter:messaging.loadRoom', { uid, data, room });
return payload.room;

@ -1,27 +1,35 @@
'use strict';
const db = require('../database');
const sockets = require('../socket.io');
const io = require('../socket.io');
module.exports = function (Messaging) {
Messaging.getUnreadCount = async (uid) => {
if (parseInt(uid, 10) <= 0) {
if (!(parseInt(uid, 10) > 0)) {
return 0;
}
return await db.sortedSetCard(`uid:${uid}:chat:rooms:unread`);
};
Messaging.pushUnreadCount = async (uid) => {
if (parseInt(uid, 10) <= 0) {
Messaging.pushUnreadCount = async (uids, data = null) => {
if (!Array.isArray(uids)) {
uids = [uids];
}
uids = uids.filter(uid => parseInt(uid, 10) > 0);
if (!uids.length) {
return;
}
const unreadCount = await Messaging.getUnreadCount(uid);
sockets.in(`uid_${uid}`).emit('event:unread.updateChatCount', unreadCount);
uids.forEach((uid) => {
io.in(`uid_${uid}`).emit('event:unread.updateChatCount', data);
});
};
Messaging.markRead = async (uid, roomId) => {
await db.sortedSetRemove(`uid:${uid}:chat:rooms:unread`, roomId);
await Promise.all([
db.sortedSetRemove(`uid:${uid}:chat:rooms:unread`, roomId),
db.setObjectField(`uid:${uid}:chat:rooms:read`, roomId, Date.now()),
]);
};
Messaging.hasRead = async (uids, roomId) => {
@ -42,6 +50,6 @@ module.exports = function (Messaging) {
return;
}
const keys = uids.map(uid => `uid:${uid}:chat:rooms:unread`);
return await db.sortedSetsAdd(keys, Date.now(), roomId);
await db.sortedSetsAdd(keys, Date.now(), roomId);
};
};

@ -113,8 +113,8 @@ Assert.room = helpers.try(async (req, res, next) => {
}
const [exists, inRoom] = await Promise.all([
await messaging.roomExists(req.params.roomId),
await messaging.isUserInRoom(req.uid, req.params.roomId),
messaging.roomExists(req.params.roomId),
messaging.isUserInRoom(req.uid, req.params.roomId),
]);
if (!exists) {

@ -169,6 +169,7 @@ module.exports = function (middleware) {
isGlobalMod: user.isGlobalModerator(req.uid),
isModerator: user.isModeratorOfAnyCategory(req.uid),
privileges: privileges.global.get(req.uid),
blocks: user.blocks.list(req.uid),
user: user.getUserData(req.uid),
isEmailConfirmSent: req.uid <= 0 ? false : await user.email.isValidationPending(req.uid),
languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang),
@ -190,6 +191,7 @@ module.exports = function (middleware) {
results.user.isGlobalMod = results.isGlobalMod;
results.user.isMod = !!results.isModerator;
results.user.privileges = results.privileges;
results.user.blocks = results.blocks;
results.user.timeagoCode = results.timeagoCode;
results.user[results.user.status] = true;
results.user.lastRoomId = results.roomIds.length ? results.roomIds[0] : null;

@ -15,6 +15,8 @@ module.exports = function () {
setupApiRoute(router, 'get', '/analytics', [...middlewares], controllers.write.admin.getAnalyticsKeys);
setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalyticsData);
setupApiRoute(router, 'delete', '/chats/:roomId', [...middlewares, middleware.assert.room], controllers.write.admin.chats.deleteRoom);
setupApiRoute(router, 'post', '/tokens', [...middlewares], controllers.write.admin.generateToken);
setupApiRoute(router, 'get', '/tokens/:token', [...middlewares], controllers.write.admin.getToken);
setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken);

@ -16,8 +16,7 @@ module.exports = function () {
setupApiRoute(router, 'head', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.exists);
setupApiRoute(router, 'get', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.get);
setupApiRoute(router, 'post', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['message'])], controllers.write.chats.post);
setupApiRoute(router, 'put', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['name'])], controllers.write.chats.rename);
// no route for room deletion, noted here just in case...
setupApiRoute(router, 'put', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.update);
setupApiRoute(router, 'put', '/:roomId/state', [...middlewares, middleware.assert.room], controllers.write.chats.mark);
setupApiRoute(router, 'delete', '/:roomId/state', [...middlewares, middleware.assert.room], controllers.write.chats.mark);

@ -20,7 +20,6 @@ SocketRooms.getAll = async function () {
totals.onlineGuestCount = 0;
totals.onlineRegisteredCount = 0;
totals.socketCount = sockets.length;
totals.topics = {};
totals.topTenTopics = [];
totals.users = {
categories: 0,

@ -65,6 +65,17 @@ SocketGroups.loadMoreMembers = async (socket, data) => {
};
};
SocketGroups.getChatGroups = async (socket) => {
const isAdmin = await user.isAdministrator(socket.uid);
if (!isAdmin) {
throw new Error('[[error:no-privileges]]');
}
const allGroups = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1);
const groupsList = allGroups.filter(g => !groups.ephemeralGroups.includes(g.name));
groupsList.sort((a, b) => b.system - a.system);
return groupsList.map(g => ({ name: g.name, displayName: g.displayName }));
};
async function canSearchMembers(uid, groupName) {
const [isHidden, isMember, hasAdminPrivilege, isGlobalMod, viewGroups] = await Promise.all([
groups.isHidden(groupName),

@ -4,9 +4,8 @@
const user = require('../user');
const topics = require('../topics');
const SocketMeta = {
rooms: {},
};
const SocketMeta = module.exports;
SocketMeta.rooms = {};
SocketMeta.reconnected = function (socket, data, callback) {
callback = callback || function () {};
@ -19,13 +18,13 @@ SocketMeta.reconnected = function (socket, data, callback) {
/* Rooms */
SocketMeta.rooms.enter = function (socket, data, callback) {
SocketMeta.rooms.enter = async function (socket, data) {
if (!socket.uid) {
return callback();
return;
}
if (!data) {
return callback(new Error('[[error:invalid-data]]'));
throw new Error('[[error:invalid-data]]');
}
if (data.enter) {
@ -33,7 +32,11 @@ SocketMeta.rooms.enter = function (socket, data, callback) {
}
if (data.enter && data.enter.startsWith('uid_') && data.enter !== `uid_${socket.uid}`) {
return callback(new Error('[[error:not-allowed]]'));
throw new Error('[[error:not-allowed]]');
}
if (data.enter && data.enter.startsWith('chat_')) {
throw new Error('[[error:not-allowed]]');
}
leaveCurrentRoom(socket);
@ -42,15 +45,13 @@ SocketMeta.rooms.enter = function (socket, data, callback) {
socket.join(data.enter);
socket.currentRoom = data.enter;
}
callback();
};
SocketMeta.rooms.leaveCurrent = function (socket, data, callback) {
SocketMeta.rooms.leaveCurrent = async function (socket) {
if (!socket.uid || !socket.currentRoom) {
return callback();
return;
}
leaveCurrentRoom(socket);
callback();
};
function leaveCurrentRoom(socket) {
@ -60,4 +61,4 @@ function leaveCurrentRoom(socket) {
}
}
module.exports = SocketMeta;
require('../promisify')(SocketMeta);

@ -1,5 +1,7 @@
'use strict';
const _ = require('lodash');
const db = require('../database');
const Messaging = require('../messaging');
const utils = require('../utils');
@ -18,13 +20,13 @@ SocketModules.chats.getRaw = async function (socket, data) {
throw new Error('[[error:invalid-data]]');
}
const roomId = await Messaging.getMessageField(data.mid, 'roomId');
const [isAdmin, hasMessage, inRoom] = await Promise.all([
const [isAdmin, canViewMessage, inRoom] = await Promise.all([
user.isAdministrator(socket.uid),
db.isSortedSetMember(`uid:${socket.uid}:chat:room:${roomId}:mids`, data.mid),
Messaging.canViewMessage(data.mid, roomId, socket.uid),
Messaging.isUserInRoom(socket.uid, roomId),
]);
if (!isAdmin && (!inRoom || !hasMessage)) {
if (!isAdmin && (!inRoom || !canViewMessage)) {
throw new Error('[[error:not-allowed]]');
}
@ -70,4 +72,107 @@ SocketModules.chats.getIP = async function (socket, mid) {
return await Messaging.getMessageField(mid, 'ip');
};
SocketModules.chats.getUnreadCount = async function (socket) {
return await Messaging.getUnreadCount(socket.uid);
};
SocketModules.chats.enter = async function (socket, roomIds) {
await joinLeave(socket, roomIds, 'join');
};
SocketModules.chats.leave = async function (socket, roomIds) {
await joinLeave(socket, roomIds, 'leave');
};
SocketModules.chats.enterPublic = async function (socket, roomIds) {
await joinLeave(socket, roomIds, 'join', 'chat_room_public');
};
SocketModules.chats.leavePublic = async function (socket, roomIds) {
await joinLeave(socket, roomIds, 'leave', 'chat_room_public');
};
async function joinLeave(socket, roomIds, method, prefix = 'chat_room') {
if (!(socket.uid > 0)) {
throw new Error('[[error:not-allowed]]');
}
if (!Array.isArray(roomIds)) {
roomIds = [roomIds];
}
if (roomIds.length) {
const [isAdmin, inRooms, roomData] = await Promise.all([
user.isAdministrator(socket.uid),
Messaging.isUserInRoom(socket.uid, roomIds),
Messaging.getRoomsData(roomIds, ['public', 'groups']),
]);
await Promise.all(roomIds.map(async (roomId, idx) => {
const isPublic = roomData[idx] && roomData[idx].public;
const groups = roomData[idx] && roomData[idx].groups;
if (isAdmin || (inRooms[idx] && (!isPublic || await groups.isMemberOfAny(socket.uid, groups)))) {
socket[method](`${prefix}_${roomId}`);
}
}));
}
}
SocketModules.chats.sortPublicRooms = async function (socket, data) {
if (!data || !Array.isArray(data.scores) || !Array.isArray(data.roomIds)) {
throw new Error('[[error:invalid-data]]');
}
const isAdmin = await user.isAdministrator(socket.uid);
if (!isAdmin) {
throw new Error('[[error:no-privileges]]');
}
await db.sortedSetAdd(`chat:rooms:public:order`, data.scores, data.roomIds);
};
SocketModules.chats.searchMembers = async function (socket, data) {
if (!data || !data.roomId) {
throw new Error('[[error:invalid-data]]');
}
const [isAdmin, inRoom, isRoomOwner] = await Promise.all([
user.isAdministrator(socket.uid),
Messaging.isUserInRoom(socket.uid, data.roomId),
Messaging.isRoomOwner(socket.uid, data.roomId),
]);
if (!isAdmin && !inRoom) {
throw new Error('[[error:no-privileges]]');
}
const results = await user.search({
query: data.username,
paginate: false,
hardCap: -1,
});
const { users } = results;
const foundUids = users.map(user => user && user.uid);
const isUidInRoom = _.zipObject(
foundUids,
await Messaging.isUsersInRoom(foundUids, data.roomId)
);
const roomUsers = users.filter(user => isUidInRoom[user.uid]);
const isOwners = await Messaging.isRoomOwner(roomUsers.map(u => u.uid), data.roomId);
roomUsers.forEach((user, index) => {
if (user) {
user.isOwner = isOwners[index];
user.canKick = isRoomOwner && (parseInt(user.uid, 10) !== parseInt(socket.uid, 10));
}
});
roomUsers.sort((a, b) => {
if (a.isOwner && !b.isOwner) {
return -1;
} else if (!a.isOwner && b.isOwner) {
return 1;
}
return 0;
});
return { users: roomUsers };
};
require('../promisify')(SocketModules);

@ -0,0 +1,64 @@
'use strict';
const _ = require('lodash');
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Update chat messages to add roomId field',
timestamp: Date.UTC(2023, 6, 2),
method: async function () {
const { progress } = this;
const nextChatRoomId = await db.getObjectField('global', 'nextChatRoomId');
const allRoomIds = [];
for (let i = 1; i <= nextChatRoomId; i++) {
allRoomIds.push(i);
}
progress.total = allRoomIds.length;
await batch.processArray(allRoomIds, async (roomIds) => {
progress.incr(roomIds.length);
await Promise.all(roomIds.map(async (roomId) => {
const [uids, roomData] = await Promise.all([
db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, -1),
db.getObject(`chat:room:${roomId}`),
]);
if (!uids.length && !roomData) {
return;
}
if (roomData && roomData.owner && !uids.includes(String(roomData.owner))) {
uids.push(roomData.owner);
}
const userKeys = uids.map(uid => `uid:${uid}:chat:room:${roomId}:mids`);
const mids = await db.getSortedSetsMembers(userKeys);
const uniqMids = _.uniq(_.flatten(mids));
let messageData = await db.getObjects(uniqMids.map(mid => `message:${mid}`));
messageData.forEach((m, idx) => {
if (m) {
m.mid = parseInt(uniqMids[idx], 10);
}
});
messageData = messageData.filter(Boolean);
const bulkSet = messageData.map(
msg => [`message:${msg.mid}`, { roomId: roomId }]
);
await db.setObjectBulk(bulkSet);
await db.setObjectField(`chat:room:${roomId}`, 'userCount', uids.length);
await db.sortedSetAdd(
`chat:room:${roomId}:mids`,
messageData.map(m => m.timestamp),
messageData.map(m => m.mid),
);
await db.deleteAll(userKeys);
}));
}, {
batch: 500,
});
},
};

@ -0,0 +1,42 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Store list of chat rooms',
timestamp: Date.UTC(2023, 6, 3),
method: async function () {
const { progress } = this;
const lastRoomId = await db.getObjectField('global', 'nextChatRoomId');
const allRoomIds = [];
for (let x = 1; x <= lastRoomId; x++) {
allRoomIds.push(x);
}
const users = await db.getSortedSetRangeWithScores(`users:joindate`, 0, 0);
const timestamp = users.length ? users[0].score : Date.now();
progress.total = allRoomIds.length;
await batch.processArray(allRoomIds, async (roomIds) => {
progress.incr(roomIds.length);
const keys = roomIds.map(id => `chat:room:${id}`);
const exists = await db.exists(keys);
roomIds = roomIds.filter((_, idx) => exists[idx]);
// get timestamp from uids, if no users use the timestamp of first user
const arrayOfUids = await Promise.all(
roomIds.map(roomId => db.getSortedSetRangeWithScores(`chat:room:${roomId}:uids`, 0, 0))
);
const timestamps = roomIds.map(
(id, idx) => (arrayOfUids[idx].length ? (arrayOfUids[idx][0].score || timestamp) : timestamp)
);
await db.sortedSetAdd('chat:rooms', timestamps, roomIds);
await db.setObjectBulk(
roomIds.map((id, idx) => ([`chat:room:${id}`, { timestamp: timestamps[idx] }]))
);
}, {
batch: 500,
});
},
};

@ -116,7 +116,9 @@ module.exports = function (User) {
`user:${uid}:emails`,
`uid:${uid}:topics`, `uid:${uid}:posts`,
`uid:${uid}:chats`, `uid:${uid}:chats:unread`,
`uid:${uid}:chat:rooms`, `uid:${uid}:chat:rooms:unread`,
`uid:${uid}:chat:rooms`,
`uid:${uid}:chat:rooms:unread`,
`uid:${uid}:chat:rooms:read`,
`uid:${uid}:upvote`, `uid:${uid}:downvote`,
`uid:${uid}:flag:pids`,
`uid:${uid}:sessions`, `uid:${uid}:sessionUUID:sessionId`,
@ -168,13 +170,10 @@ module.exports = function (User) {
}
async function deleteChats(uid) {
const roomIds = await db.getSortedSetRange(`uid:${uid}:chat:rooms`, 0, -1);
const userKeys = roomIds.map(roomId => `uid:${uid}:chat:room:${roomId}:mids`);
await Promise.all([
messaging.leaveRooms(uid, roomIds),
db.deleteAll(userKeys),
]);
const roomIds = await db.getSortedSetRange([
`uid:${uid}:chat:rooms`, `chat:rooms:public`,
], 0, -1);
await messaging.leaveRooms(uid, roomIds);
}
async function deleteUserIps(uid) {

@ -89,7 +89,7 @@ process.on('message', async (msg) => {
async function getRoomMessages(uid, roomId) {
const batch = require('../../batch');
let data = [];
await batch.processSortedSet(`uid:${uid}:chat:room:${roomId}:mids`, async (mids) => {
await batch.processSortedSet(`chat:room:${roomId}:mids`, async (mids) => {
const messageData = await db.getObjects(mids.map(mid => `message:${mid}`));
data = data.concat(
messageData

@ -0,0 +1,45 @@
<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"/>
</div>
<div class="mb-3">
<div class="dropdown mb-3">
<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;">
<li component="chat/search/start-typing"><a href="#" class="dropdown-item rounded-1">[[admin/menu:search.start-typing]]</a></li>
<li component="chat/search/no-users" class="hidden"><a href="#" class="dropdown-item rounded-1">[[users:no-users-found]]</a></li>
{{{ each searchUsers }}}
<li component="chat/search/user" data-uid="{./uid}"><a href="#" class="dropdown-item rounded-1">{buildAvatar(@value, "24px", true)} {./username}</a></li>
{{{ end }}}
</ul>
</div>
<ul component="chat/room/users" class="list-group">
{{{ 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>
<button component="chat/room/users/remove" class="btn btn-sm btn-link"><i class="fa fa-times text-danger"></i></button>
</li>
{{{ end }}}
</ul>
</div>
{{{ if user.isAdmin }}}
<select component="chat/room/type" class="form-select mb-3">
<option value="private">[[modules:chat.private.option]]</option>
<option value="public">[[modules:chat.public.option]]</option>
</select>
<div component="chat/room/public/options" class="hidden">
<select component="chat/room/groups" class="form-select" multiple size="10">
{{{ each groups }}}
<option value="{./displayName}">{./displayName}</option>
{{{ end }}}
</select>
<p class="form-text">
[[modules:chat.public.groups-help]]
</p>
</div>
{{{ end }}}
</div>

@ -1,11 +1,27 @@
<div class="mb-3">
<input class="form-control" type="text" placeholder="[[global:user-search-prompt]]" />
<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>
<p class="form-text">[[modules:chat.add-user-help]]</p>
<hr />
<ul class="list-group">
<label class="form-label">[[global:users]]</label>
<input component="chat/manage/user/list/search" class="form-control mb-1" type="text" placeholder="[[global:user-search-prompt]]" />
<ul component="chat/manage/user/list" class="list-group overflow-auto pe-1 mb-3" style="max-height: 300px;">
<li class="list-group-item"><i class="fa fa-spinner fa-spin"></i> [[modules:chat.retrieving-users]]</li>
</ul>
{{{ if (user.isAdmin && group.public ) }}}
<label class="form-label">[[modules:chat.select-groups]]</label>
<select component="chat/room/groups" class="form-select mb-1" multiple size="10">
{{{ each groups }}}
<option value="{./displayName}" {{{ if ./selected }}}selected{{{ end }}}>{./displayName}</option>
{{{ end }}}
</select>
<div class="d-flex justify-content-end">
<button component="chat/manage/save/groups" class="btn btn-sm btn-primary">[[global:save]]</button>
</div>
{{{ end }}}
</div>

@ -1,7 +1,12 @@
{{{ each users }}}
<li class="list-group-item">
{{{ if ./canKick }}}<button class="float-end btn btn-sm btn-link" data-action="kick" data-uid="{../uid}">[[modules:chat.kick]]</button>{{{ end }}}
{buildAvatar(users, "24px", true)}
<span>{../username} {{{ if ./isOwner }}}<i class="fa fa-star text-warning" title="[[modules:chat.owner]]"></i>{{{ end }}}</span>
<li class="list-group-item d-flex align-items-center justify-content-between" data-index="{./index}">
<div class="d-flex gap-1 align-items-center">
{buildAvatar(users, "24px", true)}
<span>{./username}{{{ if ./isOwner }}} <i class="fa fa-star text-warning" title="[[modules:chat.owner]]"></i>{{{ end }}}</span>
</div>
{{{ if ./canKick }}}
<button class="btn btn-sm btn-link" data-action="kick" data-uid="{./uid}">[[modules:chat.kick]]</button>
{{{ end }}}
</li>
{{{ end }}}

@ -281,7 +281,7 @@ describe('API', async () => {
await flags.create('post', 2, unprivUid, 'sample reasons', Date.now()); // for testing flag notes (since flag 1 deleted)
// Create a new chat room
await messaging.newRoom(1, [2]);
await messaging.newRoom(1, { uids: [2] });
// Create an empty file to test DELETE /files and thumb deletion
fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w'));

@ -64,6 +64,7 @@ describe('Sorted Set methods', () => {
match: '*b{',
limit: 2,
});
assert.strictEqual(data.length, 2);
assert(data.includes('aaab{'));
assert(data.includes('bbcb{'));
});
@ -73,8 +74,8 @@ describe('Sorted Set methods', () => {
const data = await db.getSortedSetScan({
key: 'scanzset4',
match: 'b*',
limit: 2,
});
assert.strictEqual(data.length, 2);
assert(data.includes('bbbb'));
assert(data.includes('bbcb'));
});
@ -85,7 +86,7 @@ describe('Sorted Set methods', () => {
key: 'scanzset5',
match: '*db',
});
assert.equal(data.length, 2);
assert.strictEqual(data.length, 2);
assert(data.includes('ddb'));
assert(data.includes('adb'));
});

@ -345,7 +345,7 @@ describe('Messaging Library', () => {
assert(messageData.fromUser);
assert(messageData.roomId, roomId);
const raw =
await util.promisify(socketModules.chats.getRaw)({ uid: mocks.users.foo.uid }, { mid: messageData.mid });
await util.promisify(socketModules.chats.getRaw)({ uid: mocks.users.foo.uid }, { mid: messageData.messageId });
assert.equal(raw, 'first chat message');
});
@ -378,7 +378,7 @@ describe('Messaging Library', () => {
assert(myRoomId);
try {
await util.promisify(socketModules.chats.getRaw)({ uid: mocks.users.baz.uid }, { mid: 200 });
await socketModules.chats.getRaw({ uid: mocks.users.baz.uid }, { mid: 200 });
} catch (err) {
assert(err);
assert.equal(err.message, '[[error:not-allowed]]');
@ -386,7 +386,7 @@ describe('Messaging Library', () => {
({ body } = await callv3API('post', `/chats/${myRoomId}`, { roomId: myRoomId, message: 'admin will see this' }, 'baz'));
const message = body.response;
const raw = await util.promisify(socketModules.chats.getRaw)({ uid: mocks.users.foo.uid }, { mid: message.mid });
const raw = await socketModules.chats.getRaw({ uid: mocks.users.foo.uid }, { mid: message.messageId });
assert.equal(raw, 'admin will see this');
});
@ -455,11 +455,8 @@ describe('Messaging Library', () => {
});
it('should fail to rename room with invalid data', async () => {
let { body } = await callv3API('put', `/chats/${roomId}`, { name: null }, 'foo');
const { body } = await callv3API('put', `/chats/${roomId}`, { name: null }, 'foo');
assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]'));
({ body } = await callv3API('put', `/chats/${roomId}`, {}, 'foo'));
assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, name]]'));
});
it('should rename room', async () => {
@ -563,9 +560,9 @@ describe('Messaging Library', () => {
before(async () => {
await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.baz.uid] }, 'foo');
let { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo');
mid = body.response.mid;
mid = body.response.messageId;
({ body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'second chat message' }, 'baz'));
mid2 = body.response.mid;
mid2 = body.response.messageId;
});
after(async () => {
@ -639,8 +636,7 @@ describe('Messaging Library', () => {
const { body } = await callv3API('get', `/chats/${roomId}`, {}, 'herp');
const { messages } = body.response;
messages.forEach((msg) => {
assert(!msg.deleted || msg.content === '[[modules:chat.message-deleted]]', msg.content);
assert(!msg.deleted || msg.cleanedContent, '[[modules:chat.message-deleted]]', msg.content);
assert(!msg.deleted || msg.content === '<p>[[modules:chat.message-deleted]]</p>', msg.content);
});
});

@ -574,7 +574,7 @@ describe('User', () => {
const socketModules = require('../src/socket.io/modules');
const uid1 = await User.create({ username: 'chatuserdelete1' });
const uid2 = await User.create({ username: 'chatuserdelete2' });
const roomId = await messaging.newRoom(uid1, [uid2]);
const roomId = await messaging.newRoom(uid1, { uids: [uid2] });
await messaging.addMessage({
uid: uid1,
content: 'hello',

Loading…
Cancel
Save