Compare commits

...

4 Commits

Author SHA1 Message Date
Kalista Payne
8bf2304330 chore(event): G1G1 date tweaks 2025-12-10 14:15:48 -06:00
Kalista Payne
6937dc4e4e fix(subscription): couple more layout tweaks 2025-12-08 16:37:04 -06:00
Fiz
2917955ef0 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>
2025-12-05 16:12:23 -06:00
Kalista Payne
55d13e44d4 fix(subs): strings and alignments 2025-12-03 17:12:08 -06:00
14 changed files with 200 additions and 21 deletions

View File

@@ -47,7 +47,7 @@ describe('GET /inbox/messages', () => {
it('returns four messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 10; i += 1) {
for (let i = 0; i < 50; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',

View File

@@ -66,7 +66,7 @@ describe('GET /inbox/conversations', () => {
it('returns five messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 10; i += 1) {
for (let i = 0; i < 50; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',

View File

@@ -396,6 +396,32 @@
class="btn btn-secondary"
@click="makeAdmin()"
>Make Admin</a>
<div class="d-flex align-items-center mt-2">
<input
v-model.number="partyChatCount"
type="number"
min="1"
class="form-control form-control-sm mr-2"
style="width: 80px;"
>
<a
class="btn btn-secondary"
@click="seedPartyChat()"
>Send Party Chat Messages</a>
</div>
<div class="d-flex align-items-center mt-2">
<input
v-model.number="inboxCount"
type="number"
min="1"
class="form-control form-control-sm mr-2"
style="width: 80px;"
>
<a
class="btn btn-secondary"
@click="seedInbox()"
>Send Inbox Messages</a>
</div>
</div>
</div>
</div>
@@ -886,6 +912,8 @@ export default {
DEBUG_ENABLED,
TIME_TRAVEL_ENABLED,
lastTimeJump: null,
partyChatCount: 450,
inboxCount: 450,
};
},
computed: {
@@ -1004,6 +1032,32 @@ export default {
// Reload the website then go to Help > Admin Panel to set contributor level, etc.');
// @TODO: sync()
},
async seedPartyChat () {
try {
const count = this.partyChatCount;
if (!Number.isInteger(count) || count < 1) {
window.alert('Please enter a positive integer'); // eslint-disable-line no-alert
return;
}
await axios.post('/api/v4/debug/seed-party-chat', { messageCount: count });
window.alert(`Successfully sent ${count} messages to your party chat!`); // eslint-disable-line no-alert
} catch (e) {
window.alert(e.response?.data?.message || 'Error sending party chat messages'); // eslint-disable-line no-alert
}
},
async seedInbox () {
try {
const count = this.inboxCount;
if (!Number.isInteger(count) || count < 1) {
window.alert('Please enter a positive integer'); // eslint-disable-line no-alert
return;
}
await axios.post('/api/v4/debug/seed-inbox', { messageCount: count });
window.alert(`Successfully sent ${count} messages to your inbox!`); // eslint-disable-line no-alert
} catch (e) {
window.alert(e.response?.data?.message || 'Error sending inbox messages'); // eslint-disable-line no-alert
}
},
donate () {
this.$root.$emit('bv::show::modal', 'buy-gems', { alreadyTracked: true });
},

View File

@@ -52,7 +52,7 @@
<div
v-if="!group.purchased.plan.dateTerminated
&& group.purchased.plan.paymentMethod === 'Stripe'"
class="btn btn-primary"
class="btn btn-primary mb-3"
@click="redirectToStripeEdit({groupId: group.id})"
>
{{ $t('subUpdateCard') }}

View File

@@ -189,6 +189,7 @@
>
</p>
<div
v-if="paymentMethodLogo.icon"
class="svg svg-icon mb-4"
:class="paymentMethodLogo.class"
v-html="paymentMethodLogo.icon"
@@ -205,6 +206,13 @@
<div>{{ $t('subUpdateCard') }}</div>
</button>
</div>
<div
v-once
v-if="!hasGroupPlan"
class="small text-center mb-4"
>
{{ $t('subscriptionBillingFYIShort') }}
</div>
<div
v-if="purchasedPlanExtraMonthsDetails.months > 0"
class="extra-months green-10 py-2 px-3 mb-4"
@@ -409,6 +417,7 @@
<div class="d-flex flex-column align-items-center mt-3">
<div
v-once
v-if="!hasSubscription"
class="small gray-100 w-50 text-center mb-5"
>
{{ $t('subscriptionBillingFYI') }}

View File

@@ -679,7 +679,7 @@ import NotificationMixins from '@/mixins/notifications';
// extract to a shared path
const CONVERSATIONS_PER_PAGE = 10;
const PM_PER_PAGE = 10;
const PM_PER_PAGE = 50;
const UI_STATES = Object.freeze({
LOADING: 'LOADING',

View File

@@ -3,7 +3,7 @@ import Vue from 'vue';
import * as Analytics from '@/libs/analytics';
export async function getChat (store, payload) {
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat`);
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat?limit=400`);
return response.data.data;
}

View File

@@ -273,5 +273,6 @@
"earn2GemsGift": "They'll earn <strong>+2 Gems</strong> every month they're subscribed",
"maxGemCapGift": "They'll have the max <strong>Gem Cap</strong>",
"subscribeAgainContinueHourglasses": "Subscribe again to continue receiving Mystic Hourglasses",
"subscriptionBillingFYI": "Subscriptions automatically renew unless you cancel at least 24 hours before the end of the current period. You can manage your subscription from the Subscription tab in settings. Your account will be charged within 24 hours of your renewal date, at the same price you initially paid."
"subscriptionBillingFYI": "Subscriptions automatically renew unless you cancel at least 24 hours before the end of the current period. You can manage your subscription from the Subscription tab in settings. Your account will be charged within 24 hours of your renewal date, at the same price you initially paid.",
"subscriptionBillingFYIShort": "Subscriptions automatically renew unless you cancel at least 24 hours before the end of the current period. Your account will be charged within 24 hours of your renewal date, at the same price you initially paid."
}

View File

@@ -109,8 +109,8 @@ export const REPEATING_EVENTS = {
foodSeason: 'Pie',
},
giftOneGetOne: {
start: new Date('1970-12-18T04:00-05:00'),
end: new Date('1970-01-05T23:59-05:00'),
start: new Date('1970-12-16T04:00-05:00'),
end: new Date('1970-01-09T23:59-05:00'),
promo: 'g1g1',
},
};

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

View File

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

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 || [];

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

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