diff --git a/app/api/server/v1/misc.js b/app/api/server/v1/misc.js index b975ab1201b26..1af2c884a860f 100644 --- a/app/api/server/v1/misc.js +++ b/app/api/server/v1/misc.js @@ -262,6 +262,8 @@ const methodCall = () => ({ const result = Meteor.call(method, ...params); return API.v1.success(mountResult({ id, result })); } catch (error) { + Meteor._debug(`Exception while invoking method ${ method }`, error.stack); + return API.v1.success(mountResult({ id, error })); } }, diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index ffcf73606f903..090d686083a6b 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -1374,6 +1374,12 @@ settings.addGroup('Layout', function() { type: 'boolean', public: true, }); + + this.add('Number_of_users_autocomplete_suggestions', 5, { + type: 'int', + public: true, + }); + this.add('UI_Unread_Counter_Style', 'Different_Style_For_User_Mentions', { type: 'select', values: [ diff --git a/app/migrations/server/migrations.js b/app/migrations/server/migrations.js index a797c1aeed29f..c7d25a94dad63 100644 --- a/app/migrations/server/migrations.js +++ b/app/migrations/server/migrations.js @@ -293,6 +293,7 @@ Migrations._migrateTo = function(version, rerun) { `Branch: ${ Info.commit.branch }`, `Tag: ${ Info.commit.tag }`, ])); + console.log(e.stack); process.exit(1); } } diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index c98ebdd695238..2913a30370e61 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -1308,6 +1308,10 @@ export class Subscriptions extends Base { Rooms.incUsersCountById(room._id); + if (!['d', 'l'].includes(room.t)) { + Users.addRoomByUserId(user._id, room._id); + } + return result; } @@ -1326,6 +1330,8 @@ export class Subscriptions extends Base { Rooms.incUsersCountNotDMsByIds(roomIds, -1); } + Users.removeAllRoomsByUserId(userId); + return result; } @@ -1340,6 +1346,8 @@ export class Subscriptions extends Base { Rooms.incUsersCountById(roomId, - result); } + Users.removeRoomByRoomId(roomId); + return result; } @@ -1355,11 +1363,17 @@ export class Subscriptions extends Base { Rooms.incUsersCountById(roomId, - result); } + Users.removeRoomByUserId(userId, roomId); + return result; } removeByRoomIds(rids) { - return this.remove({ rid: { $in: rids } }); + const result = this.remove({ rid: { $in: rids } }); + + Users.removeRoomByRoomIds(rids); + + return result; } // ////////////////////////////////////////////////////////////////// diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index e7e40ae6caab5..14d84e6968fd4 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -28,6 +28,12 @@ export class Users extends Base { constructor(...args) { super(...args); + this.defaultFields = { + __rooms: 0, + }; + + this.tryEnsureIndex({ __rooms: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ roles: 1 }, { sparse: 1 }); this.tryEnsureIndex({ name: 1 }); this.tryEnsureIndex({ bio: 1 }, { sparse: 1 }); @@ -388,6 +394,48 @@ export class Users extends Base { }); } + addRoomByUserId(_id, rid) { + return this.update({ + _id, + __rooms: { $ne: rid }, + }, { + $addToSet: { __rooms: rid }, + }); + } + + removeRoomByUserId(_id, rid) { + return this.update({ + _id, + __rooms: rid, + }, { + $pull: { __rooms: rid }, + }); + } + + removeAllRoomsByUserId(_id) { + return this.update({ + _id, + }, { + $set: { __rooms: [] }, + }); + } + + removeRoomByRoomId(rid) { + return this.update({ + __rooms: rid, + }, { + $pull: { __rooms: rid }, + }, { multi: true }); + } + + removeRoomByRoomIds(rids) { + return this.update({ + __rooms: { $in: rids }, + }, { + $pullAll: { __rooms: rids }, + }, { multi: true }); + } + update2FABackupCodesByUserId(userId, backupCodes) { return this.update({ _id: userId, @@ -509,6 +557,19 @@ export class Users extends Base { return this.findOne(query, options); } + findOneByUsernameAndRoomIgnoringCase(username, rid, options) { + if (typeof username === 'string') { + username = new RegExp(`^${ s.escapeRegExp(username) }$`, 'i'); + } + + const query = { + __rooms: rid, + username, + }; + + return this.findOne(query, options); + } + findOneByUsernameAndServiceNameIgnoringCase(username, userId, serviceName, options) { if (typeof username === 'string') { username = new RegExp(`^${ s.escapeRegExp(username) }$`, 'i'); @@ -661,7 +722,7 @@ export class Users extends Base { return this.find(query, options); } - findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = []) { + findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = [], { startsWith = false, endsWith = false } = {}) { if (exceptions == null) { exceptions = []; } if (options == null) { options = {}; } if (!_.isArray(exceptions)) { @@ -683,7 +744,7 @@ export class Users extends Base { return this._db.find(query, options); } - const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); + const termRegex = new RegExp((startsWith ? '^' : '') + s.escapeRegExp(searchTerm) + (endsWith ? '$' : ''), 'i'); const searchFields = forcedSearchFields || settings.get('Accounts_SearchFields').trim().split(','); diff --git a/app/models/server/models/_BaseDb.js b/app/models/server/models/_BaseDb.js index f8438ad25ba4a..c88a29a90b3ac 100644 --- a/app/models/server/models/_BaseDb.js +++ b/app/models/server/models/_BaseDb.js @@ -124,7 +124,15 @@ export class BaseDb extends EventEmitter { }; } + _ensureDefaultFields(options) { + if ((options?.fields == null || Object.keys(options?.fields).length === 0) && this.baseModel.defaultFields) { + options.fields = this.baseModel.defaultFields; + } + } + _doNotMixInclusionAndExclusionFields(options) { + this._ensureDefaultFields(options); + if (options && options.fields) { const keys = Object.keys(options.fields); const removeKeys = keys.filter((key) => options.fields[key] === 0); @@ -134,18 +142,18 @@ export class BaseDb extends EventEmitter { } } - find(...args) { - this._doNotMixInclusionAndExclusionFields(args[1]); - return this.model.find(...args); + find(query = {}, options = {}) { + this._doNotMixInclusionAndExclusionFields(options); + return this.model.find(query, options); } findById(_id, options) { return this.find({ _id }, options); } - findOne(...args) { - this._doNotMixInclusionAndExclusionFields(args[1]); - return this.model.findOne(...args); + findOne(query = {}, options = {}) { + this._doNotMixInclusionAndExclusionFields(options); + return this.model.findOne(query, options); } findOneById(_id, options) { diff --git a/app/models/server/raw/BaseRaw.js b/app/models/server/raw/BaseRaw.js index fa6d3fc02de02..7d126d26797d0 100644 --- a/app/models/server/raw/BaseRaw.js +++ b/app/models/server/raw/BaseRaw.js @@ -3,20 +3,28 @@ export class BaseRaw { this.col = col; } + _ensureDefaultFields(options) { + if ((options?.fields == null || Object.keys(options?.fields).length === 0) && this.defaultFields) { + options.fields = this.defaultFields; + } + } + findOneById(_id, options = {}) { return this.findOne({ _id }, options); } - findOne(...args) { - return this.col.findOne(...args); + findOne(query = {}, options = {}) { + this._ensureDefaultFields(options); + return this.col.findOne(query, options); } findUsersInRoles() { throw new Error('overwrite-function', 'You must overwrite this function in the extended classes'); } - find(...args) { - return this.col.find(...args); + find(query = {}, options = {}) { + this._ensureDefaultFields(options); + return this.col.find(query, options); } update(...args) { diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index d729302e7cf2e..5edbe960a35d3 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -1,6 +1,14 @@ import { BaseRaw } from './BaseRaw'; export class UsersRaw extends BaseRaw { + constructor(...args) { + super(...args); + + this.defaultFields = { + __rooms: 0, + }; + } + findUsersInRoles(roles, scope, options) { roles = [].concat(roles); diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index a98c4a259b2fb..b90e6116e5abe 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -1961,6 +1961,12 @@ background-size: contain; } +.rc-old .popup-user-not_in_channel { + display: inline-block; + + float: right; +} + .rc-old .popup-user-status { display: inline-block; diff --git a/app/ui-message/client/popup/messagePopupConfig.js b/app/ui-message/client/popup/messagePopupConfig.js index 79fee8de05aac..f23c4bf5651f8 100644 --- a/app/ui-message/client/popup/messagePopupConfig.js +++ b/app/ui-message/client/popup/messagePopupConfig.js @@ -7,11 +7,12 @@ import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import { Messages, Subscriptions, Users } from '../../../models'; -import { hasAllPermission, hasAtLeastOnePermission } from '../../../authorization'; +import { Messages, Subscriptions } from '../../../models/client'; +import { settings } from '../../../settings/client'; +import { hasAllPermission, hasAtLeastOnePermission } from '../../../authorization/client'; import { EmojiPicker, emoji } from '../../../emoji'; -import { call } from '../../../ui-utils'; -import { t, getUserPreference, slashCommands } from '../../../utils'; +import { call } from '../../../ui-utils/client'; +import { t, getUserPreference, slashCommands } from '../../../utils/client'; import { customMessagePopups } from './customMessagePopups'; import './messagePopupConfig.html'; import './messagePopupSlashCommand.html'; @@ -66,19 +67,20 @@ const fetchUsersFromServer = _.throttle(async (filterText, records, rid, cb) => } users - .slice(0, 5) - .forEach(({ username, nickname, name, status, avatarETag }) => { - if (records.length < 5) { - records.push({ - _id: username, - username, - nickname, - name, - status, - avatarETag, - sort: 3, - }); - } + // .slice(0, 5) + .forEach(({ username, nickname, name, status, avatarETag, outside }) => { + // if (records.length < 5) { + records.push({ + _id: username, + username, + nickname, + name, + status, + avatarETag, + outside, + sort: 3, + }); + // } }); records.sort(({ sort: sortA }, { sort: sortB }) => sortA - sortB); @@ -192,6 +194,8 @@ Template.messagePopupConfig.helpers({ popupUserConfig() { const template = Template.instance(); + const suggestionsCount = settings.get('Number_of_users_autocomplete_suggestions'); + return { title: t('People'), collection: template.usersFromRoomMessages, @@ -210,7 +214,7 @@ Template.messagePopupConfig.helpers({ .find( { ts: { $exists: true }, - ...filterRegex && { + ...filterText && { $or: [ { username: filterRegex }, { name: filterRegex }, @@ -218,114 +222,117 @@ Template.messagePopupConfig.helpers({ }, }, { - limit: 5, + limit: filterText ? 2 : suggestionsCount, sort: { ts: -1 }, }, ) - .fetch(); - - // If needed, add to list the online users - if (items.length < 5 && filterRegex) { - const usernamesAlreadyFetched = items.map(({ username }) => username); - if (!hasAllPermission('view-outside-room')) { - const usernamesFromDMs = Subscriptions - .find( - { - t: 'd', - $and: [ - { - ...filterRegex && { - $or: [ - { name: filterRegex }, - { fname: filterRegex }, - ], - }, - }, - { - name: { $nin: usernamesAlreadyFetched }, - }, - ], - }, - { - fields: { name: 1 }, - }, - ) - .map(({ name }) => name); - const newItems = Users - .find( - { - username: { - $in: usernamesFromDMs, - }, - }, - { - fields: { - username: 1, - nickname: 1, - name: 1, - status: 1, - }, - limit: 5 - usernamesAlreadyFetched.length, - }, - ) - .fetch() - .map(({ username, name, status, nickname }) => ({ - _id: username, - username, - nickname, - name, - status, - sort: 1, - })); - - items.push(...newItems); - } else { - const user = Meteor.users.findOne(Meteor.userId(), { fields: { username: 1 } }); - const newItems = Meteor.users.find({ - $and: [ - { - ...filterRegex && { - $or: [ - { username: filterRegex }, - { name: filterRegex }, - ], - }, - }, - { - username: { - $nin: [ - user && user.username, - ...usernamesAlreadyFetched, - ], - }, - }, - ], - }, - { - fields: { - username: 1, - nickname: 1, - name: 1, - status: 1, - }, - limit: 5 - usernamesAlreadyFetched.length, - }) - .fetch() - .map(({ username, name, status, nickname }) => ({ - _id: username, - username, - nickname, - name, - status, - sort: 1, - })); - - items.push(...newItems); - } - } + .fetch().map((u) => { + u.suggestion = true; + return u; + }); + + // // If needed, add to list the online users + // if (items.length < 5 && filterRegex) { + // const usernamesAlreadyFetched = items.map(({ username }) => username); + // if (!hasAllPermission('view-outside-room')) { + // const usernamesFromDMs = Subscriptions + // .find( + // { + // t: 'd', + // $and: [ + // { + // ...filterRegex && { + // $or: [ + // { name: filterRegex }, + // { fname: filterRegex }, + // ], + // }, + // }, + // { + // name: { $nin: usernamesAlreadyFetched }, + // }, + // ], + // }, + // { + // fields: { name: 1 }, + // }, + // ) + // .map(({ name }) => name); + // const newItems = Users + // .find( + // { + // username: { + // $in: usernamesFromDMs, + // }, + // }, + // { + // fields: { + // username: 1, + // nickname: 1, + // name: 1, + // status: 1, + // }, + // limit: 5 - usernamesAlreadyFetched.length, + // }, + // ) + // .fetch() + // .map(({ username, name, status, nickname }) => ({ + // _id: username, + // username, + // nickname, + // name, + // status, + // sort: 1, + // })); + + // items.push(...newItems); + // } else { + // const user = Meteor.users.findOne(Meteor.userId(), { fields: { username: 1 } }); + // const newItems = Meteor.users.find({ + // $and: [ + // { + // ...filterRegex && { + // $or: [ + // { username: filterRegex }, + // { name: filterRegex }, + // ], + // }, + // }, + // { + // username: { + // $nin: [ + // user && user.username, + // ...usernamesAlreadyFetched, + // ], + // }, + // }, + // ], + // }, + // { + // fields: { + // username: 1, + // nickname: 1, + // name: 1, + // status: 1, + // }, + // limit: 5 - usernamesAlreadyFetched.length, + // }) + // .fetch() + // .map(({ username, name, status, nickname }) => ({ + // _id: username, + // username, + // nickname, + // name, + // status, + // sort: 1, + // })); + + // items.push(...newItems); + // } + // } // Get users from Server - if (items.length < 5 && filterText !== '') { + if (items.length < suggestionsCount && filterText !== '') { fetchUsersFromServer(filterText, items, rid, cb); } diff --git a/app/ui-message/client/popup/messagePopupUser.html b/app/ui-message/client/popup/messagePopupUser.html index f86a3d49d6f9e..2e8944155ef49 100644 --- a/app/ui-message/client/popup/messagePopupUser.html +++ b/app/ui-message/client/popup/messagePopupUser.html @@ -4,4 +4,10 @@ {{/unless}} {{username}} {{name}}{{#if nickname}} ({{nickname}}){{/if}} + {{#if outside}} + + {{/if}} + {{#if suggestion}} + + {{/if}} diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 6bade089c7315..3d8df0d52a2c6 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1691,6 +1691,7 @@ "Nickname": "Nickname", "Nickname_Placeholder": "Enter your nickname...", "Not_Following": "Not Following", + "Not_in_channel": "Not in channel", "Follow_social_profiles": "Follow our social profiles, fork us on github and share your thoughts about the rocket.chat app on our trello board.", "Fonts": "Fonts", "Food_and_Drink": "Food & Drink", @@ -2652,7 +2653,8 @@ "Number_of_federated_users": "Number of federated users", "Number_of_federated_servers": "Number of federated servers", "Number_of_messages": "Number of messages", - "RetentionPolicy_DoNotExcludeDiscussion": "Do not exclude discussion messages", + "Number_of_users_autocomplete_suggestions": "Number of users' autocomplete suggestions", + "RetentionPolicy DoNotExcludeDiscussion": "Do not exclude discussion messages", "OAuth Apps": "OAuth Apps", "OAuth_Application": "OAuth Application", "OAuth_Applications": "OAuth Applications", @@ -3401,6 +3403,7 @@ "Support_Cordova_App_Description": "Allow the old mobile app, based on Cordova technology, to access the server enabling CORS for some APIs", "Survey": "Survey", "Survey_instructions": "Rate each question according to your satisfaction, 1 meaning you are completely unsatisfied and 5 meaning you are completely satisfied.", + "Suggestion_from_recent_messages": "Suggestion from recent messages", "Symbols": "Symbols", "Sync": "Sync", "Sync / Import": "Sync / Import", @@ -3957,4 +3960,4 @@ "Your_server_link": "Your server link", "Your_temporary_password_is_password": "Your temporary password is [password].", "Your_workspace_is_ready": "Your workspace is ready to use 🎉" -} \ No newline at end of file +} diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 2131f8a84ab10..b9978818f3b49 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -2325,6 +2325,7 @@ "No_discussions_yet": "Ainda sem discussões", "No_Threads": "Nenhuma thread encontrada", "No_user_with_username_%s_was_found": "Nenhum usuário com nome de usuário \"%s\" foi encontrado!", + "Not_in_channel": "Não está no canal", "Nobody_available": "Ninguém disponível", "Node_version": "Versão do Node", "None": "Nada", @@ -2349,6 +2350,7 @@ "Notify_all_in_this_room": "Notificar todos nesta sala", "Num_Agents": "# Agentes", "Number_of_messages": "Número de mensagens", + "Number_of_users_autocomplete_suggestions": "Número de sugestões para autocomplete de usuários", "RetentionPolicy_DoNotExcludeDiscussion": "Não exclua mensagens de discussões", "OAuth Apps": "Apps OAuth", "OAuth_Application": "Aplicação OAuth", @@ -2978,6 +2980,7 @@ "Support": "Apoio", "Survey": "Pesquisa", "Survey_instructions": "Classifique cada questão de acordo com a sua satisfação, 1 significa que você está completamente insatisfeito e 5 significa que você está completamente satisfeito.", + "Suggestion_from_recent_messages": "Sugestão das mensagens recentes", "Symbols": "Símbolos", "Sync": "Sincronizar", "Sync / Import": "Sincronizar / Importar", @@ -3478,4 +3481,4 @@ "Your_question": "A sua pergunta", "Your_server_link": "O link do seu servidor", "Your_workspace_is_ready": "O seu espaço de trabalho está pronto a usar 🎉" -} \ No newline at end of file +} diff --git a/server/methods/getUsersOfRoom.js b/server/methods/getUsersOfRoom.js index 089733f985ca4..2f570e7b8adf0 100644 --- a/server/methods/getUsersOfRoom.js +++ b/server/methods/getUsersOfRoom.js @@ -1,54 +1,30 @@ import { Meteor } from 'meteor/meteor'; -import s from 'underscore.string'; -import { Subscriptions } from '../../app/models'; +import { Subscriptions, Users } from '../../app/models/server'; import { hasPermission } from '../../app/authorization'; import { settings } from '../../app/settings'; function findUsers({ rid, status, skip, limit, filter = '' }) { - const regex = new RegExp(s.trim(s.escapeRegExp(filter)), 'i'); - return Subscriptions.model.rawCollection().aggregate([ - { - $match: { rid }, + const options = { + fields: { + name: 1, + username: 1, + nickname: 1, + status: 1, + avatarETag: 1, }, - { - $lookup: - { - from: 'users', - localField: 'u._id', - foreignField: '_id', - as: 'u', - }, + sort: { + statusConnection: -1, + [settings.get('UI_Use_Real_Name') ? 'name' : 'username']: 1, }, - { - $project: { - 'u._id': 1, - 'u.name': 1, - 'u.username': 1, - 'u.nickname': 1, - 'u.status': 1, - 'u.avatarETag': 1, - }, - }, - ...status ? [{ $match: { 'u.status': status } }] : [], - ...filter.trim() ? [{ $match: { $or: [{ 'u.name': regex }, { 'u.username': regex }, { 'u.nickname': regex }] } }] : [], - { - $sort: { - [settings.get('UI_Use_Real_Name') ? 'u.name' : 'u.username']: 1, - }, - }, - ...skip > 0 ? [{ $skip: skip }] : [], - ...limit > 0 ? [{ $limit: limit }] : [], - { - $project: { - _id: { $arrayElemAt: ['$u._id', 0] }, - name: { $arrayElemAt: ['$u.name', 0] }, - username: { $arrayElemAt: ['$u.username', 0] }, - nickname: { $arrayElemAt: ['$u.nickname', 0] }, - avatarETag: { $arrayElemAt: ['$u.avatarETag', 0] }, - }, - }, - ]).toArray(); + ...skip > 0 && { skip }, + ...limit > 0 && { limit }, + }; + + return Users.findByActiveUsersExcept(filter, undefined, options, undefined, [{ + __rooms: rid, + ...status && { status }, + }]).fetch(); } Meteor.methods({ @@ -68,21 +44,8 @@ Meteor.methods({ } const total = Subscriptions.findByRoomIdWhenUsernameExists(rid).count(); - const users = await findUsers({ rid, status: { $ne: 'offline' }, limit, skip, filter }); - if (showAll && (!limit || users.length < limit)) { - const offlineUsers = await findUsers({ - rid, - status: { $eq: 'offline' }, - limit: limit ? limit - users.length : 0, - skip: skip || 0, - filter, - }); - return { - total, - records: users.concat(offlineUsers), - }; - } + const users = await findUsers({ rid, status: !showAll ? { $ne: 'offline' } : undefined, limit, skip, filter }); return { total, diff --git a/server/publications/spotlight.js b/server/publications/spotlight.js index 9fe9ba87fe3ce..fdcb4b559c523 100644 --- a/server/publications/spotlight.js +++ b/server/publications/spotlight.js @@ -2,10 +2,10 @@ import { Meteor } from 'meteor/meteor'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import s from 'underscore.string'; -import { hasPermission } from '../../app/authorization'; -import { Users, Subscriptions, Rooms } from '../../app/models'; -import { settings } from '../../app/settings'; -import { roomTypes } from '../../app/utils'; +import { hasAllPermission, hasPermission, canAccessRoom } from '../../app/authorization/server'; +import { Users, Subscriptions, Rooms } from '../../app/models/server'; +import { settings } from '../../app/settings/server'; +import { roomTypes } from '../../app/utils/server'; function fetchRooms(userId, rooms) { if (!settings.get('Store_Last_Message') || hasPermission(userId, 'preview-c-room')) { @@ -18,99 +18,213 @@ function fetchRooms(userId, rooms) { }); } +function searchRooms({ userId, text }) { + const regex = new RegExp(s.trim(s.escapeRegExp(text)), 'i'); + + const roomOptions = { + limit: 5, + fields: { + t: 1, + name: 1, + joinCodeRequired: 1, + lastMessage: 1, + }, + sort: { + name: 1, + }, + }; + + if (userId == null) { + if (!settings.get('Accounts_AllowAnonymousRead') === true) { + return []; + } + + return fetchRooms(userId, Rooms.findByNameAndTypeNotDefault(regex, 'c', roomOptions).fetch()); + } + + if (hasAllPermission(userId, ['view-outside-room', 'view-c-room'])) { + const searchableRoomTypes = Object.entries(roomTypes.roomTypes) + .filter((roomType) => roomType[1].includeInRoomSearch()) + .map((roomType) => roomType[0]); + + const roomIds = Subscriptions.findByUserIdAndTypes(userId, searchableRoomTypes, { fields: { rid: 1 } }).fetch().map((s) => s.rid); + const exactRoom = Rooms.findOneByNameAndType(text, searchableRoomTypes, roomOptions); + if (exactRoom) { + roomIds.push(exactRoom.rid); + } + + return fetchRooms(userId, Rooms.findByNameAndTypesNotInIds(regex, searchableRoomTypes, roomIds, roomOptions).fetch()); + } + + return []; +} + +function mapOutsiders(u) { + u.outside = true; + return u; +} + +function processLimitAndUsernames(options, usernames, users) { + // Reduce the results from the limit for the next query + options.limit -= users.length; + + // If the limit was reached, return + if (options.limit <= 0) { + return users; + } + + // Prevent the next query to get the same users + usernames.push(...users.map((u) => u.username).filter((u) => !usernames.includes(u))); +} + +function _searchInsiderUsers({ rid, text, usernames, options, users, insiderExtraQuery, match = { startsWith: false, endsWith: false } }) { + // Get insiders first + if (rid) { + users.push(...Users.findByActiveUsersExcept(text, usernames, options, undefined, insiderExtraQuery, match).fetch()); + + // If the limit was reached, return + if (processLimitAndUsernames(options, usernames, users)) { + return users; + } + } +} + +function _searchOutsiderUsers({ text, usernames, options, users, canListOutsiders, match = { startsWith: false, endsWith: false } }) { + // Then get the outsiders if allowed + if (canListOutsiders) { + users.push(...Users.findByActiveUsersExcept(text, usernames, options, undefined, undefined, match).fetch().map(mapOutsiders)); + + // If the limit was reached, return + if (processLimitAndUsernames(options, usernames, users)) { + return users; + } + } +} + +function searchUsers({ userId, rid, text, usernames }) { + const users = []; + + const options = { + limit: settings.get('Number_of_users_autocomplete_suggestions'), + fields: { + username: 1, + nickname: 1, + name: 1, + status: 1, + statusText: 1, + avatarETag: 1, + }, + sort: { + [settings.get('UI_Use_Real_Name') ? 'name' : 'username']: 1, + }, + }; + + const room = Rooms.findOneById(rid, { fields: { _id: 1, t: 1, uids: 1 } }); + + if (rid && !room) { + return users; + } + + const canListOutsiders = hasAllPermission(userId, 'view-outside-room', 'view-d-room'); + const canListInsiders = canListOutsiders || (rid && canAccessRoom(room, { _id: userId })); + + // If can't list outsiders and, wither, the rid was not passed or the user has no access to the room, return + if (!canListOutsiders && !canListInsiders) { + return users; + } + + const insiderExtraQuery = []; + + if (rid) { + switch (room.t) { + case 'd': + insiderExtraQuery.push({ + _id: { $in: room.uids.filter((id) => id !== userId) }, + }); + break; + case 'l': + insiderExtraQuery.push({ + _id: { $in: Subscriptions.findByRoomId(room._id).fetch().map((s) => s.u?._id).filter((id) => id && id !== userId) }, + }); + break; + default: + insiderExtraQuery.push({ + __rooms: rid, + }); + break; + } + } + + const searchParams = { rid, text, usernames, options, users, canListOutsiders, insiderExtraQuery }; + + // Exact match for username only + if (rid) { + const exactMatch = Users.findOneByUsernameAndRoomIgnoringCase(text, rid, { fields: options.fields }); + if (exactMatch) { + users.push(exactMatch); + processLimitAndUsernames(options, usernames, users); + } + } + + if (users.length === 0 && canListOutsiders) { + const exactMatch = Users.findOneByUsernameIgnoringCase(text, { fields: options.fields }); + if (exactMatch) { + users.push(exactMatch); + processLimitAndUsernames(options, usernames, users); + } + } + + // Exact match for insiders + // if (_searchInsiderUsers({ ...searchParams, match: { startsWith: true, endsWith: true } })) { + // return users; + // } + + // Exact match for outsiders + // if (_searchOutsiderUsers({ ...searchParams, match: { startsWith: true, endsWith: true } })) { + // return users; + // } + + // Starts with for insiders + // if (_searchInsiderUsers({ ...searchParams, match: { startsWith: true } })) { + // return users; + // } + + // Contains for insiders + if (_searchInsiderUsers(searchParams)) { + return users; + } + + // Starts with for outsiders + // if (_searchOutsiderUsers({ ...searchParams, match: { startsWith: true } })) { + // return users; + // } + + // Contains for outsiders + if (_searchOutsiderUsers(searchParams)) { + return users; + } + + return users; +} + Meteor.methods({ spotlight(text, usernames = [], type = { users: true, rooms: true }, rid) { - const searchForChannels = text[0] === '#'; - const searchForDMs = text[0] === '@'; - if (searchForChannels) { + if (text[0] === '#') { type.users = false; text = text.slice(1); } - if (searchForDMs) { + + if (text[0] === '@') { type.rooms = false; text = text.slice(1); } - const regex = new RegExp(s.trim(s.escapeRegExp(text)), 'i'); - const result = { - users: [], - rooms: [], - }; - const roomOptions = { - limit: 5, - fields: { - t: 1, - name: 1, - joinCodeRequired: 1, - lastMessage: 1, - }, - sort: { - name: 1, - }, - }; - const { userId } = this; - if (userId == null) { - if (settings.get('Accounts_AllowAnonymousRead') === true) { - result.rooms = fetchRooms(userId, Rooms.findByNameAndTypeNotDefault(regex, 'c', roomOptions).fetch()); - } - return result; - } - const userOptions = { - limit: 5, - fields: { - username: 1, - nickname: 1, - name: 1, - status: 1, - statusText: 1, - avatarETag: 1, - }, - sort: {}, - }; - if (settings.get('UI_Use_Real_Name')) { - userOptions.sort.name = 1; - } else { - userOptions.sort.username = 1; - } - if (hasPermission(userId, 'view-outside-room')) { - if (type.users === true && hasPermission(userId, 'view-d-room')) { - const exactUser = Users.findOneByUsernameIgnoringCase(text, userOptions); - if (exactUser && !usernames.includes(exactUser.username)) { - result.users.push(exactUser); - usernames.push(exactUser.username); - } - result.users = result.users.concat(Users.findByActiveUsersExcept(text, usernames, userOptions).fetch()); - } - - if (type.rooms === true && hasPermission(userId, 'view-c-room')) { - const searchableRoomTypes = Object.entries(roomTypes.roomTypes) - .filter((roomType) => roomType[1].includeInRoomSearch()) - .map((roomType) => roomType[0]); - - const roomIds = Subscriptions.findByUserIdAndTypes(userId, searchableRoomTypes, { fields: { rid: 1 } }).fetch().map((s) => s.rid); - const exactRoom = Rooms.findOneByNameAndType(text, searchableRoomTypes, roomOptions); - if (exactRoom) { - result.exactRoom.push(exactRoom); - roomIds.push(exactRoom.rid); - } - - result.rooms = result.rooms.concat(fetchRooms(userId, Rooms.findByNameAndTypesNotInIds(regex, searchableRoomTypes, roomIds, roomOptions).fetch())); - } - } else if (type.users === true && rid) { - const subscriptions = Subscriptions.find({ - rid, - 'u.username': { - $regex: regex, - $nin: [...usernames, Meteor.user().username], - }, - }, { limit: userOptions.limit }).fetch().map(({ u }) => u._id); - result.users = Users.find({ _id: { $in: subscriptions } }, { - fields: userOptions.fields, - sort: userOptions.sort, - }).fetch(); - } + const { userId } = this; - return result; + return { + users: type.users === true ? searchUsers({ userId, rid, text, usernames }) : [], + rooms: type.rooms === true ? searchRooms({ userId, text }) : [], + }; }, }); diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 5e01f1de5e87f..cbd1354d420a9 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -195,4 +195,5 @@ import './v195'; import './v196'; import './v197'; import './v198'; +import './v199'; import './xrun'; diff --git a/server/startup/migrations/v183.js b/server/startup/migrations/v183.js index df71bd061730e..2babe6168fddd 100644 --- a/server/startup/migrations/v183.js +++ b/server/startup/migrations/v183.js @@ -1,3 +1,4 @@ +import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; import { Migrations } from '../../../app/migrations'; @@ -56,11 +57,15 @@ const fixSelfDMs = () => { rid: correctId, }, }, { multi: true }); - Uploads.update({ rid: room._id }, { - $set: { - rid: correctId, - }, - }, { multi: true }); + + // Fix error of upload permission check using Meteor.userId() + Meteor.runAsUser(room.uids[0], () => { + Uploads.update({ rid: room._id }, { + $set: { + rid: correctId, + }, + }, { multi: true }); + }); }); }; diff --git a/server/startup/migrations/v199.js b/server/startup/migrations/v199.js new file mode 100644 index 0000000000000..bd3d111216d02 --- /dev/null +++ b/server/startup/migrations/v199.js @@ -0,0 +1,73 @@ +import { Meteor } from 'meteor/meteor'; +import Future from 'fibers/future'; + +import { Users, Subscriptions } from '../../../app/models/server/raw'; +import { Migrations } from '../../../app/migrations/server'; + +const batchSize = 5000; + +async function migrateUserRecords(total, current) { + console.log(`Migrating ${ current }/${ total }`); + + const items = await Users.find({ __rooms: { $exists: false } }, { projection: { _id: 1 } }).limit(batchSize).toArray(); + + const actions = []; + + for await (const user of items) { + const rooms = await Subscriptions.find({ + 'u._id': user._id, + t: { $nin: ['d', 'l'] }, + }, { projection: { rid: 1 } }).toArray(); + + actions.push({ + updateOne: { + filter: { _id: user._id }, + update: { + $set: { + __rooms: rooms.map(({ rid }) => rid), + }, + }, + }, + }); + } + + if (actions.length === 0) { + return; + } + + const batch = await Users.col.bulkWrite(actions, { ordered: false }); + if (actions.length === batchSize) { + await batch; + return migrateUserRecords(total, current + batchSize); + } + + return batch; +} + +Migrations.add({ + version: 199, + up() { + const fut = new Future(); + + console.log('Changing schema of User records, this may take a long time ...'); + + Meteor.setTimeout(async () => { + const users = Users.find({ __rooms: { $exists: false } }, { projection: { _id: 1 } }); + const total = await users.count(); + await users.close(); + + if (total < batchSize * 10) { + await migrateUserRecords(total, 0); + return fut.return(); + } + + await migrateUserRecords(total, 0); + + fut.return(); + }, 250); + + fut.wait(); + + console.log('Changing schema of User records finished.'); + }, +});