Chat optimization (#15545)

* fix(content): textual tweaks and updates

* fix(link): direct to FAQ instead of wiki

* fix(faq): correct Markdown

* Show orb of rebirth confirmation modal after use (window refresh)

* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* message effective limit optimization

* Keep max limit for web (400 recent messages)

* Fix amount of messages initially being shown

* PM_PER_PAGE set to 50

* Increases number of messages in inbox test

* Increases number of messages for inbox pagination test

* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* message effective limit optimization

* Keep max limit for web (400 recent messages)

* Add UUID validation for 'before' query parameter

* add party message stress test tool in admin panel

* lint

* add MAX_PM_COUNT of 400, admin tool for stress testing messages

* comment

* update stress test inbox message tool to use logged in user

* comment

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
This commit is contained in:
Fiz
2025-12-05 16:12:23 -06:00
committed by GitHub
parent 55d13e44d4
commit 2917955ef0
10 changed files with 186 additions and 17 deletions
+6 -1
View File
@@ -64,6 +64,8 @@ function textContainsBannedSlur (message) {
*
* @apiParam (Path) {String} groupId The group _id ('party' for the user party and
* 'habitrpg' for tavern are accepted).
* @apiParam (Query) {Number} [limit=50] The number of messages to fetch (max 400).
* @apiParam (Query) {String} [before] Fetch messages older than this message ID.
*
* @apiSuccess {Array} data An array of <a href='https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js#L51' target='_blank'>chat messages</a>
*
@@ -78,18 +80,21 @@ api.getChat = {
const { user } = res.locals;
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
req.checkQuery('before').optional().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { groupId } = req.params;
const limit = req.query.limit ? Math.min(parseInt(req.query.limit, 10), 400) : 50;
const { before } = req.query;
const group = await Group.getGroup({ user, groupId, fields: 'chat privacy' });
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.privacy === 'public') {
throw new BadRequest(res.t('featureRetired'));
}
const groupChat = await Group.toJSONCleanChat(group, user);
const groupChat = await Group.toJSONCleanChat(group, user, { limit, before });
res.respond(200, groupChat.chat);
},
};
@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
import get from 'lodash/get';
import sinon from 'sinon';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
import { authWithHeaders } from '../../middlewares/auth';
import ensureDevelopmentMode from '../../middlewares/ensureDevelopmentMode';
import ensureTimeTravelMode from '../../middlewares/ensureTimeTravelMode';
@@ -11,6 +12,7 @@ import {
model as Group,
// basicFields as basicGroupFields,
} from '../../models/group';
import { chatModel as Chat, inboxModel as Inbox } from '../../models/message';
import connectToMongoDB from '../../libs/mongoose';
const { content } = common;
@@ -311,4 +313,93 @@ api.timeTravelAdjust = {
},
};
api.seedPartyChat = {
method: 'POST',
url: '/debug/seed-party-chat',
middlewares: [ensureDevelopmentMode, authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
const messageCount = Number(req.body.messageCount);
if (!Number.isInteger(messageCount) || messageCount < 1) {
throw new BadRequest('messageCount must be a positive integer.');
}
if (!user.party._id) {
throw new BadRequest('You are not in a party.');
}
const party = await Group.findOne({ _id: user.party._id, type: 'party' }).exec();
if (!party) {
throw new BadRequest('Party not found.');
}
const messages = [];
const baseTimestamp = Date.now();
for (let i = 1; i <= messageCount; i += 1) {
const id = uuid();
messages.push({
_id: id,
id,
groupId: party._id,
text: `#${i}`,
unformattedText: `#${i}`,
timestamp: new Date(baseTimestamp - (messageCount - i) * 1000),
likes: {},
flags: {},
flagCount: 0,
uuid: 'system',
user: 'System',
client: 'debug-seed',
});
}
await Chat.insertMany(messages);
res.respond(200, { messageCount });
},
};
// Messaging ourselves for testing
api.seedInbox = {
method: 'POST',
url: '/debug/seed-inbox',
middlewares: [ensureDevelopmentMode, authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
const messageCount = Number(req.body.messageCount);
if (!Number.isInteger(messageCount) || messageCount < 1) {
throw new BadRequest('messageCount must be a positive integer.');
}
const messages = [];
const baseTimestamp = Date.now();
for (let i = 1; i <= messageCount; i += 1) {
const id = uuid();
messages.push({
_id: id,
id,
ownerId: user._id,
uuid: user._id,
user: user.profile.name,
text: `#${i}`,
unformattedText: `#${i}`,
timestamp: new Date(baseTimestamp - (messageCount - i) * 1000),
likes: {},
flags: {},
flagCount: 0,
sent: true,
client: 'debug-seed',
});
}
await Inbox.insertMany(messages);
res.respond(200, { messageCount });
},
};
export default api;
+16 -5
View File
@@ -9,7 +9,9 @@ import { // eslint-disable-line import/no-cycle
const questScrolls = shared.content.quests;
// @TODO: Don't use this method when the group can be saved.
export async function getGroupChat (group) {
export async function getGroupChat (group, options = {}) {
const { limit, before } = options;
let maxChatCount = MAX_CHAT_COUNT;
if (group.chatLimitCount && group.chatLimitCount >= MAX_CHAT_COUNT) {
maxChatCount = group.chatLimitCount;
@@ -17,10 +19,19 @@ export async function getGroupChat (group) {
maxChatCount = MAX_SUBBED_GROUP_CHAT_COUNT;
}
const groupChat = await Chat.find({ groupId: group._id })
.limit(maxChatCount)
.sort('-timestamp')
.exec();
const effectiveLimit = limit !== undefined ? Math.min(limit, maxChatCount) : maxChatCount;
let query = Chat.find({ groupId: group._id })
.sort('-timestamp');
if (before) {
const beforeMessage = await Chat.findOne({ _id: before }).exec();
if (beforeMessage) {
query = query.where('timestamp').lt(beforeMessage.timestamp);
}
}
const groupChat = await query.limit(effectiveLimit).exec();
// @TODO: Concat old chat to keep continuity of chat stored on group object
const currentGroupChat = group.chat || [];
+13 -5
View File
@@ -37,7 +37,9 @@ export async function sentMessage (sender, receiver, message, translate) {
return messageSent;
}
const PM_PER_PAGE = 10;
// Paginate per every 50
const PM_PER_PAGE = 50;
const MAX_PM_COUNT = 400;
const getUserInboxDefaultOptions = {
asArray: true,
@@ -61,12 +63,18 @@ export async function getUserInbox (user, optionParams = getUserInboxDefaultOpti
.sort({ timestamp: -1 });
if (typeof options.page !== 'undefined') {
const page = Number(options.page);
const skip = PM_PER_PAGE * page;
if (skip >= MAX_PM_COUNT) {
return options.asArray ? [] : {};
}
const remainingAllowed = MAX_PM_COUNT - skip;
const limit = Math.min(PM_PER_PAGE, remainingAllowed);
query = query
.skip(PM_PER_PAGE * Number(options.page))
.limit(PM_PER_PAGE);
.skip(skip)
.limit(limit);
} else {
// Limit for legacy calls that are not paginated to prevent database issues
query = query.limit(200);
query = query.limit(MAX_PM_COUNT);
}
const messages = (await query.lean().exec()).map(msgObj => {
+2 -2
View File
@@ -345,12 +345,12 @@ schema.statics.getGroups = async function getGroups (options = {}) {
// unless the user is an admin or said chat is posted by that user
// Not putting into toJSON because there we can't access user
// It also removes the _meta field that can be stored inside a chat message
schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, user) {
schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, user, options = {}) {
// @TODO: Adding this here for support the old chat,
// but we should depreciate accessing chat like this
// Also only return chat if requested, eventually we don't want to return chat here
if (group && group.chat) {
await getGroupChat(group);
await getGroupChat(group, options);
}
const groupToJson = group.toJSON();