mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-03-01 01:51:18 +01:00
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
This commit is contained in:
committed by
GitHub
parent
edd8ca997f
commit
9b901783fa
@@ -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,13 @@ 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:
|
||||
@@ -300,6 +316,157 @@ get:
|
||||
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:
|
||||
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
|
||||
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}:
|
||||
|
||||
26
public/openapi/write/admin/chats/roomId.yaml
Normal file
26
public/openapi/write/admin/chats/roomId.yaml
Normal file
@@ -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,33 +503,21 @@ 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) {
|
||||
app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
85
public/src/client/chats/create.js
Normal file
85
public/src/client/chats/create.js
Normal file
@@ -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;
|
||||
});
|
||||
106
public/src/client/chats/manage.js
Normal file
106
public/src/client/chats/manage.js
Normal file
@@ -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;
|
||||
});
|
||||
|
||||
48
public/src/client/chats/user-list.js
Normal file
48
public/src/client/chats/user-list.js
Normal file
@@ -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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
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,18 +87,46 @@ chatsAPI.post = async (caller, data) => {
|
||||
return message;
|
||||
};
|
||||
|
||||
chatsAPI.update = async (caller, data) => {
|
||||
if (!data || !data.roomId) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
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]]');
|
||||
}
|
||||
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);
|
||||
return messaging.loadRoom(caller.uid, {
|
||||
roomId: data.roomId,
|
||||
});
|
||||
return await chatsAPI.update(caller, data);
|
||||
};
|
||||
|
||||
chatsAPI.mark = async (caller, 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;
|
||||
}
|
||||
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}`)
|
||||
);
|
||||
|
||||
// Mark notification read
|
||||
const nids = uidsInRoom.filter(uid => parseInt(uid, 10) !== caller.uid)
|
||||
.map(uid => `chat_${uid}_${roomId}`);
|
||||
|
||||
await notifications.markReadMultiple(nids, caller.uid);
|
||||
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;
|
||||
}
|
||||
const teasers = await Messaging.getTeasers(uid, [roomId]);
|
||||
return teasers[0];
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
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 payload = await plugins.hooks.fire('filter:messaging.getTeaser', { teaser: teaser });
|
||||
return payload.teaser;
|
||||
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);
|
||||
|
||||
64
src/upgrades/3.3.0/chat_room_refactor.js
Normal file
64
src/upgrades/3.3.0/chat_room_refactor.js
Normal file
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
42
src/upgrades/3.3.0/save_rooms_zset.js
Normal file
42
src/upgrades/3.3.0/save_rooms_zset.js
Normal file
@@ -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
|
||||
|
||||
45
src/views/modals/create-room.tpl
Normal file
45
src/views/modals/create-room.tpl
Normal file
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user