feat: allow configuring unreadCutoff per user, closes #6811

This commit is contained in:
Barış Soner Uşaklı
2026-02-16 22:57:00 -05:00
parent 3c08b7303b
commit 8c6ce198e1
9 changed files with 80 additions and 47 deletions

View File

@@ -37,6 +37,7 @@
"folder-exists": "Folder exists",
"invalid-pagination-value": "Invalid pagination value, must be at least %1 and at most %2",
"invalid-unread-cutoff": "Invalid unread cutoff value, must be at least 1 and at most %1",
"username-taken": "Username taken",
"email-taken": "Email address is already taken.",

View File

@@ -111,7 +111,7 @@
"show-email": "Show My Email",
"show-fullname": "Show My Full Name",
"restrict-chats": "Only allow chat messages from users I follow",
"disable-incoming-chats": "Disable incoming chat messages <a data-bs-toggle=\"tooltip\" href=\"#\" title=\"Admins and moderators can still send you messages\"><i class=\"fa-solid fa-circle-info\"></i></a>",
"disable-incoming-chats": "Disable incoming chat messages <a class=\"text-reset\" data-bs-toggle=\"tooltip\" href=\"#\" title=\"Admins and moderators can still send you messages\"><i class=\"fa-solid fa-circle-info\"></i></a>",
"chat-allow-list": "Allow chat messages from the following users",
"chat-deny-list": "Deny chat messages from the following users",
"chat-list-add-user": "Add user",
@@ -141,8 +141,8 @@
"hidden": "hidden",
"paginate-description" : "Paginate topics and posts instead of using infinite scroll",
"topics-per-page": "Topics per Page",
"posts-per-page": "Posts per Page",
"topics-per-page": "Topics per page",
"posts-per-page": "Posts per page",
"category-topic-sort": "Category topic sort",
"topic-post-sort": "Topic post sort",
"max-items-per-page": "Maximum %1",
@@ -157,6 +157,8 @@
"upvote-notif-freq.disabled": "Disabled",
"browsing": "Browsing Settings",
"unread.cutoff": "Unread cutoff (Maximum %1 days)",
"unread.cutoff-help": "Topics will be marked read if they have not been updated within this number of days.",
"open-links-in-new-tab": "Open outgoing links in new tab",
"enable-topic-searching": "Enable In-Topic Searching",

View File

@@ -4,6 +4,9 @@ Settings:
showemail:
type: boolean
description: Show user email in profile page
unreadCutoff:
type: number
description: Number of days after which a topic is no longer considered unread
usePagination:
type: boolean
description: Toggles between pagination (when enabled), or infinite scrolling (when disabled)

View File

@@ -133,6 +133,8 @@ get:
type: number
maxPostsPerPage:
type: number
maxUnreadCutoff:
type: number
title:
type: string
- $ref: ../../../components/schemas/Breadcrumbs.yaml#/Breadcrumbs

View File

