From b58aff9fc7e89093bcf4724997396b88ce903e3c Mon Sep 17 00:00:00 2001 From: T7alabdullah <122431835+T7alabdullah@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:16:46 +0300 Subject: [PATCH 01/48] Update README.md to include our names --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6ef180f625..a28721c93e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +## Team members: Talal, Filippos, Moza, Sara, Nour # ![NodeBB](public/images/sm-card.png) [![Workflow](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml/badge.svg)](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml) From b03f6048f4286e41729ccce5e251e7f625b7e693 Mon Sep 17 00:00:00 2001 From: Nour Alseaf Date: Wed, 18 Sep 2024 14:31:39 +0300 Subject: [PATCH 02/48] Merged my Proj1 Edits -nalseaf --- src/meta/css.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/meta/css.js b/src/meta/css.js index 4b7e999383..b901254b34 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -12,7 +12,9 @@ const db = require('../database'); const file = require('../file'); const minifier = require('./minifier'); const utils = require('../utils'); - +// +// +// const CSS = module.exports; CSS.supportedSkins = [ From b4153e2ee450ba84213ec090e99322cf965aa85e Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Thu, 19 Sep 2024 15:03:44 +0300 Subject: [PATCH 03/48] Merged Project 1 changes mthani2 --- src/upgrades/1.1.0/user_post_count_per_tid.js | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/src/upgrades/1.1.0/user_post_count_per_tid.js b/src/upgrades/1.1.0/user_post_count_per_tid.js index b6e31f3307..341625d352 100644 --- a/src/upgrades/1.1.0/user_post_count_per_tid.js +++ b/src/upgrades/1.1.0/user_post_count_per_tid.js @@ -5,44 +5,46 @@ const async = require('async'); const winston = require('winston'); const db = require('../../database'); -module.exports = { - name: 'Users post count per tid', - timestamp: Date.UTC(2016, 3, 19), - method: function (callback) { - const batch = require('../../batch'); - const topics = require('../../topics'); - let count = 0; - batch.processSortedSet('topics:tid', (tids, next) => { - winston.verbose(`upgraded ${count} topics`); - count += tids.length; - async.each(tids, (tid, next) => { - db.delete(`tid:${tid}:posters`, (err) => { +const action = function (callback) { + const batch = require('../../batch'); + const topics = require('../../topics'); + let count = 0; + batch.processSortedSet('topics:tid', (tids, next) => { + winston.verbose(`upgraded ${count} topics`); + count += tids.length; + async.each(tids, (tid, next) => { + db.delete(`tid:${tid}:posters`, (err) => { + if (err) { + return next(err); + } + topics.getPids(tid, (err, pids) => { if (err) { return next(err); } - topics.getPids(tid, (err, pids) => { - if (err) { - return next(err); - } - if (!pids.length) { - return next(); - } + if (!pids.length) { + return next(); + } - async.eachSeries(pids, (pid, next) => { - db.getObjectField(`post:${pid}`, 'uid', (err, uid) => { - if (err) { - return next(err); - } - if (!parseInt(uid, 10)) { - return next(); - } - db.sortedSetIncrBy(`tid:${tid}:posters`, 1, uid, next); - }); - }, next); - }); + async.eachSeries(pids, (pid, next) => { + db.getObjectField(`post:${pid}`, 'uid', (err, uid) => { + if (err) { + return next(err); + } + if (!parseInt(uid, 10)) { + return next(); + } + db.sortedSetIncrBy(`tid:${tid}:posters`, 1, uid, next); + }); + }, next); }); - }, next); - }, {}, callback); - }, + }); + }, next); + }, {}, callback); +}; + +module.exports = { + name: 'Users post count per tid', + timestamp: Date.UTC(2016, 3, 19), + method: action, }; From 5a1bd8e53b170a65c69840d141806d14415efad0 Mon Sep 17 00:00:00 2001 From: seif khelifi Date: Sun, 22 Sep 2024 17:30:20 +0300 Subject: [PATCH 04/48] merged project 1 to project 2 --- src/groups/create.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/groups/create.js b/src/groups/create.js index 5172038052..9ba75b88e2 100644 --- a/src/groups/create.js +++ b/src/groups/create.js @@ -1,3 +1,4 @@ + 'use strict'; const meta = require('../meta'); @@ -8,16 +9,11 @@ const db = require('../database'); module.exports = function (Groups) { Groups.create = async function (data) { const isSystem = isSystemGroup(data); - const timestamp = data.timestamp || Date.now(); - let disableJoinRequests = parseInt(data.disableJoinRequests, 10) === 1 ? 1 : 0; - if (data.name === 'administrators') { - disableJoinRequests = 1; - } + const timestamp = getTimestamp(data); + const disableJoinRequests = getDisableJoinRequests(data); const disableLeave = parseInt(data.disableLeave, 10) === 1 ? 1 : 0; const isHidden = parseInt(data.hidden, 10) === 1; - Groups.validateGroupName(data.name); - const [exists, privGroupExists] = await Promise.all([ meta.userOrGroupExists(data.name), privilegeGroupExists(data.name), @@ -25,7 +21,7 @@ module.exports = function (Groups) { if (exists || privGroupExists) { throw new Error('[[error:group-already-exists]]'); } - + console.log('sarra : refactored code executed'); const memberCount = data.hasOwnProperty('ownerUid') ? 1 : 0; const isPrivate = data.hasOwnProperty('private') && data.private !== undefined ? parseInt(data.private, 10) === 1 : true; let groupData = { @@ -69,7 +65,15 @@ module.exports = function (Groups) { plugins.hooks.fire('action:group.create', { group: groupData }); return groupData; }; - + function getTimestamp(data) { + return data.timestamp || Date.now(); + } + function getDisableJoinRequests(data) { + if (data.name === 'administrators') { + return 1; + } + return parseInt(data.disableJoinRequests, 10) === 1 ? 1 : 0; + } function isSystemGroup(data) { return data.system === true || parseInt(data.system, 10) === 1 || Groups.systemGroups.includes(data.name) || From d1ba40274f35ed1f8c122301c82e252b28a51857 Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Sun, 22 Sep 2024 19:14:37 +0300 Subject: [PATCH 05/48] Attempting to add anonymous posting checkbox to composer.tpl - frontend --- .Rhistory | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .Rhistory diff --git a/.Rhistory b/.Rhistory new file mode 100644 index 0000000000..e69de29bb2 From 8031ead94de9f9db25d8c1fd43477e60a06c7bb9 Mon Sep 17 00:00:00 2001 From: Talal Date: Sun, 22 Sep 2024 21:55:16 +0300 Subject: [PATCH 06/48] Merged project 1 changes thali --- src/user/delete.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/user/delete.js b/src/user/delete.js index 8f99117c59..51ce498fde 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -15,6 +15,21 @@ const messaging = require('../messaging'); const plugins = require('../plugins'); const batch = require('../batch'); +async function updateCount(uids, name, fieldName) { + console.log('Talal: Starting updateCount function'); + await batch.processArray(uids, async (uids) => { + const counts = await db.sortedSetsCard(uids.map(uid => name + uid)); + const bulkSet = counts.map( + (count, index) => ([`user:${uids[index]}`, { [fieldName]: count || 0 }]) + ); + await db.setObjectBulk(bulkSet); + }, { + batch: 500, + }); + console.log('Talal: Finished updateCount function'); +} + + module.exports = function (User) { const deletesInProgress = {}; @@ -208,17 +223,6 @@ module.exports = function (User) { db.getSortedSetRange(`following:${uid}`, 0, -1), ]); - async function updateCount(uids, name, fieldName) { - await batch.processArray(uids, async (uids) => { - const counts = await db.sortedSetsCard(uids.map(uid => name + uid)); - const bulkSet = counts.map( - (count, index) => ([`user:${uids[index]}`, { [fieldName]: count || 0 }]) - ); - await db.setObjectBulk(bulkSet); - }, { - batch: 500, - }); - } const followingSets = followers.map(uid => `following:${uid}`); const followerSets = following.map(uid => `followers:${uid}`); From e1c51791172d802218a3457b6c5fdfe897ba37a2 Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 22 Sep 2024 22:09:26 +0300 Subject: [PATCH 07/48] Merged Project 1 changes fdounis --- src/flags.js | 75 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/src/flags.js b/src/flags.js index 00bce1d9bd..2583e03bb5 100644 --- a/src/flags.js +++ b/src/flags.js @@ -135,12 +135,34 @@ Flags.getCount = async function ({ uid, filters, query }) { }; Flags.getFlagIdsWithFilters = async function ({ filters, uid, query }) { - let sets = []; - const orSets = []; + initializeFilters(filters); + + const { sets, orSets } = buildSets(filters, uid); + + let flagIds = await getFlagIdsFromSets(sets); + + if (orSets.length) { + const _flagIds = await getFlagIdsFromOrSets(orSets); + flagIds = mergeFlagIds(flagIds, _flagIds, sets.length > 0); + } + + const result = await plugins.hooks.fire('filter:flags.getFlagIdsWithFilters', { + filters, + uid, + query, + flagIds, + }); + return result.flagIds; +}; - // Default filter +function initializeFilters(filters) { filters.page = filters.hasOwnProperty('page') ? Math.abs(parseInt(filters.page, 10) || 1) : 1; filters.perPage = filters.hasOwnProperty('perPage') ? Math.abs(parseInt(filters.perPage, 10) || 20) : 20; +} + +function buildSets(filters, uid) { + let sets = []; + const orSets = []; for (const type of Object.keys(filters)) { if (Flags._filters.hasOwnProperty(type)) { @@ -149,39 +171,36 @@ Flags.getFlagIdsWithFilters = async function ({ filters, uid, query }) { winston.warn(`[flags/list] No flag filter type found: ${type}`); } } - sets = (sets.length || orSets.length) ? sets : ['flags:datetime']; // No filter default - let flagIds = []; + if (!(sets.length || orSets.length)) { + sets = ['flags:datetime']; // No filter default + } + + return { sets, orSets }; +} + +async function getFlagIdsFromSets(sets) { if (sets.length === 1) { - flagIds = await db.getSortedSetRevRange(sets[0], 0, -1); + return await db.getSortedSetRevRange(sets[0], 0, -1); } else if (sets.length > 1) { - flagIds = await db.getSortedSetRevIntersect({ sets: sets, start: 0, stop: -1, aggregate: 'MAX' }); + return await db.getSortedSetRevIntersect({ sets: sets, start: 0, stop: -1, aggregate: 'MAX' }); } + return []; +} - if (orSets.length) { - let _flagIds = await Promise.all(orSets.map(async orSet => await db.getSortedSetRevUnion({ sets: orSet, start: 0, stop: -1, aggregate: 'MAX' }))); - - // Each individual orSet is ANDed together to construct the final list of flagIds - _flagIds = _.intersection(..._flagIds); +async function getFlagIdsFromOrSets(orSets) { + const _flagIds = await Promise.all(orSets.map(async orSet => db.getSortedSetRevUnion({ sets: orSet, start: 0, stop: -1, aggregate: 'MAX' }))); + return _.intersection(..._flagIds); +} - // Merge with flagIds returned by sets - if (sets.length) { - // If flag ids are already present, return a subset of flags that are in both sets - flagIds = _.intersection(flagIds, _flagIds); - } else { - // Otherwise, return all flags returned via orSets - flagIds = _.union(flagIds, _flagIds); - } +function mergeFlagIds(flagIds, _flagIds, hasSets) { + if (hasSets) { + return _.intersection(flagIds, _flagIds); } + return _.union(flagIds, _flagIds); +} + - const result = await plugins.hooks.fire('filter:flags.getFlagIdsWithFilters', { - filters, - uid, - query, - flagIds, - }); - return result.flagIds; -}; Flags.list = async function (data) { const filters = data.filters || {}; From 390540160b8e9e257867a3e976dc5d941289cdf5 Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Mon, 23 Sep 2024 13:28:13 +0300 Subject: [PATCH 08/48] Add 'Post anonymously' checkbox to composer.tpl for anonymous message posting functionality --- src/views/partials/chats/composer.tpl | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/views/partials/chats/composer.tpl b/src/views/partials/chats/composer.tpl index cae9634f9a..9c6a613677 100644 --- a/src/views/partials/chats/composer.tpl +++ b/src/views/partials/chats/composer.tpl @@ -1,6 +1,7 @@
@@ -14,6 +15,15 @@
+ + +
+ + +
+
@@ -24,4 +34,4 @@ -
\ No newline at end of file +
From 1d644a64d15156ffde6ae4e2078fc21bb840aeb3 Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Mon, 23 Sep 2024 13:47:43 +0300 Subject: [PATCH 09/48] undoing changes made to src/views.partials/chats/composer.tpl --- src/views/partials/chats/composer.tpl | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/views/partials/chats/composer.tpl b/src/views/partials/chats/composer.tpl index 9c6a613677..cae9634f9a 100644 --- a/src/views/partials/chats/composer.tpl +++ b/src/views/partials/chats/composer.tpl @@ -1,7 +1,6 @@
@@ -15,15 +14,6 @@
- - -
- - -
-
@@ -34,4 +24,4 @@ -
+
\ No newline at end of file From 4e6054c17a6e9f9f68f2561efaf1f0b4198ec6ae Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Mon, 23 Sep 2024 14:04:55 +0300 Subject: [PATCH 10/48] commenting out package in gitignore. file --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 42a1b3c705..2c50c26fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ dist/ yarn.lock npm-debug.log -node_modules/ +node_modules/* +node_modules/nodebb-plugin-composer-default sftp-config.json config.json jsconfig.json @@ -66,7 +67,7 @@ coverage test/files/normalise.jpg.png test/files/normalise-resized.jpg package-lock.json -/package.json +#/package.json *.mongodb link-plugins.sh test.sh From c5cada4fbd6bc924bef7c3aea58b64c1f138b0aa Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Mon, 23 Sep 2024 14:06:34 +0300 Subject: [PATCH 11/48] removed composer default file from package.json --- package.json | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000000..bc8e333e1a --- /dev/null +++ b/package.json @@ -0,0 +1,197 @@ +{ + "name": "nodebb", + "license": "GPL-3.0", + "description": "NodeBB Forum", + "version": "3.8.4", + "homepage": "https://www.nodebb.org", + "repository": { + "type": "git", + "url": "https://github.com/NodeBB/NodeBB/" + }, + "main": "app.js", + "scripts": { + "start": "node loader.js", + "lint": "eslint --cache ./nodebb .", + "test": "nyc --reporter=html --reporter=text-summary mocha", + "coverage": "nyc report --reporter=text-lcov > ./coverage/lcov.info", + "coveralls": "nyc report --reporter=text-lcov | coveralls && rm -r coverage" + }, + "nyc": { + "exclude": [ + "src/upgrades/*", + "test/*" + ] + }, + "lint-staged": { + "*.js": [ + "eslint --fix" + ] + }, + "dependencies": { + "@adactive/bootstrap-tagsinput": "0.8.2", + "@fontsource/inter": "5.0.18", + "@fontsource/poppins": "5.0.14", + "@fortawesome/fontawesome-free": "6.5.2", + "@isaacs/ttlcache": "1.4.1", + "@nodebb/spider-detector": "2.0.3", + "@popperjs/core": "2.11.8", + "ace-builds": "1.33.2", + "archiver": "7.0.1", + "async": "3.2.5", + "autoprefixer": "10.4.19", + "bcryptjs": "2.4.3", + "benchpressjs": "2.5.1", + "body-parser": "1.20.2", + "bootbox": "6.0.0", + "bootstrap": "5.3.3", + "bootswatch": "5.3.3", + "chalk": "4.1.2", + "chart.js": "4.4.2", + "cli-graph": "3.2.2", + "clipboard": "2.0.11", + "colors": "1.4.0", + "commander": "12.0.0", + "compare-versions": "6.1.0", + "compression": "1.7.4", + "connect-flash": "0.1.1", + "connect-mongo": "5.1.0", + "connect-multiparty": "2.2.0", + "connect-pg-simple": "9.0.1", + "connect-redis": "7.1.1", + "cookie-parser": "1.4.6", + "cron": "3.1.7", + "cropperjs": "1.6.2", + "csrf-sync": "4.0.3", + "daemon": "1.1.0", + "diff": "5.2.0", + "esbuild": "0.21.2", + "express": "4.19.2", + "express-session": "1.18.0", + "express-useragent": "1.0.15", + "fetch-cookie": "3.0.1", + "file-loader": "6.2.0", + "fs-extra": "11.2.0", + "graceful-fs": "4.2.11", + "helmet": "7.1.0", + "html-to-text": "9.0.5", + "imagesloaded": "5.0.0", + "ipaddr.js": "2.2.0", + "jquery": "3.7.1", + "jquery-deserialize": "2.0.0", + "jquery-form": "4.3.0", + "jquery-serializeobject": "1.0.0", + "jquery-ui": "1.13.3", + "jsesc": "3.0.2", + "json2csv": "5.0.7", + "jsonwebtoken": "9.0.2", + "lodash": "4.17.21", + "logrotate-stream": "0.2.9", + "lru-cache": "10.2.2", + "mime": "3.0.0", + "mkdirp": "3.0.1", + "mongodb": "6.6.1", + "morgan": "1.10.0", + "mousetrap": "1.6.5", + "multiparty": "4.2.3", + "nconf": "0.12.1", + "nodebb-plugin-2factor": "7.5.3", + "nodebb-plugin-dbsearch": "6.2.5", + "nodebb-plugin-emoji": "5.1.15", + "nodebb-plugin-emoji-android": "4.0.0", + "nodebb-plugin-markdown": "12.2.6", + "nodebb-plugin-mentions": "4.4.3", + "nodebb-plugin-ntfy": "1.7.4", + "nodebb-plugin-spam-be-gone": "2.2.2", + "nodebb-rewards-essentials": "1.0.0", + "nodebb-theme-harmony": "1.2.63", + "nodebb-theme-lavender": "7.1.8", + "nodebb-theme-peace": "2.2.6", + "nodebb-theme-persona": "13.3.25", + "nodebb-widget-essentials": "7.0.18", + "nodemailer": "6.9.13", + "nprogress": "0.2.0", + "passport": "0.7.0", + "passport-http-bearer": "1.0.1", + "passport-local": "1.0.0", + "pg": "8.11.5", + "pg-cursor": "2.10.5", + "postcss": "8.4.38", + "postcss-clean": "1.2.0", + "progress-webpack-plugin": "1.0.16", + "prompt": "1.3.0", + "ioredis": "5.4.1", + "rimraf": "5.0.7", + "rss": "1.2.2", + "rtlcss": "4.1.1", + "sanitize-html": "2.13.0", + "sass": "1.77.1", + "semver": "7.6.2", + "serve-favicon": "2.5.0", + "sharp": "0.32.6", + "sitemap": "7.1.1", + "socket.io": "4.7.5", + "socket.io-client": "4.7.5", + "@socket.io/redis-adapter": "8.3.0", + "sortablejs": "1.15.2", + "spdx-license-list": "6.9.0", + "terser-webpack-plugin": "5.3.10", + "textcomplete": "0.18.2", + "textcomplete.contenteditable": "0.1.1", + "timeago": "1.6.7", + "tinycon": "0.6.8", + "toobusy-js": "0.5.1", + "tough-cookie": "4.1.4", + "validator": "13.12.0", + "webpack": "5.91.0", + "webpack-merge": "5.10.0", + "winston": "3.13.0", + "workerpool": "9.1.1", + "xml": "1.0.1", + "xregexp": "5.1.1", + "yargs": "17.7.2", + "zxcvbn": "4.4.2" + }, + "devDependencies": { + "@apidevtools/swagger-parser": "10.1.0", + "@commitlint/cli": "19.3.0", + "@commitlint/config-angular": "19.3.0", + "coveralls": "3.1.1", + "eslint": "8.57.0", + "eslint-config-nodebb": "0.2.1", + "eslint-plugin-import": "2.29.1", + "grunt": "1.6.1", + "grunt-contrib-watch": "1.1.0", + "husky": "8.0.3", + "jsdom": "24.0.0", + "lint-staged": "15.2.2", + "mocha": "10.4.0", + "mocha-lcov-reporter": "1.3.0", + "mockdate": "3.0.5", + "nyc": "15.1.0", + "smtp-server": "3.13.4" + }, + "optionalDependencies": { + "sass-embedded": "1.77.1" + }, + "resolutions": { + "*/jquery": "3.7.1" + }, + "bugs": { + "url": "https://github.com/NodeBB/NodeBB/issues" + }, + "engines": { + "node": ">=18" + }, + "maintainers": [ + { + "name": "Julian Lam", + "email": "julian@nodebb.org", + "url": "https://github.com/julianlam" + }, + { + "name": "Barış Soner Uşaklı", + "email": "baris@nodebb.org", + "url": "https://github.com/barisusakli" + } + ] +} \ No newline at end of file From 72dd0734f9d2a7433aaaeaa39b1a799f91093ea0 Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Tue, 24 Sep 2024 12:22:50 +0300 Subject: [PATCH 12/48] created exception for nodebb-plugin-composer in .gitignore --- .gitignore | 2 +- .../nodebb-plugin-composer-default/.eslintrc | 3 + .../.gitattributes | 22 + .../nodebb-plugin-composer-default/.jshintrc | 86 ++ .../nodebb-plugin-composer-default/LICENSE | 7 + .../nodebb-plugin-composer-default/README.md | 11 + .../controllers.js | 11 + .../nodebb-plugin-composer-default/library.js | 310 ++++++ .../package.json | 43 + .../plugin.json | 35 + .../screenshots/desktop.png | Bin 0 -> 27980 bytes .../screenshots/mobile.png | Bin 0 -> 21999 bytes .../static/lib/.eslintrc | 6 + .../static/lib/admin.js | 15 + .../static/lib/client.js | 89 ++ .../static/lib/composer.js | 886 ++++++++++++++++++ .../static/lib/composer/autocomplete.js | 99 ++ .../static/lib/composer/categoryList.js | 115 +++ .../static/lib/composer/controls.js | 171 ++++ .../static/lib/composer/drafts.js | 341 +++++++ .../static/lib/composer/formatting.js | 194 ++++ .../static/lib/composer/post-queue.js | 25 + .../static/lib/composer/preview.js | 105 +++ .../static/lib/composer/resize.js | 197 ++++ .../static/lib/composer/scheduler.js | 201 ++++ .../static/lib/composer/tags.js | 227 +++++ .../static/lib/composer/uploads.js | 271 ++++++ .../static/scss/composer.scss | 385 ++++++++ .../static/scss/page-compose.scss | 35 + .../static/scss/textcomplete.scss | 26 + .../static/scss/zen-mode.scss | 51 + .../admin/plugins/composer-default.tpl | 22 + .../static/templates/compose.tpl | 27 + .../static/templates/composer.tpl | 46 + .../templates/modals/topic-scheduler.tpl | 4 + .../partials/composer-formatting.tpl | 75 ++ .../templates/partials/composer-tags.tpl | 17 + .../partials/composer-title-container.tpl | 55 ++ .../partials/composer-write-preview.tpl | 10 + .../websockets.js | 94 ++ 40 files changed, 4318 insertions(+), 1 deletion(-) create mode 100644 node_modules/nodebb-plugin-composer-default/.eslintrc create mode 100644 node_modules/nodebb-plugin-composer-default/.gitattributes create mode 100644 node_modules/nodebb-plugin-composer-default/.jshintrc create mode 100644 node_modules/nodebb-plugin-composer-default/LICENSE create mode 100644 node_modules/nodebb-plugin-composer-default/README.md create mode 100644 node_modules/nodebb-plugin-composer-default/controllers.js create mode 100644 node_modules/nodebb-plugin-composer-default/library.js create mode 100644 node_modules/nodebb-plugin-composer-default/package.json create mode 100644 node_modules/nodebb-plugin-composer-default/plugin.json create mode 100644 node_modules/nodebb-plugin-composer-default/screenshots/desktop.png create mode 100644 node_modules/nodebb-plugin-composer-default/screenshots/mobile.png create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/.eslintrc create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/admin.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/client.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/categoryList.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/controls.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/drafts.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/formatting.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/post-queue.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/preview.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/resize.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/scheduler.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/lib/composer/uploads.js create mode 100644 node_modules/nodebb-plugin-composer-default/static/scss/composer.scss create mode 100644 node_modules/nodebb-plugin-composer-default/static/scss/page-compose.scss create mode 100644 node_modules/nodebb-plugin-composer-default/static/scss/textcomplete.scss create mode 100644 node_modules/nodebb-plugin-composer-default/static/scss/zen-mode.scss create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/admin/plugins/composer-default.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/compose.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/composer.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/modals/topic-scheduler.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-tags.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-write-preview.tpl create mode 100644 node_modules/nodebb-plugin-composer-default/websockets.js diff --git a/.gitignore b/.gitignore index 2c50c26fd9..239349bc13 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ dist/ yarn.lock npm-debug.log node_modules/* -node_modules/nodebb-plugin-composer-default +!node_modules/nodebb-plugin-composer-default sftp-config.json config.json jsconfig.json diff --git a/node_modules/nodebb-plugin-composer-default/.eslintrc b/node_modules/nodebb-plugin-composer-default/.eslintrc new file mode 100644 index 0000000000..74e8dc064a --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "nodebb/lib" +} \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/.gitattributes b/node_modules/nodebb-plugin-composer-default/.gitattributes new file mode 100644 index 0000000000..412eeda78d --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/.gitattributes @@ -0,0 +1,22 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Custom for Visual Studio +*.cs diff=csharp +*.sln merge=union +*.csproj merge=union +*.vbproj merge=union +*.fsproj merge=union +*.dbproj merge=union + +# Standard to msysgit +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/node_modules/nodebb-plugin-composer-default/.jshintrc b/node_modules/nodebb-plugin-composer-default/.jshintrc new file mode 100644 index 0000000000..1981c254c5 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/.jshintrc @@ -0,0 +1,86 @@ +{ + // JSHint Default Configuration File (as on JSHint website) + // See http://jshint.com/docs/ for more details + + "maxerr" : 50, // {int} Maximum error before stopping + + // Enforcing + "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) + "camelcase" : false, // true: Identifiers must be in camelCase + "curly" : true, // true: Require {} for every new block or scope + "eqeqeq" : true, // true: Require triple equals (===) for comparison + "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() + "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` + "indent" : 4, // {int} Number of spaces to use for indentation + "latedef" : false, // true: Require variables/functions to be defined before being used + "newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()` + "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` + "noempty" : true, // true: Prohibit use of empty blocks + "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) + "plusplus" : false, // true: Prohibit use of `++` & `--` + "quotmark" : false, // Quotation mark consistency: + // false : do nothing (default) + // true : ensure whatever is used is consistent + // "single" : require single quotes + // "double" : require double quotes + "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) + "unused" : true, // true: Require all defined variables be used + "strict" : true, // true: Requires all functions run in ES5 Strict Mode + "trailing" : false, // true: Prohibit trailing whitespaces + "maxparams" : false, // {int} Max number of formal params allowed per function + "maxdepth" : false, // {int} Max depth of nested blocks (within functions) + "maxstatements" : false, // {int} Max number statements per function + "maxcomplexity" : false, // {int} Max cyclomatic complexity per function + "maxlen" : false, // {int} Max number of characters per line + + // Relaxing + "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) + "boss" : false, // true: Tolerate assignments where comparisons would be expected + "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. + "eqnull" : false, // true: Tolerate use of `== null` + "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) + "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) + "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) + // (ex: `for each`, multiple try/catch, function expression…) + "evil" : false, // true: Tolerate use of `eval` and `new Function()` + "expr" : false, // true: Tolerate `ExpressionStatement` as Programs + "funcscope" : false, // true: Tolerate defining variables inside control statements" + "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') + "iterator" : false, // true: Tolerate using the `__iterator__` property + "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block + "laxbreak" : false, // true: Tolerate possibly unsafe line breakings + "laxcomma" : false, // true: Tolerate comma-first style coding + "loopfunc" : false, // true: Tolerate functions being defined in loops + "multistr" : false, // true: Tolerate multi-line strings + "proto" : false, // true: Tolerate using the `__proto__` property + "scripturl" : false, // true: Tolerate script-targeted URLs + "smarttabs" : false, // true: Tolerate mixed tabs/spaces when used for alignment + "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` + "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation + "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` + "validthis" : false, // true: Tolerate using this in a non-constructor function + + // Environments + "browser" : true, // Web Browser (window, document, etc) + "couch" : false, // CouchDB + "devel" : true, // Development/debugging (alert, confirm, etc) + "dojo" : false, // Dojo Toolkit + "jquery" : true, // jQuery + "mootools" : false, // MooTools + "node" : true, // Node.js + "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) + "prototypejs" : false, // Prototype and Scriptaculous + "rhino" : false, // Rhino + "worker" : false, // Web Workers + "wsh" : false, // Windows Scripting Host + "yui" : false, // Yahoo User Interface + + // Legacy + "nomen" : false, // true: Prohibit dangling `_` in variables + "onevar" : false, // true: Allow only one `var` statement per function + "passfail" : false, // true: Stop on first error + "white" : false, // true: Check against strict whitespace and indentation rules + + // Custom Globals + "globals" : {} // additional predefined global variables +} \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/LICENSE b/node_modules/nodebb-plugin-composer-default/LICENSE new file mode 100644 index 0000000000..b8658d3aa1 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2016 NodeBB Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/README.md b/node_modules/nodebb-plugin-composer-default/README.md new file mode 100644 index 0000000000..7bcfff9aff --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/README.md @@ -0,0 +1,11 @@ +# Default Composer for NodeBB + +This plugin activates the default composer for NodeBB. It is activated by default, but can be swapped out as necessary. + +## Screenshots + +### Desktop +![Desktop Composer](screenshots/desktop.png?raw=true) + +### Mobile Devices +![Mobile Composer](screenshots/mobile.png?raw=true) \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/controllers.js b/node_modules/nodebb-plugin-composer-default/controllers.js new file mode 100644 index 0000000000..cef271849f --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/controllers.js @@ -0,0 +1,11 @@ +'use strict'; + +const Controllers = {}; + +Controllers.renderAdminPage = function (req, res) { + res.render('admin/plugins/composer-default', { + title: 'Composer (Default)', + }); +}; + +module.exports = Controllers; diff --git a/node_modules/nodebb-plugin-composer-default/library.js b/node_modules/nodebb-plugin-composer-default/library.js new file mode 100644 index 0000000000..c80eef1f5e --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/library.js @@ -0,0 +1,310 @@ +'use strict'; + +const url = require('url'); + +const nconf = require.main.require('nconf'); +const validator = require('validator'); + +const plugins = require.main.require('./src/plugins'); +const topics = require.main.require('./src/topics'); +const categories = require.main.require('./src/categories'); +const posts = require.main.require('./src/posts'); +const user = require.main.require('./src/user'); +const meta = require.main.require('./src/meta'); +const privileges = require.main.require('./src/privileges'); +const translator = require.main.require('./src/translator'); +const utils = require.main.require('./src/utils'); +const helpers = require.main.require('./src/controllers/helpers'); +const SocketPlugins = require.main.require('./src/socket.io/plugins'); +const socketMethods = require('./websockets'); + +const plugin = module.exports; + +plugin.socketMethods = socketMethods; + +plugin.init = async function (data) { + const { router } = data; + const routeHelpers = require.main.require('./src/routes/helpers'); + const controllers = require('./controllers'); + SocketPlugins.composer = socketMethods; + routeHelpers.setupAdminPageRoute(router, '/admin/plugins/composer-default', controllers.renderAdminPage); +}; + +plugin.appendConfig = async function (config) { + config['composer-default'] = await meta.settings.get('composer-default'); + return config; +}; + +plugin.addAdminNavigation = async function (header) { + header.plugins.push({ + route: '/plugins/composer-default', + icon: 'fa-edit', + name: 'Composer (Default)', + }); + return header; +}; + +plugin.addPrefetchTags = async function (hookData) { + const prefetch = [ + '/assets/src/modules/composer.js', '/assets/src/modules/composer/uploads.js', '/assets/src/modules/composer/drafts.js', + '/assets/src/modules/composer/tags.js', '/assets/src/modules/composer/categoryList.js', '/assets/src/modules/composer/resize.js', + '/assets/src/modules/composer/autocomplete.js', '/assets/templates/composer.tpl', + `/assets/language/${meta.config.defaultLang || 'en-GB'}/topic.json`, + `/assets/language/${meta.config.defaultLang || 'en-GB'}/modules.json`, + `/assets/language/${meta.config.defaultLang || 'en-GB'}/tags.json`, + ]; + + hookData.links = hookData.links.concat(prefetch.map(path => ({ + rel: 'prefetch', + href: `${nconf.get('relative_path') + path}?${meta.config['cache-buster']}`, + }))); + + return hookData; +}; + +plugin.getFormattingOptions = async function () { + const defaultVisibility = { + mobile: true, + desktop: true, + + // op or reply + main: true, + reply: true, + }; + let payload = { + defaultVisibility, + options: [ + { + name: 'tags', + title: '[[global:tags.tags]]', + className: 'fa fa-tags', + visibility: { + ...defaultVisibility, + desktop: false, + }, + }, + { + name: 'zen', + title: '[[modules:composer.zen-mode]]', + className: 'fa fa-arrows-alt', + visibility: defaultVisibility, + }, + ], + }; + if (parseInt(meta.config.allowTopicsThumbnail, 10) === 1) { + payload.options.push({ + name: 'thumbs', + title: '[[topic:composer.thumb-title]]', + className: 'fa fa-address-card-o', + badge: true, + visibility: { + ...defaultVisibility, + reply: false, + }, + }); + } + + payload = await plugins.hooks.fire('filter:composer.formatting', payload); + + payload.options.forEach((option) => { + option.visibility = { + ...defaultVisibility, + ...option.visibility || {}, + }; + }); + + return payload ? payload.options : null; +}; + +plugin.filterComposerBuild = async function (hookData) { + const { req } = hookData; + const { res } = hookData; + + if (req.query.p) { + try { + const a = url.parse(req.query.p, true, true); + return helpers.redirect(res, `/${(a.path || '').replace(/^\/*/, '')}`); + } catch (e) { + return helpers.redirect(res, '/'); + } + } else if (!req.query.pid && !req.query.tid && !req.query.cid) { + return helpers.redirect(res, '/'); + } + const [ + isMainPost, + postData, + topicData, + categoryData, + isAdmin, + isMod, + formatting, + tagWhitelist, + globalPrivileges, + canTagTopics, + canScheduleTopics, + ] = await Promise.all([ + posts.isMain(req.query.pid), + getPostData(req), + getTopicData(req), + categories.getCategoryFields(req.query.cid, [ + 'name', 'icon', 'color', 'bgColor', 'backgroundImage', 'imageClass', 'minTags', 'maxTags', + ]), + user.isAdministrator(req.uid), + isModerator(req), + plugin.getFormattingOptions(), + getTagWhitelist(req.query, req.uid), + privileges.global.get(req.uid), + canTag(req), + canSchedule(req), + ]); + + const isEditing = !!req.query.pid; + const isGuestPost = postData && parseInt(postData.uid, 10) === 0; + const save_id = utils.generateSaveId(req.uid); + const discardRoute = generateDiscardRoute(req, topicData); + const body = await generateBody(req, postData); + + let action = 'topics.post'; + let isMain = isMainPost; + if (req.query.tid) { + action = 'posts.reply'; + } else if (req.query.pid) { + action = 'posts.edit'; + } else { + isMain = true; + } + globalPrivileges['topics:tag'] = canTagTopics; + const cid = parseInt(req.query.cid, 10); + const topicTitle = topicData && topicData.title ? topicData.title.replace(/%/g, '%').replace(/,/g, ',') : validator.escape(String(req.query.title || '')); + return { + req: req, + res: res, + templateData: { + disabled: !req.query.pid && !req.query.tid && !req.query.cid, + pid: parseInt(req.query.pid, 10), + tid: parseInt(req.query.tid, 10), + cid: cid || (topicData ? topicData.cid : null), + action: action, + toPid: parseInt(req.query.toPid, 10), + discardRoute: discardRoute, + + resizable: false, + allowTopicsThumbnail: parseInt(meta.config.allowTopicsThumbnail, 10) === 1 && isMain, + + // can't use title property as that is used for page title + topicTitle: topicTitle, + titleLength: topicTitle ? topicTitle.length : 0, + topic: topicData, + thumb: topicData ? topicData.thumb : '', + body: body, + + isMain: isMain, + isTopicOrMain: !!req.query.cid || isMain, + maximumTitleLength: meta.config.maximumTitleLength, + maximumPostLength: meta.config.maximumPostLength, + minimumTagLength: meta.config.minimumTagLength || 3, + maximumTagLength: meta.config.maximumTagLength || 15, + tagWhitelist: tagWhitelist, + selectedCategory: cid ? categoryData : null, + minTags: categoryData.minTags, + maxTags: categoryData.maxTags, + + isTopic: !!req.query.cid, + isEditing: isEditing, + canSchedule: canScheduleTopics, + showHandleInput: meta.config.allowGuestHandles === 1 && + (req.uid === 0 || (isEditing && isGuestPost && (isAdmin || isMod))), + handle: postData ? postData.handle || '' : undefined, + formatting: formatting, + isAdminOrMod: isAdmin || isMod, + save_id: save_id, + privileges: globalPrivileges, + 'composer:showHelpTab': meta.config['composer:showHelpTab'] === 1, + }, + }; +}; + +function generateDiscardRoute(req, topicData) { + if (req.query.cid) { + return `${nconf.get('relative_path')}/category/${validator.escape(String(req.query.cid))}`; + } else if ((req.query.tid || req.query.pid)) { + if (topicData) { + return `${nconf.get('relative_path')}/topic/${topicData.slug}`; + } + return `${nconf.get('relative_path')}/`; + } +} + +async function generateBody(req, postData) { + let body = ''; + // Quoted reply + if (req.query.toPid && parseInt(req.query.quoted, 10) === 1 && postData) { + const username = await user.getUserField(postData.uid, 'username'); + const translated = await translator.translate(`[[modules:composer.user-said, ${username}]]`); + body = `${translated}\n` + + `> ${postData ? `${postData.content.replace(/\n/g, '\n> ')}\n\n` : ''}`; + } else if (req.query.body || req.query.content) { + body = validator.escape(String(req.query.body || req.query.content)); + } + body = postData ? postData.content : ''; + return translator.escape(body); +} + +async function getPostData(req) { + if (!req.query.pid && !req.query.toPid) { + return null; + } + + return await posts.getPostData(req.query.pid || req.query.toPid); +} + +async function getTopicData(req) { + if (req.query.tid) { + return await topics.getTopicData(req.query.tid); + } else if (req.query.pid) { + return await topics.getTopicDataByPid(req.query.pid); + } + return null; +} + +async function isModerator(req) { + if (!req.loggedIn) { + return false; + } + const cid = cidFromQuery(req.query); + return await user.isModerator(req.uid, cid); +} + +async function canTag(req) { + if (parseInt(req.query.cid, 10)) { + return await privileges.categories.can('topics:tag', req.query.cid, req.uid); + } + return true; +} + +async function canSchedule(req) { + if (parseInt(req.query.cid, 10)) { + return await privileges.categories.can('topics:schedule', req.query.cid, req.uid); + } + return false; +} + +async function getTagWhitelist(query, uid) { + const cid = await cidFromQuery(query); + const [tagWhitelist, isAdminOrMod] = await Promise.all([ + categories.getTagWhitelist([cid]), + privileges.categories.isAdminOrMod(cid, uid), + ]); + return categories.filterTagWhitelist(tagWhitelist[0], isAdminOrMod); +} + +async function cidFromQuery(query) { + if (query.cid) { + return query.cid; + } else if (query.tid) { + return await topics.getTopicField(query.tid, 'cid'); + } else if (query.pid) { + return await posts.getCidByPid(query.pid); + } + return null; +} diff --git a/node_modules/nodebb-plugin-composer-default/package.json b/node_modules/nodebb-plugin-composer-default/package.json new file mode 100644 index 0000000000..625184980c --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/package.json @@ -0,0 +1,43 @@ +{ + "name": "nodebb-plugin-composer-default", + "version": "10.2.36", + "description": "Default composer for NodeBB", + "main": "library.js", + "repository": { + "type": "git", + "url": "https://github.com/NodeBB/nodebb-plugin-composer-default" + }, + "scripts": { + "lint": "eslint ." + }, + "keywords": [ + "nodebb", + "plugin", + "composer", + "markdown" + ], + "author": { + "name": "NodeBB Team", + "email": "sales@nodebb.org" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/NodeBB/nodebb-plugin-composer-default/issues" + }, + "readmeFilename": "README.md", + "nbbpm": { + "compatibility": "^3.0.0" + }, + "dependencies": { + "@textcomplete/contenteditable": "^0.1.12", + "@textcomplete/core": "^0.1.12", + "@textcomplete/textarea": "^0.1.12", + "screenfull": "^5.0.2", + "validator": "^13.7.0" + }, + "devDependencies": { + "eslint": "^7.32.0", + "eslint-config-nodebb": "^0.0.1", + "eslint-plugin-import": "^2.23.4" + } +} diff --git a/node_modules/nodebb-plugin-composer-default/plugin.json b/node_modules/nodebb-plugin-composer-default/plugin.json new file mode 100644 index 0000000000..c75ef14259 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/plugin.json @@ -0,0 +1,35 @@ +{ + "id": "nodebb-plugin-composer-default", + "url": "https://github.com/NodeBB/nodebb-plugin-composer-default", + "library": "library.js", + "hooks": [ + { "hook": "static:app.load", "method": "init" }, + { "hook": "filter:config.get", "method": "appendConfig" }, + { "hook": "filter:composer.build", "method": "filterComposerBuild" }, + { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }, + { "hook": "filter:meta.getLinkTags", "method": "addPrefetchTags" } + ], + "scss": [ + "./static/scss/composer.scss" + ], + "scripts": [ + "./static/lib/client.js", + "./node_modules/screenfull/dist/screenfull.js" + ], + "modules": { + "composer.js": "./static/lib/composer.js", + "composer/categoryList.js": "./static/lib/composer/categoryList.js", + "composer/controls.js": "./static/lib/composer/controls.js", + "composer/drafts.js": "./static/lib/composer/drafts.js", + "composer/formatting.js": "./static/lib/composer/formatting.js", + "composer/preview.js": "./static/lib/composer/preview.js", + "composer/resize.js": "./static/lib/composer/resize.js", + "composer/scheduler.js": "./static/lib/composer/scheduler.js", + "composer/tags.js": "./static/lib/composer/tags.js", + "composer/uploads.js": "./static/lib/composer/uploads.js", + "composer/autocomplete.js": "./static/lib/composer/autocomplete.js", + "composer/post-queue.js": "./static/lib/composer/post-queue.js", + "../admin/plugins/composer-default.js": "./static/lib/admin.js" + }, + "templates": "static/templates" +} \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/screenshots/desktop.png b/node_modules/nodebb-plugin-composer-default/screenshots/desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d4631e4e5cb94c39729b3c68fc1baebae80c2d GIT binary patch literal 27980 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 { + let pushData = { + save_id: data.save_id, + action: 'topics.post', + cid: data.cid, + handle: data.handle, + title: data.title || '', + body: data.body || '', + tags: data.tags || [], + modified: !!((data.title && data.title.length) || (data.body && data.body.length)), + isMain: true, + }; + + ({ pushData } = await hooks.fire('filter:composer.topic.push', { + data: data, + pushData: pushData, + })); + + push(pushData); + }; + + composer.addQuote = function (data) { + // tid, toPid, selectedPid, title, username, text, uuid + data.uuid = data.uuid || composer.active; + + var escapedTitle = (data.title || '') + .replace(/([\\`*_{}[\]()#+\-.!])/g, '\\$1') + .replace(/\[/g, '[') + .replace(/\]/g, ']') + .replace(/%/g, '%') + .replace(/,/g, ','); + + if (data.body) { + data.body = '> ' + data.body.replace(/\n/g, '\n> ') + '\n\n'; + } + var link = '[' + escapedTitle + '](' + config.relative_path + '/post/' + encodeURIComponent(data.selectedPid || data.toPid) + ')'; + if (data.uuid === undefined) { + if (data.title && (data.selectedPid || data.toPid)) { + composer.newReply({ + tid: data.tid, + toPid: data.toPid, + title: data.title, + body: '[[modules:composer.user-said-in, ' + data.username + ', ' + link + ']]\n' + data.body, + }); + } else { + composer.newReply({ + tid: data.tid, + toPid: data.toPid, + title: data.title, + body: '[[modules:composer.user-said, ' + data.username + ']]\n' + data.body, + }); + } + return; + } else if (data.uuid !== composer.active) { + // If the composer is not currently active, activate it + composer.load(data.uuid); + } + + var postContainer = $('.composer[data-uuid="' + data.uuid + '"]'); + var bodyEl = postContainer.find('textarea'); + var prevText = bodyEl.val(); + if (data.title && (data.selectedPid || data.toPid)) { + translator.translate('[[modules:composer.user-said-in, ' + data.username + ', ' + link + ']]\n', config.defaultLang, onTranslated); + } else { + translator.translate('[[modules:composer.user-said, ' + data.username + ']]\n', config.defaultLang, onTranslated); + } + + function onTranslated(translated) { + composer.posts[data.uuid].body = (prevText.length ? prevText + '\n\n' : '') + translated + data.body; + bodyEl.val(composer.posts[data.uuid].body); + focusElements(postContainer); + preview.render(postContainer); + } + }; + + composer.newReply = function (data) { + translator.translate(data.body, config.defaultLang, function (translated) { + push({ + save_id: data.save_id, + action: 'posts.reply', + tid: data.tid, + toPid: data.toPid, + title: data.title, + body: translated, + modified: !!(translated && translated.length), + isMain: false, + }); + }); + }; + + composer.editPost = function (data) { + // pid, text + socket.emit('plugins.composer.push', data.pid, function (err, postData) { + if (err) { + return alerts.error(err); + } + postData.save_id = data.save_id; + postData.action = 'posts.edit'; + postData.pid = data.pid; + postData.modified = false; + if (data.body) { + postData.body = data.body; + postData.modified = true; + } + if (data.title) { + postData.title = data.title; + postData.modified = true; + } + push(postData); + }); + }; + + composer.load = function (post_uuid) { + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + if (postContainer.length) { + activate(post_uuid); + resize.reposition(postContainer); + focusElements(postContainer); + onShow(); + } else if (composer.formatting) { + createNewComposer(post_uuid); + } else { + socket.emit('plugins.composer.getFormattingOptions', function (err, options) { + if (err) { + return alerts.error(err); + } + composer.formatting = options; + createNewComposer(post_uuid); + }); + } + }; + + composer.enhance = function (postContainer, post_uuid, postData) { + /* + This method enhances a composer container with client-side sugar (preview, etc) + Everything in here also applies to the /compose route + */ + + if (!post_uuid && !postData) { + post_uuid = utils.generateUUID(); + composer.posts[post_uuid] = ajaxify.data; + postData = ajaxify.data; + postContainer.attr('data-uuid', post_uuid); + } + + categoryList.init(postContainer, composer.posts[post_uuid]); + scheduler.init(postContainer, composer.posts); + + formatting.addHandler(postContainer); + formatting.addComposerButtons(); + preview.handleToggler(postContainer); + postQueue.showAlert(postContainer, postData); + uploads.initialize(post_uuid); + tags.init(postContainer, composer.posts[post_uuid]); + autocomplete.init(postContainer, post_uuid); + + postContainer.on('change', 'input, textarea', function () { + composer.posts[post_uuid].modified = true; + }); + + postContainer.on('click', '.composer-submit', function (e) { + e.preventDefault(); + e.stopPropagation(); // Other click events bring composer back to active state which is undesired on submit + + $(this).attr('disabled', true); + post(post_uuid); + }); + + require(['mousetrap'], function (mousetrap) { + mousetrap(postContainer.get(0)).bind('mod+enter', function () { + postContainer.find('.composer-submit').attr('disabled', true); + post(post_uuid); + }); + }); + + postContainer.find('.composer-discard').on('click', function (e) { + e.preventDefault(); + + if (!composer.posts[post_uuid].modified) { + composer.discard(post_uuid); + return removeComposerHistory(); + } + + formatting.exitFullscreen(); + + var btn = $(this).prop('disabled', true); + translator.translate('[[modules:composer.discard]]', function (translated) { + bootbox.confirm(translated, function (confirm) { + if (confirm) { + composer.discard(post_uuid); + removeComposerHistory(); + } + btn.prop('disabled', false); + }); + }); + }); + + postContainer.find('.composer-minimize, .minimize .trigger').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + composer.minimize(post_uuid); + }); + + const textareaEl = postContainer.find('textarea'); + textareaEl.on('input propertychange', utils.debounce(function () { + preview.render(postContainer); + }, 250)); + + textareaEl.on('scroll', function () { + preview.matchScroll(postContainer); + }); + + drafts.init(postContainer, postData); + const draft = drafts.get(postData.save_id); + + preview.render(postContainer, function () { + preview.matchScroll(postContainer); + }); + + handleHelp(postContainer); + handleSearch(postContainer); + focusElements(postContainer); + if (postData.action === 'posts.edit') { + composer.updateThumbCount(post_uuid, postContainer); + } + + // Hide "zen mode" if fullscreen API is not enabled/available (ahem, iOS...) + if (!screenfull.isEnabled) { + $('[data-format="zen"]').parent().addClass('hidden'); + } + + hooks.fire('action:composer.enhanced', { postContainer, postData, draft }); + }; + + async function getSelectedCategory(postData) { + if (ajaxify.data.template.category && parseInt(postData.cid, 10) === parseInt(ajaxify.data.cid, 10)) { + // no need to load data if we are already on the category page + return ajaxify.data; + } else if (parseInt(postData.cid, 10)) { + return await api.get(`/api/category/${postData.cid}`, {}); + } + return null; + } + + async function createNewComposer(post_uuid) { + var postData = composer.posts[post_uuid]; + + var isTopic = postData ? postData.hasOwnProperty('cid') : false; + var isMain = postData ? !!postData.isMain : false; + var isEditing = postData ? !!postData.pid : false; + var isGuestPost = postData ? parseInt(postData.uid, 10) === 0 : false; + const isScheduled = postData.timestamp > Date.now(); + + // see + // https://github.com/NodeBB/NodeBB/issues/2994 and + // https://github.com/NodeBB/NodeBB/issues/1951 + // remove when 1951 is resolved + + var title = postData.title.replace(/%/g, '%').replace(/,/g, ','); + postData.category = await getSelectedCategory(postData); + const privileges = postData.category ? postData.category.privileges : ajaxify.data.privileges; + var data = { + topicTitle: title, + titleLength: title.length, + body: translator.escape(utils.escapeHTML(postData.body)), + mobile: composer.bsEnvironment === 'xs' || composer.bsEnvironment === 'sm', + resizable: true, + thumb: postData.thumb, + isTopicOrMain: isTopic || isMain, + maximumTitleLength: config.maximumTitleLength, + maximumPostLength: config.maximumPostLength, + minimumTagLength: config.minimumTagLength, + maximumTagLength: config.maximumTagLength, + 'composer:showHelpTab': config['composer:showHelpTab'], + isTopic: isTopic, + isEditing: isEditing, + canSchedule: !!(isMain && privileges && + ((privileges['topics:schedule'] && !isEditing) || (isScheduled && privileges.view_scheduled))), + showHandleInput: config.allowGuestHandles && + (app.user.uid === 0 || (isEditing && isGuestPost && app.user.isAdmin)), + handle: postData ? postData.handle || '' : undefined, + formatting: composer.formatting, + tagWhitelist: postData.category ? postData.category.tagWhitelist : ajaxify.data.tagWhitelist, + privileges: app.user.privileges, + selectedCategory: postData.category, + submitOptions: [ + // Add items using `filter:composer.create`, or just add them to the
    in DOM + // { + // action: 'foobar', + // text: 'Text Label', + // } + ], + }; + + if (data.mobile) { + mobileHistoryAppend(); + + app.toggleNavbar(false); + } + + postData.mobile = composer.bsEnvironment === 'xs' || composer.bsEnvironment === 'sm'; + + ({ postData, createData: data } = await hooks.fire('filter:composer.create', { + postData: postData, + createData: data, + })); + + app.parseAndTranslate('composer', data, function (composerTemplate) { + if ($('.composer.composer[data-uuid="' + post_uuid + '"]').length) { + return; + } + composerTemplate = $(composerTemplate); + + composerTemplate.find('.title').each(function () { + $(this).text(translator.unescape($(this).text())); + }); + + composerTemplate.attr('data-uuid', post_uuid); + + $(document.body).append(composerTemplate); + + var postContainer = $(composerTemplate[0]); + + resize.reposition(postContainer); + composer.enhance(postContainer, post_uuid, postData); + /* + Everything after this line is applied to the resizable composer only + Want something done to both resizable composer and the one in /compose? + Put it in composer.enhance(). + + Eventually, stuff after this line should be moved into composer.enhance(). + */ + + activate(post_uuid); + + postContainer.on('click', function () { + if (!taskbar.isActive(post_uuid)) { + taskbar.updateActive(post_uuid); + } + }); + + resize.handleResize(postContainer); + + if (composer.bsEnvironment === 'xs' || composer.bsEnvironment === 'sm') { + var submitBtns = postContainer.find('.composer-submit'); + var mobileSubmitBtn = postContainer.find('.mobile-navbar .composer-submit'); + var textareaEl = postContainer.find('.write'); + var idx = textareaEl.attr('tabindex'); + + submitBtns.removeAttr('tabindex'); + mobileSubmitBtn.attr('tabindex', parseInt(idx, 10) + 1); + } + + $(window).trigger('action:composer.loaded', { + postContainer: postContainer, + post_uuid: post_uuid, + composerData: composer.posts[post_uuid], + formatting: composer.formatting, + }); + + scrollStop.apply(postContainer.find('.write')); + focusElements(postContainer); + onShow(); + }); + } + + function mobileHistoryAppend() { + var path = 'compose?p=' + window.location.pathname; + var returnPath = window.location.pathname.slice(1) + window.location.search; + + // Remove relative path from returnPath + if (returnPath.startsWith(config.relative_path.slice(1))) { + returnPath = returnPath.slice(config.relative_path.length); + } + + // Add in return path to be caught by ajaxify when post is completed, or if back is pressed + window.history.replaceState({ + url: null, + returnPath: returnPath, + }, returnPath, config.relative_path + '/' + returnPath); + + // Update address bar in case f5 is pressed + window.history.pushState({ + url: path, + }, path, `${config.relative_path}/${returnPath}`); + } + + function handleHelp(postContainer) { + const helpBtn = postContainer.find('[data-action="help"]'); + helpBtn.on('click', async function () { + const html = await socket.emit('plugins.composer.renderHelp'); + if (html && html.length > 0) { + bootbox.dialog({ + size: 'large', + message: html, + onEscape: true, + backdrop: true, + onHidden: function () { + helpBtn.focus(); + }, + }); + } + }); + } + + function handleSearch(postContainer) { + var uuid = postContainer.attr('data-uuid'); + var isEditing = composer.posts[uuid] && composer.posts[uuid].action === 'posts.edit'; + var env = utils.findBootstrapEnvironment(); + var isMobile = env === 'xs' || env === 'sm'; + if (isEditing || isMobile) { + return; + } + + search.enableQuickSearch({ + searchElements: { + inputEl: postContainer.find('input.title'), + resultEl: postContainer.find('.quick-search-container'), + }, + searchOptions: { + composer: 1, + }, + hideOnNoMatches: true, + hideDuringSearch: true, + }); + } + + function activate(post_uuid) { + if (composer.active && composer.active !== post_uuid) { + composer.minimize(composer.active); + } + + composer.active = post_uuid; + const postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + postContainer.css('visibility', 'visible'); + $(window).trigger('action:composer.activate', { + post_uuid: post_uuid, + postContainer: postContainer, + }); + } + + function focusElements(postContainer) { + setTimeout(function () { + var title = postContainer.find('input.title'); + + if (title.length) { + title.focus(); + } else { + postContainer.find('textarea').focus().putCursorAtEnd(); + } + }, 20); + } + + async function post(post_uuid) { + var postData = composer.posts[post_uuid]; + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + var handleEl = postContainer.find('.handle'); + var titleEl = postContainer.find('.title'); + var bodyEl = postContainer.find('textarea'); + var thumbEl = postContainer.find('input#topic-thumb-url'); + var onComposeRoute = postData.hasOwnProperty('template') && postData.template.compose === true; + const submitBtn = postContainer.find('.composer-submit'); + + titleEl.val(titleEl.val().trim()); + bodyEl.val(utils.rtrim(bodyEl.val())); + if (thumbEl.length) { + thumbEl.val(thumbEl.val().trim()); + } + + var action = postData.action; + + var checkTitle = (postData.hasOwnProperty('cid') || parseInt(postData.pid, 10)) && postContainer.find('input.title').length; + var isCategorySelected = !checkTitle || (checkTitle && parseInt(postData.cid, 10)); + + // Specifically for checking title/body length via plugins + var payload = { + post_uuid: post_uuid, + postData: postData, + postContainer: postContainer, + titleEl: titleEl, + titleLen: titleEl.val().length, + bodyEl: bodyEl, + bodyLen: bodyEl.val().length, + }; + + await hooks.fire('filter:composer.check', payload); + $(window).trigger('action:composer.check', payload); + + if (payload.error) { + return composerAlert(post_uuid, payload.error); + } + + if (uploads.inProgress[post_uuid] && uploads.inProgress[post_uuid].length) { + return composerAlert(post_uuid, '[[error:still-uploading]]'); + } else if (checkTitle && payload.titleLen < parseInt(config.minimumTitleLength, 10)) { + return composerAlert(post_uuid, '[[error:title-too-short, ' + config.minimumTitleLength + ']]'); + } else if (checkTitle && payload.titleLen > parseInt(config.maximumTitleLength, 10)) { + return composerAlert(post_uuid, '[[error:title-too-long, ' + config.maximumTitleLength + ']]'); + } else if (action === 'topics.post' && !isCategorySelected) { + return composerAlert(post_uuid, '[[error:category-not-selected]]'); + } else if (payload.bodyLen < parseInt(config.minimumPostLength, 10)) { + return composerAlert(post_uuid, '[[error:content-too-short, ' + config.minimumPostLength + ']]'); + } else if (payload.bodyLen > parseInt(config.maximumPostLength, 10)) { + return composerAlert(post_uuid, '[[error:content-too-long, ' + config.maximumPostLength + ']]'); + } else if (checkTitle && !tags.isEnoughTags(post_uuid)) { + return composerAlert(post_uuid, '[[error:not-enough-tags, ' + tags.minTagCount() + ']]'); + } else if (scheduler.isActive() && scheduler.getTimestamp() <= Date.now()) { + return composerAlert(post_uuid, '[[error:scheduling-to-past]]'); + } + + let composerData = { + uuid: post_uuid, + }; + let method = 'post'; + let route = ''; + + if (action === 'topics.post') { + route = '/topics'; + composerData = { + ...composerData, + handle: handleEl ? handleEl.val() : undefined, + title: titleEl.val(), + content: bodyEl.val(), + thumb: thumbEl.val() || '', + cid: categoryList.getSelectedCid(), + tags: tags.getTags(post_uuid), + timestamp: scheduler.getTimestamp(), + }; + } else if (action === 'posts.reply') { + route = `/topics/${postData.tid}`; + composerData = { + ...composerData, + tid: postData.tid, + handle: handleEl ? handleEl.val() : undefined, + content: bodyEl.val(), + toPid: postData.toPid, + }; + } else if (action === 'posts.edit') { + method = 'put'; + route = `/posts/${postData.pid}`; + composerData = { + ...composerData, + pid: postData.pid, + handle: handleEl ? handleEl.val() : undefined, + content: bodyEl.val(), + title: titleEl.val(), + thumb: thumbEl.val() || '', + tags: tags.getTags(post_uuid), + timestamp: scheduler.getTimestamp(), + }; + } + var submitHookData = { + composerEl: postContainer, + action: action, + composerData: composerData, + postData: postData, + redirect: true, + }; + + await hooks.fire('filter:composer.submit', submitHookData); + hooks.fire('action:composer.submit', Object.freeze(submitHookData)); + + // Minimize composer (and set textarea as readonly) while submitting + var taskbarIconEl = $('#taskbar .composer[data-uuid="' + post_uuid + '"] i'); + var textareaEl = postContainer.find('.write'); + taskbarIconEl.removeClass('fa-plus').addClass('fa-circle-o-notch fa-spin'); + composer.minimize(post_uuid); + textareaEl.prop('readonly', true); + + api[method](route, composerData) + .then((data) => { + submitBtn.removeAttr('disabled'); + postData.submitted = true; + + composer.discard(post_uuid); + drafts.removeDraft(postData.save_id); + + if (data.queued) { + alerts.alert({ + type: 'success', + title: '[[global:alert.success]]', + message: data.message, + timeout: 10000, + clickfn: function () { + ajaxify.go(`/post-queue/${data.id}`); + }, + }); + } else if (action === 'topics.post') { + if (submitHookData.redirect) { + ajaxify.go('topic/' + data.slug, undefined, (onComposeRoute || composer.bsEnvironment === 'xs' || composer.bsEnvironment === 'sm')); + } + } else if (action === 'posts.reply') { + if (onComposeRoute || composer.bsEnvironment === 'xs' || composer.bsEnvironment === 'sm') { + window.history.back(); + } else if (submitHookData.redirect && + ((ajaxify.data.template.name !== 'topic') || + (ajaxify.data.template.topic && parseInt(postData.tid, 10) !== parseInt(ajaxify.data.tid, 10))) + ) { + ajaxify.go('post/' + data.pid); + } + } else { + removeComposerHistory(); + } + + hooks.fire('action:composer.' + action, { composerData: composerData, data: data }); + }) + .catch((err) => { + // Restore composer on error + composer.load(post_uuid); + textareaEl.prop('readonly', false); + if (err.message === '[[error:email-not-confirmed]]') { + return messagesModule.showEmailConfirmWarning(err.message); + } + composerAlert(post_uuid, err.message); + }); + } + + function onShow() { + $('html').addClass('composing'); + } + + function onHide() { + $('#content').css({ paddingBottom: 0 }); + $('html').removeClass('composing'); + app.toggleNavbar(true); + formatting.exitFullscreen(); + } + + composer.discard = function (post_uuid) { + if (composer.posts[post_uuid]) { + var postData = composer.posts[post_uuid]; + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + postContainer.remove(); + drafts.removeDraft(postData.save_id); + topicThumbs.deleteAll(post_uuid); + + taskbar.discard('composer', post_uuid); + $('[data-action="post"]').removeAttr('disabled'); + + hooks.fire('action:composer.discard', { + post_uuid: post_uuid, + postData: postData, + }); + delete composer.posts[post_uuid]; + composer.active = undefined; + } + scheduler.reset(); + onHide(); + }; + + // Alias to .discard(); + composer.close = composer.discard; + + composer.minimize = function (post_uuid) { + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + postContainer.css('visibility', 'hidden'); + composer.active = undefined; + taskbar.minimize('composer', post_uuid); + $(window).trigger('action:composer.minimize', { + post_uuid: post_uuid, + }); + + onHide(); + }; + + composer.minimizeActive = function () { + if (composer.active) { + composer.miminize(composer.active); + } + }; + + composer.updateThumbCount = function (uuid, postContainer) { + const composerObj = composer.posts[uuid]; + if (composerObj.action === 'topics.post' || (composerObj.action === 'posts.edit' && composerObj.isMain)) { + const calls = [ + topicThumbs.get(uuid), + ]; + if (composerObj.pid) { + calls.push(topicThumbs.getByPid(composerObj.pid)); + } + Promise.all(calls).then((thumbs) => { + const thumbCount = thumbs.flat().length; + const formatEl = postContainer.find('[data-format="thumbs"]'); + formatEl.find('.badge') + .text(thumbCount) + .toggleClass('hidden', !thumbCount); + }); + } + }; + + return composer; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js new file mode 100644 index 0000000000..ec2ce15d33 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js @@ -0,0 +1,99 @@ +'use strict'; + +define('composer/autocomplete', [ + 'composer/preview', '@textcomplete/core', '@textcomplete/textarea', '@textcomplete/contenteditable', +], function (preview, { Textcomplete }, { TextareaEditor }, { ContenteditableEditor }) { + var autocomplete = { + _active: {}, + }; + + $(window).on('action:composer.discard', function (evt, data) { + if (autocomplete._active.hasOwnProperty(data.post_uuid)) { + autocomplete._active[data.post_uuid].destroy(); + delete autocomplete._active[data.post_uuid]; + } + }); + + autocomplete.init = function (postContainer, post_uuid) { + var element = postContainer.find('.write'); + var dropdownClass = 'composer-autocomplete-dropdown-' + post_uuid; + var timer; + + if (!element.length) { + /** + * Some composers do their own thing before calling autocomplete.init() again. + * One reason is because they want to override the textarea with their own element. + * In those scenarios, they don't specify the "write" class, and this conditional + * looks for that and stops the autocomplete init process. + */ + return; + } + + var data = { + element: element, + strategies: [], + options: { + style: { + 'z-index': 20000, + }, + className: dropdownClass + ' dropdown-menu textcomplete-dropdown', + }, + }; + + element.on('keyup', function () { + clearTimeout(timer); + timer = setTimeout(function () { + var dropdown = document.querySelector('.' + dropdownClass); + if (dropdown) { + var pos = dropdown.getBoundingClientRect(); + + var margin = parseFloat(dropdown.style.marginTop, 10) || 0; + + var offset = window.innerHeight + margin - 10 - pos.bottom; + dropdown.style.marginTop = Math.min(offset, 0) + 'px'; + } + }, 0); + }); + + $(window).trigger('composer:autocomplete:init', data); + + autocomplete._active[post_uuid] = autocomplete.setup(data); + + data.element.on('textComplete:select', function () { + preview.render(postContainer); + }); + }; + + // This is a generic method that is also used by the chat + autocomplete.setup = function ({ element, strategies, options }) { + const targetEl = element.get(0); + if (!targetEl) { + return; + } + var editor; + if (targetEl.nodeName === 'TEXTAREA' || targetEl.nodeName === 'INPUT') { + editor = new TextareaEditor(targetEl); + } else if (targetEl.nodeName === 'DIV' && targetEl.getAttribute('contenteditable') === 'true') { + editor = new ContenteditableEditor(targetEl); + } + if (!editor) { + throw new Error('unknown target element type'); + } + // yuku-t/textcomplete inherits directionality from target element itself + targetEl.setAttribute('dir', document.querySelector('html').getAttribute('data-dir')); + + var textcomplete = new Textcomplete(editor, strategies, { + dropdown: options, + }); + textcomplete.on('rendered', function () { + if (textcomplete.dropdown.items.length) { + // Activate the first item by default. + textcomplete.dropdown.items[0].activate(); + } + }); + + return textcomplete; + }; + + return autocomplete; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/categoryList.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/categoryList.js new file mode 100644 index 0000000000..79f5b7a46b --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/categoryList.js @@ -0,0 +1,115 @@ +'use strict'; + +define('composer/categoryList', [ + 'categorySelector', 'taskbar', 'api', +], function (categorySelector, taskbar, api) { + var categoryList = {}; + + var selector; + + categoryList.init = function (postContainer, postData) { + var listContainer = postContainer.find('.category-list-container'); + if (!listContainer.length) { + return; + } + + postContainer.on('action:composer.resize', function () { + toggleDropDirection(postContainer); + }); + + categoryList.updateTaskbar(postContainer, postData); + + selector = categorySelector.init(listContainer.find('[component="category-selector"]'), { + privilege: 'topics:create', + states: ['watching', 'tracking', 'notwatching', 'ignoring'], + onSelect: function (selectedCategory) { + if (postData.hasOwnProperty('cid')) { + changeCategory(postContainer, postData, selectedCategory); + } + }, + }); + if (!selector) { + return; + } + if (postData.cid && postData.category) { + selector.selectedCategory = { cid: postData.cid, name: postData.category.name }; + } else if (ajaxify.data.template.compose && ajaxify.data.selectedCategory) { + // separate composer route + selector.selectedCategory = { cid: ajaxify.data.cid, name: ajaxify.data.selectedCategory }; + } + + // this is the mobile category selector + postContainer.find('.category-name') + .translateHtml(selector.selectedCategory ? selector.selectedCategory.name : '[[modules:composer.select-category]]') + .on('click', function () { + categorySelector.modal({ + privilege: 'topics:create', + states: ['watching', 'tracking', 'notwatching', 'ignoring'], + openOnLoad: true, + showLinks: false, + onSubmit: function (selectedCategory) { + postContainer.find('.category-name').text(selectedCategory.name); + selector.selectCategory(selectedCategory.cid); + if (postData.hasOwnProperty('cid')) { + changeCategory(postContainer, postData, selectedCategory); + } + }, + }); + }); + + toggleDropDirection(postContainer); + }; + + function toggleDropDirection(postContainer) { + postContainer.find('.category-list-container [component="category-selector"]').toggleClass('dropup', postContainer.outerHeight() < $(window).height() / 2); + } + + categoryList.getSelectedCid = function () { + var selectedCategory; + if (selector) { + selectedCategory = selector.getSelectedCategory(); + } + return selectedCategory ? selectedCategory.cid : 0; + }; + + categoryList.updateTaskbar = function (postContainer, postData) { + if (parseInt(postData.cid, 10)) { + api.get(`/categories/${postData.cid}`, {}).then(function (category) { + updateTaskbarByCategory(postContainer, category); + }); + } + }; + + function updateTaskbarByCategory(postContainer, category) { + if (category) { + var uuid = postContainer.attr('data-uuid'); + taskbar.update('composer', uuid, { + image: category.backgroundImage, + color: category.color, + 'background-color': category.bgColor, + icon: category.icon && category.icon.slice(3), + }); + } + } + + async function changeCategory(postContainer, postData, selectedCategory) { + postData.cid = selectedCategory.cid; + const categoryData = await window.fetch(`${config.relative_path}/api/category/${selectedCategory.cid}`).then(r => r.json()); + postData.category = categoryData; + updateTaskbarByCategory(postContainer, categoryData); + require(['composer/scheduler', 'composer/tags', 'composer/post-queue'], function (scheduler, tags, postQueue) { + scheduler.onChangeCategory(categoryData); + tags.onChangeCategory(postContainer, postData, selectedCategory.cid, categoryData); + postQueue.onChangeCategory(postContainer, postData); + + $(window).trigger('action:composer.changeCategory', { + postContainer: postContainer, + postData: postData, + selectedCategory: selectedCategory, + categoryData: categoryData, + }); + }); + } + + return categoryList; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/controls.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/controls.js new file mode 100644 index 0000000000..bf393fc21a --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/controls.js @@ -0,0 +1,171 @@ +'use strict'; + +define('composer/controls', ['composer/preview'], function (preview) { + var controls = {}; + + /** ********************************************** */ + /* Rich Textarea Controls */ + /** ********************************************** */ + controls.insertIntoTextarea = function (textarea, value) { + var payload = { + context: this, + textarea: textarea, + value: value, + preventDefault: false, + }; + $(window).trigger('action:composer.insertIntoTextarea', payload); + + if (payload.preventDefault) { + return; + } + + var $textarea = $(payload.textarea); + var currentVal = $textarea.val(); + var postContainer = $textarea.parents('[component="composer"]'); + + $textarea.val( + currentVal.slice(0, payload.textarea.selectionStart) + + payload.value + + currentVal.slice(payload.textarea.selectionStart) + ); + + preview.render(postContainer); + }; + + controls.replaceSelectionInTextareaWith = function (textarea, value) { + var payload = { + context: this, + textarea: textarea, + value: value, + preventDefault: false, + }; + $(window).trigger('action:composer.replaceSelectionInTextareaWith', payload); + + if (payload.preventDefault) { + return; + } + + var $textarea = $(payload.textarea); + var currentVal = $textarea.val(); + var postContainer = $textarea.parents('[component="composer"]'); + + $textarea.val( + currentVal.slice(0, payload.textarea.selectionStart) + + payload.value + + currentVal.slice(payload.textarea.selectionEnd) + ); + + preview.render(postContainer); + }; + + controls.wrapSelectionInTextareaWith = function (textarea, leading, trailing) { + var payload = { + context: this, + textarea: textarea, + leading: leading, + trailing: trailing, + preventDefault: false, + }; + $(window).trigger('action:composer.wrapSelectionInTextareaWith', payload); + + if (payload.preventDefault) { + return; + } + + if (trailing === undefined) { + trailing = leading; + } + + var $textarea = $(textarea); + var currentVal = $textarea.val(); + + var matches = /^(\s*)([\s\S]*?)(\s*)$/.exec(currentVal.slice(textarea.selectionStart, textarea.selectionEnd)); + + if (!matches[2]) { + // selection is entirely whitespace + matches = [null, '', currentVal.slice(textarea.selectionStart, textarea.selectionEnd), '']; + } + + $textarea.val( + currentVal.slice(0, textarea.selectionStart) + + matches[1] + + leading + + matches[2] + + trailing + + matches[3] + + currentVal.slice(textarea.selectionEnd) + ); + + return [matches[1].length, matches[3].length]; + }; + + controls.updateTextareaSelection = function (textarea, start, end) { + var payload = { + context: this, + textarea: textarea, + start: start, + end: end, + preventDefault: false, + }; + $(window).trigger('action:composer.updateTextareaSelection', payload); + + if (payload.preventDefault) { + return; + } + + textarea.setSelectionRange(payload.start, payload.end); + $(payload.textarea).focus(); + }; + + controls.getBlockData = function (textareaEl, query, selectionStart) { + // Determines whether the cursor is sitting inside a block-type element (bold, italic, etc.) + var value = textareaEl.value; + query = query.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + var regex = new RegExp(query, 'g'); + var match; + var matchIndices = []; + var payload; + + // Isolate the line the cursor is on + value = value.split('\n').reduce(function (memo, line) { + if (memo !== null) { + return memo; + } + + memo = selectionStart <= line.length ? line : null; + + if (memo === null) { + selectionStart -= (line.length + 1); + } + + return memo; + }, null); + + // Find query characters and determine return payload + while ((match = regex.exec(value)) !== null) { + matchIndices.push(match.index); + } + + payload = { + in: !!(matchIndices.reduce(function (memo, cur) { + if (selectionStart >= cur + 2) { + memo += 1; + } + + return memo; + }, 0) % 2), + atEnd: matchIndices.reduce(function (memo, cur) { + if (memo) { + return memo; + } + + return selectionStart === cur; + }, false), + }; + + payload.atEnd = payload.in ? payload.atEnd : false; + return payload; + }; + + return controls; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/drafts.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/drafts.js new file mode 100644 index 0000000000..5a23cd10dc --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/drafts.js @@ -0,0 +1,341 @@ +'use strict'; + +define('composer/drafts', ['api', 'alerts'], function (api, alerts) { + const drafts = {}; + const draftSaveDelay = 1000; + drafts.init = function (postContainer, postData) { + const draftIconEl = postContainer.find('.draft-icon'); + const uuid = postContainer.attr('data-uuid'); + function doSaveDraft() { + // check if composer is still around, + // it might have been gone by the time this timeout triggers + if (!$(`[component="composer"][data-uuid="${uuid}"]`).length) { + return; + } + + if (!postData.save_id) { + postData.save_id = utils.generateSaveId(app.user.uid); + } + // Post is modified, save to list of opened drafts + drafts.addToDraftList('available', postData.save_id); + drafts.addToDraftList('open', postData.save_id); + saveDraft(postContainer, draftIconEl, postData); + } + + postContainer.on('keyup', 'textarea, input.handle, input.title', utils.debounce(doSaveDraft, draftSaveDelay)); + postContainer.on('click', 'input[type="checkbox"]', utils.debounce(doSaveDraft, draftSaveDelay)); + postContainer.on('click', '[component="category/list"] [data-cid]', utils.debounce(doSaveDraft, draftSaveDelay)); + postContainer.on('itemAdded', '.tags', utils.debounce(doSaveDraft, draftSaveDelay)); + postContainer.on('thumb.uploaded', doSaveDraft); + + draftIconEl.on('animationend', function () { + $(this).toggleClass('active', false); + }); + + $(window).on('unload', function () { + // remove all drafts from the open list + const open = drafts.getList('open'); + if (open.length) { + open.forEach(save_id => drafts.removeFromDraftList('open', save_id)); + } + }); + + drafts.migrateGuest(); + drafts.migrateThumbs(...arguments); + }; + + function getStorage(uid) { + return parseInt(uid, 10) > 0 ? localStorage : sessionStorage; + } + + drafts.get = function (save_id) { + if (!save_id) { + return null; + } + const uid = save_id.split(':')[1]; + const storage = getStorage(uid); + try { + const draftJson = storage.getItem(save_id); + const draft = JSON.parse(draftJson) || null; + if (!draft) { + throw new Error(`can't parse draft json for ${save_id}`); + } + draft.save_id = save_id; + if (draft.timestamp) { + draft.timestampISO = utils.toISOString(draft.timestamp); + } + $(window).trigger('action:composer.drafts.get', { + save_id: save_id, + draft: draft, + storage: storage, + }); + return draft; + } catch (e) { + console.warn(`[composer/drafts] Could not get draft ${save_id}, removing`); + drafts.removeFromDraftList('available'); + drafts.removeFromDraftList('open'); + return null; + } + }; + + function saveDraft(postContainer, draftIconEl, postData) { + if (canSave(app.user.uid ? 'localStorage' : 'sessionStorage') && postData && postData.save_id && postContainer.length) { + const titleEl = postContainer.find('input.title'); + const title = titleEl && titleEl.length && titleEl.val(); + const raw = postContainer.find('textarea').val(); + const storage = getStorage(app.user.uid); + + if (raw.length || (title && title.length)) { + const draftData = { + save_id: postData.save_id, + action: postData.action, + text: raw, + uuid: postContainer.attr('data-uuid'), + timestamp: Date.now(), + }; + + if (postData.action === 'topics.post') { + // New topic only + const tags = postContainer.find('input.tags').val(); + draftData.tags = tags; + draftData.title = title; + draftData.cid = postData.cid; + } else if (postData.action === 'posts.reply') { + // new reply only + draftData.title = postData.title; + draftData.tid = postData.tid; + draftData.toPid = postData.toPid; + } else if (postData.action === 'posts.edit') { + draftData.pid = postData.pid; + draftData.title = title || postData.title; + } + if (!app.user.uid) { + draftData.handle = postContainer.find('input.handle').val(); + } + + // save all draft data into single item as json + storage.setItem(postData.save_id, JSON.stringify(draftData)); + + $(window).trigger('action:composer.drafts.save', { + storage: storage, + postData: postData, + postContainer: postContainer, + }); + draftIconEl.toggleClass('active', true); + } else { + drafts.removeDraft(postData.save_id); + } + } + } + + drafts.removeDraft = function (save_id) { + if (!save_id) { + return; + } + + // Remove save_id from list of open and available drafts + drafts.removeFromDraftList('available', save_id); + drafts.removeFromDraftList('open', save_id); + const uid = save_id.split(':')[1]; + const storage = getStorage(uid); + storage.removeItem(save_id); + + $(window).trigger('action:composer.drafts.remove', { + storage: storage, + save_id: save_id, + }); + }; + + drafts.getList = function (set) { + try { + const draftIds = localStorage.getItem(`drafts:${set}`); + return JSON.parse(draftIds) || []; + } catch (e) { + console.warn('[composer/drafts] Could not read list of available drafts'); + return []; + } + }; + + drafts.addToDraftList = function (set, save_id) { + if (!canSave(app.user.uid ? 'localStorage' : 'sessionStorage') || !save_id) { + return; + } + const list = drafts.getList(set); + if (!list.includes(save_id)) { + list.push(save_id); + localStorage.setItem('drafts:' + set, JSON.stringify(list)); + } + }; + + drafts.removeFromDraftList = function (set, save_id) { + if (!canSave(app.user.uid ? 'localStorage' : 'sessionStorage') || !save_id) { + return; + } + const list = drafts.getList(set); + if (list.includes(save_id)) { + list.splice(list.indexOf(save_id), 1); + localStorage.setItem('drafts:' + set, JSON.stringify(list)); + } + }; + + drafts.migrateGuest = function () { + // If any drafts are made while as guest, and user then logs in, assume control of those drafts + if (canSave('localStorage') && app.user.uid) { + // composer:: + const test = /^composer:\d+:\d$/; + const keys = Object.keys(sessionStorage).filter(function (key) { + return test.test(key); + }); + const migrated = new Set([]); + const renamed = keys.map(function (key) { + const parts = key.split(':'); + parts[1] = app.user.uid; + + migrated.add(parts.join(':')); + return parts.join(':'); + }); + + keys.forEach(function (key, idx) { + localStorage.setItem(renamed[idx], sessionStorage.getItem(key)); + sessionStorage.removeItem(key); + }); + + migrated.forEach(function (save_id) { + drafts.addToDraftList('available', save_id); + }); + + return migrated; + } + }; + + drafts.migrateThumbs = function (postContainer, postData) { + if (!app.uid) { + return; + } + + // If any thumbs were uploaded, migrate them to this new composer's uuid + const newUUID = postContainer.attr('data-uuid'); + const draft = drafts.get(postData.save_id); + + if (draft && draft.uuid) { + api.put(`/topics/${draft.uuid}/thumbs`, { + tid: newUUID, + }).then(() => { + require(['composer'], function (composer) { + composer.updateThumbCount(newUUID, postContainer); + }); + }); + } + }; + + drafts.listAvailable = function () { + const available = drafts.getList('available'); + return available.map(drafts.get).filter(Boolean); + }; + + drafts.getAvailableCount = function () { + return drafts.listAvailable().length; + }; + + drafts.open = function (save_id) { + if (!save_id) { + return; + } + const draft = drafts.get(save_id); + openComposer(save_id, draft); + }; + + drafts.loadOpen = function () { + if (ajaxify.data.template.login || ajaxify.data.template.register || (config.hasOwnProperty('openDraftsOnPageLoad') && !config.openDraftsOnPageLoad)) { + return; + } + // Load drafts if they were open + const available = drafts.getList('available'); + const open = drafts.getList('open'); + + if (available.length) { + // Deconstruct each save_id and open up composer + available.forEach(function (save_id) { + if (!save_id || open.includes(save_id)) { + return; + } + const draft = drafts.get(save_id); + if (!draft || (!draft.text && !draft.title)) { + drafts.removeFromDraftList('available', save_id); + drafts.removeFromDraftList('open', save_id); + return; + } + openComposer(save_id, draft); + }); + } + }; + + function openComposer(save_id, draft) { + const saveObj = save_id.split(':'); + const uid = saveObj[1]; + // Don't open other peoples' drafts + if (parseInt(app.user.uid, 10) !== parseInt(uid, 10)) { + return; + } + require(['composer'], function (composer) { + if (draft.action === 'topics.post') { + composer.newTopic({ + save_id: draft.save_id, + cid: draft.cid, + handle: app.user && app.user.uid ? undefined : utils.escapeHTML(draft.handle), + title: utils.escapeHTML(draft.title), + body: draft.text, + tags: String(draft.tags || '').split(','), + }); + } else if (draft.action === 'posts.reply') { + api.get('/topics/' + draft.tid, {}, function (err, topicObj) { + if (err) { + return alerts.error(err); + } + + composer.newReply({ + save_id: draft.save_id, + tid: draft.tid, + toPid: draft.toPid, + title: topicObj.title, + body: draft.text, + }); + }); + } else if (draft.action === 'posts.edit') { + composer.editPost({ + save_id: draft.save_id, + pid: draft.pid, + title: draft.title ? utils.escapeHTML(draft.title) : undefined, + body: draft.text, + }); + } + }); + } + + // Feature detection courtesy of: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API + function canSave(type) { + var storage; + try { + storage = window[type]; + var x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + return e instanceof DOMException && ( + // everything except Firefox + e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === 'QuotaExceededError' || + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + // acknowledge QuotaExceededError only if there's something already stored + (storage && storage.length !== 0); + } + } + + return drafts; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/formatting.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/formatting.js new file mode 100644 index 0000000000..dca150fdd4 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/formatting.js @@ -0,0 +1,194 @@ +'use strict'; + +define('composer/formatting', [ + 'composer/preview', 'composer/resize', 'topicThumbs', 'screenfull', +], function (preview, resize, topicThumbs, screenfull) { + var formatting = {}; + + var formattingDispatchTable = { + picture: function () { + var postContainer = this; + postContainer.find('#files') + .attr('accept', 'image/*') + .click(); + }, + + upload: function () { + var postContainer = this; + postContainer.find('#files') + .attr('accept', '') + .click(); + }, + + thumbs: function () { + formatting.exitFullscreen(); + var postContainer = this; + require(['composer'], function (composer) { + const uuid = postContainer.get(0).getAttribute('data-uuid'); + const composerObj = composer.posts[uuid]; + + if (composerObj.action === 'topics.post' || (composerObj.action === 'posts.edit' && composerObj.isMain)) { + topicThumbs.modal.open({ id: uuid, pid: composerObj.pid }).then(() => { + postContainer.trigger('thumb.uploaded'); // toggle draft save + + // Update client-side with count + composer.updateThumbCount(uuid, postContainer); + }); + } + }); + }, + + tags: function () { + var postContainer = this; + postContainer.find('.tags-container').toggleClass('hidden'); + }, + + zen: function () { + var postContainer = this; + $(window).one('resize', function () { + function onResize() { + if (!screenfull.isFullscreen) { + app.toggleNavbar(true); + $('html').removeClass('zen-mode'); + resize.reposition(postContainer); + $(window).off('resize', onResize); + } + } + + if (screenfull.isFullscreen) { + app.toggleNavbar(false); + $('html').addClass('zen-mode'); + postContainer.find('.write').focus(); + + $(window).on('resize', onResize); + $(window).one('action:composer.topics.post action:composer.posts.reply action:composer.posts.edit action:composer.discard', screenfull.exit); + } + }); + + screenfull.toggle(postContainer.get(0)); + $(window).trigger('action:composer.fullscreen', { postContainer: postContainer }); + }, + }; + + var buttons = []; + + formatting.exitFullscreen = function () { + if (screenfull.isEnabled && screenfull.isFullscreen) { + screenfull.exit(); + } + }; + + formatting.addComposerButtons = function () { + const formattingBarEl = $('.formatting-bar'); + const fileForm = formattingBarEl.find('.formatting-group #fileForm'); + buttons.forEach((btn) => { + let markup = ``; + if (Array.isArray(btn.dropdownItems) && btn.dropdownItems.length) { + markup = generateFormattingDropdown(btn); + } else { + markup = ` +
  • + +
  • + `; + } + fileForm.before(markup); + }); + + const els = formattingBarEl.find('.formatting-group>li'); + els.tooltip({ + container: '#content', + animation: false, + trigger: 'manual', + }).on('mouseenter', function (ev) { + const target = $(ev.target); + const isDropdown = target.hasClass('dropdown-menu') || !!target.parents('.dropdown-menu').length; + if (!isDropdown) { + $(this).tooltip('show'); + } + }).on('click mouseleave', function () { + $(this).tooltip('hide'); + }); + }; + + function generateBadgetHtml(btn) { + let badgeHtml = ''; + if (btn.badge) { + badgeHtml = ``; + } + return badgeHtml; + } + + function generateFormattingDropdown(btn) { + const dropdownItemsHtml = btn.dropdownItems.map(function (btn) { + return ` +
  • + + ${btn.text} + ${generateBadgetHtml(btn)} + +
  • + `; + }); + return ` + + `; + } + + formatting.addButton = function (iconClass, onClick, title, name) { + name = name || iconClass.replace('fa fa-', ''); + formattingDispatchTable[name] = onClick; + buttons.push({ + name, + iconClass, + title, + }); + }; + + formatting.addDropdown = function (data) { + buttons.push({ + iconClass: data.iconClass, + title: data.title, + dropdownItems: data.dropdownItems, + }); + data.dropdownItems.forEach((btn) => { + if (btn.name && btn.onClick) { + formattingDispatchTable[btn.name] = btn.onClick; + } + }); + }; + + formatting.getDispatchTable = function () { + return formattingDispatchTable; + }; + + formatting.addButtonDispatch = function (name, onClick) { + formattingDispatchTable[name] = onClick; + }; + + formatting.addHandler = function (postContainer) { + postContainer.on('click', '.formatting-bar [data-format]', function (event) { + var format = $(this).attr('data-format'); + var textarea = $(this).parents('[component="composer"]').find('textarea')[0]; + + if (formattingDispatchTable.hasOwnProperty(format)) { + formattingDispatchTable[format].call( + postContainer, textarea, textarea.selectionStart, textarea.selectionEnd, event + ); + preview.render(postContainer); + } + }); + }; + + return formatting; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/post-queue.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/post-queue.js new file mode 100644 index 0000000000..2022430842 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/post-queue.js @@ -0,0 +1,25 @@ +'use strict'; + +define('composer/post-queue', [], function () { + const postQueue = {}; + + postQueue.showAlert = async function (postContainer, postData) { + const alertEl = postContainer.find('[component="composer/post-queue/alert"]') + if (!config.postQueue || app.user.isAdmin || app.user.isGlobalMod || app.user.isMod) { + alertEl.remove(); + return; + } + const shouldQueue = await socket.emit('plugins.composer.shouldQueue', { postData: postData }); + alertEl.toggleClass('show', shouldQueue); + alertEl.toggleClass('pe-none', !shouldQueue); + }; + + postQueue.onChangeCategory = async function (postContainer, postData) { + if (!config.postQueue) { + return; + } + postQueue.showAlert(postContainer, postData); + }; + + return postQueue; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/preview.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/preview.js new file mode 100644 index 0000000000..9074e6edc2 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/preview.js @@ -0,0 +1,105 @@ +'use strict'; + +define('composer/preview', ['hooks'], function (hooks) { + var preview = {}; + + preview.render = function (postContainer, callback) { + callback = callback || function () {}; + if (!postContainer.find('.preview-container').is(':visible')) { + return callback(); + } + + var textarea = postContainer.find('textarea'); + + socket.emit('plugins.composer.renderPreview', textarea.val(), function (err, preview) { + if (err) { + return; + } + preview = $('
    ' + preview + '
    '); + preview.find('img:not(.not-responsive)').addClass('img-fluid'); + postContainer.find('.preview').html(preview); + hooks.fire('action:composer.preview', { postContainer, preview }); + callback(); + }); + }; + + preview.matchScroll = function (postContainer) { + if (!postContainer.find('.preview-container').is(':visible')) { + return; + } + var textarea = postContainer.find('textarea'); + var preview = postContainer.find('.preview'); + + if (textarea.length && preview.length) { + var diff = textarea[0].scrollHeight - textarea.height(); + + if (diff === 0) { + return; + } + + var scrollPercent = textarea.scrollTop() / diff; + + preview.scrollTop(Math.max(preview[0].scrollHeight - preview.height(), 0) * scrollPercent); + } + }; + + preview.handleToggler = function ($postContainer) { + const postContainer = $postContainer.get(0); + preview.env = utils.findBootstrapEnvironment(); + const isMobile = ['xs', 'sm'].includes(preview.env); + const toggler = postContainer.querySelector('.formatting-bar [data-action="preview"]'); + const showText = toggler.querySelector('.show-text'); + const hideText = toggler.querySelector('.hide-text'); + const previewToggled = localStorage.getItem('composer:previewToggled'); + const hidePreviewOnOpen = config['composer-default'].hidePreviewOnOpen === 'on'; + let show = !isMobile && ( + ((previewToggled === null && !hidePreviewOnOpen) || previewToggled === 'true') + ); + const previewContainer = postContainer.querySelector('.preview-container'); + const writeContainer = postContainer.querySelector('.write-container'); + + if (!toggler) { + return; + } + + function togglePreview(show) { + if (isMobile) { + previewContainer.classList.toggle('hide', false); + writeContainer.classList.toggle('maximized', false); + + previewContainer.classList.toggle('d-none', !show); + previewContainer.classList.toggle('d-flex', show); + previewContainer.classList.toggle('w-100', show); + + writeContainer.classList.toggle('d-flex', !show); + writeContainer.classList.toggle('d-none', show); + writeContainer.classList.toggle('w-100', !show); + } else { + previewContainer.classList.toggle('hide', !show); + writeContainer.classList.toggle('w-50', show); + writeContainer.classList.toggle('w-100', !show); + localStorage.setItem('composer:previewToggled', show); + } + showText.classList.toggle('hide', show); + hideText.classList.toggle('hide', !show); + if (show) { + preview.render($postContainer); + } + preview.matchScroll($postContainer); + } + preview.toggle = togglePreview; + + toggler.addEventListener('click', (e) => { + if (e.button !== 0) { + return; + } + + show = !show; + togglePreview(show); + }); + + togglePreview(show); + }; + + return preview; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/resize.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/resize.js new file mode 100644 index 0000000000..5fa84f3a3f --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/resize.js @@ -0,0 +1,197 @@ + +'use strict'; + +define('composer/resize', ['taskbar'], function (taskbar) { + var resize = {}; + var oldRatio = 0; + var minimumRatio = 0.3; + var snapMargin = 0.05; + var smallMin = 768; + + var $body = $('body'); + var $window = $(window); + var $headerMenu = $('[component="navbar"]'); + const content = document.getElementById('content'); + + var header = $headerMenu[0]; + + function getSavedRatio() { + return localStorage.getItem('composer:resizeRatio') || 0.5; + } + + function saveRatio(ratio) { + localStorage.setItem('composer:resizeRatio', Math.min(ratio, 1)); + } + + function getBounds() { + var headerRect; + if (header) { + headerRect = header.getBoundingClientRect(); + } else { + // Mock data + headerRect = { bottom: 0 }; + } + + var headerBottom = Math.max(headerRect.bottom, 0); + + var rect = { + top: 0, + left: 0, + right: window.innerWidth, + bottom: window.innerHeight, + }; + + rect.width = rect.right; + rect.height = rect.bottom; + + rect.boundedTop = headerBottom; + rect.boundedHeight = rect.bottom - headerBottom; + + return rect; + } + + function doResize(postContainer, ratio) { + var bounds = getBounds(); + var elem = postContainer[0]; + var style = window.getComputedStyle(elem); + + // Adjust minimumRatio for shorter viewports + var minHeight = parseInt(style.getPropertyValue('min-height'), 10); + var adjustedMinimum = Math.max(minHeight / window.innerHeight, minimumRatio); + + if (bounds.width >= smallMin) { + const boundedDifference = (bounds.height - bounds.boundedHeight) / bounds.height; + ratio = Math.min(Math.max(ratio, adjustedMinimum + boundedDifference), 1); + + var top = ratio * bounds.boundedHeight / bounds.height; + elem.style.top = ((1 - top) * 100).toString() + '%'; + + // Add some extra space at the bottom of the body so that + // the user can still scroll to the last post w/ composer open + var rect = elem.getBoundingClientRect(); + content.style.paddingBottom = (rect.bottom - rect.top).toString() + 'px'; + } else { + elem.style.top = 0; + content.style.paddingBottom = 0; + } + + postContainer.ratio = ratio; + + taskbar.updateActive(postContainer.attr('data-uuid')); + } + + var resizeIt = doResize; + var raf = window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame; + + if (raf) { + resizeIt = function (postContainer, ratio) { + raf(function () { + doResize(postContainer, ratio); + + setTimeout(function () { + $window.trigger('action:composer.resize'); + postContainer.trigger('action:composer.resize'); + }, 0); + }); + }; + } + + resize.reposition = function (postContainer) { + var ratio = getSavedRatio(); + + if (ratio >= 1 - snapMargin) { + ratio = 1; + postContainer.addClass('maximized'); + } + + resizeIt(postContainer, ratio); + }; + + resize.maximize = function (postContainer, state) { + if (state) { + resizeIt(postContainer, 1); + } else { + resize.reposition(postContainer); + } + }; + + resize.handleResize = function (postContainer) { + var resizeOffset = 0; + var resizeBegin = 0; + var resizeEnd = 0; + var $resizer = postContainer.find('.resizer'); + var resizer = $resizer[0]; + + function resizeStart(e) { + var resizeRect = resizer.getBoundingClientRect(); + var resizeCenterY = (resizeRect.top + resizeRect.bottom) / 2; + + resizeOffset = (resizeCenterY - e.clientY) / 2; + resizeBegin = e.clientY; + + $window.on('mousemove', resizeAction); + $window.on('mouseup', resizeStop); + $body.on('touchmove', resizeTouchAction); + } + + function resizeAction(e) { + var position = e.clientY - resizeOffset; + var bounds = getBounds(); + var ratio = (bounds.height - position) / (bounds.boundedHeight); + + resizeIt(postContainer, ratio); + } + + function resizeStop(e) { + e.preventDefault(); + resizeEnd = e.clientY; + + postContainer.find('textarea').focus(); + $window.off('mousemove', resizeAction); + $window.off('mouseup', resizeStop); + $body.off('touchmove', resizeTouchAction); + + var position = resizeEnd - resizeOffset; + var bounds = getBounds(); + var ratio = (bounds.height - position) / (bounds.boundedHeight); + + if (resizeEnd - resizeBegin === 0 && postContainer.hasClass('maximized')) { + postContainer.removeClass('maximized'); + ratio = (!oldRatio || oldRatio >= 1 - snapMargin) ? 0.5 : oldRatio; + resizeIt(postContainer, ratio); + } else if (resizeEnd - resizeBegin === 0 || ratio >= 1 - snapMargin) { + resizeIt(postContainer, 1); + postContainer.addClass('maximized'); + oldRatio = ratio; + } else { + postContainer.removeClass('maximized'); + } + + saveRatio(ratio); + } + + function resizeTouchAction(e) { + e.preventDefault(); + resizeAction(e.touches[0]); + } + + $resizer + .on('mousedown', function (e) { + if (e.button !== 0) { + return; + } + + e.preventDefault(); + resizeStart(e); + }) + .on('touchstart', function (e) { + e.preventDefault(); + resizeStart(e.touches[0]); + }) + .on('touchend', resizeStop); + }; + + return resize; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/scheduler.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/scheduler.js new file mode 100644 index 0000000000..e238c33bdc --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/scheduler.js @@ -0,0 +1,201 @@ +'use strict'; + +define('composer/scheduler', ['benchpress', 'bootbox', 'alerts', 'translator'], function ( + Benchpress, + bootbox, + alerts, + translator +) { + const scheduler = {}; + const state = { + timestamp: 0, + open: false, + edit: false, + posts: {}, + }; + let displayBtnCons = []; + let displayBtns; + let cancelBtn; + let submitContainer; + let submitOptionsCon; + + const dropdownDisplayBtn = { + el: null, + defaultText: '', + activeText: '', + }; + + const submitBtn = { + el: null, + icon: null, + defaultText: '', + activeText: '', + }; + let dateInput; + let timeInput; + + $(window).on('action:composer.activate', handleOnActivate); + + scheduler.init = function ($postContainer, posts) { + state.timestamp = 0; + state.posts = posts; + + translator.translateKeys(['[[topic:composer.post-later]]', '[[modules:composer.change-schedule-date]]']).then((translated) => { + dropdownDisplayBtn.defaultText = translated[0]; + dropdownDisplayBtn.activeText = translated[1]; + }); + + displayBtnCons = $postContainer[0].querySelectorAll('.display-scheduler'); + displayBtns = $postContainer[0].querySelectorAll('.display-scheduler i'); + dropdownDisplayBtn.el = $postContainer[0].querySelector('.dropdown-item.display-scheduler'); + cancelBtn = $postContainer[0].querySelector('.dropdown-item.cancel-scheduling'); + submitContainer = $postContainer.find('[component="composer/submit/container"]'); + submitOptionsCon = $postContainer.find('[component="composer/submit/options/container"]'); + + submitBtn.el = $postContainer[0].querySelector('.composer-submit:not(.btn-sm)'); + submitBtn.icon = submitBtn.el.querySelector('i'); + submitBtn.defaultText = submitBtn.el.lastChild.textContent; + submitBtn.activeText = submitBtn.el.getAttribute('data-text-variant'); + + cancelBtn.addEventListener('click', cancelScheduling); + displayBtnCons.forEach(el => el.addEventListener('click', openModal)); + }; + + scheduler.getTimestamp = function () { + if (!scheduler.isActive() || isNaN(state.timestamp)) { + return 0; + } + return state.timestamp; + }; + + scheduler.isActive = function () { + return state.timestamp > 0; + }; + + scheduler.isOpen = function () { + return state.open; + }; + + scheduler.reset = function () { + state.timestamp = 0; + }; + + scheduler.onChangeCategory = function (categoryData) { + toggleDisplayButtons(categoryData.privileges['topics:schedule']); + toggleItems(false); + const optionsVisible = categoryData.privileges['topics:schedule'] || submitOptionsCon.attr('data-submit-options') > 0; + submitContainer.find('.composer-submit').toggleClass('rounded-1', !optionsVisible); + submitOptionsCon.toggleClass('hidden', !optionsVisible); + scheduler.reset(); + }; + + async function openModal() { + const html = await Benchpress.render('modals/topic-scheduler'); + bootbox.dialog({ + message: html, + title: '[[modules:composer.schedule-for]]', + className: 'topic-scheduler', + onShown: initModal, + onHidden: handleOnHidden, + onEscape: true, + buttons: { + cancel: { + label: state.timestamp ? '[[modules:composer.cancel-scheduling]]' : '[[modules:bootbox.cancel]]', + className: (state.timestamp ? 'btn-warning' : 'btn-outline-secondary') + (state.edit ? ' hidden' : ''), + callback: cancelScheduling, + }, + set: { + label: '[[modules:composer.set-schedule-date]]', + className: 'btn-primary', + callback: setTimestamp, + }, + }, + }); + } + + function initModal(ev) { + state.open = true; + const schedulerContainer = ev.target.querySelector('.datetime-picker'); + dateInput = schedulerContainer.querySelector('input[type="date"]'); + timeInput = schedulerContainer.querySelector('input[type="time"]'); + initDateTimeInputs(); + } + + function handleOnHidden() { + state.open = false; + } + + function handleOnActivate(ev, { post_uuid }) { + state.edit = false; + + const postData = state.posts[post_uuid]; + if (postData && postData.isMain && postData.timestamp > Date.now()) { + state.timestamp = postData.timestamp; + state.edit = true; + toggleItems(); + } + } + + function initDateTimeInputs() { + const d = new Date(); + // Update min. selectable date and time + const nowLocalISO = new Date(d.getTime() - (d.getTimezoneOffset() * 60000)).toJSON(); + dateInput.setAttribute('min', nowLocalISO.slice(0, 10)); + timeInput.setAttribute('min', nowLocalISO.slice(11, -8)); + + if (scheduler.isActive()) { + const scheduleDate = new Date(state.timestamp - (d.getTimezoneOffset() * 60000)).toJSON(); + dateInput.value = scheduleDate.slice(0, 10); + timeInput.value = scheduleDate.slice(11, -8); + } + } + + function setTimestamp() { + const bothFilled = dateInput.value && timeInput.value; + const timestamp = new Date(`${dateInput.value} ${timeInput.value}`).getTime(); + if (!bothFilled || isNaN(timestamp) || timestamp < Date.now()) { + state.timestamp = 0; + const message = timestamp < Date.now() ? '[[error:scheduling-to-past]]' : '[[error:invalid-schedule-date]]'; + alerts.alert({ + type: 'danger', + timeout: 3000, + title: '', + alert_id: 'post_error', + message, + }); + return false; + } + if (!state.timestamp) { + toggleItems(true); + } + state.timestamp = timestamp; + } + + function cancelScheduling() { + if (!state.timestamp) { + return; + } + toggleItems(false); + state.timestamp = 0; + } + + function toggleItems(active = true) { + displayBtns.forEach(btn => btn.classList.toggle('active', active)); + if (submitBtn.icon) { + submitBtn.icon.classList.toggle('fa-check', !active); + submitBtn.icon.classList.toggle('fa-clock-o', active); + } + if (dropdownDisplayBtn.el) { + dropdownDisplayBtn.el.textContent = active ? dropdownDisplayBtn.activeText : dropdownDisplayBtn.defaultText; + cancelBtn.classList.toggle('hidden', !active); + } + // Toggle submit button text + submitBtn.el.lastChild.textContent = active ? submitBtn.activeText : submitBtn.defaultText; + } + + function toggleDisplayButtons(show) { + displayBtnCons.forEach(btn => btn.classList.toggle('hidden', !show)); + } + + return scheduler; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js new file mode 100644 index 0000000000..338e4546d2 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js @@ -0,0 +1,227 @@ + +'use strict'; + +define('composer/tags', ['alerts'], function (alerts) { + var tags = {}; + + var minTags; + var maxTags; + + tags.init = function (postContainer, postData) { + var tagEl = postContainer.find('.tags'); + if (!tagEl.length) { + return; + } + + minTags = ajaxify.data.hasOwnProperty('minTags') ? ajaxify.data.minTags : config.minimumTagsPerTopic; + maxTags = ajaxify.data.hasOwnProperty('maxTags') ? ajaxify.data.maxTags : config.maximumTagsPerTopic; + + tagEl.tagsinput({ + tagClass: 'badge bg-info rounded-1', + confirmKeys: [13, 44], + trimValue: true, + }); + var input = postContainer.find('.bootstrap-tagsinput input'); + + toggleTagInput(postContainer, postData, ajaxify.data); + + app.loadJQueryUI(function () { + input.autocomplete({ + delay: 100, + position: { my: 'left bottom', at: 'left top', collision: 'flip' }, + appendTo: postContainer.find('.bootstrap-tagsinput'), + open: function () { + $(this).autocomplete('widget').css('z-index', 20000); + }, + source: function (request, response) { + socket.emit('topics.autocompleteTags', { + query: request.term, + cid: postData.cid, + }, function (err, tags) { + if (err) { + return alerts.error(err); + } + if (tags) { + response(tags); + } + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, + select: function (/* event, ui */) { + // when autocomplete is selected from the dropdown simulate a enter key down to turn it into a tag + triggerEnter(input); + }, + }); + + addTags(postData.tags, tagEl); + + tagEl.on('beforeItemAdd', function (event) { + var reachedMaxTags = maxTags && maxTags <= tags.getTags(postContainer.attr('data-uuid')).length; + var cleanTag = utils.cleanUpTag(event.item, config.maximumTagLength); + var different = cleanTag !== event.item; + event.cancel = different || + event.item.length < config.minimumTagLength || + event.item.length > config.maximumTagLength || + reachedMaxTags; + + if (event.item.length < config.minimumTagLength) { + return alerts.error('[[error:tag-too-short, ' + config.minimumTagLength + ']]'); + } else if (event.item.length > config.maximumTagLength) { + return alerts.error('[[error:tag-too-long, ' + config.maximumTagLength + ']]'); + } else if (reachedMaxTags) { + return alerts.error('[[error:too-many-tags, ' + maxTags + ']]'); + } + if (different) { + tagEl.tagsinput('add', cleanTag); + } + }); + + var skipAddCheck = false; + var skipRemoveCheck = false; + tagEl.on('itemRemoved', function (event) { + if (skipRemoveCheck) { + skipRemoveCheck = false; + return; + } + + if (!event.item) { + return; + } + socket.emit('topics.canRemoveTag', { tag: event.item }, function (err, allowed) { + if (err) { + return alerts.error(err); + } + if (!allowed) { + alerts.error('[[error:cant-remove-system-tag]]'); + skipAddCheck = true; + tagEl.tagsinput('add', event.item); + } + }); + }); + + tagEl.on('itemAdded', function (event) { + if (skipAddCheck) { + skipAddCheck = false; + return; + } + var cid = postData.hasOwnProperty('cid') ? postData.cid : ajaxify.data.cid; + socket.emit('topics.isTagAllowed', { tag: event.item, cid: cid || 0 }, function (err, allowed) { + if (err) { + return alerts.error(err); + } + if (!allowed) { + skipRemoveCheck = true; + return tagEl.tagsinput('remove', event.item); + } + $(window).trigger('action:tag.added', { cid: cid, tagEl: tagEl, tag: event.item }); + if (input.length) { + input.autocomplete('close'); + } + }); + }); + }); + + input.attr('tabIndex', tagEl.attr('tabIndex')); + input.on('blur', function () { + triggerEnter(input); + }); + + $('[component="composer/tag/dropdown"]').on('click', 'li', function () { + var tag = $(this).attr('data-tag'); + if (tag) { + addTags([tag], tagEl); + } + return false; + }); + }; + + tags.isEnoughTags = function (post_uuid) { + return tags.getTags(post_uuid).length >= minTags; + }; + + tags.minTagCount = function () { + return minTags; + }; + + tags.onChangeCategory = function (postContainer, postData, cid, categoryData) { + var tagDropdown = postContainer.find('[component="composer/tag/dropdown"]'); + if (!tagDropdown.length) { + return; + } + + toggleTagInput(postContainer, postData, categoryData); + tagDropdown.toggleClass('hidden', !categoryData.tagWhitelist || !categoryData.tagWhitelist.length); + if (categoryData.tagWhitelist) { + app.parseAndTranslate('composer', 'tagWhitelist', { tagWhitelist: categoryData.tagWhitelist }, function (html) { + tagDropdown.find('.dropdown-menu').html(html); + }); + } + }; + + function toggleTagInput(postContainer, postData, data) { + var tagEl = postContainer.find('.tags'); + var input = postContainer.find('.bootstrap-tagsinput input'); + if (!input.length) { + return; + } + + if (data.hasOwnProperty('minTags')) { + minTags = data.minTags; + } + if (data.hasOwnProperty('maxTags')) { + maxTags = data.maxTags; + } + + if (data.tagWhitelist && data.tagWhitelist.length) { + input.attr('readonly', ''); + input.attr('placeholder', ''); + + tagEl.tagsinput('items').slice().forEach(function (tag) { + if (data.tagWhitelist.indexOf(tag) === -1) { + tagEl.tagsinput('remove', tag); + } + }); + } else { + input.removeAttr('readonly'); + input.attr('placeholder', postContainer.find('input.tags').attr('placeholder')); + } + postContainer.find('.tags-container').toggleClass('haswhitelist', !!(data.tagWhitelist && data.tagWhitelist.length)); + postContainer.find('.tags-container').toggleClass('hidden', ( + data.privileges && data.privileges.hasOwnProperty('topics:tag') && !data.privileges['topics:tag']) || + (maxTags === 0 && !postData && !postData.tags && !postData.tags.length)); + + if (data.privileges && data.privileges.hasOwnProperty('topics:tag') && !data.privileges['topics:tag']) { + tagEl.tagsinput('removeAll'); + } + + $(window).trigger('action:tag.toggleInput', { + postContainer: postContainer, + tagWhitelist: data.tagWhitelist, + tagsInput: input, + }); + } + + function triggerEnter(input) { + // http://stackoverflow.com/a/3276819/583363 + var e = jQuery.Event('keypress'); + e.which = 13; + e.keyCode = 13; + setTimeout(function () { + input.trigger(e); + }, 100); + } + + function addTags(tags, tagEl) { + if (tags && tags.length) { + for (var i = 0; i < tags.length; ++i) { + tagEl.tagsinput('add', tags[i]); + } + } + } + + tags.getTags = function (post_uuid) { + return $('.composer[data-uuid="' + post_uuid + '"] .tags').tagsinput('items'); + }; + + return tags; +}); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/uploads.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/uploads.js new file mode 100644 index 0000000000..6da01db605 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/uploads.js @@ -0,0 +1,271 @@ +'use strict'; + +define('composer/uploads', [ + 'composer/preview', + 'composer/categoryList', + 'translator', + 'alerts', + 'uploadHelpers', + 'jquery-form', +], function (preview, categoryList, translator, alerts, uploadHelpers) { + var uploads = { + inProgress: {}, + }; + + var uploadingText = ''; + + uploads.initialize = function (post_uuid) { + initializeDragAndDrop(post_uuid); + initializePaste(post_uuid); + + addChangeHandlers(post_uuid); + addTopicThumbHandlers(post_uuid); + translator.translate('[[modules:composer.uploading, ' + 0 + '%]]', function (translated) { + uploadingText = translated; + }); + }; + + function addChangeHandlers(post_uuid) { + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + + postContainer.find('#files').on('change', function (e) { + var files = (e.target || {}).files || + ($(this).val() ? [{ name: $(this).val(), type: utils.fileMimeType($(this).val()) }] : null); + if (files) { + uploadContentFiles({ files: files, post_uuid: post_uuid, route: '/api/post/upload' }); + } + }); + } + + function addTopicThumbHandlers(post_uuid) { + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + + postContainer.on('click', '.topic-thumb-clear-btn', function (e) { + postContainer.find('input#topic-thumb-url').val('').trigger('change'); + resetInputFile(postContainer.find('input#topic-thumb-file')); + $(this).addClass('hide'); + e.preventDefault(); + }); + + postContainer.on('paste change keypress', 'input#topic-thumb-url', function () { + var urlEl = $(this); + setTimeout(function () { + var url = urlEl.val(); + if (url) { + postContainer.find('.topic-thumb-clear-btn').removeClass('hide'); + } else { + resetInputFile(postContainer.find('input#topic-thumb-file')); + postContainer.find('.topic-thumb-clear-btn').addClass('hide'); + } + postContainer.find('img.topic-thumb-preview').attr('src', url); + }, 100); + }); + } + + function resetInputFile($el) { + $el.wrap('
    ').closest('form').get(0).reset(); + $el.unwrap(); + } + + function initializeDragAndDrop(post_uuid) { + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + uploadHelpers.handleDragDrop({ + container: postContainer, + callback: function (upload) { + uploadContentFiles({ + files: upload.files, + post_uuid: post_uuid, + route: '/api/post/upload', + formData: upload.formData, + }); + }, + }); + } + + function initializePaste(post_uuid) { + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + uploadHelpers.handlePaste({ + container: postContainer, + callback: function (upload) { + uploadContentFiles({ + files: upload.files, + fileNames: upload.fileNames, + post_uuid: post_uuid, + route: '/api/post/upload', + formData: upload.formData, + }); + }, + }); + } + + function escapeRegExp(text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + } + + function insertText(str, index, insert) { + return str.slice(0, index) + insert + str.slice(index); + } + + function uploadContentFiles(params) { + var files = [...params.files]; + var post_uuid = params.post_uuid; + var postContainer = $('.composer[data-uuid="' + post_uuid + '"]'); + var textarea = postContainer.find('textarea'); + var text = textarea.val(); + var uploadForm = postContainer.find('#fileForm'); + var doneUploading = false; + uploadForm.attr('action', config.relative_path + params.route); + + var cid = categoryList.getSelectedCid(); + if (!cid && ajaxify.data.cid) { + cid = ajaxify.data.cid; + } + var i = 0; + var isImage = false; + for (i = 0; i < files.length; ++i) { + isImage = files[i].type.match(/image./); + if ((isImage && !app.user.privileges['upload:post:image']) || (!isImage && !app.user.privileges['upload:post:file'])) { + return alerts.error('[[error:no-privileges]]'); + } + } + + var filenameMapping = []; + let filesText = ''; + for (i = 0; i < files.length; ++i) { + // The filename map has datetime and iterator prepended so that they can be properly tracked even if the + // filenames are identical. + filenameMapping.push(i + '_' + Date.now() + '_' + (params.fileNames ? params.fileNames[i] : files[i].name)); + isImage = files[i].type.match(/image./); + + if (!app.user.isAdmin && files[i].size > parseInt(config.maximumFileSize, 10) * 1024) { + uploadForm[0].reset(); + return alerts.error('[[error:file-too-big, ' + config.maximumFileSize + ']]'); + } + filesText += (isImage ? '!' : '') + '[' + filenameMapping[i] + '](' + uploadingText + ') '; + } + + const cursorPosition = textarea.getCursorPosition(); + const textLen = text.length; + text = insertText(text, cursorPosition, filesText); + + if (uploadForm.length) { + postContainer.find('[data-action="post"]').prop('disabled', true); + } + textarea.val(text); + + $(window).trigger('action:composer.uploadStart', { + post_uuid: post_uuid, + files: filenameMapping.map(function (filename, i) { + return { + filename: filename.replace(/^\d+_\d{13}_/, ''), + isImage: /image./.test(files[i].type), + }; + }), + text: uploadingText, + }); + + uploadForm.off('submit').submit(function () { + function updateTextArea(filename, text, trim) { + var newFilename; + if (trim) { + newFilename = filename.replace(/^\d+_\d{13}_/, ''); + } + var current = textarea.val(); + var re = new RegExp(escapeRegExp(filename) + ']\\([^)]+\\)', 'g'); + textarea.val(current.replace(re, (newFilename || filename) + '](' + text + ')')); + + $(window).trigger('action:composer.uploadUpdate', { + post_uuid: post_uuid, + filename: filename, + text: text, + }); + } + + uploads.inProgress[post_uuid] = uploads.inProgress[post_uuid] || []; + uploads.inProgress[post_uuid].push(1); + + if (params.formData) { + params.formData.append('cid', cid); + } + + $(this).ajaxSubmit({ + headers: { + 'x-csrf-token': config.csrf_token, + }, + resetForm: true, + clearForm: true, + formData: params.formData, + data: { cid: cid }, + + error: function (xhr) { + doneUploading = true; + postContainer.find('[data-action="post"]').prop('disabled', false); + const errorMsg = onUploadError(xhr, post_uuid); + for (var i = 0; i < files.length; ++i) { + updateTextArea(filenameMapping[i], errorMsg, true); + } + preview.render(postContainer); + }, + + uploadProgress: function (event, position, total, percent) { + translator.translate('[[modules:composer.uploading, ' + percent + '%]]', function (translated) { + if (doneUploading) { + return; + } + for (var i = 0; i < files.length; ++i) { + updateTextArea(filenameMapping[i], translated); + } + }); + }, + + success: function (res) { + const uploads = res.response.images; + doneUploading = true; + if (uploads && uploads.length) { + for (var i = 0; i < uploads.length; ++i) { + uploads[i].filename = filenameMapping[i].replace(/^\d+_\d{13}_/, ''); + uploads[i].isImage = /image./.test(files[i].type); + updateTextArea(filenameMapping[i], uploads[i].url, true); + } + } + preview.render(postContainer); + textarea.prop('selectionEnd', cursorPosition + textarea.val().length - textLen); + textarea.focus(); + postContainer.find('[data-action="post"]').prop('disabled', false); + $(window).trigger('action:composer.upload', { + post_uuid: post_uuid, + files: uploads, + }); + }, + + complete: function () { + uploadForm[0].reset(); + uploads.inProgress[post_uuid].pop(); + }, + }); + + return false; + }); + + uploadForm.submit(); + } + + function onUploadError(xhr, post_uuid) { + var msg = (xhr.responseJSON && + (xhr.responseJSON.error || (xhr.responseJSON.status && xhr.responseJSON.status.message))) || + '[[error:parse-error]]'; + + if (xhr && xhr.status === 413) { + msg = xhr.statusText || 'Request Entity Too Large'; + } + alerts.error(msg); + $(window).trigger('action:composer.uploadError', { + post_uuid: post_uuid, + message: msg, + }); + return msg; + } + + return uploads; +}); + diff --git a/node_modules/nodebb-plugin-composer-default/static/scss/composer.scss b/node_modules/nodebb-plugin-composer-default/static/scss/composer.scss new file mode 100644 index 0000000000..e9157dc92d --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/scss/composer.scss @@ -0,0 +1,385 @@ +.composer { + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + z-index: $zindex-dropdown; + visibility: hidden; + padding: 0; + position: fixed; + bottom: 0; + top: 0; + right: 0; + left: 0; + + .mobile-navbar { + position: static; + min-height: 40px; + margin: 0; + + .btn-group { + flex-shrink: 0; + } + + button { + font-size: 20px; + } + + display: flex; + + .category-name-container, .title { + text-align: center; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex-grow: 2; + font-size: 16px; + line-height: inherit; + padding: 9px 5px; + margin: 0; + } + } + + .title-container { + > div[data-component="composer/handle"] { + flex: 0.33; + } + + .category-list-container { + + [component="category-selector"] { + .category-dropdown-menu { + max-height: 300px; + } + } + } + + .category-list { + padding: 0 2rem; + } + + .action-bar { + .dropdown-menu:empty { + & ~ .dropdown-toggle { + display: none; + } + } + } + } + + .formatting-bar { + .spacer { + &:before { + content: ' | '; + color: $gray-200; + } + } + } + + .tags-container { + [component="composer/tag/dropdown"] { + .dropdown-menu { + max-height: 400px; + overflow-y: auto; + } + + > button { + border: 0; + } + } + // if picking tags from taglist dropdown hide the input + &.haswhitelist .bootstrap-tagsinput { + input { + display: none; + } + } + .bootstrap-tagsinput { + background: transparent; + flex-grow: 1; + border: 0; + padding: 0; + box-shadow: none; + max-height: 80px; + overflow: auto; + + input { + &::placeholder{ + color: $input-placeholder-color; + } + color: $body-color; + font-size: 16px; + width: 50%; + @include media-breakpoint-down(md) { + width: 100%; + } + + + height: 28px; + padding: 4px 6px; + } + + .ui-autocomplete { + max-height: 350px; + overflow-x: hidden; + overflow-y: auto; + } + } + } + + .resizer { + background: linear-gradient(transparent, var(--bs-body-bg)); + margin-left: calc($spacer * -0.5); + padding-left: $spacer; + + .trigger { + cursor: ns-resize; + + .handle { + border-top-left-radius: 50%; + border-top-right-radius: 50%; + border-bottom: 0 !important; + } + } + } + + .minimize { + display: none; + position: absolute; + top: 0px; + right: 10px; + height: 0; + + @include pointer; + + .trigger { + position: relative; + display: block; + top: -20px; + right: 0px; + margin: 0 auto; + margin-left: 20px; + line-height: 26px; + @include transition(filter .15s linear); + + &:hover { + filter: invert(100%); + } + + i { + width: 32px; + height: 32px; + background: #333; + border: 1px solid #333; + border-radius: 50%; + + position: relative; + + color: #FFF; + font-size: 16px; + + &:before { + position: relative; + top: 25%; + } + } + } + } + + &.reply { + .title-container { + display: none; + } + } + + &.resizable.maximized { + .resizer { + top: 0 !important; + background: transparent; + + .trigger { + height: $spacer * 0.5; + + .handle { + border-top-left-radius: 0%; + border-top-right-radius: 0%; + border-bottom-left-radius: 50%; + border-bottom-right-radius: 50%; + border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; + } + + i { + &:before { + content: fa-content($fa-var-chevron-down); + } + } + } + } + } + + .draft-icon { + font-family: 'FontAwesome'; + color: $success; + opacity: 0; + + &::before { + content: fa-content($fa-var-save); + } + + &.active { + animation: draft-saved 3s ease; + } + } + + textarea { + resize: none; + } + + .preview { + padding: $input-padding-y $input-padding-x; + } +} + +.datetime-picker { + display: flex; + justify-content: center; + flex-direction: row; + min-width: 310px; + max-width: 310px; + margin: 0 auto; + + input { + flex: 3; + line-height: inherit; + } + + input + input { + border-left: none; + flex: 2; + } +} + +.modal.topic-scheduler { + z-index: 1070; + & + .modal-backdrop { + z-index: 1060; + } +} + +@keyframes draft-saved { + 0%, 100% { + opacity: 0; + } + + 15% { + opacity: 1; + } + + 30% { + opacity: 0.5; + } + + 45% { + opacity: 1; + } + + 85% { + opacity: 1; + } +} + +@keyframes pulse { + from { + transform: scale(1); + color: inherit; + } + 50% { + transform: scale(.9); + } + to { + transform: scale(1); + color: #00adff; + } +} + +@include media-breakpoint-down(lg) { + html.composing .composer { z-index: $zindex-modal; } +} + +@include media-breakpoint-down(sm) { + html.composing { + .composer { + height: 100%; + + .draft-icon { + position: absolute; + bottom: 1em; + right: 0em; + + &::after { + top: 7px; + } + } + + .preview-container { + max-width: initial; + } + } + + body { + padding-bottom: 0 !important; + } + } +} + +@include media-breakpoint-up(lg) { + html.composing { + .composer { + left: 15%; + width: 70%; + min-height: 400px; + + .resizer { + display: block; + } + + .minimize { + display: block; + } + } + } +} + +@include media-breakpoint-up(md) { + // without this formatting elements that are dropdowns are not visible on desktop. + // on mobile dropdowns use bottom-sheet and overflow is auto + .formatting-group { + overflow: visible!important; + } +} + +@import './zen-mode'; +@import './page-compose'; +@import './textcomplete'; + + +.skin-noskin, .skin-cosmo, .skin-flatly, +.skin-journal, .skin-litera, .skin-minty, .skin-pulse, +.skin-sandstone, .skin-sketchy, .skin-spacelab, .skin-united { + .composer { + color: var(--bs-secondary) !important; + background-color: var(--bs-light) !important; + } +} + +.skin-cerulean, .skin-lumen, .skin-lux, .skin-morph, +.skin-simplex, .skin-yeti, .skin-zephyr { + .composer { + color: var(--bs-body) !important; + background-color: var(--bs-light) !important; + } +} + +@include color-mode(dark) { + .skin-noskin .composer { + color: var(--bs-secondary)!important; + background-color: var(--bs-body-bg)!important; + } +} \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/static/scss/page-compose.scss b/node_modules/nodebb-plugin-composer-default/static/scss/page-compose.scss new file mode 100644 index 0000000000..2b2756f426 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/scss/page-compose.scss @@ -0,0 +1,35 @@ +.page-compose .composer { + z-index: initial; + position: static; + [data-action="hide"] { + display: none; + } + + @include media-breakpoint-down(md) { + .title-container { + flex-wrap: wrap; + } + .category-list-container { + [component="category-selector-selected"] > span { + display: inline!important; + } + width: 100%; + } + } +} + +.zen-mode .page-compose .composer { + position: absolute; +} +.page-compose { + &.skin-noskin, &.skin-cosmo, &.skin-flatly, + &.skin-journal, &.skin-litera, &.skin-minty, &.skin-pulse, + &.skin-sandstone, &.skin-sketchy, &.skin-spacelab, &.skin-united, + &.skin-cerulean, &.skin-lumen, &.skin-lux, &.skin-morph, + &.skin-simplex, &.skin-yeti, &.skin-zephyr { + .composer { + color: var(--bs-body-color) !important; + background-color: var(--bs-body-bg) !important; + } + } +} diff --git a/node_modules/nodebb-plugin-composer-default/static/scss/textcomplete.scss b/node_modules/nodebb-plugin-composer-default/static/scss/textcomplete.scss new file mode 100644 index 0000000000..7a4cad943a --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/scss/textcomplete.scss @@ -0,0 +1,26 @@ +.textcomplete-dropdown { + border: 1px solid $border-color; + background-color: $body-bg; + color: $body-color; + list-style: none; + padding: 0; + margin: 0; + + li { + margin: 0; + } + + .textcomplete-footer, .textcomplete-item { + border-top: 1px solid $border-color; + } + + .textcomplete-item { + padding: 2px 5px; + cursor: pointer; + + &:hover, &.active { + color: $dropdown-link-hover-color; + background-color: $dropdown-link-hover-bg; + } + } +} \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/static/scss/zen-mode.scss b/node_modules/nodebb-plugin-composer-default/static/scss/zen-mode.scss new file mode 100644 index 0000000000..b29340e8d3 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/scss/zen-mode.scss @@ -0,0 +1,51 @@ +html.zen-mode { + overflow: hidden; +} + +.zen-mode .composer { + &.resizable { + padding-top: 0; + } + + .composer-container { + padding-top: 5px; + } + + .tag-row { + display: none; + } + + .title-container .category-list-container { + margin-top: 3px; + } + + .write, .preview { + border: none; + outline: none; + } + + .resizer { + display: none; + } + + &.reply { + .title-container { + display: none; + } + } + + @include media-breakpoint-up(md) { + & { + padding-left: 15px; + padding-right: 15px; + } + .write-preview-container { + margin-bottom: 0; + + > div { + padding: 0; + margin: 0; + } + } + } +} \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/admin/plugins/composer-default.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/admin/plugins/composer-default.tpl new file mode 100644 index 0000000000..a7fa31ce59 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/admin/plugins/composer-default.tpl @@ -0,0 +1,22 @@ +
    + + +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/compose.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/compose.tpl new file mode 100644 index 0000000000..9fb4300baf --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/compose.tpl @@ -0,0 +1,27 @@ +
    +
    +
    + {{{ if pid }}} + + + {{{ end }}} + {{{ if tid }}} + + {{{ end }}} + {{{ if cid }}} + + {{{ end }}} + +
    + + + + + + + + {{{ if isTopicOrMain }}} + + {{{ end }}} +
    +
    diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/composer.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/composer.tpl new file mode 100644 index 0000000000..f4ee338c69 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/composer.tpl @@ -0,0 +1,46 @@ +
    +
    + + +
    + + + + + + + {{{ if isTopicOrMain }}} + + {{{ end }}} + +
    [[topic:composer.drag-and-drop-images]]
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/modals/topic-scheduler.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/modals/topic-scheduler.tpl new file mode 100644 index 0000000000..29736747c6 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/modals/topic-scheduler.tpl @@ -0,0 +1,4 @@ +
    + + +
    \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl new file mode 100644 index 0000000000..941f06f2e4 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl @@ -0,0 +1,75 @@ +
    +
      + {{{ each formatting }}} + {{{ if ./spacer }}} +
    • + {{{ else }}} + {{{ if (./visibility.desktop && ((isTopicOrMain && ./visibility.main) || (!isTopicOrMain && ./visibility.reply))) }}} + {{{ if ./dropdownItems.length }}} + + {{{ else }}} +
    • + +
    • + {{{ end }}} + {{{ end }}} + {{{ end }}} + {{{ end }}} + + {{{ if privileges.upload:post:image }}} +
    • + +
    • + {{{ end }}} + + {{{ if privileges.upload:post:file }}} +
    • + +
    • + {{{ end }}} + +
      + +
      +
    +
    + + + {{{ if composer:showHelpTab }}} + + {{{ end }}} +
    +
    + diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-tags.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-tags.tpl new file mode 100644 index 0000000000..e247403419 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-tags.tpl @@ -0,0 +1,17 @@ +
    +
    +
    + + + +
    + +
    +
    \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl new file mode 100644 index 0000000000..18b44784e3 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl @@ -0,0 +1,55 @@ +
    + {{{ if isTopic }}} +
    + +
    + {{{ end }}} + + {{{ if showHandleInput }}} +
    + +
    + {{{ end }}} + +
    + {{{ if isTopicOrMain }}} + + {{{ else }}} + {{{ if isEditing }}}[[topic:composer.editing-in, "{topicTitle}"]]{{{ else }}}[[topic:composer.replying-to, "{topicTitle}"]]{{{ end }}} + {{{ end }}} + +
    + +
    + + + + +
    + + +
    + +
    + +
    + + +
    +
    +
    +
    diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-write-preview.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-write-preview.tpl new file mode 100644 index 0000000000..37cefbd220 --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-write-preview.tpl @@ -0,0 +1,10 @@ +
    +
    +
    [[modules:composer.post-queue-alert]]
    + + +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/node_modules/nodebb-plugin-composer-default/websockets.js b/node_modules/nodebb-plugin-composer-default/websockets.js new file mode 100644 index 0000000000..882dbb2b0d --- /dev/null +++ b/node_modules/nodebb-plugin-composer-default/websockets.js @@ -0,0 +1,94 @@ +'use strict'; + +const meta = require.main.require('./src/meta'); +const privileges = require.main.require('./src/privileges'); +const posts = require.main.require('./src/posts'); +const topics = require.main.require('./src/topics'); +const plugins = require.main.require('./src/plugins'); + +const Sockets = module.exports; + +Sockets.push = async function (socket, pid) { + const canRead = await privileges.posts.can('topics:read', pid, socket.uid); + if (!canRead) { + throw new Error('[[error:no-privileges]]'); + } + + const postData = await posts.getPostFields(pid, ['content', 'tid', 'uid', 'handle', 'timestamp']); + if (!postData && !postData.content) { + throw new Error('[[error:invalid-pid]]'); + } + + const [topic, tags, isMain] = await Promise.all([ + topics.getTopicDataByPid(pid), + topics.getTopicTags(postData.tid), + posts.isMain(pid), + ]); + + if (!topic) { + throw new Error('[[error:no-topic]]'); + } + + const result = await plugins.hooks.fire('filter:composer.push', { + pid: pid, + uid: postData.uid, + handle: parseInt(meta.config.allowGuestHandles, 10) ? postData.handle : undefined, + body: postData.content, + title: topic.title, + thumb: topic.thumb, + tags: tags, + isMain: isMain, + timestamp: postData.timestamp, + }); + return result; +}; + +Sockets.editCheck = async function (socket, pid) { + const isMain = await posts.isMain(pid); + return { titleEditable: isMain }; +}; + +Sockets.renderPreview = async function (socket, content) { + return await plugins.hooks.fire('filter:parse.raw', content); +}; + +Sockets.renderHelp = async function () { + const helpText = meta.config['composer:customHelpText'] || ''; + if (!meta.config['composer:showHelpTab']) { + throw new Error('help-hidden'); + } + + const parsed = await plugins.hooks.fire('filter:parse.raw', helpText); + if (meta.config['composer:allowPluginHelp'] && plugins.hooks.hasListeners('filter:composer.help')) { + return await plugins.hooks.fire('filter:composer.help', parsed) || helpText; + } + return helpText; +}; + +Sockets.getFormattingOptions = async function () { + return await require('./library').getFormattingOptions(); +}; + +Sockets.shouldQueue = async function (socket, data) { + if (!data || !data.postData) { + throw new Error('[[error:invalid-data]]'); + } + if (socket.uid <= 0) { + return false; + } + + let shouldQueue = false; + const { postData } = data; + if (postData.action === 'posts.reply') { + shouldQueue = await posts.shouldQueue(socket.uid, { + tid: postData.tid, + content: postData.content || '', + }); + } else if (postData.action === 'topics.post') { + shouldQueue = await posts.shouldQueue(socket.uid, { + cid: postData.cid, + content: postData.content || '', + }); + } + return shouldQueue; +}; From d41a0cd612590b0a842c70aa78c557cecb4dbddf Mon Sep 17 00:00:00 2001 From: Nour Alseaf Date: Tue, 24 Sep 2024 12:53:49 +0300 Subject: [PATCH 13/48] reintsalled the composer-default file --- .../package.json | 5 +-- .../static/lib/composer.js | 15 +++++++- .../static/lib/composer/autocomplete.js | 37 ++----------------- .../static/lib/composer/tags.js | 35 +++++++++++------- .../partials/composer-title-container.tpl | 11 +----- package.json | 7 ++-- 6 files changed, 44 insertions(+), 66 deletions(-) diff --git a/node_modules/nodebb-plugin-composer-default/package.json b/node_modules/nodebb-plugin-composer-default/package.json index 625184980c..1f0329d993 100644 --- a/node_modules/nodebb-plugin-composer-default/package.json +++ b/node_modules/nodebb-plugin-composer-default/package.json @@ -1,6 +1,6 @@ { "name": "nodebb-plugin-composer-default", - "version": "10.2.36", + "version": "10.2.39", "description": "Default composer for NodeBB", "main": "library.js", "repository": { @@ -29,9 +29,6 @@ "compatibility": "^3.0.0" }, "dependencies": { - "@textcomplete/contenteditable": "^0.1.12", - "@textcomplete/core": "^0.1.12", - "@textcomplete/textarea": "^0.1.12", "screenfull": "^5.0.2", "validator": "^13.7.0" }, diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer.js index 70e381b8b4..c519108168 100644 --- a/node_modules/nodebb-plugin-composer-default/static/lib/composer.js +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer.js @@ -411,6 +411,9 @@ define('composer', [ preview.matchScroll(postContainer); }); + if (postData.action === 'posts.edit' && !utils.isNumber(postData.pid)) { + handleRemotePid(postContainer); + } handleHelp(postContainer); handleSearch(postContainer); focusElements(postContainer); @@ -579,6 +582,16 @@ define('composer', [ }, path, `${config.relative_path}/${returnPath}`); } + function handleRemotePid(postContainer) { + alerts.alert({ + title: '[[modules:composer.remote-pid-editing]]', + message: '[[modules:composer.remote-pid-content-immutable]]', + timeout: 15000, + }); + var container = postContainer.find('.write-container'); + container.addClass('hidden'); + } + function handleHelp(postContainer) { const helpBtn = postContainer.find('[data-action="help"]'); helpBtn.on('click', async function () { @@ -731,7 +744,7 @@ define('composer', [ }; } else if (action === 'posts.edit') { method = 'put'; - route = `/posts/${postData.pid}`; + route = `/posts/${encodeURIComponent(postData.pid)}`; composerData = { ...composerData, pid: postData.pid, diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js index ec2ce15d33..afceca0ed3 100644 --- a/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js @@ -1,8 +1,8 @@ 'use strict'; define('composer/autocomplete', [ - 'composer/preview', '@textcomplete/core', '@textcomplete/textarea', '@textcomplete/contenteditable', -], function (preview, { Textcomplete }, { TextareaEditor }, { ContenteditableEditor }) { + 'composer/preview', 'autocomplete', +], function (preview, Autocomplete) { var autocomplete = { _active: {}, }; @@ -57,43 +57,12 @@ define('composer/autocomplete', [ $(window).trigger('composer:autocomplete:init', data); - autocomplete._active[post_uuid] = autocomplete.setup(data); + autocomplete._active[post_uuid] = Autocomplete.setup(data); data.element.on('textComplete:select', function () { preview.render(postContainer); }); }; - // This is a generic method that is also used by the chat - autocomplete.setup = function ({ element, strategies, options }) { - const targetEl = element.get(0); - if (!targetEl) { - return; - } - var editor; - if (targetEl.nodeName === 'TEXTAREA' || targetEl.nodeName === 'INPUT') { - editor = new TextareaEditor(targetEl); - } else if (targetEl.nodeName === 'DIV' && targetEl.getAttribute('contenteditable') === 'true') { - editor = new ContenteditableEditor(targetEl); - } - if (!editor) { - throw new Error('unknown target element type'); - } - // yuku-t/textcomplete inherits directionality from target element itself - targetEl.setAttribute('dir', document.querySelector('html').getAttribute('data-dir')); - - var textcomplete = new Textcomplete(editor, strategies, { - dropdown: options, - }); - textcomplete.on('rendered', function () { - if (textcomplete.dropdown.items.length) { - // Activate the first item by default. - textcomplete.dropdown.items[0].activate(); - } - }); - - return textcomplete; - }; - return autocomplete; }); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js index 338e4546d2..c9b92daabe 100644 --- a/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js @@ -71,37 +71,40 @@ define('composer/tags', ['alerts'], function (alerts) { } else if (reachedMaxTags) { return alerts.error('[[error:too-many-tags, ' + maxTags + ']]'); } + var cid = postData.hasOwnProperty('cid') ? postData.cid : ajaxify.data.cid; + $(window).trigger('action:tag.beforeAdd', { + cid, + tagEl, + tag: event.item, + event, + inputAutocomplete: input, + }); if (different) { tagEl.tagsinput('add', cleanTag); } + if (event.cancel && input.length) { + input.autocomplete('close'); + } }); - var skipAddCheck = false; - var skipRemoveCheck = false; tagEl.on('itemRemoved', function (event) { - if (skipRemoveCheck) { - skipRemoveCheck = false; + if (!event.item || (event.options && event.options.skipRemoveCheck)) { return; } - if (!event.item) { - return; - } socket.emit('topics.canRemoveTag', { tag: event.item }, function (err, allowed) { if (err) { return alerts.error(err); } if (!allowed) { alerts.error('[[error:cant-remove-system-tag]]'); - skipAddCheck = true; - tagEl.tagsinput('add', event.item); + tagEl.tagsinput('add', event.item, { skipAddCheck: true }); } }); }); tagEl.on('itemAdded', function (event) { - if (skipAddCheck) { - skipAddCheck = false; + if (event.options && event.options.skipAddCheck) { return; } var cid = postData.hasOwnProperty('cid') ? postData.cid : ajaxify.data.cid; @@ -110,10 +113,14 @@ define('composer/tags', ['alerts'], function (alerts) { return alerts.error(err); } if (!allowed) { - skipRemoveCheck = true; - return tagEl.tagsinput('remove', event.item); + return tagEl.tagsinput('remove', event.item, { skipRemoveCheck: true }); } - $(window).trigger('action:tag.added', { cid: cid, tagEl: tagEl, tag: event.item }); + $(window).trigger('action:tag.added', { + cid, + tagEl, + tag: event.item, + inputAutocomplete: input, + }); if (input.length) { input.autocomplete('close'); } diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl index 18b44784e3..185393f285 100644 --- a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl @@ -25,16 +25,7 @@
    - - - -
    - - -
    - +
    diff --git a/package.json b/package.json index bc8e333e1a..a625b8a9fe 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@isaacs/ttlcache": "1.4.1", "@nodebb/spider-detector": "2.0.3", "@popperjs/core": "2.11.8", + "@socket.io/redis-adapter": "8.3.0", "ace-builds": "1.33.2", "archiver": "7.0.1", "async": "3.2.5", @@ -75,6 +76,7 @@ "helmet": "7.1.0", "html-to-text": "9.0.5", "imagesloaded": "5.0.0", + "ioredis": "5.4.1", "ipaddr.js": "2.2.0", "jquery": "3.7.1", "jquery-deserialize": "2.0.0", @@ -95,6 +97,7 @@ "multiparty": "4.2.3", "nconf": "0.12.1", "nodebb-plugin-2factor": "7.5.3", + "nodebb-plugin-composer-default": "^10.2.39", "nodebb-plugin-dbsearch": "6.2.5", "nodebb-plugin-emoji": "5.1.15", "nodebb-plugin-emoji-android": "4.0.0", @@ -119,7 +122,6 @@ "postcss-clean": "1.2.0", "progress-webpack-plugin": "1.0.16", "prompt": "1.3.0", - "ioredis": "5.4.1", "rimraf": "5.0.7", "rss": "1.2.2", "rtlcss": "4.1.1", @@ -131,7 +133,6 @@ "sitemap": "7.1.1", "socket.io": "4.7.5", "socket.io-client": "4.7.5", - "@socket.io/redis-adapter": "8.3.0", "sortablejs": "1.15.2", "spdx-license-list": "6.9.0", "terser-webpack-plugin": "5.3.10", @@ -194,4 +195,4 @@ "url": "https://github.com/barisusakli" } ] -} \ No newline at end of file +} From 53b80c2da5f863c98af208231c0da2636aa298a8 Mon Sep 17 00:00:00 2001 From: Nour Alseaf Date: Tue, 24 Sep 2024 12:59:36 +0300 Subject: [PATCH 14/48] Added an Anonymous toggle to the submit button --- .../partials/composer-title-container.tpl | 92 ++++++++++--------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl index 185393f285..e16cfdeb41 100644 --- a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl @@ -1,46 +1,56 @@
    - {{{ if isTopic }}} -
    - -
    - {{{ end }}} + {{{ if isTopic }}} +
    + +
    + {{{ end }}} - {{{ if showHandleInput }}} -
    - -
    - {{{ end }}} + {{{ if showHandleInput }}} +
    + +
    + {{{ end }}} -
    - {{{ if isTopicOrMain }}} - - {{{ else }}} - {{{ if isEditing }}}[[topic:composer.editing-in, "{topicTitle}"]]{{{ else }}}[[topic:composer.replying-to, "{topicTitle}"]]{{{ end }}} - {{{ end }}} - -
    +
    + {{{ if isTopicOrMain }}} + + {{{ else }}} + {{{ if isEditing }}}[[topic:composer.editing-in, "{topicTitle}"]]{{{ else }}}[[topic:composer.replying-to, "{topicTitle}"]]{{{ end }}} + {{{ end }}} + +
    -
    - - -
    - -
    - - -
    -
    -
    +
    + + + +
    + +
    + + +
    +
    +
    From 94c2883c39e4e7e070d19d3ac5d12decfad4edc6 Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Tue, 24 Sep 2024 13:58:15 +0300 Subject: [PATCH 15/48] final design changes to composer-titiel-container.tpl --- .../partials/composer-title-container.tpl | 99 +++++++++---------- 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl index e16cfdeb41..18b44784e3 100644 --- a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl @@ -1,56 +1,55 @@
    - {{{ if isTopic }}} -
    - -
    - {{{ end }}} + {{{ if isTopic }}} +
    + +
    + {{{ end }}} - {{{ if showHandleInput }}} -
    - -
    - {{{ end }}} + {{{ if showHandleInput }}} +
    + +
    + {{{ end }}} -
    - {{{ if isTopicOrMain }}} - - {{{ else }}} - {{{ if isEditing }}}[[topic:composer.editing-in, "{topicTitle}"]]{{{ else }}}[[topic:composer.replying-to, "{topicTitle}"]]{{{ end }}} - {{{ end }}} - -
    +
    + {{{ if isTopicOrMain }}} + + {{{ else }}} + {{{ if isEditing }}}[[topic:composer.editing-in, "{topicTitle}"]]{{{ else }}}[[topic:composer.replying-to, "{topicTitle}"]]{{{ end }}} + {{{ end }}} + +
    -
    - - +
    + + -
    - -
    - - -
    -
    -
    + +
    + + +
    + +
    + +
    + + +
    +
    +
    From ade626990270766ac52f90b76317737a2b69b9e7 Mon Sep 17 00:00:00 2001 From: Moza Al Thani Date: Mon, 30 Sep 2024 22:10:35 +0300 Subject: [PATCH 16/48] Composer-title-container final changes --- .../package.json | 5 ++- .../static/lib/composer.js | 15 +------- .../static/lib/composer/autocomplete.js | 37 +++++++++++++++++-- .../static/lib/composer/tags.js | 35 +++++++----------- .../partials/composer-title-container.tpl | 23 ++++++++---- package.json | 4 +- 6 files changed, 71 insertions(+), 48 deletions(-) diff --git a/node_modules/nodebb-plugin-composer-default/package.json b/node_modules/nodebb-plugin-composer-default/package.json index 1f0329d993..625184980c 100644 --- a/node_modules/nodebb-plugin-composer-default/package.json +++ b/node_modules/nodebb-plugin-composer-default/package.json @@ -1,6 +1,6 @@ { "name": "nodebb-plugin-composer-default", - "version": "10.2.39", + "version": "10.2.36", "description": "Default composer for NodeBB", "main": "library.js", "repository": { @@ -29,6 +29,9 @@ "compatibility": "^3.0.0" }, "dependencies": { + "@textcomplete/contenteditable": "^0.1.12", + "@textcomplete/core": "^0.1.12", + "@textcomplete/textarea": "^0.1.12", "screenfull": "^5.0.2", "validator": "^13.7.0" }, diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer.js index c519108168..70e381b8b4 100644 --- a/node_modules/nodebb-plugin-composer-default/static/lib/composer.js +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer.js @@ -411,9 +411,6 @@ define('composer', [ preview.matchScroll(postContainer); }); - if (postData.action === 'posts.edit' && !utils.isNumber(postData.pid)) { - handleRemotePid(postContainer); - } handleHelp(postContainer); handleSearch(postContainer); focusElements(postContainer); @@ -582,16 +579,6 @@ define('composer', [ }, path, `${config.relative_path}/${returnPath}`); } - function handleRemotePid(postContainer) { - alerts.alert({ - title: '[[modules:composer.remote-pid-editing]]', - message: '[[modules:composer.remote-pid-content-immutable]]', - timeout: 15000, - }); - var container = postContainer.find('.write-container'); - container.addClass('hidden'); - } - function handleHelp(postContainer) { const helpBtn = postContainer.find('[data-action="help"]'); helpBtn.on('click', async function () { @@ -744,7 +731,7 @@ define('composer', [ }; } else if (action === 'posts.edit') { method = 'put'; - route = `/posts/${encodeURIComponent(postData.pid)}`; + route = `/posts/${postData.pid}`; composerData = { ...composerData, pid: postData.pid, diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js index afceca0ed3..ec2ce15d33 100644 --- a/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/autocomplete.js @@ -1,8 +1,8 @@ 'use strict'; define('composer/autocomplete', [ - 'composer/preview', 'autocomplete', -], function (preview, Autocomplete) { + 'composer/preview', '@textcomplete/core', '@textcomplete/textarea', '@textcomplete/contenteditable', +], function (preview, { Textcomplete }, { TextareaEditor }, { ContenteditableEditor }) { var autocomplete = { _active: {}, }; @@ -57,12 +57,43 @@ define('composer/autocomplete', [ $(window).trigger('composer:autocomplete:init', data); - autocomplete._active[post_uuid] = Autocomplete.setup(data); + autocomplete._active[post_uuid] = autocomplete.setup(data); data.element.on('textComplete:select', function () { preview.render(postContainer); }); }; + // This is a generic method that is also used by the chat + autocomplete.setup = function ({ element, strategies, options }) { + const targetEl = element.get(0); + if (!targetEl) { + return; + } + var editor; + if (targetEl.nodeName === 'TEXTAREA' || targetEl.nodeName === 'INPUT') { + editor = new TextareaEditor(targetEl); + } else if (targetEl.nodeName === 'DIV' && targetEl.getAttribute('contenteditable') === 'true') { + editor = new ContenteditableEditor(targetEl); + } + if (!editor) { + throw new Error('unknown target element type'); + } + // yuku-t/textcomplete inherits directionality from target element itself + targetEl.setAttribute('dir', document.querySelector('html').getAttribute('data-dir')); + + var textcomplete = new Textcomplete(editor, strategies, { + dropdown: options, + }); + textcomplete.on('rendered', function () { + if (textcomplete.dropdown.items.length) { + // Activate the first item by default. + textcomplete.dropdown.items[0].activate(); + } + }); + + return textcomplete; + }; + return autocomplete; }); diff --git a/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js b/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js index c9b92daabe..338e4546d2 100644 --- a/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js +++ b/node_modules/nodebb-plugin-composer-default/static/lib/composer/tags.js @@ -71,40 +71,37 @@ define('composer/tags', ['alerts'], function (alerts) { } else if (reachedMaxTags) { return alerts.error('[[error:too-many-tags, ' + maxTags + ']]'); } - var cid = postData.hasOwnProperty('cid') ? postData.cid : ajaxify.data.cid; - $(window).trigger('action:tag.beforeAdd', { - cid, - tagEl, - tag: event.item, - event, - inputAutocomplete: input, - }); if (different) { tagEl.tagsinput('add', cleanTag); } - if (event.cancel && input.length) { - input.autocomplete('close'); - } }); + var skipAddCheck = false; + var skipRemoveCheck = false; tagEl.on('itemRemoved', function (event) { - if (!event.item || (event.options && event.options.skipRemoveCheck)) { + if (skipRemoveCheck) { + skipRemoveCheck = false; return; } + if (!event.item) { + return; + } socket.emit('topics.canRemoveTag', { tag: event.item }, function (err, allowed) { if (err) { return alerts.error(err); } if (!allowed) { alerts.error('[[error:cant-remove-system-tag]]'); - tagEl.tagsinput('add', event.item, { skipAddCheck: true }); + skipAddCheck = true; + tagEl.tagsinput('add', event.item); } }); }); tagEl.on('itemAdded', function (event) { - if (event.options && event.options.skipAddCheck) { + if (skipAddCheck) { + skipAddCheck = false; return; } var cid = postData.hasOwnProperty('cid') ? postData.cid : ajaxify.data.cid; @@ -113,14 +110,10 @@ define('composer/tags', ['alerts'], function (alerts) { return alerts.error(err); } if (!allowed) { - return tagEl.tagsinput('remove', event.item, { skipRemoveCheck: true }); + skipRemoveCheck = true; + return tagEl.tagsinput('remove', event.item); } - $(window).trigger('action:tag.added', { - cid, - tagEl, - tag: event.item, - inputAutocomplete: input, - }); + $(window).trigger('action:tag.added', { cid: cid, tagEl: tagEl, tag: event.item }); if (input.length) { input.autocomplete('close'); } diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl index 18b44784e3..3ab2e5c180 100644 --- a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl +++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-title-container.tpl @@ -24,19 +24,28 @@
    - - + + - -
    - + +
    +
    - +
    + + +
    + + + +
    +
    + + + + + {{{ if (config.topicPostSort != "most_votes") }}} + {{{ each ./events}}}{{{ end }}} + {{{ end }}} + {{{ end }}} +
+ {{{ if browsingUsers }}} +
+ +
+
+ {{{ end }}} + {{{ if config.theme.enableQuickReply }}} + + {{{ end }}} + + + + + + {{{ if config.usePagination }}} + + {{{ end }}} + +
+ {{{each widgets.sidebar}}} + {{widgets.sidebar.html}} + {{{end}}} +
+ + + + +
+{{{each widgets.footer}}} +{{widgets.footer.html}} +{{{end}}} +
+ +{{{ if !config.usePagination }}} + +{{{ end }}} From 2c6b4d70fe32fdd8b5ce3025c79dec30a012ecff Mon Sep 17 00:00:00 2001 From: Filippos Date: Mon, 7 Oct 2024 20:31:20 +0300 Subject: [PATCH 28/48] Add the javascript functionality to make the buttons clickable (already implemented before, but refined) --- .../nodebb-theme-harmony/templates/topic.tpl | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/node_modules/nodebb-theme-harmony/templates/topic.tpl b/node_modules/nodebb-theme-harmony/templates/topic.tpl index 3434e2bba8..e66fe7c3af 100644 --- a/node_modules/nodebb-theme-harmony/templates/topic.tpl +++ b/node_modules/nodebb-theme-harmony/templates/topic.tpl @@ -89,21 +89,6 @@ - - {{{ if (config.topicPostSort != "most_votes") }}} {{{ each ./events}}}{{{ end }}} @@ -148,3 +133,21 @@ {{{ end }}} + + + From af2af4d7763b0d52d8be3f46d2b44a163c0def2d Mon Sep 17 00:00:00 2001 From: Filippos Date: Mon, 7 Oct 2024 20:33:58 +0300 Subject: [PATCH 29/48] Create API Route for Handling Reactions - src/routes/reactions.js --- src/routes/reactions.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/routes/reactions.js diff --git a/src/routes/reactions.js b/src/routes/reactions.js new file mode 100644 index 0000000000..4f39f01237 --- /dev/null +++ b/src/routes/reactions.js @@ -0,0 +1,22 @@ +const db = require.main.require('./src/database'); + +module.exports = function(app) { + // Handle the emoji reaction for a post + app.post('/api/post/:postId/reaction', async function(req, res) { + const postId = req.params.postId; + const reaction = req.body.reaction; + const uid = req.user ? req.user.uid : 0; + + if (uid === 0) { + return res.status(403).json({ error: 'You must be logged in to react.' }); + } + + try { + // Store the reaction in the database (for example, as a Redis set) + await db.setAdd(`post:${postId}:reactions:${reaction}`, uid); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); +} From 014aa67904ca9b339c5dec1097bd11cb7ee408d7 Mon Sep 17 00:00:00 2001 From: Filippos Date: Mon, 7 Oct 2024 20:38:33 +0300 Subject: [PATCH 30/48] Create API Route to fetch reaction counts - added a get route to retrieve the counts --- src/routes/reactions.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/routes/reactions.js b/src/routes/reactions.js index 4f39f01237..9ac8e4324f 100644 --- a/src/routes/reactions.js +++ b/src/routes/reactions.js @@ -19,4 +19,20 @@ module.exports = function(app) { res.status(500).json({ error: error.message }); } }); + + // GET route to retrieve reaction counts + app.get('/api/post/:postId/reactions', async function(req, res) { + const postId = req.params.postId; + + try { + const reactions = { + '👍': await db.setCard(`post:${postId}:reactions:👍`), + '❤️': await db.setCard(`post:${postId}:reactions:❤️`), + '😂': await db.setCard(`post:${postId}:reactions:😂`) + }; + res.json({ success: true, reactions: reactions }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); } From b9c91ef7cd3c0ea05039cef2ee91d4a9bbff3b23 Mon Sep 17 00:00:00 2001 From: Filippos Date: Mon, 7 Oct 2024 20:40:54 +0300 Subject: [PATCH 31/48] Added a function to fetch and display initial reaction counts when the page loads to the topic.tpl file --- .../nodebb-theme-harmony/templates/topic.tpl | 258 ++++++++++-------- 1 file changed, 137 insertions(+), 121 deletions(-) diff --git a/node_modules/nodebb-theme-harmony/templates/topic.tpl b/node_modules/nodebb-theme-harmony/templates/topic.tpl index e66fe7c3af..da649db778 100644 --- a/node_modules/nodebb-theme-harmony/templates/topic.tpl +++ b/node_modules/nodebb-theme-harmony/templates/topic.tpl @@ -11,115 +11,115 @@ {{{ end }}}
- - - - - - - -
-
-
-

- {title} -

- -
- - - [[topic:scheduled]] - - - {{{ if !pinExpiry }}}[[topic:pinned]]{{{ else }}}[[topic:pinned-with-expiry, {isoTimeToLocaleString(./pinExpiryISO, config.userLang)}]]{{{ end }}} - - - [[topic:locked]] - - - {{{ if privileges.isAdminOrMod }}}[[topic:moved-from, {oldCategory.name}]]{{{ else }}}[[topic:moved]]{{{ end }}} - - {{{each icons}}}{@value}{{{end}}} - - {function.buildCategoryLabel, category, "a", "border"} - - -
-
-
-
- -
-
- - {{{ if merger }}} - - {{{ end }}} - {{{ if forker }}} - - {{{ end }}} - {{{ if !scheduled }}} - - {{{ end }}} - -
-
-
    - {{{ each posts }}} -
  • > - - - {{{ if ./editedISO }}} - - {{{ end }}} - - - -
    - - - -
    - - - -
    -
    - -
  • - {{{ if (config.topicPostSort != "most_votes") }}} - {{{ each ./events}}}{{{ end }}} - {{{ end }}} - {{{ end }}} -
- {{{ if browsingUsers }}} -
- -
-
- {{{ end }}} - {{{ if config.theme.enableQuickReply }}} - - {{{ end }}} -
- - -
- - {{{ if config.usePagination }}} - - {{{ end }}} -
-
- {{{each widgets.sidebar}}} - {{widgets.sidebar.html}} - {{{end}}} -
-
-
+ + + + + + + +
+
+
+

+ {title} +

+ +
+ + + [[topic:scheduled]] + + + {{{ if !pinExpiry }}}[[topic:pinned]]{{{ else }}}[[topic:pinned-with-expiry, {isoTimeToLocaleString(./pinExpiryISO, config.userLang)}]]{{{ end }}} + + + [[topic:locked]] + + + {{{ if privileges.isAdminOrMod }}}[[topic:moved-from, {oldCategory.name}]]{{{ else }}}[[topic:moved]]{{{ end }}} + + {{{each icons}}}{@value}{{{end}}} + + {function.buildCategoryLabel, category, "a", "border"} + + +
+
+
+
+ +
+
+ + {{{ if merger }}} + + {{{ end }}} + {{{ if forker }}} + + {{{ end }}} + {{{ if !scheduled }}} + + {{{ end }}} + +
+
+
    + {{{ each posts }}} +
  • > + + + {{{ if ./editedISO }}} + + {{{ end }}} + + + + +
    + + + +
    + + + +
    +
    +
  • + {{{ if (config.topicPostSort != "most_votes") }}} + {{{ each ./events}}}{{{ end }}} + {{{ end }}} + {{{ end }}} +
+ {{{ if browsingUsers }}} +
+ +
+
+ {{{ end }}} + {{{ if config.theme.enableQuickReply }}} + + {{{ end }}} +
+ + +
+ + {{{ if config.usePagination }}} + + {{{ end }}} +
+
+ {{{each widgets.sidebar}}} + {{widgets.sidebar.html}} + {{{end}}} +
+
+
@@ -134,20 +134,36 @@ {{{ end }}} - + From 69c430b9eacf87380644a30426aa17dbfc62817c Mon Sep 17 00:00:00 2001 From: Filippos Date: Mon, 7 Oct 2024 20:55:42 +0300 Subject: [PATCH 32/48] Updated the Javascript code, to try and properly send AJAX requests --- .../nodebb-theme-harmony/templates/topic.tpl | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/node_modules/nodebb-theme-harmony/templates/topic.tpl b/node_modules/nodebb-theme-harmony/templates/topic.tpl index da649db778..5738d72943 100644 --- a/node_modules/nodebb-theme-harmony/templates/topic.tpl +++ b/node_modules/nodebb-theme-harmony/templates/topic.tpl @@ -134,18 +134,15 @@ {{{ end }}} - From dd7516b059118c38f6613332d0f69f12b264cda1 Mon Sep 17 00:00:00 2001 From: Filippos Date: Mon, 7 Oct 2024 21:17:43 +0300 Subject: [PATCH 35/48] Updated the reactions.js route to ensure it is using the api suffix --- .../nodebb-theme-harmony/templates/topic.tpl | 267 ++++++++---------- src/routes/reactions.js | 26 +- 2 files changed, 134 insertions(+), 159 deletions(-) diff --git a/node_modules/nodebb-theme-harmony/templates/topic.tpl b/node_modules/nodebb-theme-harmony/templates/topic.tpl index afb9ea7f06..e66fe7c3af 100644 --- a/node_modules/nodebb-theme-harmony/templates/topic.tpl +++ b/node_modules/nodebb-theme-harmony/templates/topic.tpl @@ -11,115 +11,115 @@ {{{ end }}}
- - - - - - - -
-
-
-

- {title} -

- -
- - - [[topic:scheduled]] - - - {{{ if !pinExpiry }}}[[topic:pinned]]{{{ else }}}[[topic:pinned-with-expiry, {isoTimeToLocaleString(./pinExpiryISO, config.userLang)}]]{{{ end }}} - - - [[topic:locked]] - - - {{{ if privileges.isAdminOrMod }}}[[topic:moved-from, {oldCategory.name}]]{{{ else }}}[[topic:moved]]{{{ end }}} - - {{{each icons}}}{@value}{{{end}}} - - {function.buildCategoryLabel, category, "a", "border"} - - -
-
-
-
- -
-
- - {{{ if merger }}} - - {{{ end }}} - {{{ if forker }}} - - {{{ end }}} - {{{ if !scheduled }}} - - {{{ end }}} - -
-
-
    - {{{ each posts }}} -
  • > - - - {{{ if ./editedISO }}} - - {{{ end }}} - - - - -
    - - - -
    - - - -
    -
    -
  • - {{{ if (config.topicPostSort != "most_votes") }}} - {{{ each ./events}}}{{{ end }}} - {{{ end }}} - {{{ end }}} -
- {{{ if browsingUsers }}} -
- -
-
- {{{ end }}} - {{{ if config.theme.enableQuickReply }}} - - {{{ end }}} -
- - -
- - {{{ if config.usePagination }}} - - {{{ end }}} -
-
- {{{each widgets.sidebar}}} - {{widgets.sidebar.html}} - {{{end}}} -
-
-
+ + + + + + + +
+
+
+

+ {title} +

+ +
+ + + [[topic:scheduled]] + + + {{{ if !pinExpiry }}}[[topic:pinned]]{{{ else }}}[[topic:pinned-with-expiry, {isoTimeToLocaleString(./pinExpiryISO, config.userLang)}]]{{{ end }}} + + + [[topic:locked]] + + + {{{ if privileges.isAdminOrMod }}}[[topic:moved-from, {oldCategory.name}]]{{{ else }}}[[topic:moved]]{{{ end }}} + + {{{each icons}}}{@value}{{{end}}} + + {function.buildCategoryLabel, category, "a", "border"} + + +
+
+
+
+ +
+
+ + {{{ if merger }}} + + {{{ end }}} + {{{ if forker }}} + + {{{ end }}} + {{{ if !scheduled }}} + + {{{ end }}} + +
+
+
    + {{{ each posts }}} +
  • > + + + {{{ if ./editedISO }}} + + {{{ end }}} + + + +
    + + + +
    + + + +
    +
    + +
  • + {{{ if (config.topicPostSort != "most_votes") }}} + {{{ each ./events}}}{{{ end }}} + {{{ end }}} + {{{ end }}} +
+ {{{ if browsingUsers }}} +
+ +
+
+ {{{ end }}} + {{{ if config.theme.enableQuickReply }}} + + {{{ end }}} +
+ + +
+ + {{{ if config.usePagination }}} + + {{{ end }}} +
+
+ {{{each widgets.sidebar}}} + {{widgets.sidebar.html}} + {{{end}}} +
+
+
@@ -134,47 +134,20 @@ {{{ end }}} + diff --git a/src/routes/reactions.js b/src/routes/reactions.js index 9ac8e4324f..217e32571b 100644 --- a/src/routes/reactions.js +++ b/src/routes/reactions.js @@ -1,8 +1,10 @@ +// src/routes/reactions.js + const db = require.main.require('./src/database'); -module.exports = function(app) { - // Handle the emoji reaction for a post - app.post('/api/post/:postId/reaction', async function(req, res) { +module.exports = function (app) { + // Handle adding a reaction + app.post('/api/post/:postId/reaction', async function (req, res) { const postId = req.params.postId; const reaction = req.body.reaction; const uid = req.user ? req.user.uid : 0; @@ -12,7 +14,6 @@ module.exports = function(app) { } try { - // Store the reaction in the database (for example, as a Redis set) await db.setAdd(`post:${postId}:reactions:${reaction}`, uid); res.json({ success: true }); } catch (error) { @@ -20,19 +21,20 @@ module.exports = function(app) { } }); - // GET route to retrieve reaction counts - app.get('/api/post/:postId/reactions', async function(req, res) { + // Handle fetching reactions + app.get('/api/post/:postId/reactions', async function (req, res) { const postId = req.params.postId; try { - const reactions = { - '👍': await db.setCard(`post:${postId}:reactions:👍`), - '❤️': await db.setCard(`post:${postId}:reactions:❤️`), - '😂': await db.setCard(`post:${postId}:reactions:😂`) - }; + const reactions = {}; + const emojis = ['👍', '❤️', '😂']; + for (const emoji of emojis) { + const count = await db.setCard(`post:${postId}:reactions:${emoji}`); + reactions[emoji] = count || 0; + } res.json({ success: true, reactions: reactions }); } catch (error) { res.status(500).json({ error: error.message }); } }); -} +}; From b0926a225f8f144e313c96e97f40543c0746d729 Mon Sep 17 00:00:00 2001 From: Filippos Date: Thu, 10 Oct 2024 13:07:32 +0300 Subject: [PATCH 36/48] Created the nodebb-plugin-emoji-reactions directory and added a plugin.json file inside --- nodebb-plugin-emoji-reactions/plugin.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 nodebb-plugin-emoji-reactions/plugin.json diff --git a/nodebb-plugin-emoji-reactions/plugin.json b/nodebb-plugin-emoji-reactions/plugin.json new file mode 100644 index 0000000000..54798f15ef --- /dev/null +++ b/nodebb-plugin-emoji-reactions/plugin.json @@ -0,0 +1,18 @@ +{ + "id": "nodebb-plugin-emoji-reactions", + "name": "NodeBB Emoji Reactions", + "description": "Adds emoji reactions to posts with counts.", + "url": "https://github.com/fdounis/nodebb-plugin-emoji-reactions", + "library": "./library.js", + "hooks": [ + { + "hook": "static:app.load", + "method": "init" + } + ], + "assets": { + "static/css": "./public/css" + }, + "scripts": "./public/js/main.js", + "templates": "./public/templates" +} From a8e0a7fe60d10950cf1bf9853e25ef873535c579 Mon Sep 17 00:00:00 2001 From: Filippos Date: Thu, 10 Oct 2024 13:09:46 +0300 Subject: [PATCH 37/48] Added the library.json file within the nodebb-plugin-emoji-reactions folder, with the server-side logic for handling reactions --- nodebb-plugin-emoji-reactions/library.js | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 nodebb-plugin-emoji-reactions/library.js diff --git a/nodebb-plugin-emoji-reactions/library.js b/nodebb-plugin-emoji-reactions/library.js new file mode 100644 index 0000000000..9010afbd34 --- /dev/null +++ b/nodebb-plugin-emoji-reactions/library.js @@ -0,0 +1,55 @@ +'use strict'; + +const db = require.main.require('./src/database'); +const user = require.main.require('./src/user'); + +const EmojiReactions = {}; + +EmojiReactions.init = function(params, callback) { + const app = params.router; + const middleware = params.middleware; + + // Route to handle adding a reaction + app.post('/api/post/:postId/reaction', middleware.authenticate, async function(req, res) { + const postId = req.params.postId; + const reaction = req.body.reaction; + const uid = req.user ? req.user.uid : 0; + + if (!reaction) { + return res.status(400).json({ error: 'No reaction provided.' }); + } + + try { + // Add the user's reaction to the post + await db.setAdd(`post:${postId}:reactions:${reaction}`, uid); + res.json({ success: true }); + } catch (error) { + console.error('Error adding reaction:', error); + res.status(500).json({ error: 'Internal server error.' }); + } + }); + + // Route to handle fetching reactions + app.get('/api/post/:postId/reactions', async function(req, res) { + const postId = req.params.postId; + + try { + const reactions = {}; + const emojis = ['👍', '❤️', '😂']; // Define your emojis here + + for (const emoji of emojis) { + const count = await db.setCard(`post:${postId}:reactions:${emoji}`); + reactions[emoji] = count || 0; + } + + res.json({ success: true, reactions: reactions }); + } catch (error) { + console.error('Error fetching reactions:', error); + res.status(500).json({ error: 'Internal server error.' }); + } + }); + + callback(); +}; + +module.exports = EmojiReactions; From 5e377cc912a1ebc6c7a28f11c6b544d22469c0eb Mon Sep 17 00:00:00 2001 From: Filippos Date: Thu, 10 Oct 2024 13:10:26 +0300 Subject: [PATCH 38/48] Create a package.json file to define the package --- nodebb-plugin-emoji-reactions/package.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 nodebb-plugin-emoji-reactions/package.json diff --git a/nodebb-plugin-emoji-reactions/package.json b/nodebb-plugin-emoji-reactions/package.json new file mode 100644 index 0000000000..d667feef42 --- /dev/null +++ b/nodebb-plugin-emoji-reactions/package.json @@ -0,0 +1,8 @@ +{ + "name": "nodebb-plugin-emoji-reactions", + "version": "1.0.0", + "description": "Adds emoji reactions to posts with counts.", + "main": "library.js", + "author": "Your Name", + "license": "ISC" +} From 46571f4db1e8fb5e6bd30390250c0af73d5c364a Mon Sep 17 00:00:00 2001 From: Filippos Date: Thu, 10 Oct 2024 13:16:18 +0300 Subject: [PATCH 39/48] Restructured the topic.tpl file to work with the new back-end logic and keep emoji count --- .../nodebb-theme-harmony/templates/topic.tpl | 285 ++++++++++-------- 1 file changed, 163 insertions(+), 122 deletions(-) diff --git a/node_modules/nodebb-theme-harmony/templates/topic.tpl b/node_modules/nodebb-theme-harmony/templates/topic.tpl index e66fe7c3af..08971d8265 100644 --- a/node_modules/nodebb-theme-harmony/templates/topic.tpl +++ b/node_modules/nodebb-theme-harmony/templates/topic.tpl @@ -11,115 +11,116 @@ {{{ end }}}
- - - - - - - -
-
-
-

- {title} -

- -
- - - [[topic:scheduled]] - - - {{{ if !pinExpiry }}}[[topic:pinned]]{{{ else }}}[[topic:pinned-with-expiry, {isoTimeToLocaleString(./pinExpiryISO, config.userLang)}]]{{{ end }}} - - - [[topic:locked]] - - - {{{ if privileges.isAdminOrMod }}}[[topic:moved-from, {oldCategory.name}]]{{{ else }}}[[topic:moved]]{{{ end }}} - - {{{each icons}}}{@value}{{{end}}} - - {function.buildCategoryLabel, category, "a", "border"} - - -
-
-
-
- -
-
- - {{{ if merger }}} - - {{{ end }}} - {{{ if forker }}} - - {{{ end }}} - {{{ if !scheduled }}} - - {{{ end }}} - -
-
-
    - {{{ each posts }}} -
  • > - - - {{{ if ./editedISO }}} - - {{{ end }}} - - - -
    - - - -
    - - - -
    -
    - -
  • - {{{ if (config.topicPostSort != "most_votes") }}} - {{{ each ./events}}}{{{ end }}} - {{{ end }}} - {{{ end }}} -
- {{{ if browsingUsers }}} -
- -
-
- {{{ end }}} - {{{ if config.theme.enableQuickReply }}} - - {{{ end }}} -
- - -
- - {{{ if config.usePagination }}} - - {{{ end }}} -
-
- {{{each widgets.sidebar}}} - {{widgets.sidebar.html}} - {{{end}}} -
-
-
+ + + + + + + +
+
+
+

+ {title} +

+ +
+ + + [[topic:scheduled]] + + + {{{ if !pinExpiry }}}[[topic:pinned]]{{{ else }}}[[topic:pinned-with-expiry, {isoTimeToLocaleString(./pinExpiryISO, config.userLang)}]]{{{ end }}} + + + [[topic:locked]] + + + {{{ if privileges.isAdminOrMod }}}[[topic:moved-from, {oldCategory.name}]]{{{ else }}}[[topic:moved]]{{{ end }}} + + {{{each icons}}}{@value}{{{end}}} + + {function.buildCategoryLabel, category, "a", "border"} + + +
+
+
+
+ +
+
+ + {{{ if merger }}} + + {{{ end }}} + {{{ if forker }}} + + {{{ end }}} + {{{ if !scheduled }}} + + {{{ end }}} + +
+
+
    + {{{ each posts }}} +
  • > + + + {{{ if ./editedISO }}} + + {{{ end }}} + + + + +
    + + + +
    + + + + +
    +
    +
  • + {{{ if (config.topicPostSort != "most_votes") }}} + {{{ each ./events}}}{{{ end }}} + {{{ end }}} + {{{ end }}} +
+ {{{ if browsingUsers }}} +
+ +
+
+ {{{ end }}} + {{{ if config.theme.enableQuickReply }}} + + {{{ end }}} +
+ + +
+ + {{{ if config.usePagination }}} + + {{{ end }}} +
+
+ {{{each widgets.sidebar}}} + {{widgets.sidebar.html}} + {{{end}}} +
+
+
@@ -134,20 +135,60 @@ {{{ end }}} - + From ae78637b2acd52103838354c4d60d441afd21add Mon Sep 17 00:00:00 2001 From: Filippos Date: Thu, 10 Oct 2024 13:20:30 +0300 Subject: [PATCH 40/48] Troubleshooting the issue with library.js --- dump.rdb | Bin 0 -> 48486 bytes nodebb-plugin-emoji-reactions/library.js | 22 ++++++++++------------ package.json | 1 + 3 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 dump.rdb diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..456352d17bb6b3045914b03a74e4286871515377 GIT binary patch literal 48486 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 literal 0 HcmV?d00001 diff --git a/nodebb-plugin-emoji-reactions/library.js b/nodebb-plugin-emoji-reactions/library.js index 9010afbd34..aa6296f95b 100644 --- a/nodebb-plugin-emoji-reactions/library.js +++ b/nodebb-plugin-emoji-reactions/library.js @@ -1,7 +1,8 @@ +// library.js + 'use strict'; const db = require.main.require('./src/database'); -const user = require.main.require('./src/user'); const EmojiReactions = {}; @@ -9,23 +10,23 @@ EmojiReactions.init = function(params, callback) { const app = params.router; const middleware = params.middleware; + console.log('Emoji Reactions plugin initialized.'); + // Route to handle adding a reaction app.post('/api/post/:postId/reaction', middleware.authenticate, async function(req, res) { const postId = req.params.postId; const reaction = req.body.reaction; - const uid = req.user ? req.user.uid : 0; + const uid = req.user.uid; - if (!reaction) { - return res.status(400).json({ error: 'No reaction provided.' }); + if (!reaction || !['👍', '❤️', '😂'].includes(reaction)) { + return res.status(400).json({ error: 'Invalid reaction.' }); } try { - // Add the user's reaction to the post await db.setAdd(`post:${postId}:reactions:${reaction}`, uid); res.json({ success: true }); } catch (error) { - console.error('Error adding reaction:', error); - res.status(500).json({ error: 'Internal server error.' }); + res.status(500).json({ error: error.message }); } }); @@ -35,17 +36,14 @@ EmojiReactions.init = function(params, callback) { try { const reactions = {}; - const emojis = ['👍', '❤️', '😂']; // Define your emojis here - + const emojis = ['👍', '❤️', '😂']; for (const emoji of emojis) { const count = await db.setCard(`post:${postId}:reactions:${emoji}`); reactions[emoji] = count || 0; } - res.json({ success: true, reactions: reactions }); } catch (error) { - console.error('Error fetching reactions:', error); - res.status(500).json({ error: 'Internal server error.' }); + res.status(500).json({ error: error.message }); } }); diff --git a/package.json b/package.json index 928bac18c8..4d46920bc4 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "nodebb-plugin-dbsearch": "6.2.5", "nodebb-plugin-emoji": "5.1.15", "nodebb-plugin-emoji-android": "4.0.0", + "nodebb-plugin-emoji-reactions": "file:nodebb-plugin-emoji-reactions", "nodebb-plugin-location-to-map": "^0.1.1", "nodebb-plugin-markdown": "12.2.6", "nodebb-plugin-mentions": "4.4.3", From 3ba2a04ee30100524055f940936e43884764a5dc Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 20 Oct 2024 15:54:20 +0300 Subject: [PATCH 41/48] Created the test/back-end directory within the emoji reactions plugin folder to start testing on the backend --- dump.rdb | Bin 48486 -> 48536 bytes nodebb-plugin-emoji-reactions/library.js | 2 +- nodebb-plugin-emoji-reactions/package.json | 8 ++++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/dump.rdb b/dump.rdb index 456352d17bb6b3045914b03a74e4286871515377..31b69a9f5587516b129248ffee4050b0ab9472ae 100644 GIT binary patch delta 2881 zcmaJ@X;f6#9iRKy=RIK_&N#pTgTSzg0;1wTbri)FS&G#Ll^GEg3k))j3L-*-A}C?` z1x0~^iqtKFzId!kay-?<_B6*O$HvC?SUtmBgtg$|D>sdw_whJap&OSW)8xyA+4_ zy9MN>>FALH5L9%l&Ca7|b~T~IbQthpraP`yD#6bVg6Z8E(wtO(iry&!UCHVZmL}n9 z`LttM>CWeDrQQKEKA$jA@;T>6;M%3eUoE|o<&YQQ==6hC;TZ7I;mr11$V%sNxyniA zZq5gX(=|9ze-vSFmPw{u`sb2aqii#h(y(%QkkXxMvfP+(ZB8MgJd@R;S7Wiow#8an zTvS@fKX(+EwwfiIr;f;&)*fxZzPe~^uJw<&79Oh^Wonci$`obip@Mf|P`#cK>-yiC z8q-k#afsA)>A{q5Z|ofU6{iZNvI@p6yACf_Ul&v!TdZY81r#OIX*8|qRMiL^NA{Wg zNnyF!+eIL_VT@sHZ0?Cd#zie0-9EUoJpf0~_G5URKc3k_Fy)*F1};597lr@$W6>#3cH45_HzZ7+w&8^*_JQY)%fuQYA%Fu_Ph% zN)%?E<)vA_b0J;=+44^-^av^$>cL+ zWtfBnW72eEV$$S*q=a~MWqj&6J$^=VQc`>(b}qBInrq24JX@BIw0>Ri|3%UtPZv9V zcJ*=ror7hSb*K_ESKeUJ#e&TlT`9Pv4a4_tEogH|LEw&0;RsLl6xNw!$HTiO8uwSl zqBXf*sx;(NrB9*}LVd^u*t%)HG8pko?bTI)yvd)Ju_hFjzn19MyaVFpU@~~S^4Ol zWgOK9u{-tAx2u>_Ma!c#lkbp6O%`GwhtgavF3-7(o4+|itGnV)|>LN z-g`s$lL*Kq@v?n0tGsC-8La)r5&0Qf?4l0;h3}pzDJ``IiSa2@>H5W z)6lh$5vm?hD@&v)_~Vd(7K<9s4%_Im4Cv0yG4ii(SL@+u7f{{r#A`V3YuQ426nE!v z;*1dq=3&6@K(V(|oTt}6q?0&xV;zbe+d_3wEjyESOc;6^G`RKdke?{X{;(PUCq$_l z_LF4FTsTTsXCbhUr)3k+y5nD1Fp$V(Cy@a4hV1L#TuRM|AN=~462)!6T?!-WbBhP=J;mx^(?N8g`S)Fb0m14)ztel@Fr zQ-x6iSv~1+V*Ce^4igAU)jVoi4ue0SqiE&JzB6D_p-eZ61AbElqne@FOgm)f$y?0i zEXY~9JGyVmp%|mQK!;?bgxKBa@e*lbMax;%yY?$tsx;SHcLZTg-)&h2c^x{MR4Taz znCAZ08Hww^o)6==09L#VuFNWic~aXT3+w=z@Lx)kkB=wke|erzdRgUlZXSmxYl6_6 zHv{1p>E451<2#QiM*i4I7*JYR&)>u3Ro+Xl{IdJQ^zAA^wVwxUK3c48FJtMNofa$;H^ue=MaXHv(JN2)&+d?5ScC~! zZ3skQ?@AP1(&OA4jW8byp*I&Vv}XPj-xyQm9JGg1#fT$~I#;UhR}$pb2BN`7fH0_* zCB+KzLWMJun=T%QV~a4-HJ&gnWObaY8a3NUJ!zW*-eMy86=F~3y;6&j%vm#)^Jw=)&pd$fYvNN9RFRMBlm zBC);0A47$=r3_!a^A5py580DNDKNlzbgE=-R+BOJu(2*wT$hC$a=H$EmQV_H$cPg~ zobJsfP5_wC_KHrQofe%w`%tXM16S%x>Y&kC7A?JtrM?W{2w8ql-0#V&`*qbx;z7Fg tA3S2YD0aW<#`uMMk#bUy@;clI&TgfKrq&24U+U#};XBuSA)tKwe*lV3Q2GD> delta 2789 zcmZWrdr(tX9?rQyAju^pkPt{93GxUc@3#>Vq+%6OLB^_B5lPUfybMqQK}0QRiv_-& zeX*`pr*&O-SMXM+Rea34b=U4Jk3QC7amCK;L#9=0={i+M_naG$?)1O>k$cYXe9zy{ zpK$+r%yq?EK0E2T`LS1>({78Yuvh%C%VCr}_A0F^kJ(h}v|o`tlRU}#mQzJxxFifM zBjx=Yc>jDf(czufqU2I~`3`Hs)odFUhNkv8BipgX7 z_gaNRp$39)BFtFn&Z9X`jy!!K{CqT6TX7aU0!$cg^M+!V9Ia=AF{VsIiS-edy?hf# zYV@2cS{(k;qHob9DDeAu4IbDG7>n1!Y**q`*HL6?yrF6K6&CEE^9t=TXic|Zs9J?M zZ7`n2CL_O#$L+HkoE8jFp3~yCpv2_n*dwxEJj&!JP8FevxNd5#`BD_HojXeU8c}6grNr=b_KZkhMhLsp}yUi3}%prwHjUZ6usJ4F$Xr!NtYGvhEBu`g1x& zc>J5*z?YjX^jImpJ9K!i8pPVfNKEX>nC8@^mEsSTI_y>q<3ch|DK+5LKrh^0qC<+= z2P*?VL|-nCUO_|A4@H+#ix%RK3r+=;yL)k`-Gsl%mGn{b@F0^iLFLd=UWOyQc9zK` zkx1MnldbaKxF+Vvq~!a5xPEKNb9FMh1ttk8lmRweX)+22BXf!H^$Kr-Q?U_rZWW%s z#VANYn<~{qPM)h6j>evjFkCvLMOUE?6K50{o+m|yS^@XS*D+*|pcBO4X>}SE;|k98 z1s&d#QYwYd7TwgRp7?L4YU=Y}iqx@34(RXT(0f|+6||!@Edp&94nW`T0>DLR9ixX1 zA<}bvfdvl?amZX_5t&t9?-F`_uJDpKvI2>H$3~?aPY85b*M*I>XULQ|hpJ=5*A%t> z&~QcQZ!`TLx=n>aBC;n6)zBSM!hKykI&z|rtzRKDRL;EjaIXer!KJ*>IZwq@X{Rjrta-nWn z5kYrSAR~wwLfPPn`CFHBs@c?C7UXXW#KqIYVhj}X8vJbiIF2<1yR2Xo<-J?MH zLas;U#Soe)j475=mKT|Fv&0of9$FuaH=h%Z#P9@nbqai8H^3)AjYH+ZqL9Di!mzXW zxd_1_ZZrDBL#9IJ)K(?l)KrojG+lu+Ne?4rT1HdBnQSNL2mNNeMN7h zqp^WgCr**fz0 zvPoEfPKV%KlWK}lhm9gzRcoI?Rzitjx_3qMbVGDG4H&Of(d2&z;mtKjPK^=9+v&l5 zxvo)N>wyl{eg>R!s?h8AF05P>bDyisW+8uINKhH&1iy{tF|?Q#r)Y@v)Ge(3Sn>E| z%cx~CFjT@^poi>q0g`GgqS-?}*hf`7UCjl!nHNcQ&zv4_ltEax6r(gEIukWL-XW;p zK8WvE9-}ttFAJw;n8tAx3|uAB|~Kr+E->{$(ckfs?gw4*a6f8uxo5s zTdu&(55!{N3RLG~SUhA--pBkBMlPtwYk|yZm*?Q6f*<1@6iWL~M fP2%xN*HCOzI=-MDKS`~XJ#TjWYVlt-G=BO&H?1Y} diff --git a/nodebb-plugin-emoji-reactions/library.js b/nodebb-plugin-emoji-reactions/library.js index aa6296f95b..0ffeb46e90 100644 --- a/nodebb-plugin-emoji-reactions/library.js +++ b/nodebb-plugin-emoji-reactions/library.js @@ -7,7 +7,7 @@ const db = require.main.require('./src/database'); const EmojiReactions = {}; EmojiReactions.init = function(params, callback) { - const app = params.router; +const app = params.router; const middleware = params.middleware; console.log('Emoji Reactions plugin initialized.'); diff --git a/nodebb-plugin-emoji-reactions/package.json b/nodebb-plugin-emoji-reactions/package.json index d667feef42..08b865f9a3 100644 --- a/nodebb-plugin-emoji-reactions/package.json +++ b/nodebb-plugin-emoji-reactions/package.json @@ -6,3 +6,11 @@ "author": "Your Name", "license": "ISC" } +{ + "scripts": { + "test": "mocha --recursive", + "test:watch": "mocha --watch --recursive", + "test:front-end": "jest", + "test:e2e": "jest --config jest.e2e.config.js" + } +} From 941ddba481cea20ea188deb18194bcb2911d6d8d Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 20 Oct 2024 16:02:59 +0300 Subject: [PATCH 42/48] Created the initial back-end test file reactions.test.js within the test/back-end directory. This file will house all tests related to the emoji reactions API endpoints. --- .../test/back-end/reactions.test.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js diff --git a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js new file mode 100644 index 0000000000..78fd57df82 --- /dev/null +++ b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js @@ -0,0 +1,34 @@ +// reactions.test.js + +const chai = require('chai'); +const chaiHttp = require('chai-http'); +const sinon = require('sinon'); +const app = require('../../library'); // Adjust the path based on your plugin structure +const db = require.main.require('./src/database'); +const user = require.main.require('./src/user'); + +chai.use(chaiHttp); +const { expect } = chai; + +describe('Emoji Reactions API', () => { + let server; + let authenticateStub; + + before((done) => { + // Initialize the plugin or start the server if needed + server = app.init(); // Adjust based on your plugin's initialization + done(); + }); + + beforeEach(() => { + // Stub authentication middleware to simulate a logged-in user + authenticateStub = sinon.stub(user, 'isAdmin').returns(true); + }); + + afterEach(() => { + // Restore the stubbed methods + authenticateStub.restore(); + }); + + // Test cases will be added here in subsequent commits +}); From a726bb420938647a6be444d5d37e876f167b958e Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 20 Oct 2024 16:06:08 +0300 Subject: [PATCH 43/48] =?UTF-8?q?Implemented=20a=20test=20case=20to=20veri?= =?UTF-8?q?fy=20that=20a=20valid=20reaction=20(=F0=9F=91=8D)=20can=20be=20?= =?UTF-8?q?successfully=20added=20to=20a=20post.=20This=20ensures=20that?= =?UTF-8?q?=20the=20POST=20/api/post/:postId/reaction=20endpoint=20functio?= =?UTF-8?q?ns=20correctly=20under=20normal=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/back-end/reactions.test.js | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js index 78fd57df82..4f4da4341f 100644 --- a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js +++ b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js @@ -30,5 +30,25 @@ describe('Emoji Reactions API', () => { authenticateStub.restore(); }); - // Test cases will be added here in subsequent commits + describe('POST /api/post/:postId/reaction', () => { + it('should add a reaction successfully', (done) => { + const postId = '3'; + const reaction = '👍'; + const userId = 1; + + // Stub the database method to simulate adding a reaction + const setAddStub = sinon.stub(db, 'setAdd').resolves(); + + chai.request(server) + .post(`/api/post/${postId}/reaction`) + .send({ reaction }) + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.property('success', true); + expect(setAddStub.calledOnce).to.be.true; + setAddStub.restore(); + done(); + }); + }); + }); }); From f6323841741023fb9a5acf437c2efbd79d93f855 Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 20 Oct 2024 16:08:05 +0300 Subject: [PATCH 44/48] =?UTF-8?q?Added=20a=20test=20case=20to=20ensure=20t?= =?UTF-8?q?hat=20the=20API=20correctly=20handles=20invalid=20reactions.=20?= =?UTF-8?q?Attempting=20to=20add=20a=20reaction=20that=20is=20not=20among?= =?UTF-8?q?=20the=20predefined=20valid=20emojis=20(=F0=9F=91=8D,=20?= =?UTF-8?q?=E2=9D=A4=EF=B8=8F,=20=F0=9F=98=82)=20should=20result=20in=20a?= =?UTF-8?q?=20400=20Bad=20Request=20response=20with=20an=20appropriate=20e?= =?UTF-8?q?rror=20message.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/back-end/reactions.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js index 4f4da4341f..4f8157cb84 100644 --- a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js +++ b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js @@ -51,4 +51,19 @@ describe('Emoji Reactions API', () => { }); }); }); + + it('should return 400 for invalid reaction', (done) => { + const postId = '3'; + const reaction = 'invalid'; + + chai.request(server) + .post(`/api/post/${postId}/reaction`) + .send({ reaction }) + .end((err, res) => { + expect(res).to.have.status(400); + expect(res.body).to.have.property('error', 'Invalid reaction.'); + done(); + }); + }); + }); From 36a207e2691aeca85a9be118952570b7a5bcbf0c Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 20 Oct 2024 16:09:10 +0300 Subject: [PATCH 45/48] Implemented a test to verify that unauthenticated users cannot add reactions. The API should respond with a 403 Forbidden status and an appropriate error message when a user who is not logged in attempts to add a reaction. --- .../test/back-end/reactions.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js index 4f8157cb84..9e382d0925 100644 --- a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js +++ b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js @@ -66,4 +66,21 @@ describe('Emoji Reactions API', () => { }); }); + it('should return 403 if user is not authenticated', (done) => { + // Modify the authentication stub to simulate an unauthenticated user + authenticateStub.returns(false); + + const postId = '3'; + const reaction = '👍'; + + chai.request(server) + .post(`/api/post/${postId}/reaction`) + .send({ reaction }) + .end((err, res) => { + expect(res).to.have.status(403); + expect(res.body).to.have.property('error', 'You must be logged in to react.'); + done(); + }); + }); + }); From 52048095665b53839e007ce39aa3cb50ab496bb2 Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 20 Oct 2024 16:14:09 +0300 Subject: [PATCH 46/48] Add a test case to ensure that the API gracefully handles server-side errors during reaction addition. Simulate a database error and verify that the API responds with a 500 Internal Server Error and an appropriate error message. --- .../test/back-end/reactions.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js index 9e382d0925..c87bf89843 100644 --- a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js +++ b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js @@ -83,4 +83,22 @@ describe('Emoji Reactions API', () => { }); }); + it('should handle server errors gracefully', (done) => { + const postId = '3'; + const reaction = '👍'; + + // Stub the database method to simulate a server error + const setAddStub = sinon.stub(db, 'setAdd').rejects(new Error('Database Error')); + + chai.request(server) + .post(`/api/post/${postId}/reaction`) + .send({ reaction }) + .end((err, res) => { + expect(res).to.have.status(500); + expect(res.body).to.have.property('error', 'Database Error'); + setAddStub.restore(); + done(); + }); + }); + }); From 709bc43d9adc5b6849d56042bfb9bb1d7d08a12f Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 27 Oct 2024 19:42:27 +0300 Subject: [PATCH 47/48] Add OWASP ZAP security scan workflow --- .github/workflows/zap-analysis.yml | 34 ++++++++++++ dump.rdb | Bin 48536 -> 55842 bytes nodebb-plugin-emoji-reactions/package.json | 8 --- .../test/back-end/reactions.test.js | 50 ++++++++++++++++-- package.json | 12 ++--- 5 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/zap-analysis.yml diff --git a/.github/workflows/zap-analysis.yml b/.github/workflows/zap-analysis.yml new file mode 100644 index 0000000000..ead97f4bfe --- /dev/null +++ b/.github/workflows/zap-analysis.yml @@ -0,0 +1,34 @@ +name: OWASP ZAP Security Scan + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + zap: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Start NodeBB Application + run: | + # Commands to start your deployed NodeBB application + npm install + npm start & # Starts NodeBB in the background + + - name: Run OWASP ZAP Scan + uses: zaproxy/action-full-scan@v0.6.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + target: 'https://nodebb-pawsitive-p3.azurewebsites.net' + + - name: Stop NodeBB Application + run: | + # Stop NodeBB if necessary + npm stop diff --git a/dump.rdb b/dump.rdb index 31b69a9f5587516b129248ffee4050b0ab9472ae..3fcd0077d4f01584bbaff4adbd5c5ed143057ffb 100644 GIT binary patch delta 2427 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 diff --git a/nodebb-plugin-emoji-reactions/package.json b/nodebb-plugin-emoji-reactions/package.json index 08b865f9a3..d667feef42 100644 --- a/nodebb-plugin-emoji-reactions/package.json +++ b/nodebb-plugin-emoji-reactions/package.json @@ -6,11 +6,3 @@ "author": "Your Name", "license": "ISC" } -{ - "scripts": { - "test": "mocha --recursive", - "test:watch": "mocha --watch --recursive", - "test:front-end": "jest", - "test:e2e": "jest --config jest.e2e.config.js" - } -} diff --git a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js index c87bf89843..ab1cb4ce38 100644 --- a/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js +++ b/nodebb-plugin-emoji-reactions/test/back-end/reactions.test.js @@ -16,7 +16,7 @@ describe('Emoji Reactions API', () => { before((done) => { // Initialize the plugin or start the server if needed - server = app.init(); // Adjust based on your plugin's initialization + server = app.init(); done(); }); @@ -50,7 +50,6 @@ describe('Emoji Reactions API', () => { done(); }); }); - }); it('should return 400 for invalid reaction', (done) => { const postId = '3'; @@ -87,7 +86,7 @@ describe('Emoji Reactions API', () => { const postId = '3'; const reaction = '👍'; - // Stub the database method to simulate a server error + // Stub the database method to throw an error const setAddStub = sinon.stub(db, 'setAdd').rejects(new Error('Database Error')); chai.request(server) @@ -100,5 +99,50 @@ describe('Emoji Reactions API', () => { done(); }); }); + }); + + describe('GET /api/post/:postId/reactions', () => { + it('should retrieve reaction counts successfully', (done) => { + const postId = '3'; + const reactionsData = { + '👍': 5, + '❤️': 3, + '😂': 2 + }; + + // Stub the database methods to return predefined reaction counts + const setCardStubs = [ + sinon.stub(db, 'setCard').withArgs(`post:${postId}:reactions:👍`).resolves(reactionsData['👍']), + sinon.stub(db, 'setCard').withArgs(`post:${postId}:reactions:❤️`).resolves(reactionsData['❤️']), + sinon.stub(db, 'setCard').withArgs(`post:${postId}:reactions:😂`).resolves(reactionsData['😂']), + ]; + + chai.request(server) + .get(`/api/post/${postId}/reactions`) + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reactions'); + expect(res.body.reactions).to.deep.equal(reactionsData); + setCardStubs.forEach(stub => stub.restore()); + done(); + }); + }); + it('should handle server errors gracefully', (done) => { + const postId = '3'; + + // Stub the database method to throw an error + const setCardStub = sinon.stub(db, 'setCard').rejects(new Error('Database Error')); + + chai.request(server) + .get(`/api/post/${postId}/reactions`) + .end((err, res) => { + expect(res).to.have.status(500); + expect(res.body).to.have.property('error', 'Database Error'); + setCardStub.restore(); + done(); + }); + }); + }); }); diff --git a/package.json b/package.json index 4d46920bc4..0a542ad205 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "autoprefixer": "10.4.19", "bcryptjs": "2.4.3", "benchpressjs": "2.5.1", - "body-parser": "^1.20.3", + "body-parser": "1.20.2", "bootbox": "6.0.0", "bootstrap": "5.3.3", "bootswatch": "5.3.3", @@ -66,7 +66,7 @@ "daemon": "1.1.0", "diff": "5.2.0", "esbuild": "0.21.2", - "express": "^4.21.0", + "express": "4.19.2", "express-session": "1.18.0", "express-useragent": "1.0.15", "fetch-cookie": "3.0.1", @@ -106,7 +106,7 @@ "nodebb-plugin-markdown": "12.2.6", "nodebb-plugin-mentions": "4.4.3", "nodebb-plugin-ntfy": "1.7.4", - "nodebb-plugin-spam-be-gone": "^0.4.4", + "nodebb-plugin-spam-be-gone": "2.2.2", "nodebb-rewards-essentials": "1.0.0", "nodebb-theme-harmony": "1.2.63", "nodebb-theme-lavender": "7.1.8", @@ -145,7 +145,7 @@ "toobusy-js": "0.5.1", "tough-cookie": "4.1.4", "validator": "13.12.0", - "webpack": "^5.95.0", + "webpack": "5.91.0", "webpack-merge": "5.10.0", "winston": "3.13.0", "workerpool": "9.1.1", @@ -166,7 +166,7 @@ "grunt-contrib-watch": "1.1.0", "husky": "8.0.3", "jsdom": "24.0.0", - "lint-staged": "^15.2.10", + "lint-staged": "15.2.2", "mocha": "10.4.0", "mocha-lcov-reporter": "1.3.0", "mockdate": "3.0.5", @@ -197,4 +197,4 @@ "url": "https://github.com/barisusakli" } ] -} +} \ No newline at end of file From 254202e5eafbd697c9edbcf354c453e5b8477ab3 Mon Sep 17 00:00:00 2001 From: Filippos Date: Sun, 27 Oct 2024 20:04:30 +0300 Subject: [PATCH 48/48] Add Snyk vulnerability scan workflow --- .github/workflows/snyk-analysis.yml | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/snyk-analysis.yml diff --git a/.github/workflows/snyk-analysis.yml b/.github/workflows/snyk-analysis.yml new file mode 100644 index 0000000000..344e320dbd --- /dev/null +++ b/.github/workflows/snyk-analysis.yml @@ -0,0 +1,32 @@ +name: Snyk Vulnerability Scan + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + snyk: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '16' + + - name: Install dependencies + run: npm install + + - name: Run Snyk Scan + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + npx snyk auth $SNYK_TOKEN + npx snyk test --all-projects