diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13fce6fb89..1dec06f21f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,414 @@
+#### v4.9.0 (2026-02-27)
+
+##### Chores
+
+* up peace (cf0f2923)
+* up peace (0b3dd38b)
+* up peace (e4b15e05)
+* up peace (64a3c822)
+* up tdbsearch (50f5541e)
+* up harmony (44f663b1)
+* up harmony (fd2c9927)
+* up harmony (066470b1)
+* up harmony (bfa8d5aa)
+* up harmony (df3de36c)
+* up harmony (c1581a12)
+* up mentions (537d3676)
+* up harmony (41ef71fa)
+* up themes (38680c3e)
+* up harmony (6da00614)
+* up persona (033f9198)
+* up themes (84ec8ef9)
+* up harmony (f818dd96)
+* up themes (bac67399)
+* up harmony (dd84a303)
+* up composer (41bc49f7)
+* up themes, add hide to alert (824a9b77)
+* update harmony (b061d078)
+* up composer (d0cc1c95)
+* white space (3dfd9a43)
+* up themes (97917103)
+* up composer (1543650a)
+* up themes (47217803)
+* up composer (8423da04)
+* up themes (a84464cf)
+* up themes (bafd5db0)
+* up mentions (bc1fd892)
+* up mentions (0fd8200a)
+* up mentions (9cd87fca)
+* up mentions/composer (06f4f700)
+* up link-preview (4aac6562)
+* forcibly resetting all translations for custom-reason.json (c2695d89)
+* up harmony (d6b7f27c)
+* new fallbacks (1d17352f)
+* up harmony (72510734)
+* up harmony (2a5ab6dd)
+* up harmony (7d4a440a)
+* up markdown (86c62708)
+* up composer (3de603f6)
+* up deps (b3807656)
+* up deps (a241c624)
+* up composer (f06557b7)
+* harmony (118ceb72)
+* up harmony (f2795753)
+* up harmony (5c3f2651)
+* up composer & harmony (931ae67d)
+* up harmony (e6737941)
+* up harmony (8d6b6f6a)
+* incrementing version number - v4.8.1 (713ae0c0)
+* update changelog for v4.8.1 (f53aab43)
+* incrementing version number - v4.8.0 (3fac737a)
+* incrementing version number - v4.7.2 (cd419d8a)
+* incrementing version number - v4.7.1 (afb88805)
+* incrementing version number - v4.7.0 (e82d40f8)
+* incrementing version number - v4.6.3 (9fc5b0f3)
+* incrementing version number - v4.6.2 (f98747db)
+* incrementing version number - v4.6.1 (f47aa678)
+* incrementing version number - v4.6.0 (ee395bc5)
+* incrementing version number - v4.5.2 (ad2da639)
+* incrementing version number - v4.5.1 (69f4b61f)
+* incrementing version number - v4.5.0 (f05c5d06)
+* incrementing version number - v4.4.6 (074043ad)
+* incrementing version number - v4.4.5 (6f106923)
+* incrementing version number - v4.4.4 (d323af44)
+* incrementing version number - v4.4.3 (d354c2eb)
+* incrementing version number - v4.4.2 (55c510ae)
+* incrementing version number - v4.4.1 (5ae79b4e)
+* incrementing version number - v4.4.0 (0a75eee3)
+* incrementing version number - v4.3.2 (b92b5d80)
+* incrementing version number - v4.3.1 (308e6b9f)
+* incrementing version number - v4.3.0 (bff291db)
+* incrementing version number - v4.2.2 (17fecc24)
+* incrementing version number - v4.2.1 (852a270c)
+* incrementing version number - v4.2.0 (87581958)
+* incrementing version number - v4.1.1 (b2afbb16)
+* incrementing version number - v4.1.0 (36c80850)
+* incrementing version number - v4.0.6 (4a52fb2e)
+* incrementing version number - v4.0.5 (1792a62b)
+* incrementing version number - v4.0.4 (b1125cce)
+* incrementing version number - v4.0.3 (2b65c735)
+* incrementing version number - v4.0.2 (73fe5fcf)
+* incrementing version number - v4.0.1 (a461b758)
+* incrementing version number - v4.0.0 (c1eaee45)
+* **i18n:**
+ * fallback strings for new resources: nodebb.admin-manage-categories (a797090f)
+ * fallback strings for new resources: nodebb.notifications, nodebb.admin-menu, nodebb.admin-settings-activitypub (bde5e0b4)
+ * fallback strings for new resources: nodebb.admin-menu, nodebb.admin-settings-activitypub (b43fadd0)
+ * fallback strings for new resources: nodebb.admin-menu (7b71252e)
+ * fallback strings for new resources: nodebb.admin-dashboard (3f5dafd0)
+ * fallback strings for new resources: nodebb.admin-settings-uploads (aa4f4808)
+ * fallback strings for new resources: nodebb.notifications (c84a86e4)
+ * fallback strings for new resources: nodebb.notifications, nodebb.user (41e7c144)
+ * fallback strings for new resources: nodebb.admin-manage-users (c3315063)
+ * fallback strings for new resources: nodebb.notifications (9524359d)
+ * fallback strings for new resources: nodebb.topic (a4369b93)
+ * fallback strings for new resources: nodebb.error, nodebb.user (0b9efa85)
+ * fallback strings for new resources: nodebb.admin-manage-privileges (7f6fcd05)
+ * fallback strings for new resources: nodebb.topic (781a900c)
+ * fallback strings for new resources: nodebb.admin-advanced-cache (64dad9db)
+ * fallback strings for new resources: nodebb.admin-advanced-cache (f0fb661c)
+ * fallback strings for new resources: nodebb.admin-settings-chat, nodebb.admin-settings-notifications (756e2434)
+ * fallback strings for new resources: nodebb.groups (d133f910)
+ * fallback strings for new resources: nodebb.groups (4bccc311)
+ * fallback strings for new resources: nodebb.admin-settings-web-crawler (e4455b1c)
+ * fallback strings for new resources: nodebb.world (b527cb5a)
+ * fallback strings for new resources: nodebb.admin-manage-users, nodebb.notifications (b04d2dbc)
+ * fallback strings for new resources: nodebb.admin-manage-users (2c0a60c4)
+ * fallback strings for new resources: nodebb.category, nodebb.world (5bf6b335)
+ * fallback strings for new resources: nodebb.notifications (2a8b6d44)
+ * fallback strings for new resources: nodebb.notifications (304a2ab1)
+ * fallback strings for new resources: nodebb.notifications (3c6804d4)
+ * fallback strings for new resources: nodebb.modules, nodebb.notifications (317bcd89)
+ * fallback strings for new resources: nodebb.admin-development-info (54743724)
+ * fallback strings for new resources: nodebb.admin-settings-uploads (f8d6c4e8)
+ * fallback strings for new resources: nodebb.topic (77eef491)
+ * fallback strings for new resources: nodebb.themes-harmony (cc2772ba)
+ * fallback strings for new resources: nodebb.admin-settings-email (c26698d6)
+ * fallback strings for new resources: nodebb.error (aaa9570e)
+ * fallback strings for new resources: nodebb.topic (dce82aae)
+* **deps:**
+ * update redis docker tag to v8.6.1 (#14009) (898c3f1d)
+ * update commitlint monorepo to v20.4.2 (#14005) (1ae5041f)
+ * update dependency @stylistic/eslint-plugin to v5.9.0 (#14006) (efae106e)
+ * update dependency jsdom to v28.1.0 (#13992) (ec8e547c)
+ * update postgres docker tag to v18.2 (#13987) (25f866ca)
+ * update redis docker tag to v8.6.0 (#13976) (bb5e7118)
+ * update dependency @stylistic/eslint-plugin to v5.8.0 (#13965) (c61326df)
+ * update dependency jsdom to v28 (#13947) (b61fa426)
+ * update commitlint monorepo to v20.4.1 (#13945) (a9042602)
+ * update commitlint monorepo to v20.4.0 (#13938) (531b8374)
+ * update dependency smtp-server to v3.18.1 (#13936) (d8595d69)
+ * update dependency @stylistic/eslint-plugin to v5.7.1 (#13920) (b036034a)
+ * update dependency sass-embedded to v1.97.3 (#13921) (75a04aed)
+
+##### Documentation Changes
+
+* added new ACP routes (82b7f429)
+* add missing privileges prop to world schema (4ef9d5fa)
+* add bookmarks to postobject in /world (07f9eda9)
+* add bookmarks to postobject (9a15b571)
+* chat teasers are different (91323dce)
+* add teaser to postobject schema (0178e4fb)
+* OpenAPI schema for rules re-ordering route (072dd1ae)
+
+##### New Features
+
+* support the magic break string '[...]' in content, such that if found, outbound federation will use all content up to (and including) that string in its summary (cd357aaf)
+* add a '[...]' to the end of the generated summary when federating out articles (fep-b2b8) (bf0f5228)
+* prune topics remote cids, closes #13461 (20eb02f1)
+* change notif controls to toggles (523e3b49)
+* add invitedBy to user info page, closes #13972, closes #13997 (1dae3d22)
+* quick create on world page (5df2b8b7)
+* allow configuring unreadCutoff per user, closes #6811 (8c6ce198)
+* introduce new topics:crosspost privilege (5c35dc86)
+* add guest-cta.tpl and lang strings (9da67474)
+* track all caches created in acp (9ac507e5)
+* closes #5867, dont email if user already read notification (a55651d1)
+* allow re-ordering of auto-categorization rules (fd43368a)
+* add group tx key (45ff9f0d)
+* eslint10 (#13967) (62d88555)
+* closes #13968, add sitemap cache duration (fe35ad4f)
+* add data-field values from tpl into search dict (abcfc1a5)
+* closes https://github.com/NodeBB/NodeBB/pull/11970 (007efc21)
+* closes #13961, rename ban-reasons to custom reasons (0eaf2bee)
+* ban/mute reasons (#13960) (d086ed2c)
+* show bodyLong in notifications, closes #4767 (30541a96)
+* allow converting pasted images, closes #10352 (472a8fc1)
+* add language key for untitled topic draft (0125ab55)
+* add missing lang key for light/dark (870c6310)
+* closes #13009, add dedicated test smtp button (c8488012)
+* closes #13203, make users room owners on private chats (61662f19)
+* topic crossposts generate topic events, #13908 (0c79eaa5)
+* remote account banning, #13904 (560ad81f)
+* opportunistic backfill, #13895 (33c2de9c)
+
+##### Bug Fixes
+
+* #14003, set ACAO header on webfinger responses (415602d8)
+* restore coveralls script, update gh action to ignore failures (3fab2074)
+* change && to ; (95ea376a)
+* add `--no-fail` flag to coveralls invocation (c6ddef67)
+* dont show /register page after req.session.forceLogin is set (0ef27187)
+* escape fullname in chatWithMessage (64a072c9)
+* send guests to login page if they access uncategorized topics without local posts (b4f8e20b)
+* remove allowed check from notes.assert as it is already done downstream in topics.(post|reply), update privilege check to inherit world privs if passed-in cid is remote cid (64724629)
+* NaN on ap post delete (53b208b7)
+* guard against crash when malformed URL present in deliverees (6a0663cd)
+* remove unused ACP route (f7d87ecb)
+* remove unused ACP controller, add instance count to federation > safety (a35b7d73)
+* allow break string and summary limits to be defined and applied (29111ba7)
+* make tables responsive on relays/rules pages (0071216c)
+* don't show magic break string in regularly parsed posts, tests for ap mocks lib (b460506e)
+* remove `preview` in mocked note for now, due to lack of support (fep-b2b8) (31773694)
+* topic purge (e484899d)
+* upgrade script, dont remove while processing zset (7a23e291)
+* dont store ap tids in topics:tid (e8ef2e5c)
+* closes #14010 (003e6d07)
+* hook name (d6cf5fba)
+* add missing timestamp (68eba728)
+* gate /world behind ensureLoggedIn middleware (bcfdbf7c)
+* #14007, deny access for guests to topics in cid -1, unless a post from a local user exists (de4f016f)
+* closes #7221 restrict cropperjs box to profileImageDimension form ACP (f2bbf369)
+* category search shoudn't return results that match in the cid part (75477202)
+* favicon url (1a35131a)
+* allow passing a selected category markup (309d3003)
+* overflowing images on /world (438b4f62)
+* missing cb in world IS handler (ab62a8e4)
+* closes #14002, add max-height (9c5ffe36)
+* fallbacks and latest translations for nodebb.user (053ce073)
+* keep chat input in view after adding new messages (2f88f776)
+* #14001, regression from adjusted acceptable types list (80f61022)
+* don't publish name on generated titles (9fbdc792)
+* closes #13999, delay cache creation (42362ccf)
+* when registering through an invite, prepopulate the email field on /register/complete with the email (2015777f)
+* bump persona (a68311de)
+* bump harmony for world page changes (e76f8a60)
+* only call syncUserInboxes on post create if local uid creates post in cid -1 (45d2e628)
+* schema... not sure why I need this all of a sudden (3e2070b2)
+* minor cleanup of quick-reply args; opts.body (a9c2457f)
+* render new post in feed when posting via quick create (eb0aa6d8)
+* call syncUserInboxes asyncronously (5da35bda)
+* update quickreply.init so that it can be passed an options parameter, generate proper draft id for world page (94df9738)
+* lint, unused class (4bf0f61e)
+* increase categories sent to 1k closes #12841 (3c08b730)
+* #13993, encodeURICompoent pid since it can be AP url (b607a80a)
+* wrong wrapping of route (bb9033af)
+* dont call getInbox for /recent (1ca9841c)
+* #13990, don't blindly set `user` field on notification objects (8c8782fd)
+* unbans not triggering if user data is loaded wit 'banned' property only (0b7df274)
+* #13894, buildCategoryLabel helper checks fa-nbb-none (4b9b3648)
+* missing gap (e16c5677)
+* gate crossposting behind new topics:crosspost privilege (fe8fd9d6)
+* #13983, show only local categories in ACP privilege selector (c4411423)
+* add example value for failing schema test (292e70f7)
+* lint (1598004e)
+* bad relative path (7eb49136)
+* regression from refactor of uploadedpicture refactor (b95cd882)
+* organize rules and relays logic to separate methods (78d7130c)
+* #13969, bump mentions (4a38d67c)
+* lint (9ebd8f4f)
+* #13962, infinite scroll and pagination not working on world (9f1369a2)
+* bump themes, l10n world sort label (76fe4bdd)
+* rename translations as well (433d318f)
+* cant store URL in nconf (6cb6cf7d)
+* update tx config (1e109c2e)
+* derpy api page (aef0bd97)
+* guard against incomplete objects when building context/chain (13422bc8)
+* closes #13953, show uid pic in post queue notification (a8a1089e)
+* remote post notifs missing bodyLong (18c04d34)
+* double-ajaxify on socket connect (381334f4)
+* dont update teaser for public chats (149d649a)
+* acp graph labels, dont use indices (17bfd73e)
+* closes #8642, stricter username check (94885109)
+* if there is no hr create one (f249699d)
+* closes #13240, move the updatedTeaser to the top of the recent chat list (0a9c5d30)
+* regression with updateHistory (b7ea2767)
+* #13939, dont append / if url is empty (2dc49c82)
+* acp category selector when category has image (ef75f1ba)
+* pagination always getting set to default (eab4025b)
+* folder name (f05f8b63)
+* proper attachment generation on replies, fixed replies getting thumb attachment when it wasn't part of it (69c5f941)
+* closes #13734, set process.env.NODE_ENV early using argv (252d1d09)
+* update buildRecipents to add option to skip target creation step, update ap actors for note to not bother building targets (ad27347f)
+* notes announce cache, use cache when retrieving tid posters (eb27b964)
+* optimizations (e697d600)
+* cache detection logic in context parseItem executing earlier than needed causing false positives (f9affbad)
+* closes #12458, on socket.io reconnect (13bf64c9)
+* restrict topic backfill to logged-in users when browsing to a category (0262bb83)
+* replace attachment generation logic in notes.public (428b6e73)
+* #13900, assertion re-index (6383bb58)
+* simplicity tweak (39582cbd)
+* export sendMessage as _sendMessage for use in ap jobs lib (4bab9fb4)
+* #13892, logical flaw (8b7d350e)
+* **i18n:**
+ * global fallbacks again (955579fa)
+ * 'global' resource fallbacks (c47acec9)
+* **deps:**
+ * update dependency sanitize-html to v2.17.1 (#14004) (096e9a3e)
+ * update dependency redis to v5.11.0 (#13996) (63199ea7)
+ * update dependency rimraf to v6.1.3 (#13994) (b40f5c7b)
+ * update dependency qs to v6.15.0 (#13995) (5f82e56b)
+ * update dependency sortablejs to v1.15.7 (#13985) (71d4a6fc)
+ * update dependency nodebb-plugin-composer-default to v10.3.16 (#13991) (ff292f7d)
+ * update dependency webpack to v5.105.2 (#13986) (1020092b)
+ * update dependency qs to v6.14.2 (#13978) (6e4e02a6)
+ * update dependency lru-cache to v11.2.6 (#13970) (2cddaf86)
+ * update dependency satori to v0.19.2 (#13974) (c4420da3)
+ * update dependency webpack to v5.105.1 (#13975) (48929aae)
+ * update dependency esbuild to v0.27.3 (#13957) (15ba76e3)
+ * update dependency semver to v7.7.4 (#13958) (fe66c812)
+ * update dependency nodemailer to v8 (#13951) (ab60c39c)
+ * update dependency mongodb to v7.1.0 (#13950) (85e99d6b)
+ * update dependency webpack to v5.105.0 (#13949) (e7101330)
+ * update dependency commander to v14.0.3 (#13946) (765c1291)
+ * update dependency pg-cursor to v2.17.0 (#13942) (1f285293)
+ * update dependency pg to v8.18.0 (#13941) (4eb8854c)
+ * update dependency autoprefixer to v10.4.24 (#13940) (b837c253)
+ * update dependency nodebb-theme-harmony to v2.1.37 (#13935) (9ec96aec)
+ * update dependency nodemailer to v7.0.13 (#13934) (290198b1)
+ * update dependency express-useragent to v2.1.0 (#13929) (82d6f35b)
+ * update dependency lru-cache to v11.2.5 (#13932) (b4c5657a)
+ * update dependency ace-builds to v1.43.6 (#13922) (d911a736)
+ * update dependency sass to v1.97.3 (#13925) (87c4d416)
+ * update dependency express-session to v1.19.0 (#13926) (e0e7c5ea)
+ * update dependency nodebb-theme-harmony to v2.1.36 (#13923) (abfb10e3)
+ * update dependency pg-cursor to v2.16.2 (#13915) (14e20a32)
+ * update dependency lodash to v4.17.23 (#13916) (0b822c96)
+ * update dependency pg to v8.17.2 (#13914) (3f50d52a)
+ * update dependency pg to v8.17.1 (#13901) (271239d4)
+ * update dependency pg to v8.17.1 (#13893) (cc8b2db5)
+ * update dependency pg-cursor to v2.16.1 (#13894) (62498a3c)
+ * update dependency nodebb-theme-harmony to v2.1.35 (#13896) (5b5955d6)
+ * update dependency satori to v0.19.1 (#13898) (dd6fda81)
+
+##### Other Changes
+
+* fix lint errors (fc474238)
+* remove unused (68acc059)
+* remove useless assignment (652629df)
+* #13982, dont use btn-group on dropdowns (803473ca)
+* //github.com/NodeBB/NodeBB/issues/13982 (26af029a)
+* remove unused regex (1747cf81)
+* filter at the end of user.search (dcbbc187)
+* invalid-username doesnt have params (fb460725)
+
+##### Performance Improvements
+
+* don't call getUids on every topic load (8a60d9c2)
+
+##### Refactors
+
+* add topic-await-review notif text (bac9f1f7)
+* ActivityPub ACP page to its own top-level section with sub-pages (f0d2be35)
+* allow passing an array to topics.purge and topics.purgePost… (#14018) (e4c945f6)
+* dont store ap pids in posts:pid (de1f04d9)
+* closes #7155, (bb7be8c5)
+* shorter check (a70a62cc)
+* isHookValid (63c9a6e0)
+* add icons to selected/all (970a4204)
+* users table (e3d7abe0)
+* pass options to thumbs.load (0424728f)
+* slugify isn't heavy anymore (fb95f8a3)
+* don't create giant array, process in batches of 500 (ed8cbd6e)
+* use opendir instead of loading all files (ce9bd0bb)
+* teaser object schema to its own file (1869b807)
+* privileges.global.can works with array of privileges (fd6984d2)
+* shorter hook (a8a85bcb)
+* updateTags to modern js (3756a8fe)
+* shorter check (b0f2fead)
+* cache page to table (7336c58c)
+* emoji replacement code into helper function, remove use of regex on untrusted user input (9608cce6)
+* add createFieldChecker (#13973) (c65af199)
+* run searches in parallel (9a198c38)
+* shorter (3f67a000)
+* remove unused url (7f4d537d)
+* get rid of url.parse in core (157959df)
+* use translator.compile which escapes % and , (a2f4c185)
+* shorter (b3dc7f43)
+* remove log (ff1376b3)
+* get rid of cache for tid posters, was never cleared (065abbf2)
+* dont use module, explodes on latest webpack (a4e3fe10)
+* use lru directly (c8cd34bf)
+* format (070d77fd)
+* shorter tpl (e2fc349d)
+* get rid of global.env, use process.env.NODE_ENV (07d1f224)
+* make custom user field icons fixed width in the acp (2ded6813)
+* use local cache for plugin isActive check (7ac5446a)
+* Actors.getLocalFollowers to Actors.getFollowers, can pass in both local and remote ids (fac31859)
+* move username check to createOrQueue (37675689)
+* checkUsername function https://github.com/NodeBB/NodeBB/issues/10864 (7e27da61)
+* shorter params (05e76edd)
+* move ap jobs to its own file (c595edb4)
+
+##### Tests
+
+* remove assert (872d7c74)
+* remove old test (59f35e6f)
+* update tests to allow title-less topics (b1c097f8)
+* show topic data on test fail (6e6900ba)
+* fix redis, from was string in map, but int in notif object (a8c68ddc)
+* fix spec (0e2a42d5)
+* fix missing priv in tests (5c73d338)
+* add test to check picture!=uploadedpicture (0c2ab232)
+* fix hasOwn (d52b1359)
+* group members test (ffc4c0dd)
+* add more info to failiing response (87fdca2a)
+* fix spec, remove log (05dd46c3)
+* favicon test (ca237e67)
+* dont return cover:url if its not requested (e4d852b4)
+* fix username test (db07456b)
+* fix test that explodes on new URL (694b545c)
+* another test fix (94873c33)
+* fix file name (05d4d857)
+* fix spec (d601847a)
+* fix tests (0d19294a)
+* fix tests, update mentions composer (d6c69465)
+* fix typo in spec (43be594a)
+* fix spec (d1a39554)
+* add missing spec for admin page (30014f41)
+* remove unused (018e1c5f)
+* fix spec (bc1593b2)
+
#### v4.8.1 (2026-01-28)
##### Chores
diff --git a/install/package.json b/install/package.json
index 091f652ba8..cde6256eb3 100644
--- a/install/package.json
+++ b/install/package.json
@@ -97,7 +97,7 @@
"multer": "2.0.2",
"nconf": "0.13.0",
"nodebb-plugin-2factor": "7.6.1",
- "nodebb-plugin-composer-default": "10.3.22",
+ "nodebb-plugin-composer-default": "10.3.23",
"nodebb-plugin-dbsearch": "6.4.0",
"nodebb-plugin-emoji": "6.0.5",
"nodebb-plugin-emoji-android": "4.1.1",
@@ -107,7 +107,7 @@
"nodebb-plugin-spam-be-gone": "2.3.2",
"nodebb-plugin-web-push": "0.7.6",
"nodebb-rewards-essentials": "1.0.2",
- "nodebb-theme-harmony": "2.2.42",
+ "nodebb-theme-harmony": "2.2.43",
"nodebb-theme-lavender": "7.1.21",
"nodebb-theme-peace": "2.2.55",
"nodebb-theme-persona": "14.2.21",
diff --git a/public/language/en-GB/admin/dashboard.json b/public/language/en-GB/admin/dashboard.json
index a35775fdce..1cec2284e7 100644
--- a/public/language/en-GB/admin/dashboard.json
+++ b/public/language/en-GB/admin/dashboard.json
@@ -8,10 +8,11 @@
"topics": "Topics",
"remote-posts": "Remote Posts",
"remote-topics": "Remote Topics",
+ "messages": "Messages",
"page-views-seven": "Last 7 Days",
"page-views-thirty": "Last 30 Days",
"page-views-last-day": "Last 24 hours",
- "page-views-custom": "Custom Date Range",
+ "page-views-custom": "Custom Range",
"page-views-custom-start": "Range Start",
"page-views-custom-end": "Range End",
"page-views-custom-help": "Enter a date range of page views you would like to view. If no date picker is available, the accepted format is YYYY-MM-DD",
diff --git a/public/openapi/write/categories/cid/watch.yaml b/public/openapi/write/categories/cid/watch.yaml
index 8ed5a10d1e..c46ceaaa2e 100644
--- a/public/openapi/write/categories/cid/watch.yaml
+++ b/public/openapi/write/categories/cid/watch.yaml
@@ -55,7 +55,7 @@ put:
type: array
description: A list of cids that have had their watch states modified.
items:
- type: number
+ type: string
delete:
tags:
- categories
@@ -99,4 +99,4 @@ delete:
type: array
description: A list of cids that have had their watch states modified.
items:
- type: number
\ No newline at end of file
+ type: string
\ No newline at end of file
diff --git a/public/scss/admin/general/dashboard.scss b/public/scss/admin/general/dashboard.scss
index 2cfc461a0e..50c9390072 100644
--- a/public/scss/admin/general/dashboard.scss
+++ b/public/scss/admin/general/dashboard.scss
@@ -1,20 +1,26 @@
+.template-admin-dashboard #analytics-panel .graph-container {
+ min-height: 300px;
+}
.dashboard {
.card {
max-width: 100% !important;
}
+ #analytics-panel.fullscreen .graph-container {
+ width: 100%!important;
+ height: 100%!important;
+ max-height: 100%!important;
+ padding: 40px;
+ }
+
.graph-container {
+ max-height: 400px;
position: relative;
background: var(--bg-body-bg);
&.pie-chart {
max-height: 180px;
}
-
- &.fullscreen {
- width: 100%;
- padding: 40px;
- }
}
.graph-legend {
diff --git a/public/src/admin/dashboard.js b/public/src/admin/dashboard.js
index 8be06177b0..a290a791c7 100644
--- a/public/src/admin/dashboard.js
+++ b/public/src/admin/dashboard.js
@@ -18,6 +18,8 @@ import * as alerts from '../modules/alerts';
import * as translator from '../modules/translator';
import { formattedNumber } from '../modules/helpers';
+import { setupFullscreen } from './modules/fullscreen';
+
Chart.register(
LineController,
DoughnutController,
@@ -75,7 +77,7 @@ export function init() {
socket.emit('admin.rooms.getAll', updateRoomUsage);
initiateDashboard();
});
- setupFullscreen();
+ setupFullscreen($('#expand-analytics'), $('#analytics-panel'));
}
function updateRoomUsage(err, data) {
@@ -579,36 +581,3 @@ function initiateDashboard(realtime) {
}, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.graphInterval);
}
-function setupFullscreen() {
- const container = document.getElementById('analytics-panel');
- const $container = $(container);
- const btn = $container.find('#expand-analytics');
- let fsMethod;
- let exitMethod;
-
- if (container.requestFullscreen) {
- fsMethod = 'requestFullscreen';
- exitMethod = 'exitFullscreen';
- } else if (container.mozRequestFullScreen) {
- fsMethod = 'mozRequestFullScreen';
- exitMethod = 'mozCancelFullScreen';
- } else if (container.webkitRequestFullscreen) {
- fsMethod = 'webkitRequestFullscreen';
- exitMethod = 'webkitCancelFullScreen';
- } else if (container.msRequestFullscreen) {
- fsMethod = 'msRequestFullscreen';
- exitMethod = 'msCancelFullScreen';
- }
-
- if (fsMethod) {
- btn.on('click', function () {
- if ($container.hasClass('fullscreen')) {
- document[exitMethod]();
- $container.removeClass('fullscreen');
- } else {
- container[fsMethod]();
- $container.addClass('fullscreen');
- }
- });
- }
-}
diff --git a/public/src/admin/dashboard/logins.js b/public/src/admin/dashboard/logins.js
index eea048293c..c82b54f17b 100644
--- a/public/src/admin/dashboard/logins.js
+++ b/public/src/admin/dashboard/logins.js
@@ -1,6 +1,6 @@
'use strict';
-define('admin/dashboard/logins', ['admin/modules/dashboard-line-graph'], (graph) => {
+define('admin/dashboard/logins', ['admin/modules/dashboard-line-graph', 'admin/modules/fullscreen'], (graph, { setupFullscreen }) => {
const ACP = {};
ACP.init = () => {
@@ -8,6 +8,7 @@ define('admin/dashboard/logins', ['admin/modules/dashboard-line-graph'], (graph)
set: 'logins',
dataset: ajaxify.data.dataset,
});
+ setupFullscreen($('#expand-analytics'), $('#analytics-panel'));
};
return ACP;
diff --git a/public/src/admin/dashboard/topics.js b/public/src/admin/dashboard/topics.js
index 91e0754779..32e2bede3e 100644
--- a/public/src/admin/dashboard/topics.js
+++ b/public/src/admin/dashboard/topics.js
@@ -1,6 +1,8 @@
'use strict';
-define('admin/dashboard/topics', ['admin/modules/dashboard-line-graph', 'hooks'], (graph, hooks) => {
+define('admin/dashboard/topics', [
+ 'admin/modules/dashboard-line-graph', 'hooks', 'admin/modules/fullscreen',
+], (graph, hooks, { setupFullscreen }) => {
const ACP = {};
ACP.init = () => {
@@ -10,6 +12,7 @@ define('admin/dashboard/topics', ['admin/modules/dashboard-line-graph', 'hooks']
}).then(() => {
hooks.onPage('action:admin.dashboard.updateGraph', ACP.updateTable);
});
+ setupFullscreen($('#expand-analytics'), $('#analytics-panel'));
};
ACP.updateTable = () => {
diff --git a/public/src/admin/dashboard/users.js b/public/src/admin/dashboard/users.js
index c9798e9572..7ef00ea477 100644
--- a/public/src/admin/dashboard/users.js
+++ b/public/src/admin/dashboard/users.js
@@ -1,6 +1,8 @@
'use strict';
-define('admin/dashboard/users', ['admin/modules/dashboard-line-graph', 'hooks'], (graph, hooks) => {
+define('admin/dashboard/users', [
+ 'admin/modules/dashboard-line-graph', 'hooks', 'admin/modules/fullscreen',
+], (graph, hooks, { setupFullscreen }) => {
const ACP = {};
ACP.init = () => {
@@ -10,6 +12,7 @@ define('admin/dashboard/users', ['admin/modules/dashboard-line-graph', 'hooks'],
}).then(() => {
hooks.onPage('action:admin.dashboard.updateGraph', ACP.updateTable);
});
+ setupFullscreen($('#expand-analytics'), $('#analytics-panel'));
};
ACP.updateTable = () => {
diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js
index a8a6a2fb28..babdb83367 100644
--- a/public/src/admin/manage/categories.js
+++ b/public/src/admin/manage/categories.js
@@ -11,7 +11,7 @@ define('admin/manage/categories', [
], function (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) {
Sortable = Sortable.default;
const Categories = {};
- let newCategoryId = -1;
+ let newCategoryId = '-1';
let sortables;
Categories.init = function () {
@@ -261,15 +261,15 @@ define('admin/manage/categories', [
};
function itemDidAdd(e) {
- newCategoryId = e.to.dataset.cid;
+ newCategoryId = String(e.to.dataset.cid);
}
function itemDragDidEnd(e) {
- const isCategoryUpdate = parseInt(newCategoryId, 10) !== -1;
+ const isCategoryUpdate = String(newCategoryId) !== '-1';
// Update needed?
if ((e.newIndex != null && parseInt(e.oldIndex, 10) !== parseInt(e.newIndex, 10)) || isCategoryUpdate) {
- const cid = e.item.dataset.cid;
+ const cid = String(e.item.dataset.cid);
const modified = {};
// on page 1 baseIndex is 0, on page n baseIndex is (n - 1) * ajaxify.data.categoriesPerPage
// this makes sure order is correct when drag & drop is used on pages > 1
@@ -282,8 +282,8 @@ define('admin/manage/categories', [
modified[cid].parentCid = newCategoryId;
// Show/hide expand buttons after drag completion
- const oldParentCid = parseInt(e.from.getAttribute('data-cid'), 10);
- const newParentCid = parseInt(e.to.getAttribute('data-cid'), 10);
+ const oldParentCid = String(e.from.getAttribute('data-cid') || '');
+ const newParentCid = String(e.to.getAttribute('data-cid') || '');
if (oldParentCid !== newParentCid) {
const toggle = document.querySelector(`.categories li[data-cid="${newParentCid}"] .toggle`);
if (toggle) {
diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js
index 7a6e85486e..d75318a4fd 100644
--- a/public/src/admin/manage/category.js
+++ b/public/src/admin/manage/category.js
@@ -160,7 +160,7 @@ define('admin/manage/category', [
label: '[[modules:bootbox.confirm]]',
className: 'btn-primary',
callback: function () {
- if (!selectedCid || parseInt(selectedCid, 10) === parseInt(ajaxify.data.category.cid, 10)) {
+ if (!selectedCid || String(selectedCid) === String(ajaxify.data.category.cid)) {
return;
}
diff --git a/public/src/admin/manage/group.js b/public/src/admin/manage/group.js
index c856f0b487..fc68fc8f63 100644
--- a/public/src/admin/manage/group.js
+++ b/public/src/admin/manage/group.js
@@ -68,10 +68,14 @@ define('admin/manage/group', [
const cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), {
onSelect: function (selectedCategory) {
- let cids = ($('#memberPostCids').val() || '').split(',').map(cid => parseInt(cid, 10));
- cids.push(selectedCategory.cid);
- cids = cids.filter((cid, index, array) => array.indexOf(cid) === index);
- $('#memberPostCids').val(cids.join(','));
+ const cids = new Set(($('#memberPostCids').val() || '').split(',').filter(Boolean));
+ if (cids.has(String(selectedCategory.cid))) {
+ cids.delete(String(selectedCategory.cid));
+ } else {
+ cids.add(String(selectedCategory.cid));
+ }
+
+ $('#memberPostCids').val(Array.from(cids).join(','));
cidSelector.selectCategory(0);
return false;
},
diff --git a/public/src/admin/modules/dashboard-line-graph.js b/public/src/admin/modules/dashboard-line-graph.js
index 3087518c9d..cbc2bcf5bf 100644
--- a/public/src/admin/modules/dashboard-line-graph.js
+++ b/public/src/admin/modules/dashboard-line-graph.js
@@ -72,13 +72,14 @@ export function init({ set, dataset }) {
data: data,
options: {
responsive: true,
+ maintainAspectRatio: false,
scales: {
'left-y-axis': {
type: 'linear',
position: 'left',
beginAtZero: true,
title: {
- display: true,
+ display: false,
text: key,
},
},
diff --git a/public/src/admin/modules/fullscreen.js b/public/src/admin/modules/fullscreen.js
new file mode 100644
index 0000000000..eac4ffd0c5
--- /dev/null
+++ b/public/src/admin/modules/fullscreen.js
@@ -0,0 +1,30 @@
+export function setupFullscreen($btn, $container) {
+ let fsMethod;
+ let exitMethod;
+ const container = $container.get(0);
+ if (container.requestFullscreen) {
+ fsMethod = 'requestFullscreen';
+ exitMethod = 'exitFullscreen';
+ } else if (container.mozRequestFullScreen) {
+ fsMethod = 'mozRequestFullScreen';
+ exitMethod = 'mozCancelFullScreen';
+ } else if (container.webkitRequestFullscreen) {
+ fsMethod = 'webkitRequestFullscreen';
+ exitMethod = 'webkitCancelFullScreen';
+ } else if (container.msRequestFullscreen) {
+ fsMethod = 'msRequestFullscreen';
+ exitMethod = 'msCancelFullScreen';
+ }
+
+ if (fsMethod) {
+ $btn.on('click', function () {
+ if ($container.hasClass('fullscreen')) {
+ document[exitMethod]().catch(err => console.error(err));
+ $container.removeClass('fullscreen');
+ } else {
+ container[fsMethod]().catch(err => console.error(err));
+ $container.addClass('fullscreen');
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/public/src/client/groups/details.js b/public/src/client/groups/details.js
index 9ed57cd6ed..39395258cb 100644
--- a/public/src/client/groups/details.js
+++ b/public/src/client/groups/details.js
@@ -233,11 +233,16 @@ define('forum/groups/details', [
const cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), {
onSelect: function (selectedCategory) {
- let cids = ($('#memberPostCids').val() || '').split(',').map(cid => parseInt(cid, 10));
- cids.push(selectedCategory.cid);
- cids = cids.filter((cid, index, array) => array.indexOf(cid) === index);
- $('#memberPostCids').val(cids.join(','));
+ const cids = new Set(($('#memberPostCids').val() || '').split(',').filter(Boolean));
+ if (cids.has(String(selectedCategory.cid))) {
+ cids.delete(String(selectedCategory.cid));
+ } else {
+ cids.add(String(selectedCategory.cid));
+ }
+
+ $('#memberPostCids').val(Array.from(cids).join(','));
cidSelector.selectCategory(0);
+ return false;
},
});
};
diff --git a/public/src/client/unread.js b/public/src/client/unread.js
index 6851ba1ec4..d0b6428dc8 100644
--- a/public/src/client/unread.js
+++ b/public/src/client/unread.js
@@ -65,9 +65,7 @@ define('forum/unread', [
// Generate list of default categories based on topic list
let defaultCategories = ajaxify.data.topics.reduce((map, topic) => {
const { category } = topic;
- let { cid } = category;
- cid = utils.isNumber(cid) ? parseInt(cid, 10) : cid;
- map.set(cid, category);
+ map.set(String(category.cid), category);
return map;
}, new Map());
defaultCategories = Array.from(defaultCategories.values());
diff --git a/public/src/modules/api.js b/public/src/modules/api.js
index 55abd1ad36..7c1038fbde 100644
--- a/public/src/modules/api.js
+++ b/public/src/modules/api.js
@@ -79,7 +79,11 @@ async function xhr(options) {
if (!res.ok) {
if (response) {
- throw new Error(isJSON ? response.status.message : response);
+ const jsonError = isJSON && (response.status?.message || response.error || '');
+ throw new Error(isJSON && jsonError ?
+ jsonError :
+ response
+ );
}
throw new Error(res.statusText);
}
diff --git a/public/src/modules/topicList.js b/public/src/modules/topicList.js
index 4af7eee9c5..1d026c1719 100644
--- a/public/src/modules/topicList.js
+++ b/public/src/modules/topicList.js
@@ -95,11 +95,11 @@ define('topicList', [
const categories = d.selectedCids &&
d.selectedCids.length &&
- d.selectedCids.indexOf(parseInt(data.cid, 10)) === -1;
+ !d.selectedCids.includes(parseInt(data.cid, 10));
const filterWatched = d.selectedFilter &&
d.selectedFilter.filter === 'watched';
const category = d.template.category &&
- parseInt(d.cid, 10) !== parseInt(data.cid, 10);
+ String(d.cid) !== String(data.cid);
const preventAlert = !!(categories || filterWatched || category || scheduledTopics.includes(data.tid));
hooks.fire('filter:topicList.onNewTopic', { topic: data, preventAlert }).then((result) => {
@@ -126,14 +126,14 @@ define('topicList', [
const isMain = parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10);
const categories = d.selectedCids &&
d.selectedCids.length &&
- d.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1;
+ !d.selectedCids.includes(parseInt(post.topic.cid, 10));
const filterNew = d.selectedFilter &&
d.selectedFilter.filter === 'new';
const filterWatched = d.selectedFilter &&
d.selectedFilter.filter === 'watched' &&
!post.topic.isFollowing;
const category = d.template.category &&
- parseInt(d.cid, 10) !== parseInt(post.topic.cid, 10);
+ String(d.cid) !== String(post.topic.cid);
const preventAlert = !!(isMain || categories || filterNew || filterWatched || category);
hooks.fire('filter:topicList.onNewPost', { post, preventAlert }).then((result) => {
diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js
index 558a4ee8d7..044fc2c82d 100644
--- a/src/activitypub/helpers.js
+++ b/src/activitypub/helpers.js
@@ -559,6 +559,7 @@ Helpers.renderEmoji = (text, tags, strip = false) => {
tags = Array.isArray(tags) ? tags : [tags];
let result = text;
+ const parsed = new Set();
tags.forEach((tag) => {
const isEmoji = tag.type === 'Emoji';
const hasUrl = tag.icon && tag.icon.url;
@@ -566,6 +567,9 @@ Helpers.renderEmoji = (text, tags, strip = false) => {
if (isEmoji && (strip || (hasUrl && isImage))) {
let { name } = tag;
+ if (parsed.has(name)) {
+ return;
+ }
if (!name.startsWith(':')) {
name = `:${name}`;
@@ -583,6 +587,7 @@ Helpers.renderEmoji = (text, tags, strip = false) => {
result = result.substring(0, index) + imgTag + result.substring(index + name.length);
index = result.indexOf(name, index + imgTag.length);
}
+ parsed.add(name);
}
});
diff --git a/src/api/categories.js b/src/api/categories.js
index 4806868f93..d0dd8d47df 100644
--- a/src/api/categories.js
+++ b/src/api/categories.js
@@ -8,7 +8,6 @@ const user = require('../user');
const groups = require('../groups');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
-const utils = require('../utils');
const categoriesAPI = module.exports;
@@ -158,7 +157,7 @@ categoriesAPI.getTopics = async (caller, data) => {
categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
let targetUid = caller.uid;
let cids = Array.isArray(cid) ? cid : [cid];
- cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
+ cids = cids.map(cid => String(cid));
if (uid) {
targetUid = uid;
@@ -170,9 +169,9 @@ categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
// filter to subcategories of cid
let cat;
do {
- cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid));
+ cat = categoryData.find(c => !cids.includes(String(c.cid)) && cids.includes(String(c.parentCid)));
if (cat) {
- cids.push(cat.cid);
+ cids.push(String(cat.cid));
}
} while (cat);
diff --git a/src/api/search.js b/src/api/search.js
index 83db189de3..a5bc32f83a 100644
--- a/src/api/search.js
+++ b/src/api/search.js
@@ -22,7 +22,7 @@ searchApi.categories = async (caller, data) => {
data.states = (data.states || ['watching', 'tracking', 'notwatching', 'ignoring']).map(
state => categories.watchStates[state]
);
- data.parentCid = parseInt(data.parentCid || 0, 10);
+ data.parentCid = String(data.parentCid || 0);
let cids;
if (data.search) {
@@ -42,15 +42,15 @@ searchApi.categories = async (caller, data) => {
});
if (Array.isArray(data.selectedCids)) {
- data.selectedCids = data.selectedCids.map(cid => parseInt(cid, 10));
+ data.selectedCids = data.selectedCids.map(cid => String(cid));
}
let categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass'], data.parentCid);
categoriesData = categoriesData.slice(0, 1000);
categoriesData.forEach((category) => {
- category.selected = data.selectedCids ? data.selectedCids.includes(category.cid) : false;
- if (matchedCids.includes(category.cid)) {
+ category.selected = data.selectedCids ? data.selectedCids.includes(String(category.cid)) : false;
+ if (matchedCids.includes(String(category.cid))) {
category.match = true;
}
});
@@ -72,7 +72,7 @@ async function findMatchedCids(uid, data) {
localOnly: data.localOnly,
});
- let matchedCids = result.categories.map(c => c.cid);
+ let matchedCids = result.categories.map(c => String(c.cid));
// no need to filter if all 3 states are used
const filterByWatchState = !Object.values(categories.watchStates)
.every(state => data.states.includes(state));
@@ -82,8 +82,12 @@ async function findMatchedCids(uid, data) {
matchedCids = matchedCids.filter((cid, index) => data.states.includes(states[index]));
}
- const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids))));
- const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids))));
+ const rootCids = _.uniq(_.flatten(
+ await Promise.all(matchedCids.map(categories.getParentCids))
+ ));
+ const allChildCids = _.uniq(_.flatten(
+ await Promise.all(matchedCids.map(categories.getChildrenCids))
+ ));
return {
cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)),
@@ -145,7 +149,7 @@ searchApi.roomUsers = async (caller, { query, roomId }) => {
roomUsers.forEach((user, index) => {
if (user) {
user.isOwner = isOwners[index];
- user.canKick = isRoomOwner && (parseInt(user.uid, 10) !== parseInt(caller.uid, 10));
+ user.canKick = isRoomOwner && String(user.uid) !== String(caller.uid);
}
});
diff --git a/src/categories/create.js b/src/categories/create.js
index 92f63f27e6..fa49468834 100644
--- a/src/categories/create.js
+++ b/src/categories/create.js
@@ -101,7 +101,7 @@ module.exports = function (Categories) {
await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']);
cache.del('categories:cid');
- await clearParentCategoryCache(parentCid);
+ await Categories.clearParentCategoryCache(parentCid);
if (data.cloneFromCid && parseInt(data.cloneFromCid, 10)) {
category = await Categories.copySettingsFrom(data.cloneFromCid, category.cid, !data.parentCid);
@@ -115,8 +115,8 @@ module.exports = function (Categories) {
return category;
};
- async function clearParentCategoryCache(parentCid) {
- while (parseInt(parentCid, 10) >= 0) {
+ Categories.clearParentCategoryCache = async function (parentCid) {
+ while (parentCid || parseInt(parentCid, 10) === 0) {
cache.del([
`cid:${parentCid}:children`,
`cid:${parentCid}:children:all`,
@@ -129,7 +129,7 @@ module.exports = function (Categories) {
// eslint-disable-next-line no-await-in-loop
parentCid = await Categories.getCategoryField(parentCid, 'parentCid');
}
- }
+ };
async function duplicateCategoriesChildren(parentCid, cid, uid) {
let children = await Categories.getChildren([cid], uid);
@@ -188,17 +188,13 @@ module.exports = function (Categories) {
throw new Error('[[error:invalid-cid]]');
}
- const oldParent = parseInt(destination.parentCid, 10) || 0;
- const newParent = parseInt(source.parentCid, 10) || 0;
- if (copyParent && newParent !== parseInt(toCid, 10)) {
+ const oldParent = String(destination.parentCid || 0);
+ const newParent = String(source.parentCid || 0);
+ if (copyParent && newParent !== String(toCid)) {
await db.sortedSetRemove(`cid:${oldParent}:children`, toCid);
await db.sortedSetAdd(`cid:${newParent}:children`, source.order, toCid);
- cache.del([
- `cid:${oldParent}:children`,
- `cid:${oldParent}:children:all`,
- `cid:${newParent}:children`,
- `cid:${newParent}:children:all`,
- ]);
+ await Categories.clearParentCategoryCache(oldParent);
+ await Categories.clearParentCategoryCache(newParent);
}
destination.description = source.description;
diff --git a/src/categories/delete.js b/src/categories/delete.js
index 3074cf3f55..5f96e32676 100644
--- a/src/categories/delete.js
+++ b/src/categories/delete.js
@@ -79,12 +79,13 @@ module.exports = function (Categories) {
cache.del([
'categories:cid',
'cid:0:children',
- `cid:${parentCid}:children`,
- `cid:${parentCid}:children:all`,
- `cid:${cid}:children`,
- `cid:${cid}:children:all`,
+ 'cid:0:children:all',
`cid:${cid}:tag:whitelist`,
]);
+ await Promise.all([
+ Categories.clearParentCategoryCache(parentCid),
+ Categories.clearParentCategoryCache(cid),
+ ]);
}
async function deleteTags(cid) {
diff --git a/src/categories/index.js b/src/categories/index.js
index 948ff86226..df66600794 100644
--- a/src/categories/index.js
+++ b/src/categories/index.js
@@ -104,7 +104,6 @@ Categories.getAllCidsFromSet = async function (key) {
}
cids = await db.getSortedSetRange(key, 0, -1);
- cids = cids.map(cid => utils.isNumber(cid) ? parseInt(cid, 10) : cid);
cache.set(key, cids);
return cids.slice();
};
@@ -261,7 +260,7 @@ Categories.getChildren = async function (cids, uid) {
async function getChildrenTree(category, uid) {
let childrenCids = await Categories.getChildrenCids(category.cid);
childrenCids = await privileges.categories.filterCids('find', childrenCids, uid);
- childrenCids = childrenCids.filter(cid => parseInt(category.cid, 10) !== parseInt(cid, 10));
+ childrenCids = childrenCids.filter(cid => String(category.cid) !== String(cid));
if (!childrenCids.length) {
category.children = [];
return;
@@ -280,7 +279,7 @@ Categories.getParentCids = async function (currentCid) {
// eslint-disable-next-line
cid = await Categories.getCategoryField(cid, 'parentCid');
if (cid) {
- parents.unshift(cid);
+ parents.unshift(String(cid));
}
}
return parents;
@@ -291,12 +290,12 @@ Categories.getChildrenCids = async function (rootCid) {
async function recursive(keys) {
let childrenCids = await db.getSortedSetRange(keys, 0, -1);
- childrenCids = childrenCids.filter(cid => !allCids.includes(utils.isNumber(cid) ? parseInt(cid, 10) : cid));
+ childrenCids = childrenCids.filter(cid => !allCids.includes(cid));
if (!childrenCids.length) {
return;
}
+ allCids.push(...childrenCids);
keys = childrenCids.map(cid => `cid:${cid}:children`);
- childrenCids.forEach(cid => allCids.push(utils.isNumber(cid) ? parseInt(cid, 10) : cid));
await recursive(keys);
}
const key = `cid:${rootCid}:children`;
@@ -331,7 +330,7 @@ Categories.flattenCategories = function (allCategories, categoryData) {
* @param parentCid {number} start from 0 to build full tree
*/
Categories.getTree = function (categories, parentCid) {
- parentCid = parentCid || 0;
+ parentCid = String(parentCid || 0);
const cids = categories.map(category => category && category.cid);
const cidToCategory = {};
const parents = {};
@@ -352,15 +351,15 @@ Categories.getTree = function (categories, parentCid) {
return;
}
if (!category.hasOwnProperty('parentCid') || category.parentCid === null) {
- category.parentCid = 0;
+ category.parentCid = '0';
}
- if (category.parentCid === parentCid) {
+ if (String(category.parentCid) === parentCid) {
tree.push(category);
category.parent = parents[parentCid];
} else {
- const parent = cidToCategory[category.parentCid];
+ const parent = cidToCategory[String(category.parentCid)];
if (parent && parent.cid !== category.cid) {
- category.parent = parents[category.parentCid];
+ category.parent = parents[String(category.parentCid)];
parent.children = parent.children || [];
parent.children.push(category);
}
@@ -414,10 +413,10 @@ Categories.buildForSelectCategories = function (categories, fields, parentCid) {
category.children.forEach(child => recursive(child, categoriesData, ` ${level}`, depth + 1));
}
}
- parentCid = parentCid || 0;
+ parentCid = String(parentCid || 0);
const categoriesData = [];
- const rootCategories = categories.filter(category => category && category.parentCid === parentCid);
+ const rootCategories = categories.filter(category => category && String(category.parentCid) === parentCid);
rootCategories.sort((a, b) => {
if (a.order !== b.order) {
diff --git a/src/categories/search.js b/src/categories/search.js
index b0b8bef9b9..9f8c407e76 100644
--- a/src/categories/search.js
+++ b/src/categories/search.js
@@ -94,7 +94,7 @@ module.exports = function (Categories) {
}
async function getChildrenCids(cids, uid) {
- const childrenCids = await Promise.all(cids.map(cid => Categories.getChildrenCids(cid)));
+ const childrenCids = await Promise.all(cids.map(Categories.getChildrenCids));
return await privileges.categories.filterCids('find', _.flatten(childrenCids), uid);
}
};
diff --git a/src/categories/update.js b/src/categories/update.js
index ff4d6e4d11..2c2053f9fa 100644
--- a/src/categories/update.js
+++ b/src/categories/update.js
@@ -67,8 +67,8 @@ module.exports = function (Categories) {
}
async function updateParent(cid, newParent) {
- newParent = parseInt(newParent, 10) || 0;
- if (parseInt(cid, 10) === newParent) {
+ newParent = String(newParent || 0);
+ if (String(cid) === newParent) {
throw new Error('[[error:cant-set-self-as-parent]]');
}
const childrenCids = await Categories.getChildrenCids(cid);
@@ -76,7 +76,7 @@ module.exports = function (Categories) {
throw new Error('[[error:cant-set-child-as-parent]]');
}
const categoryData = await Categories.getCategoryFields(cid, ['parentCid', 'order']);
- const oldParent = categoryData.parentCid;
+ const oldParent = String(categoryData.parentCid);
if (oldParent === newParent) {
return;
}
@@ -85,12 +85,9 @@ module.exports = function (Categories) {
db.sortedSetAdd(`cid:${newParent}:children`, categoryData.order, cid),
db.setObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, 'parentCid', newParent),
]);
-
- cache.del([
- `cid:${oldParent}:children`,
- `cid:${newParent}:children`,
- `cid:${oldParent}:children:all`,
- `cid:${newParent}:children:all`,
+ await Promise.all([
+ Categories.clearParentCategoryCache(oldParent),
+ Categories.clearParentCategoryCache(newParent),
]);
}
@@ -134,11 +131,9 @@ module.exports = function (Categories) {
await db.setObjectBulk(
childrenCids.map((cid, index) => [`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, { order: index + 1 }])
);
-
+ await Categories.clearParentCategoryCache(parentCid);
cache.del([
'categories:cid',
- `cid:${parentCid}:children`,
- `cid:${parentCid}:children:all`,
]);
}
diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js
index 5e503b1964..96fd2336c7 100644
--- a/src/controllers/admin/categories.js
+++ b/src/controllers/admin/categories.js
@@ -52,7 +52,7 @@ categoriesController.getAll = async function (req, res) {
const rootCid = parseInt(req.query.cid, 10) || 0;
const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`);
async function getRootAndChildren() {
- const childCids = _.flatten(await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid))));
+ const childCids = _.flatten(await Promise.all(rootChildren.map(categories.getChildrenCids)));
return [rootCid].concat(rootChildren.concat(childCids));
}
@@ -202,13 +202,14 @@ categoriesController.addRemote = async function (req, res) {
return res.sendStatus(404);
}
- const score = await db.sortedSetCard('cid:0:children');
- const order = score + 1; // order is 1-based lol
+ const lastItem = await db.getSortedSetRevRangeWithScores('cid:0:children', 0, 0);
+ const order = lastItem.length ? lastItem[0].score + 1 : 1;
await Promise.all([
db.sortedSetAdd('cid:0:children', order, id),
categories.setCategoryField(id, 'order', order),
]);
cache.del('cid:0:children');
+ cache.del('cid:0:children:all');
res.sendStatus(200);
};
@@ -231,7 +232,7 @@ categoriesController.removeRemote = async function (req, res) {
const parentCid = await categories.getCategoryField(req.params.cid, 'parentCid');
await db.sortedSetRemove(`cid:${parentCid || 0}:children`, req.params.cid);
- cache.del(`cid:${parentCid || 0}:children`);
-
+ await categories.clearParentCategoryCache(parentCid || 0);
+ await categories.setCategoryField(req.params.cid, 'parentCid', 0);
res.sendStatus(200);
};
diff --git a/src/controllers/admin/dashboard.js b/src/controllers/admin/dashboard.js
index f573c5e677..0cfd5350c1 100644
--- a/src/controllers/admin/dashboard.js
+++ b/src/controllers/admin/dashboard.js
@@ -140,7 +140,8 @@ async function getStats() {
getStatsForSet('topics:tid', 'topicCount'),
meta.config.activitypubEnabled ? getStatsForSet('postsRemote:pid', '') : null,
meta.config.activitypubEnabled ? getStatsForSet('topicsRemote:tid', '') : null,
- ])).filter(Boolean);
+ getStatsForSet('messages:mid', 'messageCount'),
+ ]));
results[0].name = '[[admin/dashboard:graphs.page-views]]';
results[1].name = '[[admin/dashboard:unique-visitors]]';
@@ -162,6 +163,8 @@ async function getStats() {
if (results[7]) {
results[7].name = '[[admin/dashboard:remote-topics]]';
}
+ results[8].name = '[[admin/dashboard:messages]]';
+ results = results.filter(Boolean);
({ results } = await plugins.hooks.fire('filter:admin.getStats', {
results,
@@ -224,7 +227,7 @@ function calculateDeltas(results) {
function increasePercent(last, now) {
const percent = last ? (now - last) / last * 100 : 0;
- return percent.toFixed(0);
+ return (percent > 0 ? `+` : '') + percent.toFixed(0);
}
results.yesterday -= results.today;
results.dayIncrease = increasePercent(results.yesterday, results.today);
diff --git a/src/controllers/search.js b/src/controllers/search.js
index 440deb0fc5..3a2c5af54e 100644
--- a/src/controllers/search.js
+++ b/src/controllers/search.js
@@ -201,7 +201,7 @@ async function buildSelectedCategoryLabel(selectedCids) {
label = `[[search:categories-x, ${selectedCids.length}]]`;
} else if (selectedCids.length === 1 && selectedCids[0] === 'watched') {
label = `[[search:categories-watched-categories]]`;
- } else if (selectedCids.length === 1 && parseInt(selectedCids[0], 10)) {
+ } else if (selectedCids.length === 1 && selectedCids[0]) {
const categoryData = await categories.getCategoryData(selectedCids[0]);
if (categoryData && categoryData.name) {
label = `[[search:categories-x, ${categoryData.name}]]`;
diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js
index 83de1c07c7..4356775a47 100644
--- a/src/messaging/rooms.js
+++ b/src/messaging/rooms.js
@@ -110,7 +110,7 @@ module.exports = function (Messaging) {
[`chat:room:${roomId}:uids:online`, now, uid],
...(
isPublic ?
- [`chat:room:${roomId}:owners`, now, uid] :
+ [[`chat:room:${roomId}:owners`, now, uid]] :
[uid].concat(data.uids).map(uid => ([`chat:room:${roomId}:owners`, now, uid]))
),
]),
diff --git a/src/posts/category.js b/src/posts/category.js
index b87342a89f..7ce02bf490 100644
--- a/src/posts/category.js
+++ b/src/posts/category.js
@@ -40,7 +40,7 @@ module.exports = function (Posts) {
};
async function filterPidsBySingleCid(pids, cid) {
- const isMembers = await db.isSortedSetMembers(`cid:${parseInt(cid, 10)}:pids`, pids);
+ const isMembers = await db.isSortedSetMembers(`cid:${cid}:pids`, pids);
return pids.filter((pid, index) => pid && isMembers[index]);
}
};
diff --git a/src/search.js b/src/search.js
index e1a7fb4287..9993a8121d 100644
--- a/src/search.js
+++ b/src/search.js
@@ -412,8 +412,12 @@ async function getChildrenCids(data) {
if (!data.searchChildren) {
return [];
}
- const childrenCids = await Promise.all(data.categories.map(cid => categories.getChildrenCids(cid)));
- return await privileges.categories.filterCids('find', _.uniq(_.flatten(childrenCids)), data.uid);
+ const childrenCids = await Promise.all(data.categories.map(categories.getChildrenCids));
+ return await privileges.categories.filterCids(
+ 'find',
+ _.uniq(_.flatten(childrenCids)),
+ data.uid
+ );
}
async function getSearchUids(data) {
diff --git a/src/socket.io/admin/categories.js b/src/socket.io/admin/categories.js
index 53c541598d..1406edb596 100644
--- a/src/socket.io/admin/categories.js
+++ b/src/socket.io/admin/categories.js
@@ -36,7 +36,7 @@ Categories.copyPrivilegesFrom = async function (socket, data) {
Categories.copyPrivilegesToAllCategories = async function (socket, data) {
let cids = await categories.getAllCidsFromSet('categories:cid');
- cids = cids.filter(cid => parseInt(cid, 10) !== parseInt(data.cid, 10));
+ cids = cids.filter(cid => String(cid) !== String(data.cid));
for (const toCid of cids) {
// eslint-disable-next-line no-await-in-loop
await categories.copyPrivilegesFrom(data.cid, toCid, data.group, data.filter);
diff --git a/src/socket.io/categories.js b/src/socket.io/categories.js
index ae2378d73a..68e9f1942b 100644
--- a/src/socket.io/categories.js
+++ b/src/socket.io/categories.js
@@ -111,7 +111,9 @@ SocketCategories.ignore = async function (socket, data) {
async function ignoreOrWatch(fn, socket, data) {
let targetUid = socket.uid;
- const cids = Array.isArray(data.cid) ? data.cid.map(cid => parseInt(cid, 10)) : [parseInt(data.cid, 10)];
+ const cids = Array.isArray(data.cid) ?
+ data.cid.map(cid => String(cid)) :
+ [String(data.cid)];
if (data.hasOwnProperty('uid')) {
targetUid = data.uid;
}
@@ -122,9 +124,9 @@ async function ignoreOrWatch(fn, socket, data) {
// filter to subcategories of cid
let cat;
do {
- cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid));
+ cat = categoryData.find(c => !cids.includes(String(c.cid)) && cids.includes(String(c.parentCid)));
if (cat) {
- cids.push(cat.cid);
+ cids.push(String(cat.cid));
}
} while (cat);
diff --git a/src/topics/teaser.js b/src/topics/teaser.js
index 03ea62b5c0..1ee9f0d437 100644
--- a/src/topics/teaser.js
+++ b/src/topics/teaser.js
@@ -17,9 +17,11 @@ module.exports = function (Topics) {
}
let uid = options;
let { teaserPost } = meta.config;
+ let teaserParseType = 'plaintext';
if (typeof options === 'object') {
uid = options.uid;
teaserPost = options.teaserPost || meta.config.teaserPost;
+ teaserParseType = options.teaserParseType || 'plaintext';
}
const counts = [];
@@ -68,7 +70,7 @@ module.exports = function (Topics) {
post.timestampISO = utils.toISOString(post.timestamp);
tidToPost[post.tid] = post;
});
- await Promise.all(postData.map(p => posts.parsePost(p, 'plaintext')));
+ await Promise.all(postData.map(p => posts.parsePost(p, teaserParseType)));
const teasers = topics.map((topic, index) => {
if (!topic) {
diff --git a/src/views/admin/dashboard/logins.tpl b/src/views/admin/dashboard/logins.tpl
index 552ded83e1..814b10156d 100644
--- a/src/views/admin/dashboard/logins.tpl
+++ b/src/views/admin/dashboard/logins.tpl
@@ -23,7 +23,7 @@
{./user.username}
{function.userAgentIcons} {../browser} {../version} on {../platform}
-
| {../title} | [[topic:posted-by, {../user.username}]] | -+ | {../uid} | {../username} | {../email} | -+ | {{{ end }}} diff --git a/src/views/admin/partials/dashboard/graph.tpl b/src/views/admin/partials/dashboard/graph.tpl index e6eb91ff29..c01b923109 100644 --- a/src/views/admin/partials/dashboard/graph.tpl +++ b/src/views/admin/partials/dashboard/graph.tpl @@ -3,21 +3,21 @@ |
| - - {{{ if ./href }}} - {./name} - {{{ else }}} - {./name} - {{{ end }}} - + | + {{{ if ./href }}} + {./name} + {{{ else }}} + {./name} + {{{ end }}} | {formattedNumber(./yesterday)} | {formattedNumber(./today)} | diff --git a/test/messaging.js b/test/messaging.js index 4e2e709007..bbe310e27d 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -599,6 +599,20 @@ describe('Messaging Library', () => { const { roomId } = await api.users.getPrivateRoomId({ uid: mocks.users.foo.uid }, { uid: mocks.users.herp.uid }); assert(roomId); }); + + it('should create a public chat room', async () => { + const data = await api.chats.create({ + uid: mocks.users.foo.uid, + session: {}, + }, { + name: 'public room', + type: 'public', + uids: [], + groups: ['registered-users'], + }); + assert(data.roomId); + assert.strictEqual(data.public, true); + }); }); describe('toMid', () => { diff --git a/test/search.js b/test/search.js index f0e285cb9d..e0b1421786 100644 --- a/test/search.js +++ b/test/search.js @@ -130,9 +130,10 @@ describe('Search', () => { it('should search for categories', async () => { const socketCategories = require('../src/socket.io/categories'); - let data = await socketCategories.categorySearch({ uid: phoebeUid }, { query: 'baz', parentCid: 0 }); + let data = await socketCategories.categorySearch({ uid: phoebeUid }, { search: 'baz', parentCid: 0 }); + assert.strictEqual(data.length, 1); assert.strictEqual(data[0].name, 'baz category'); - data = await socketCategories.categorySearch({ uid: phoebeUid }, { query: '', parentCid: 0 }); + data = await socketCategories.categorySearch({ uid: phoebeUid }, { search: '', parentCid: 0 }); assert.strictEqual(data.length, 5); });