@@ -20,6 +20,8 @@ define('forum/account/settings', [
savedSkin = $('#bootswatchSkin').length && $('#bootswatchSkin').val();
header.init();
$('[data-bs-toggle]').tooltip();
$('#submitBtn').on('click', function () {
const settings = loadSettings();

View File

@@ -132,6 +132,7 @@ usersAPI.updateSettings = async function (caller, data) {
let defaults = await user.getSettings(0);
defaults = {
unreadCutoff: defaults.unreadCutoff,
postsPerPage: defaults.postsPerPage,
topicsPerPage: defaults.topicsPerPage,
userLang: defaults.userLang,

View File

@@ -96,6 +96,7 @@ settingsController.get = async function (req, res, next) {
userData.maxTopicsPerPage = meta.config.maxTopicsPerPage;
userData.maxPostsPerPage = meta.config.maxPostsPerPage;
userData.maxUnreadCutoff = Math.max(meta.config.unreadCutoff, 14);
userData.title = '[[pages:account/settings]]';
userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[user:settings]]' }]);

View File

@@ -11,7 +11,6 @@ const posts = require('../posts');
const notifications = require('../notifications');
const categories = require('../categories');
const privileges = require('../privileges');
const meta = require('../meta');
const utils = require('../utils');
const plugins = require('../plugins');
@@ -48,8 +47,9 @@ module.exports = function (Topics) {
};
Topics.unreadCutoff = async function (uid) {
const cutoff = Date.now() - (meta.config.unreadCutoff * 86400000);
const data = await plugins.hooks.fire('filter:topics.unreadCutoff', { uid: uid, cutoff: cutoff });
const { unreadCutoff } = await user.getSettings(uid);
const cutoff = Date.now() - (unreadCutoff * 86400000);
const data = await plugins.hooks.fire('filter:topics.unreadCutoff', { uid, cutoff });
return parseInt(data.cutoff, 10);
};

View File

@@ -48,7 +48,7 @@ module.exports = function (User) {
};
async function onSettingsLoaded(uid, settings) {
const data = await plugins.hooks.fire('filter:user.getSettings', { uid: uid, settings: settings });
const data = await plugins.hooks.fire('filter:user.getSettings', { uid, settings });
settings = data.settings;
const defaultTopicsPerPage = meta.config.topicsPerPage;
@@ -59,6 +59,12 @@ module.exports = function (User) {
settings.openOutgoingLinksInNewTab = parseInt(getSetting(settings, 'openOutgoingLinksInNewTab', 0), 10) === 1;
settings.dailyDigestFreq = getSetting(settings, 'dailyDigestFreq', 'off');
settings.usePagination = parseInt(getSetting(settings, 'usePagination', 0), 10) === 1;
settings.unreadCutoff = Math.max(1, Math.min(
Math.max(meta.config.unreadCutoff, 14),
settings.unreadCutoff ?
parseInt(settings.unreadCutoff, 10) :
meta.config.unreadCutoff,
));
settings.topicsPerPage = Math.max(1, Math.min(
meta.config.maxTopicsPerPage,
settings.topicsPerPage ?
@@ -116,6 +122,60 @@ module.exports = function (User) {
}
User.saveSettings = async function (uid, data) {
await validateSettings(data);
data.userLang = data.userLang || meta.config.defaultLang;
plugins.hooks.fire('action:user.saveSettings', { uid: uid, settings: data });
const settings = {
showemail: data.showemail,
showfullname: data.showfullname,
unreadCutoff: data.unreadCutoff,
openOutgoingLinksInNewTab: data.openOutgoingLinksInNewTab,
dailyDigestFreq: data.dailyDigestFreq || 'off',
usePagination: data.usePagination,
topicsPerPage: data.topicsPerPage,
postsPerPage: data.postsPerPage,
userLang: data.userLang,
acpLang: data.acpLang || meta.config.defaultLang,
followTopicsOnCreate: data.followTopicsOnCreate,
followTopicsOnReply: data.followTopicsOnReply,
disableIncomingChats: data.disableIncomingChats,
topicSearchEnabled: data.topicSearchEnabled,
updateUrlWithPostIndex: data.updateUrlWithPostIndex,
homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''),
scrollToMyPost: data.scrollToMyPost,
upvoteNotifFreq: data.upvoteNotifFreq,
bootswatchSkin: data.bootswatchSkin,
categoryWatchState: data.categoryWatchState,
categoryTopicSort: data.categoryTopicSort,
topicPostSort: data.topicPostSort,
chatAllowList: data.chatAllowList,
chatDenyList: data.chatDenyList,
};
const notificationTypes = await notifications.getAllNotificationTypes();
notificationTypes.forEach((notificationType) => {
if (data[notificationType]) {
settings[notificationType] = data[notificationType];
}
});
const result = await plugins.hooks.fire('filter:user.saveSettings', { uid, settings, data });
await db.setObject(`user:${uid}:settings`, result.settings);
await User.updateDigestSetting(uid, data.dailyDigestFreq);
return await User.getSettings(uid);
};
async function validateSettings(data) {
const maxUnreadCutoff = Math.max(meta.config.unreadCutoff, 14);
if (
!data.unreadCutoff ||
parseInt(data.unreadCutoff, 10) <= 0 ||
parseInt(data.unreadCutoff, 10) > maxUnreadCutoff
) {
throw new Error(`[[error:invalid-unread-cutoff, ${maxUnreadCutoff}]]`);
}
const maxPostsPerPage = meta.config.maxPostsPerPage || 20;
if (
!data.postsPerPage ||
@@ -141,46 +201,7 @@ module.exports = function (User) {
if (data.acpLang && !languageCodes.includes(data.acpLang)) {
throw new Error('[[error:invalid-language]]');
}
data.userLang = data.userLang || meta.config.defaultLang;
plugins.hooks.fire('action:user.saveSettings', { uid: uid, settings: data });
const settings = {
showemail: data.showemail,
showfullname: data.showfullname,
openOutgoingLinksInNewTab: data.openOutgoingLinksInNewTab,
dailyDigestFreq: data.dailyDigestFreq || 'off',
usePagination: data.usePagination,
topicsPerPage: Math.min(data.topicsPerPage, parseInt(maxTopicsPerPage, 10) || 20),
postsPerPage: Math.min(data.postsPerPage, parseInt(maxPostsPerPage, 10) || 20),
userLang: data.userLang || meta.config.defaultLang,
acpLang: data.acpLang || meta.config.defaultLang,
followTopicsOnCreate: data.followTopicsOnCreate,
followTopicsOnReply: data.followTopicsOnReply,
disableIncomingChats: data.disableIncomingChats,
topicSearchEnabled: data.topicSearchEnabled,
updateUrlWithPostIndex: data.updateUrlWithPostIndex,
homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''),
scrollToMyPost: data.scrollToMyPost,
upvoteNotifFreq: data.upvoteNotifFreq,
bootswatchSkin: data.bootswatchSkin,
categoryWatchState: data.categoryWatchState,
categoryTopicSort: data.categoryTopicSort,
topicPostSort: data.topicPostSort,
chatAllowList: data.chatAllowList,
chatDenyList: data.chatDenyList,
};
const notificationTypes = await notifications.getAllNotificationTypes();
notificationTypes.forEach((notificationType) => {
if (data[notificationType]) {
settings[notificationType] = data[notificationType];
}
});
const result = await plugins.hooks.fire('filter:user.saveSettings', { uid: uid, settings: settings, data: data });
await db.setObject(`user:${uid}:settings`, result.settings);
await User.updateDigestSetting(uid, data.dailyDigestFreq);
return await User.getSettings(uid);
};
}
User.updateDigestSetting = async function (uid, dailyDigestFreq) {
await db.sortedSetsRemove(['digest:day:uids', 'digest:week:uids', 'digest:month:uids'], uid);