├── .eslintrc ├── public ├── .eslintrc ├── js │ ├── loader.js │ ├── lib │ │ ├── actions │ │ │ ├── bug.js │ │ │ ├── hide.js │ │ │ ├── settings.js │ │ │ ├── gist.js │ │ │ └── default.js │ │ ├── actions.js │ │ ├── commands.js │ │ ├── commands │ │ │ └── default.js │ │ ├── utils.js │ │ ├── settings.js │ │ ├── sockets.js │ │ └── base.js │ └── admin.js └── scss │ └── style.scss ├── .gitignore ├── assets └── sounds │ ├── shoutbox-cena.mp3 │ ├── shoutbox-wobble.mp3 │ └── shoutbox-notification.mp3 ├── .editorconfig ├── README.md ├── templates ├── shoutbox │ ├── user │ │ ├── panel.tpl │ │ └── settings.tpl │ ├── features │ │ └── gist.tpl │ ├── shouts.tpl │ └── panel.tpl ├── shoutbox.tpl └── admin │ └── plugins │ └── shoutbox.tpl ├── lib ├── nodebb.js ├── commands.js ├── sockets.js ├── shouts.js └── config.js ├── package.json ├── LICENSE ├── plugin.json ├── languages ├── en-GB │ ├── admin.json │ └── shoutbox.json ├── en-US │ ├── admin.json │ └── shoutbox.json ├── de │ ├── admin.json │ └── shoutbox.json └── fr │ ├── admin.json │ └── shoutbox.json └── library.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nodebb/lib" 3 | } 4 | -------------------------------------------------------------------------------- /public/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nodebb/public" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | *.sublime-project 4 | *.sublime-workspace 5 | .project 6 | .idea 7 | .settings -------------------------------------------------------------------------------- /assets/sounds/shoutbox-cena.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeBB-Community/nodebb-plugin-shoutbox/HEAD/assets/sounds/shoutbox-cena.mp3 -------------------------------------------------------------------------------- /assets/sounds/shoutbox-wobble.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeBB-Community/nodebb-plugin-shoutbox/HEAD/assets/sounds/shoutbox-wobble.mp3 -------------------------------------------------------------------------------- /assets/sounds/shoutbox-notification.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeBB-Community/nodebb-plugin-shoutbox/HEAD/assets/sounds/shoutbox-notification.mp3 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [{*.js, *.css, *.tpl, *.json}] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = false -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NodeBB Shoutbox plugin 2 | 3 | This is a NodeBB plugin that will add a shoutbox to your homepage. It's still a work in progress. 4 | 5 | ## Installation 6 | 7 | npm install nodebb-plugin-shoutbox 8 | -------------------------------------------------------------------------------- /templates/shoutbox/user/panel.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |

[[shoutbox:users_online, 0]]

4 |
5 |
6 | 7 |
8 |
9 | -------------------------------------------------------------------------------- /templates/shoutbox.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 8 |
-------------------------------------------------------------------------------- /public/js/loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals Shoutbox, app */ 4 | 5 | $(window).on('action:ajaxify.end', function () { 6 | if ($('#shoutbox-main').length > 0) { 7 | Shoutbox.init(); 8 | } 9 | }); 10 | 11 | window.Shoutbox = { 12 | init: function () { 13 | Shoutbox.instances.main = Shoutbox.base.init($('#shoutbox-main'), {}); 14 | }, 15 | instances: {}, 16 | alert: async function (type, message) { 17 | const alerts = await app.require('alerts'); 18 | alerts[type](message); 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /public/js/lib/actions/bug.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Bug = function (sbInstance) { 5 | this.register = function () { 6 | sbInstance.dom.container.find('.shoutbox-button-bug').off('click').on('click', function () { 7 | window.open('https://github.com/NodeBB-Community/nodebb-plugin-shoutbox/issues/new', '_blank').focus(); 8 | }); 9 | }; 10 | }; 11 | $(window).on('action:app.load', function () { 12 | Shoutbox.actions.register('bug', Bug); 13 | }); 14 | }(window.Shoutbox)); 15 | -------------------------------------------------------------------------------- /public/js/lib/actions/hide.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Hide = function (sbInstance) { 5 | this.register = function () { 6 | sbInstance.settings 7 | .off('toggles.hide') 8 | .on('toggles.hide', handle); 9 | }; 10 | 11 | function handle(value) { 12 | var body = sbInstance.dom.container.find('.card-body'); 13 | body.toggleClass('hidden', value === 1); 14 | } 15 | }; 16 | $(window).on('action:app.load', function () { 17 | Shoutbox.actions.register('hide', Hide); 18 | }); 19 | }(window.Shoutbox)); 20 | -------------------------------------------------------------------------------- /public/js/lib/actions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var allActions = []; 5 | 6 | var Actions = function (sbInstance) { 7 | var action; 8 | allActions.forEach(function (actObj) { 9 | action = new actObj.obj(sbInstance); 10 | action.register(); 11 | 12 | this[actObj.name] = action; 13 | }, this); 14 | }; 15 | 16 | Shoutbox.actions = { 17 | init: function (sbInstance) { 18 | return new Actions(sbInstance); 19 | }, 20 | register: function (name, obj) { 21 | allActions.push({ 22 | name: name, 23 | obj: obj, 24 | }); 25 | }, 26 | }; 27 | }(window.Shoutbox)); 28 | -------------------------------------------------------------------------------- /lib/nodebb.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | Settings: require.main.require('./src/settings'), 5 | Meta: require.main.require('./src/meta'), 6 | User: require.main.require('./src/user'), 7 | Plugins: require.main.require('./src/plugins'), 8 | SocketIndex: require.main.require('./src/socket.io/index'), 9 | SocketPlugins: require.main.require('./src/socket.io/plugins'), 10 | SocketAdmin: require.main.require('./src/socket.io/admin').plugins, 11 | db: require.main.require('./src/database'), 12 | winston: require.main.require('winston'), 13 | translator: require.main.require('./src/translator'), 14 | utils: require.main.require('./src/utils'), 15 | api: require.main.require('./src/api'), 16 | }; 17 | -------------------------------------------------------------------------------- /templates/shoutbox/user/settings.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 | 7 | 8 |
9 |
10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodebb-plugin-shoutbox", 3 | "version": "2.1.6", 4 | "description": "NodeBB Shoutbox Plugin", 5 | "main": "library.js", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Schamper/nodebb-plugin-shoutbox" 13 | }, 14 | "keywords": [ 15 | "nodebb", 16 | "plugin", 17 | "shoutbox" 18 | ], 19 | "author": "Schamper ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/Schamper/nodebb-plugin-shoutbox/issues" 23 | }, 24 | "dependencies": { 25 | "lodash": "^4.17.21" 26 | }, 27 | "devDependencies": { 28 | "eslint": "7.32.0", 29 | "eslint-config-nodebb": "^0.0.2", 30 | "eslint-plugin-import": "^2.24.2" 31 | }, 32 | "nbbpm": { 33 | "compatibility": "^4.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/js/lib/actions/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Settings = function (sbInstance) { 5 | this.register = function () { 6 | sbInstance.dom.container 7 | .off('click', '.shoutbox-settings-menu a') 8 | .on('click', '.shoutbox-settings-menu a', handle); 9 | }; 10 | 11 | function handle() { 12 | var el = $(this); 13 | var key = el.data('shoutbox-setting'); 14 | var statusEl = el.find('span'); 15 | var status = statusEl.hasClass('fa-check'); 16 | 17 | if (status) { 18 | statusEl.removeClass('fa-check').addClass('fa-times'); 19 | status = 0; 20 | } else { 21 | statusEl.removeClass('fa-times').addClass('fa-check'); 22 | status = 1; 23 | } 24 | 25 | sbInstance.settings.set(key, status); 26 | 27 | return false; 28 | } 29 | }; 30 | $(window).on('action:app.load', function () { 31 | Shoutbox.actions.register('settings', Settings); 32 | }); 33 | }(window.Shoutbox)); 34 | -------------------------------------------------------------------------------- /lib/commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const NodeBB = module.require('./nodebb'); 4 | const Sockets = require('./sockets'); 5 | 6 | const slugify = require.main.require('./src/slugify'); 7 | 8 | const Commands = {}; 9 | 10 | Commands.sockets = { 11 | wobble: soundCommand('wobble'), 12 | cena: soundCommand('cena'), 13 | }; 14 | 15 | function soundCommand(sound) { 16 | return async function (socket, data) { 17 | if (!socket.uid || !data) { 18 | throw new Error('[[error:invalid-data]]'); 19 | } 20 | 21 | if (data.victim && data.victim.length) { 22 | const userslug = slugify(data.victim); 23 | const uid = await NodeBB.User.getUidByUserslug(userslug); 24 | if (uid) { 25 | NodeBB.SocketIndex.in(`uid_${uid}`).emit(`event:shoutbox.${sound}`); 26 | } 27 | } else { 28 | socket.emit(`event:shoutbox.${sound}`); 29 | } 30 | }; 31 | } 32 | Object.keys(Commands.sockets).forEach((s) => { 33 | Sockets.events[s] = Commands.sockets[s]; 34 | }); 35 | 36 | module.exports = Commands; 37 | -------------------------------------------------------------------------------- /templates/shoutbox/features/gist.tpl: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Erik Schamper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /public/js/lib/commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var regex = /^\/(\w+)\s?(.+)?/; 5 | var allCommands = {}; 6 | 7 | var Commands = function (instance) { 8 | this.sb = instance; 9 | this.commands = {}; 10 | 11 | // TODO: Permission based 12 | for (var c in allCommands) { 13 | if (allCommands.hasOwnProperty(c)) { 14 | if (typeof allCommands[c].register === 'function') { 15 | allCommands[c].register(this.sb); 16 | } 17 | 18 | this.commands[c] = allCommands[c]; 19 | } 20 | } 21 | }; 22 | 23 | Commands.prototype.getCommands = function () { 24 | return this.commands; 25 | }; 26 | 27 | Commands.prototype.parse = function (msg, sendShout) { 28 | var match = msg.match(regex); 29 | 30 | if (match && match.length > 0 && this.commands.hasOwnProperty(match[1])) { 31 | this.commands[match[1]].handlers.action(match[2] || '', sendShout, this.sb); 32 | } else { 33 | sendShout(msg); 34 | } 35 | }; 36 | 37 | Shoutbox.commands = { 38 | init: function (instance) { 39 | return new Commands(instance); 40 | }, 41 | getCommands: function () { 42 | return allCommands; 43 | }, 44 | register: function (command, commandObj) { 45 | allCommands[command] = commandObj; 46 | }, 47 | }; 48 | }(window.Shoutbox)); 49 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nodebb-plugin-shoutbox", 3 | "name": "Shoutbox", 4 | "description": "NodeBB Plugin Shoutbox", 5 | "url": "https://github.com/Schamper/nodebb-plugin-shoutbox", 6 | "library": "./library.js", 7 | "hooks": [ 8 | { "hook": "static:app.load", "method": "init.load" }, 9 | { "hook": "filter:config.get", "method": "init.filterConfigGet" }, 10 | 11 | { "hook": "filter:admin.header.build", "method": "init.addAdminNavigation" }, 12 | 13 | { "hook": "filter:user.customSettings", "method": "settings.addUserSettings" }, 14 | { "hook": "filter:user.getSettings", "method": "settings.filterUserGetSettings" }, 15 | { "hook": "filter:user.saveSettings", "method": "settings.filterUserSaveSettings" }, 16 | { "hook": "filter:user.whitelistFields", "method": "settings.addUserFieldWhitelist" }, 17 | 18 | { "hook": "filter:widgets.getWidgets", "method": "widget.define" }, 19 | { "hook": "filter:widget.render:shoutbox", "method": "widget.render" } 20 | ], 21 | "staticDirs": { 22 | "public": "./public", 23 | "assets": "./assets" 24 | }, 25 | "scss": [ 26 | "public/scss/style.scss" 27 | ], 28 | "scripts": [ 29 | "public/js/loader.js", 30 | "public/js/lib/" 31 | ], 32 | "modules": { 33 | "../admin/plugins/shoutbox.js": "public/js/admin.js" 34 | }, 35 | "languages": "languages", 36 | "templates": "./templates" 37 | } 38 | -------------------------------------------------------------------------------- /languages/en-GB/admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_shouts_number": "Maximum number of shouts that can be returned", 3 | "deleted_shouts_included": "Shouts marked as deleted will be included in this number", 4 | "allow_guest_read_access": "Allow read access to guests", 5 | "show_navigation_link": "Show navigation link", 6 | "features": "Features", 7 | "control_panel": "Shoutbox control panel", 8 | "save_settings": "Save settings", 9 | "administrative_actions": "Administrative actions", 10 | "warning_permanent": "Warning: These actions are permanent and cannot be undone!", 11 | "remove_deleted": "Remove deleted shouts", 12 | "remove_all": "Remove all shouts", 13 | "remove_deleted_confirmation": "Are you sure you wish to remove all shouts marked as deleted from the database?", 14 | "success_removing_deleted": "Successfully removed all shouts marked as deleted from the database", 15 | "remove_all_confirmation": "Are you sure you wish to remove all shouts from the database", 16 | "success_removing_all": "Successfully removed all shouts from the database", 17 | "gists": "Gists", 18 | "gists_description": "Easily create Gists", 19 | "gists_button": "Create Gist", 20 | "archive": "Archive", 21 | "archive_description": "View older shouts", 22 | "archive_button": "View Archive", 23 | "bugs": "Bugs", 24 | "bugs_description": "Report bugs quickly", 25 | "bugs_button": "Report Bug" 26 | } 27 | -------------------------------------------------------------------------------- /languages/en-US/admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_shouts_number": "Maximum number of shouts that can be returned", 3 | "deleted_shouts_included": "Shouts marked as deleted will be included in this number", 4 | "allow_guest_read_access": "Allow read access to guests", 5 | "show_navigation_link": "Show navigation link", 6 | "features": "Features", 7 | "control_panel": "Shoutbox control panel", 8 | "save_settings": "Save settings", 9 | "administrative_actions": "Administrative actions", 10 | "warning_permanent": "Warning: These actions are permanent and cannot be undone!", 11 | "remove_deleted": "Remove deleted shouts", 12 | "remove_all": "Remove all shouts", 13 | "remove_deleted_confirmation": "Are you sure you wish to remove all shouts marked as deleted from the database?", 14 | "success_removing_deleted": "Successfully removed all shouts marked as deleted from the database", 15 | "remove_all_confirmation": "Are you sure you wish to remove all shouts from the database", 16 | "success_removing_all": "Successfully removed all shouts from the database", 17 | "gists": "Gists", 18 | "gists_description": "Easily create Gists", 19 | "gists_button": "Create Gist", 20 | "archive": "Archive", 21 | "archive_description": "View older shouts", 22 | "archive_button": "View Archive", 23 | "bugs": "Bugs", 24 | "bugs_description": "Report bugs quickly", 25 | "bugs_button": "Report Bug" 26 | } 27 | -------------------------------------------------------------------------------- /languages/de/admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_shouts_number": "Maximale Anzahl an Nachrichten, die dargestellt werden können", 3 | "deleted_shouts_included": "Das schließt gelöschte Nachrichten ein", 4 | "allow_guest_read_access": "Gästen das Betrachten erlauben", 5 | "show_navigation_link": "Shoutbox in Navigation anzeigen", 6 | "features": "Funktionen", 7 | "control_panel": "Shoutbox-Einstellungen", 8 | "save_settings": "Einstellungen speichern", 9 | "administrative_actions": "Verwaltungsaufgaben", 10 | "warning_permanent": "Warnung: Diese Optionen sind permanent und können nicht rückgängig gemacht werden!", 11 | "remove_deleted": "Gelöschte Nachrichten entfernen", 12 | "remove_all": "Alle Nachrichten entfernen", 13 | "remove_deleted_confirmation": "Bist du sicher, dass du alle gelöschten Nachrichten entfernen willst?", 14 | "success_removing_deleted": "Alle gelöschten Nachrichten wurden erfolgreich entfernt", 15 | "remove_all_confirmation": "Bist du sicher, dass du alle Nachrichten entfernen willst?", 16 | "success_removing_all": "Alle Nachrichten wurden erfolgreich entfernt", 17 | "gists": "Gists", 18 | "gists_description": "Gists einfach erstellen", 19 | "gists_button": "Gist erstellen", 20 | "archive": "Archiv", 21 | "archive_description": "Ältere Nachrichten anzeigen", 22 | "archive_button": "Archiv anzeigen", 23 | "bugs": "Bugs", 24 | "bugs_description": "Bugs melden", 25 | "bugs_button": "Bug melden" 26 | } 27 | -------------------------------------------------------------------------------- /languages/fr/admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_shouts_number": "Nombre maximum de messages pouvant être envoyés", 3 | "deleted_shouts_included": "Les messages marqués comme supprimés seront inclus dans ce nombre", 4 | "allow_guest_read_access": "Autoriser l'accès en lecture aux invités", 5 | "show_navigation_link": "Afficher le lien de navigation", 6 | "features": "Fonctionnalités", 7 | "control_panel": "Panneau de configuration de la Shoutbox", 8 | "save_settings": "Enregistrer les paramètres", 9 | "administrative_actions": "Actions d'administration", 10 | "warning_permanent": "Attention : ces actions sont permanentes et ne peuvent pas être annulées", 11 | "remove_deleted": "Supprimer les messages effacés", 12 | "remove_all": "Supprimer tous les messages", 13 | "remove_deleted_confirmation": "Êtes-vous sûr de vouloir supprimer tous les messages marqués comme effacés de la base de données ?", 14 | "success_removing_deleted": "Suppression réussie de tous les messages marqués comme effacés de la base de données", 15 | "remove_all_confirmation": "Êtes-vous sûr de vouloir supprimer tous les messages de la base de données", 16 | "success_removing_all": "Suppression réussie de tous les messages de la base de données", 17 | "gists": "Gists", 18 | "gists_description": "Créez facilement des Gists", 19 | "gists_button": "Créer un Gist", 20 | "archive": "Archive", 21 | "archive_description": "Afficher les anciens messages", 22 | "archive_button": "Voir les archives", 23 | "bugs": "Bugs", 24 | "bugs_description": "Signaler les bugs rapidement", 25 | "bugs_button": "Signaler un bug" 26 | } 27 | -------------------------------------------------------------------------------- /languages/en-GB/shoutbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "shoutbox": "Shoutbox", 3 | "sound": "Sound", 4 | "play_sound": "Play a sound on a new shout", 5 | "notification": "Notification", 6 | "show_notification": "Display a notification in the titlebar on a new shout", 7 | "hide": "Hide", 8 | "hide_shoutbox": "Hide shoutbox", 9 | "send_message": "Send", 10 | "enter_message": "Enter message", 11 | "users_online": "Users (%1)", 12 | "new_shout": "[ %u ] - new shout!", 13 | "empty": "The shoutbox is empty, start shouting!", 14 | "scroll_down": "Scroll down", 15 | "error_saving_settings": "Error saving settings!", 16 | "displays_available_commands": "Displays the available commands", 17 | "available_commands": "Available commands:
", 18 | "reminds_noobs": "Remind the n00bs of the obvious", 19 | "clear_cache": "This again... Clear your cache and refresh.", 20 | "wobble": "WOBULLY SASUGE", 21 | "cena": "AND HIS NAME IS", 22 | "sound_user": "/%1 <username>", 23 | "shout_delete_success": "Successfully deleted shout!", 24 | "shout_delete_error": "Error deleting shout: %1", 25 | "shout_edit_success": "Successfully edited shout!", 26 | "shout_edit_error": "Error editing shout: %1", 27 | "create_gist": "Create gist", 28 | "paste_code_here": "Paste code here", 29 | "close_gist": "Close", 30 | "submit_gist": "Submit", 31 | "gist_only_registered": "Only registered users can create Gists!", 32 | "gist_created_from_shoutbox": "Gist created from NodeBB shoutbox", 33 | "gist_create_success": "Successfully created Gist!", 34 | "gist_create_error": "Error while creating Gist, try again later!" 35 | } 36 | -------------------------------------------------------------------------------- /languages/en-US/shoutbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "shoutbox": "Shoutbox", 3 | "sound": "Sound", 4 | "play_sound": "Play a sound on a new shout", 5 | "notification": "Notification", 6 | "show_notification": "Display a notification in the titlebar on a new shout", 7 | "hide": "Hide", 8 | "hide_shoutbox": "Hide shoutbox", 9 | "send_message": "Send", 10 | "enter_message": "Enter message", 11 | "users_online": "Users (%1)", 12 | "new_shout": "[ %u ] - new shout!", 13 | "empty": "The shoutbox is empty, start shouting!", 14 | "scroll_down": "Scroll down", 15 | "error_saving_settings": "Error saving settings!", 16 | "displays_available_commands": "Displays the available commands", 17 | "available_commands": "Available commands:
", 18 | "reminds_noobs": "Remind the n00bs of the obvious", 19 | "clear_cache": "This again... Clear your cache and refresh.", 20 | "wobble": "WOBULLY SASUGE", 21 | "cena": "AND HIS NAME IS", 22 | "sound_user": "/%1 <username>", 23 | "shout_delete_success": "Successfully deleted shout!", 24 | "shout_delete_error": "Error deleting shout: %1", 25 | "shout_edit_success": "Successfully edited shout!", 26 | "shout_edit_error": "Error editing shout: %1", 27 | "create_gist": "Create gist", 28 | "paste_code_here": "Paste code here", 29 | "close_gist": "Close", 30 | "submit_gist": "Submit", 31 | "gist_only_registered": "Only registered users can create Gists!", 32 | "gist_created_from_shoutbox": "Gist created from NodeBB shoutbox", 33 | "gist_create_success": "Successfully created Gist!", 34 | "gist_create_error": "Error while creating Gist, try again later!" 35 | } 36 | -------------------------------------------------------------------------------- /public/js/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | define('admin/plugins/shoutbox', ['settings', 'alerts'], function (Settings, alerts) { 4 | var wrapper; 5 | 6 | var ACP = {}; 7 | 8 | ACP.init = function () { 9 | wrapper = $('.shoutbox-settings'); 10 | 11 | Settings.sync('shoutbox', wrapper); 12 | 13 | $('#save').on('click', function () { 14 | save(); 15 | }); 16 | 17 | prepareButtons(); 18 | }; 19 | 20 | function save() { 21 | Settings.persist('shoutbox', wrapper, function () { 22 | socket.emit('admin.plugins.shoutbox.sync'); 23 | }); 24 | } 25 | 26 | function prepareButtons() { 27 | $('#shoutbox-remove-deleted-button').off('click').on('click', function () { 28 | bootbox.confirm('Are you sure you wish to remove all shouts marked as deleted from the database?', function (confirm) { 29 | if (confirm) { 30 | socket.emit('plugins.shoutbox.removeAll', { which: 'deleted' }, function (err) { 31 | if (err) { 32 | return alerts.error(err.message); 33 | } 34 | alerts.success('Successfully removed all shouts marked as deleted from the database'); 35 | }); 36 | } 37 | }); 38 | }); 39 | 40 | $('#shoutbox-remove-all-button').off('click').on('click', function () { 41 | bootbox.confirm('Are you sure you wish to remove all shouts from the database?', function (confirm) { 42 | if (confirm) { 43 | socket.emit('plugins.shoutbox.removeAll', { which: 'all' }, function (err) { 44 | if (err) { 45 | return alerts.error(err.message); 46 | } 47 | alerts.success('Successfully removed all shouts from the database'); 48 | }); 49 | } 50 | }); 51 | }); 52 | } 53 | 54 | return ACP; 55 | }); 56 | -------------------------------------------------------------------------------- /templates/shoutbox/shouts.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | {buildAvatar(shouts.user, "28px", true)} 6 | 7 |
8 | 9 | 10 | 11 |
12 |
13 | 14 | {shouts.user.username} 15 | 16 | 17 | 18 |
19 | 20 | 21 |
22 |
{shouts.content}
23 | 24 | 25 |
26 | 27 | 28 |
29 | 30 |
31 | -------------------------------------------------------------------------------- /languages/de/shoutbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "shoutbox": "Shoutbox", 3 | "sound": "Töne", 4 | "play_sound": "Ton bei neuer Nachricht abspielen", 5 | "notification": "Benachrichtigungen", 6 | "show_notification": "Benachrichtigung bei neuer Nachricht anzeigen", 7 | "hide": "Ausblenden", 8 | "hide_shoutbox": "Shoutbox ausblenden", 9 | "send_message": "Senden", 10 | "enter_message": "Nachricht eingeben", 11 | "users_online": "Benutzer (%1)", 12 | "new_shout": "[ %u ] - neue Nachricht!", 13 | "empty": "Die Shoutbox ist leer, schreib' etwas!", 14 | "scroll_down": "Nach unten scrollen", 15 | "error_saving_settings": "Fehler beim Speichern der Einstellungen!", 16 | "displays_available_commands": "Zeigt verfügbare Befehle an", 17 | "available_commands": "Verfügbare Befehle:
", 18 | "reminds_noobs": "Erinnert die n00bs an das offensichtliche", 19 | "clear_cache": "Das schon wieder... Lösche deinen Cache und lade die Seite neu.", 20 | "wobble": "WOBULLY SASUGE", 21 | "cena": "AND HIS NAME IS", 22 | "sound_user": "/%1 <Benutzername>", 23 | "shout_delete_success": "Nachricht erfolgreich gelöscht!", 24 | "shout_delete_error": "Fehler beim Löschen der Nachricht: %1", 25 | "shout_edit_success": "Nachricht erfolgreich bearbeitet!", 26 | "shout_edit_error": "Fehler beim Bearbeiten der Nachricht: %1", 27 | "create_gist": "Gist erstellen", 28 | "paste_code_here": "Code hier eingeben", 29 | "close_gist": "Schließen", 30 | "submit_gist": "Senden", 31 | "gist_only_registered": "Nur registrierte Benutzer können Gists erstellen!", 32 | "gist_created_from_shoutbox": "Gist erstellt von NodeBB Shoutbox", 33 | "gist_create_success": "Gist erfolgreich erstellt!", 34 | "gist_create_error": "Fehler beim erstellen des Gists, versuche es später nochmal!" 35 | } 36 | -------------------------------------------------------------------------------- /languages/fr/shoutbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "shoutbox": "Shoutbox", 3 | "sound": "Son", 4 | "play_sound": "Jouer un son à chaque message entrant", 5 | "notification": "Notification", 6 | "show_notification": "Afficher une notification dans la barre d'onglet du navigateur lors d'un nouveau message", 7 | "hide": "Cacher", 8 | "hide_shoutbox": "Cacher la shoutbox", 9 | "send_message": "Envoyer", 10 | "enter_message": "Entrer un message", 11 | "users_online": "Users (%1)", 12 | "new_shout": "[ %u ] - Nouveau message !", 13 | "empty": "La shoutbox est vide, commencer à discuter !", 14 | "scroll_down": "Défiler vers le bas", 15 | "error_saving_settings": "Erreur lors de l'enregistrement des paramètres !", 16 | "displays_available_commands": "Afficher les commandes disponibles", 17 | "available_commands": "Commandes disponibles:
", 18 | "reminds_noobs": "Rappelez l'évidence aux n00bs", 19 | "clear_cache": "Encore une fois... Effacez votre cache et actualisez..", 20 | "wobble": "WOBULLY SASUGE", 21 | "cena": "AND HIS NAME IS", 22 | "sound_user": "/%1 <username>", 23 | "shout_delete_success": "Message supprimé avec succès !", 24 | "shout_delete_error": "Erreur lors de la suppression du message: %1", 25 | "shout_edit_success": "Message édité avec succès !", 26 | "shout_edit_error": "Erreur lors de l'édition du message: %1", 27 | "create_gist": "Créer un gist", 28 | "paste_code_here": "Coller le code ici", 29 | "close_gist": "Fermer le gist", 30 | "submit_gist": "Faire parvenir", 31 | "gist_only_registered": "Seuls les utilisateurs enregistrés peuvent créer des Gists !", 32 | "gist_created_from_shoutbox": "Gist créé à partir de la Shoutbox NodeBB ", 33 | "gist_create_success": "Gist créé avec succès !", 34 | "gist_create_error": "Erreur lors de la création du Gist, réessayez plus tard !" 35 | } 36 | -------------------------------------------------------------------------------- /public/js/lib/actions/gist.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Gist = function (sbInstance) { 5 | this.register = function () { 6 | app.parseAndTranslate('shoutbox/features/gist', {}, function (tpl) { 7 | $(document.body).append(tpl); 8 | 9 | var gistModal = $('#shoutbox-modal-gist'); 10 | 11 | sbInstance.dom.container.find('.shoutbox-button-gist').off('click').on('click', function () { 12 | gistModal.modal('show'); 13 | }); 14 | 15 | gistModal.find('#shoutbox-button-create-gist-submit').off('click').on('click', function () { 16 | createGist(gistModal.find('textarea').val(), gistModal); 17 | }); 18 | }); 19 | }; 20 | 21 | function createGist(code, gistModal) { 22 | if (app.user.uid === null) { 23 | gistModal.modal('hide'); 24 | 25 | Shoutbox.alert('error', 'Only registered users can create Gists!'); 26 | return; 27 | } 28 | 29 | var json = { 30 | description: 'Gist created from NodeBB shoutbox', 31 | public: true, 32 | files: { 33 | 'Snippet.txt': { 34 | content: code, 35 | }, 36 | }, 37 | }; 38 | 39 | $.post('https://api.github.com/gists', JSON.stringify(json), function (data) { 40 | var input = sbInstance.dom.textInput; 41 | var link = data.html_url; 42 | 43 | if (input.val().length > 0) { 44 | link = ' ' + link; 45 | } 46 | 47 | input.val(input.val() + link); 48 | 49 | gistModal.modal('hide'); 50 | gistModal.find('textarea').val(''); 51 | 52 | Shoutbox.alert('success', 'Successfully created Gist!'); 53 | }).fail(function () { 54 | gistModal.modal('hide'); 55 | Shoutbox.alert('error', 'Error while creating Gist, try again later!'); 56 | }); 57 | } 58 | }; 59 | 60 | $(window).on('action:app.load', function () { 61 | Shoutbox.actions.register('gist', Gist); 62 | }); 63 | }(window.Shoutbox)); 64 | -------------------------------------------------------------------------------- /public/js/lib/commands/default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var ArgumentHandlers = { 5 | username: function (argument) { 6 | if (argument.indexOf('@') === 0) { 7 | argument = argument.slice(1); 8 | } 9 | return argument; 10 | }, 11 | }; 12 | 13 | var DefaultCommands = { 14 | help: { 15 | info: { 16 | usage: '/help', 17 | description: 'Displays the available commands', 18 | }, 19 | handlers: { 20 | action: function (argument, sendShout, sbInstance) { 21 | var message = 'Available commands:
'; 22 | var commands = sbInstance.commands.getCommands(); 23 | 24 | for (var c in commands) { 25 | if (commands.hasOwnProperty(c)) { 26 | message += commands[c].info.usage + ' - ' + commands[c].info.description + '
'; 27 | } 28 | } 29 | 30 | sbInstance.utils.showOverlay(message); 31 | }, 32 | }, 33 | }, 34 | thisagain: { 35 | info: { 36 | usage: '/thisagain', 37 | description: 'Remind the n00bs of the obvious', 38 | }, 39 | handlers: { 40 | action: function (argument, sendShout) { 41 | sendShout('This again... Clear your cache and refresh.'); 42 | }, 43 | }, 44 | }, 45 | wobble: soundCommand('wobble', 'WOBULLY SASUGE'), 46 | cena: soundCommand('cena', 'AND HIS NAME IS'), 47 | }; 48 | 49 | function soundCommand(sound, description) { 50 | return { 51 | info: { 52 | usage: '/' + sound + ' <username>', 53 | description: description, 54 | }, 55 | register: function (sbInstance) { 56 | sbInstance.sockets.registerMessage(sound, 'plugins.shoutbox.' + sound); 57 | sbInstance.sockets.registerEvent('event:shoutbox.' + sound, function () { 58 | sbInstance.utils.playSound('shoutbox-' + sound + '.mp3'); 59 | }); 60 | }, 61 | handlers: { 62 | action: function (argument, sendShout, sbInstance) { 63 | sbInstance.sockets[sound]({ 64 | victim: ArgumentHandlers.username(argument), 65 | }); 66 | }, 67 | }, 68 | }; 69 | } 70 | $(window).on('action:app.load', function () { 71 | for (var c in DefaultCommands) { 72 | if (DefaultCommands.hasOwnProperty(c)) { 73 | Shoutbox.commands.register(c, DefaultCommands[c]); 74 | } 75 | } 76 | }); 77 | }(window.Shoutbox)); 78 | -------------------------------------------------------------------------------- /public/js/lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Utils = function (instance) { 5 | this.sb = instance; 6 | }; 7 | 8 | Utils.prototype.isAnon = function () { 9 | return app.user.uid === 0; 10 | }; 11 | 12 | Utils.prototype.notify = function (data) { 13 | const shoutboxOnPage = $('#shoutbox-main').length > 0; 14 | if (parseInt(this.sb.settings.get('toggles.notification'), 10) === 1 && shoutboxOnPage) { 15 | window.document.title = $('
').html(this.sb.vars.messages.alert.replace(/%u/g, data.user.username)).text(); 16 | } 17 | if (parseInt(this.sb.settings.get('toggles.sound'), 10) === 1 && shoutboxOnPage) { 18 | this.playSound('shoutbox-notification.mp3'); 19 | } 20 | }; 21 | 22 | Utils.prototype.playSound = function (file) { 23 | if (!file) { 24 | return; 25 | } 26 | var audio = new Audio( 27 | config.relative_path + '/plugins/nodebb-plugin-shoutbox/assets/sounds/' + file 28 | ); 29 | 30 | audio.pause(); 31 | audio.currentTime = 0; 32 | try { 33 | audio.play(); 34 | } catch (err) { 35 | console.error(err); 36 | } 37 | }; 38 | 39 | Utils.prototype.showOverlay = function (message) { 40 | this.sb.dom.overlayMessage.html(message); 41 | this.sb.dom.overlay.addClass('active'); 42 | }; 43 | 44 | Utils.prototype.closeOverlay = function () { 45 | this.sb.dom.overlay.removeClass('active'); 46 | }; 47 | 48 | Utils.prototype.scrollToBottom = function (force) { 49 | var shoutsContainer = this.sb.dom.shoutsContainer; 50 | var lastShoutHeight = shoutsContainer.find('[data-sid]:last').height(); 51 | var scrollHeight = getScrollHeight(shoutsContainer) - lastShoutHeight; 52 | 53 | if (scrollHeight < this.sb.vars.scrollBreakpoint || force) { 54 | shoutsContainer.scrollTop( 55 | shoutsContainer[0].scrollHeight - shoutsContainer.height() 56 | ); 57 | } 58 | }; 59 | 60 | function getScrollHeight(container) { 61 | if (container[0]) { 62 | var padding = container.css('padding-top').replace('px', '') + container.css('padding-bottom').replace('px', ''); 63 | return (((container[0].scrollHeight - container.scrollTop()) - container.height()) - padding); 64 | } 65 | return -1; 66 | } 67 | 68 | Shoutbox.utils = { 69 | init: function (instance) { 70 | return new Utils(instance); 71 | }, 72 | getScrollHeight: getScrollHeight, 73 | }; 74 | }(window.Shoutbox)); 75 | 76 | -------------------------------------------------------------------------------- /public/js/lib/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Settings = function (instance) { 5 | this.sb = instance; 6 | this.settings = null; 7 | this.listeners = {}; 8 | }; 9 | 10 | Settings.prototype.load = function () { 11 | var self = this; 12 | this.sb.sockets.getSettings(function (err, result) { 13 | if (err || !result || !result.settings) { 14 | return; 15 | } 16 | 17 | self.settings = result.settings; 18 | parse(self.settings); 19 | }); 20 | 21 | function parse(settings) { 22 | var settingsMenu = self.sb.dom.container; 23 | 24 | for (var key in settings) { 25 | if (settings.hasOwnProperty(key)) { 26 | var value = settings[key]; 27 | key = prettyString(key); 28 | var el = settingsMenu.find('[data-shoutbox-setting="' + key + '"] span'); 29 | 30 | if (el.length > 0) { 31 | if (parseInt(value, 10) === 1) { 32 | el.removeClass('fa-times').addClass('fa-check'); 33 | } else { 34 | el.removeClass('fa-check').addClass('fa-times'); 35 | } 36 | } 37 | } 38 | } 39 | } 40 | }; 41 | 42 | Settings.prototype.get = function (key) { 43 | key = formalString(key); 44 | return this.settings[key]; 45 | }; 46 | 47 | Settings.prototype.set = function (key, value) { 48 | var fullKey = formalString(key); 49 | this.settings[fullKey] = value; 50 | 51 | if (this.listeners.hasOwnProperty(key)) { 52 | this.listeners[key].forEach(function (cb) { 53 | cb(value); 54 | }); 55 | } 56 | 57 | this.sb.sockets.saveSettings({ settings: this.settings }, function (err) { 58 | if (err) { 59 | Shoutbox.alert('error', 'Error saving settings!'); 60 | } 61 | }); 62 | }; 63 | 64 | Settings.prototype.on = function (key, callback) { 65 | if (!this.listeners.hasOwnProperty(key)) { 66 | this.listeners[key] = []; 67 | } 68 | 69 | this.listeners[key].push(callback); 70 | 71 | return this; 72 | }; 73 | 74 | Settings.prototype.off = function (key) { 75 | delete this.listeners[key]; 76 | 77 | return this; 78 | }; 79 | 80 | function prettyString(key) { 81 | return key.replace('shoutbox:', '').replace(/:/g, '.'); 82 | } 83 | 84 | function formalString(key) { 85 | return 'shoutbox:' + key.replace(/\./g, ':'); 86 | } 87 | 88 | Shoutbox.settings = { 89 | init: function (instance) { 90 | return new Settings(instance); 91 | }, 92 | }; 93 | }(window.Shoutbox)); 94 | -------------------------------------------------------------------------------- /templates/admin/plugins/shoutbox.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 |
7 |
8 |
9 |
10 |
[[shoutbox:shoutbox]]
11 |
12 |
13 | 14 | 19 |

[[admin:deleted_shouts_included]]

20 |
21 |
22 | 23 | 24 |
25 |
26 |
27 | 28 |
29 |
[[admin:features]]
30 |
31 |
32 | {{{ each features }}} 33 |
34 |
35 | 36 | 44 |
45 |
46 | {{{ end }}} 47 |
48 |
49 |
50 |
51 |
52 | 53 |
54 |
55 |
[[admin:administrative_actions]]
56 |
57 |
[[admin:warning_permanent]]
58 |
59 | 60 | 61 |
62 |
63 |
64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 | -------------------------------------------------------------------------------- /public/scss/style.scss: -------------------------------------------------------------------------------- 1 | /* Dimensions */ 2 | $border-size: 2px; 3 | $avatar-size: 28px; 4 | $avatar-image-size: $avatar-size - (2 * $border-size); 5 | $chain-margin: 10px; 6 | 7 | /* Colours */ 8 | $status-online: #4caf50; 9 | $status-away: #ff6d00; 10 | $status-dnd: #f44336; 11 | $status-offline: #555; 12 | 13 | .shoutbox-opacity-transition { 14 | opacity: 0; 15 | transition: opacity 0.3s, visibility 0s linear 0.3s; 16 | } 17 | 18 | .shoutbox-avatar { 19 | border-radius: 50%; 20 | width: $avatar-size; 21 | height: $avatar-size; 22 | .avatar { 23 | border: $border-size solid $status-offline; 24 | } 25 | 26 | &.online .avatar { 27 | border: $border-size solid $status-online; 28 | } 29 | 30 | &.away .avatar { 31 | border: $border-size solid $status-away; 32 | } 33 | 34 | &.dnd .avatar { 35 | border: $border-size solid $status-dnd; 36 | } 37 | 38 | &.offline .avatar { 39 | border: $border-size solid $status-offline; 40 | } 41 | 42 | .shoutbox-avatar-overlay { 43 | background-color: black; 44 | width: $avatar-size; 45 | height: $avatar-size; 46 | border-radius: inherit; 47 | text-align: center; 48 | line-height: $avatar-image-size + $border-size * 2; 49 | } 50 | 51 | &.isTyping { 52 | .shoutbox-avatar-overlay { 53 | opacity: 0.6; 54 | } 55 | 56 | .shoutbox-avatar-typing { 57 | opacity: 1; 58 | } 59 | } 60 | 61 | .shoutbox-avatar-overlay, .shoutbox-avatar-typing { 62 | @extend .shoutbox-opacity-transition; 63 | } 64 | } 65 | 66 | [data-widget-area] { 67 | .shoutbox { 68 | .card { 69 | height: 400px!important; 70 | } 71 | .shoutbox-content { 72 | 73 | overflow-y: scroll; 74 | padding-top: 0; 75 | position: relative; 76 | & p { 77 | margin: 0; 78 | } 79 | } 80 | } 81 | } 82 | 83 | .shoutbox-shout { 84 | .shoutbox-shout-text { 85 | .plugin-mentions-user { 86 | font-weight: bold; 87 | } 88 | } 89 | 90 | .shoutbox-shout-edited p:after { 91 | content: "\f040"; 92 | font: normal normal normal 14px/1 FontAwesome; 93 | font-size: 10px; 94 | text-rendering: auto; 95 | color: $text-muted; 96 | margin-left: 5px; 97 | } 98 | 99 | .shoutbox-shout-options { 100 | @extend .shoutbox-opacity-transition; 101 | white-space: nowrap; 102 | } 103 | 104 | &:hover { 105 | .shoutbox-shout-options, .shoutbox-shout-edited p:after { 106 | opacity: 1; 107 | } 108 | } 109 | 110 | p { 111 | margin: 0; 112 | } 113 | } 114 | 115 | .shoutbox { 116 | .card { 117 | height: calc(70vh - var(--panel-offset)); 118 | overflow: hidden; 119 | } 120 | 121 | &-content { 122 | overflow-y: auto; 123 | padding-top: 0; 124 | position: relative; 125 | 126 | & p { 127 | margin: 0; 128 | } 129 | } 130 | 131 | .shoutbox-content-container { 132 | .shoutbox-content-overlay { 133 | @extend .shoutbox-opacity-transition; 134 | z-index: 1; 135 | visibility: hidden; 136 | 137 | &.active { 138 | opacity: 0.9; 139 | visibility: visible; 140 | transition-delay: 0s; 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /templates/shoutbox/panel.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | 29 | 30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 | 42 | 43 | 44 |
45 | 46 | {{{ if features.length }}} 47 |
48 | {{{ each features }}} 49 | {{{ if ./enabled }}} 50 | 53 | {{{ end }}} 54 | {{{ end }}} 55 |
56 | {{{ end }}} 57 |
58 |
59 |
60 | -------------------------------------------------------------------------------- /library.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const NodeBB = require('./lib/nodebb'); 4 | const Config = require('./lib/config'); 5 | const Sockets = require('./lib/sockets'); 6 | require('./lib/commands'); 7 | 8 | let app; 9 | 10 | const Shoutbox = module.exports; 11 | 12 | Shoutbox.init = {}; 13 | Shoutbox.widget = {}; 14 | Shoutbox.settings = {}; 15 | 16 | Shoutbox.init.load = function (params, callback) { 17 | const { router } = params; 18 | const routeHelpers = require.main.require('./src/routes/helpers'); 19 | routeHelpers.setupPageRoute(router, `/${Config.plugin.id}`, async (req, res) => { 20 | const data = Config.getTemplateData(); 21 | res.render(Config.plugin.id, data); 22 | }); 23 | 24 | routeHelpers.setupAdminPageRoute(router, `/admin/plugins/${Config.plugin.id}`, async (req, res) => { 25 | const data = Config.getTemplateData(); 26 | data.title = Config.plugin.name; 27 | res.render(`admin/plugins/${Config.plugin.id}`, data); 28 | }); 29 | 30 | NodeBB.SocketPlugins[Config.plugin.id] = Sockets.events; 31 | NodeBB.SocketAdmin[Config.plugin.id] = Config.adminSockets; 32 | 33 | app = params.app; 34 | 35 | Config.init(callback); 36 | }; 37 | 38 | Shoutbox.init.filterConfigGet = async (config) => { 39 | config.shoutbox = Config.getTemplateData(); 40 | config.shoutbox.settings = await Config.user.load(config.uid); 41 | return config; 42 | }; 43 | 44 | Shoutbox.init.addAdminNavigation = function (header, callback) { 45 | header.plugins.push({ 46 | route: `/plugins/${Config.plugin.id}`, 47 | icon: Config.plugin.icon, 48 | name: Config.plugin.name, 49 | }); 50 | 51 | callback(null, header); 52 | }; 53 | 54 | Shoutbox.widget.define = function (widgets, callback) { 55 | widgets.push({ 56 | name: Config.plugin.name, 57 | widget: Config.plugin.id, 58 | description: Config.plugin.description, 59 | content: '', 60 | }); 61 | 62 | callback(null, widgets); 63 | }; 64 | 65 | Shoutbox.widget.render = async function (widget) { 66 | if (widget.templateData.template.shoutbox) { 67 | return null; 68 | } 69 | // Remove any container 70 | widget.data.container = ''; 71 | 72 | const data = Config.getTemplateData(); 73 | data.title = widget.data.title || ''; 74 | data.features = data.features.filter(f => f && f.enabled); 75 | 76 | widget.html = await app.renderAsync('shoutbox/panel', data); 77 | return widget; 78 | }; 79 | 80 | Shoutbox.settings.addUserSettings = async function (settings) { 81 | const html = await app.renderAsync('shoutbox/user/settings', { settings: settings.settings }); 82 | settings.customSettings.push({ 83 | title: Config.plugin.name, 84 | content: html, 85 | }); 86 | 87 | return settings; 88 | }; 89 | 90 | Shoutbox.settings.addUserFieldWhitelist = function (data, callback) { 91 | data.whitelist.push('shoutbox:toggles:sound'); 92 | data.whitelist.push('shoutbox:toggles:notification'); 93 | data.whitelist.push('shoutbox:toggles:hide'); 94 | 95 | data.whitelist.push('shoutbox:muted'); 96 | 97 | callback(null, data); 98 | }; 99 | 100 | Shoutbox.settings.filterUserGetSettings = async function (data) { 101 | return await Config.user.get(data); 102 | }; 103 | 104 | Shoutbox.settings.filterUserSaveSettings = async function (hookData) { 105 | return await Config.user.save(hookData); 106 | }; 107 | -------------------------------------------------------------------------------- /lib/sockets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Config = require('./config'); 4 | const Shouts = require('./shouts'); 5 | 6 | const NodeBB = require('./nodebb'); 7 | 8 | const Sockets = module.exports; 9 | 10 | Sockets.events = { 11 | get: getShouts, 12 | send: sendShout, 13 | edit: editShout, 14 | getPlain: getPlainShout, 15 | remove: removeShout, 16 | removeAll: removeAllShouts, 17 | startTyping: startTyping, 18 | stopTyping: stopTyping, 19 | getSettings: Config.user.sockets.getSettings, 20 | saveSetting: Config.user.sockets.saveSettings, 21 | }; 22 | 23 | async function getShouts(socket, data) { 24 | const shoutLimit = parseInt(Config.global.get('limits.shoutLimit'), 10); 25 | const guestsAllowed = Boolean(Config.global.get('toggles.guestsAllowed')); 26 | let start = (-shoutLimit); 27 | let end = -1; 28 | 29 | if (data && data.start) { 30 | const parsedStart = parseInt(data.start, 10); 31 | 32 | if (!isNaN(parsedStart)) { 33 | start = parsedStart; 34 | end = start + shoutLimit - 1; 35 | } 36 | } 37 | 38 | if (socket.uid <= 0 && !guestsAllowed) { 39 | return []; 40 | } 41 | 42 | return await Shouts.getShouts(start, end); 43 | } 44 | 45 | async function sendShout(socket, data) { 46 | if (!socket.uid || !data || !data.message || !data.message.length) { 47 | throw new Error('[[error:invalid-data]]'); 48 | } 49 | 50 | const msg = NodeBB.utils.stripHTMLTags(data.message, NodeBB.utils.stripTags); 51 | if (msg.length) { 52 | const shout = await Shouts.addShout(socket.uid, msg); 53 | emitEvent('event:shoutbox.receive', shout); 54 | return true; 55 | } 56 | } 57 | 58 | async function editShout(socket, data) { 59 | if (!socket.uid || !data || !data.sid || isNaN(parseInt(data.sid, 10)) || !data.edited || !data.edited.length) { 60 | throw new Error('[[error:invalid-data]]'); 61 | } 62 | 63 | const msg = NodeBB.utils.stripHTMLTags(data.edited, NodeBB.utils.stripTags); 64 | if (msg.length) { 65 | const result = await Shouts.editShout(data.sid, msg, socket.uid); 66 | emitEvent('event:shoutbox.edit', result); 67 | return true; 68 | } 69 | } 70 | 71 | async function getPlainShout(socket, data) { 72 | if (!socket.uid || !data || !data.sid || isNaN(parseInt(data.sid, 10))) { 73 | throw new Error('[[error:invalid-data]]'); 74 | } 75 | 76 | return await Shouts.getPlainShouts([data.sid]); 77 | } 78 | 79 | async function removeShout(socket, data) { 80 | if (!socket.uid || !data || !data.sid || isNaN(parseInt(data.sid, 10))) { 81 | throw new Error('[[error:invalid-data]]'); 82 | } 83 | 84 | const result = await Shouts.removeShout(data.sid, socket.uid); 85 | if (result === true) { 86 | emitEvent('event:shoutbox.delete', { sid: data.sid }); 87 | } 88 | return result; 89 | } 90 | 91 | async function removeAllShouts(socket, data) { 92 | if (!socket.uid || !data || !data.which || !data.which.length) { 93 | throw new Error('[[error:invalid-data]]'); 94 | } 95 | if (data.which === 'all') { 96 | return await Shouts.removeAll(socket.uid); 97 | } else if (data.which === 'deleted') { 98 | return await Shouts.pruneDeleted(socket.uid); 99 | } 100 | throw new Error('invalid-data'); 101 | } 102 | 103 | function startTyping(socket, data, callback) { 104 | if (!socket.uid) return callback(new Error('invalid-data')); 105 | 106 | notifyStartTyping(socket.uid); 107 | 108 | if (socket.listeners('disconnect').length === 0) { 109 | socket.on('disconnect', () => { 110 | notifyStopTyping(socket.uid); 111 | }); 112 | } 113 | 114 | callback(); 115 | } 116 | 117 | function stopTyping(socket, data, callback) { 118 | if (!socket.uid) return callback(new Error('invalid-data')); 119 | 120 | notifyStopTyping(socket.uid); 121 | 122 | callback(); 123 | } 124 | 125 | function notifyStartTyping(uid) { 126 | emitEvent('event:shoutbox.startTyping', { uid: uid }); 127 | } 128 | 129 | function notifyStopTyping(uid) { 130 | emitEvent('event:shoutbox.stopTyping', { uid: uid }); 131 | } 132 | 133 | function emitEvent(event, data) { 134 | NodeBB.SocketIndex.server.sockets.emit(event, data); 135 | } 136 | -------------------------------------------------------------------------------- /public/js/lib/sockets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Messages = { 5 | getShouts: 'plugins.shoutbox.get', 6 | sendShout: 'plugins.shoutbox.send', 7 | removeShout: 'plugins.shoutbox.remove', 8 | editShout: 'plugins.shoutbox.edit', 9 | notifyStartTyping: 'plugins.shoutbox.startTyping', 10 | notifyStopTyping: 'plugins.shoutbox.stopTyping', 11 | getOriginalShout: 'plugins.shoutbox.getPlain', 12 | saveSettings: 'plugins.shoutbox.saveSetting', 13 | getSettings: 'plugins.shoutbox.getSettings', 14 | getUsers: 'user.loadMore', 15 | getUserStatus: 'user.checkStatus', 16 | }; 17 | 18 | var Events = { 19 | onUserStatusChange: 'event:user_status_change', 20 | onReceive: 'event:shoutbox.receive', 21 | onDelete: 'event:shoutbox.delete', 22 | onEdit: 'event:shoutbox.edit', 23 | onStartTyping: 'event:shoutbox.startTyping', 24 | onStopTyping: 'event:shoutbox.stopTyping', 25 | }; 26 | 27 | var Handlers = { 28 | defaultSocketHandler: function (message) { 29 | var self = this; 30 | this.message = message; 31 | 32 | return function (data, callback) { 33 | if (typeof data === 'function') { 34 | callback = data; 35 | data = null; 36 | } 37 | 38 | socket.emit(self.message, data, callback); 39 | }; 40 | }, 41 | }; 42 | 43 | var Sockets = function (sbInstance) { 44 | this.sb = sbInstance; 45 | 46 | this.messages = Messages; 47 | this.events = Events; 48 | // TODO: move this into its own file? 49 | this.handlers = { 50 | onReceive: function (data) { 51 | sbInstance.addShouts(data); 52 | 53 | if (parseInt(data[0].fromuid, 10) !== app.user.uid) { 54 | sbInstance.utils.notify(data[0]); 55 | } 56 | }, 57 | onDelete: function (data) { 58 | var shout = $('[data-sid="' + data.sid + '"]'); 59 | var uid = shout.data('uid'); 60 | 61 | var prevUser = shout.prev('[data-uid].shoutbox-user'); 62 | var prevUserUid = parseInt(prevUser.data('uid'), 10); 63 | 64 | var nextShout = shout.next('[data-uid].shoutbox-shout'); 65 | var nextShoutUid = parseInt(nextShout.data('uid'), 10); 66 | 67 | var prevUserIsSelf = prevUser.length > 0 && prevUserUid === parseInt(uid, 10); 68 | var nextShoutIsSelf = nextShout.length > 0 && nextShoutUid === parseInt(uid, 10); 69 | 70 | if (shout.length > 0) { 71 | shout.remove(); 72 | } 73 | 74 | if (prevUserIsSelf && !nextShoutIsSelf) { 75 | prevUser.prev('.shoutbox-avatar').remove(); 76 | prevUser.remove(); 77 | 78 | var lastShout = sbInstance.dom.shoutsContainer.find('[data-sid]:last'); 79 | if (lastShout.length > 0) { 80 | sbInstance.vars.lastUid = parseInt(lastShout.data('uid'), 10); 81 | sbInstance.vars.lastSid = parseInt(lastShout.data('sid'), 10); 82 | } else { 83 | sbInstance.vars.lastUid = -1; 84 | sbInstance.vars.lastSid = -1; 85 | } 86 | } 87 | 88 | if (parseInt(data.sid, 10) === parseInt(sbInstance.vars.editing, 10)) { 89 | sbInstance.actions.edit.finish(); 90 | } 91 | }, 92 | onEdit: function (data) { 93 | $('[data-sid="' + data[0].sid + '"] .shoutbox-shout-text') 94 | .html(data[0].content).addClass('shoutbox-shout-edited'); 95 | }, 96 | onUserStatusChange: function (data) { 97 | sbInstance.updateUserStatus(data.uid, data.status); 98 | }, 99 | onStartTyping: function (data) { 100 | $('[data-uid="' + data.uid + '"].shoutbox-avatar').addClass('isTyping'); 101 | }, 102 | onStopTyping: function (data) { 103 | $('[data-uid="' + data.uid + '"].shoutbox-avatar').removeClass('isTyping'); 104 | }, 105 | }; 106 | 107 | for (var e in this.events) { 108 | if (this.events.hasOwnProperty(e)) { 109 | this.registerEvent(this.events[e], this.handlers[e]); 110 | } 111 | } 112 | 113 | for (var m in this.messages) { 114 | if (this.messages.hasOwnProperty(m)) { 115 | this.registerMessage(m, this.messages[m]); 116 | } 117 | } 118 | }; 119 | 120 | Sockets.prototype.registerMessage = function (handle, message) { 121 | if (!this.hasOwnProperty(handle)) { 122 | this[handle] = new Handlers.defaultSocketHandler(message); 123 | } 124 | }; 125 | 126 | Sockets.prototype.registerEvent = function (event, handler) { 127 | socket.on(event, handler); 128 | }; 129 | 130 | Shoutbox.sockets = { 131 | init: function (instance) { 132 | return new Sockets(instance); 133 | }, 134 | }; 135 | }(window.Shoutbox)); 136 | -------------------------------------------------------------------------------- /lib/shouts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const NodeBB = require('./nodebb'); 4 | 5 | const Shouts = module.exports; 6 | 7 | Shouts.addShout = async function (fromuid, content) { 8 | const sid = await NodeBB.db.incrObjectField('global', 'nextSid'); 9 | const shout = { 10 | sid: sid, 11 | content: content, 12 | timestamp: Date.now(), 13 | fromuid: fromuid, 14 | deleted: '0', 15 | }; 16 | await Promise.all([ 17 | NodeBB.db.setObject(`shout:${sid}`, shout), 18 | NodeBB.db.listAppend('shouts', sid), 19 | ]); 20 | return await getShouts([sid]); 21 | }; 22 | 23 | Shouts.getPlainShouts = async function (sids) { 24 | const keys = sids.map(sid => `shout:${sid}`); 25 | const shouts = await NodeBB.db.getObjects(keys); 26 | return addSids(shouts, sids); 27 | }; 28 | 29 | function addSids(shouts, sids) { 30 | shouts.forEach((s, index) => { 31 | if (s && !s.hasOwnProperty('sid')) { 32 | s.sid = sids[index]; 33 | } 34 | }); 35 | return shouts; 36 | } 37 | 38 | Shouts.getShouts = async function (start, end) { 39 | const sids = await NodeBB.db.getListRange('shouts', start, end); 40 | if (!Array.isArray(sids) || !sids.length) { 41 | return []; 42 | } 43 | 44 | const shoutData = await getShouts(sids); 45 | shoutData.forEach((s, index) => { 46 | if (s) { 47 | s.index = start + index; 48 | } 49 | }); 50 | return shoutData; 51 | }; 52 | 53 | async function getShouts(sids) { 54 | const keys = sids.map(sid => `shout:${sid}`); 55 | const shouts = await NodeBB.db.getObjects(keys); 56 | addSids(shouts, sids); 57 | 58 | // Get a list of unique uids of the users of non-deleted shouts 59 | const uniqUids = shouts.map(s => (parseInt(s.deleted, 10) !== 1 ? parseInt(s.fromuid, 10) : null)) 60 | .filter((u, index, self) => (u === null ? false : self.indexOf(u) === index)); 61 | 62 | 63 | const usersData = await NodeBB.User.getUsersFields(uniqUids, ['uid', 'username', 'userslug', 'picture', 'status']); 64 | const uidToUserData = {}; 65 | uniqUids.forEach((uid, index) => { 66 | uidToUserData[uid] = usersData[index]; 67 | }); 68 | return await Promise.all(shouts.map(async (shout) => { 69 | if (parseInt(shout.deleted, 10) === 1) { 70 | return null; 71 | } 72 | 73 | const userData = uidToUserData[parseInt(shout.fromuid, 10)]; 74 | 75 | const s = await Shouts.parse(shout.content, userData); 76 | shout.user = s.user; 77 | shout.content = s.content; 78 | return shout; 79 | })); 80 | } 81 | 82 | Shouts.parse = async function (raw, userData) { 83 | const [parsed, isAdmin, isMod, status] = await Promise.all([ 84 | NodeBB.Plugins.hooks.fire('filter:parse.raw', raw), 85 | NodeBB.User.isAdministrator(userData.uid), 86 | NodeBB.User.isGlobalModerator(userData.uid), 87 | NodeBB.User.isOnline(userData.uid), 88 | ]); 89 | 90 | userData.status = status ? (userData.status || 'online') : 'offline'; 91 | userData.isAdmin = isAdmin; 92 | userData.isMod = isMod; 93 | return { 94 | user: userData, 95 | content: parsed, 96 | }; 97 | }; 98 | 99 | Shouts.removeShout = async function (sid, uid) { 100 | const [isAdmin, isMod, fromUid] = await Promise.all([ 101 | NodeBB.User.isAdministrator(uid), 102 | NodeBB.User.isGlobalModerator(uid), 103 | NodeBB.db.getObjectField(`shout:${sid}`, 'fromuid'), 104 | ]); 105 | 106 | if (isAdmin || isMod || parseInt(fromUid, 10) === parseInt(uid, 10)) { 107 | await NodeBB.db.setObjectField(`shout:${sid}`, 'deleted', '1'); 108 | return true; 109 | } 110 | throw new Error('[[error:no-privileges]]'); 111 | }; 112 | 113 | Shouts.editShout = async function (sid, msg, uid) { 114 | const [isAdmin, isMod, fromUid] = await Promise.all([ 115 | NodeBB.User.isAdministrator(uid), 116 | NodeBB.User.isGlobalModerator(uid), 117 | NodeBB.db.getObjectField(`shout:${sid}`, 'fromuid'), 118 | ]); 119 | 120 | if (isAdmin || isMod || parseInt(fromUid, 10) === parseInt(uid, 10)) { 121 | await NodeBB.db.setObjectField(`shout:${sid}`, 'content', msg); 122 | return await getShouts([sid]); 123 | } 124 | throw new Error('[[error:no-privileges]]'); 125 | }; 126 | 127 | Shouts.pruneDeleted = async function (uid) { 128 | const isAdmin = await NodeBB.User.isAdministrator(uid); 129 | if (!isAdmin) { 130 | throw new Error('[[error:no-privileges]]'); 131 | } 132 | 133 | const sids = await NodeBB.db.getListRange('shouts', 0, -1); 134 | if (!sids || !sids.length) { 135 | return; 136 | } 137 | 138 | const keys = sids.map(sid => `shout:${sid}`); 139 | const items = await NodeBB.db.getObjectsFields(keys, ['deleted']); 140 | const toDelete = []; 141 | items.forEach((shout, index) => { 142 | shout.sid = sids[index]; 143 | if (parseInt(shout.deleted, 10) === 1) { 144 | toDelete.push(shout); 145 | } 146 | }); 147 | 148 | await Promise.all([ 149 | NodeBB.db.listRemoveAll('shouts', toDelete.map(s => s.sid)), 150 | NodeBB.db.deleteAll(toDelete.map(s => `shout:${s.sid}`)), 151 | ]); 152 | return true; 153 | }; 154 | 155 | Shouts.removeAll = async function (uid) { 156 | const isAdmin = await NodeBB.User.isAdministrator(uid); 157 | if (!isAdmin) { 158 | throw new Error('not-authorized'); 159 | } 160 | 161 | const sids = await NodeBB.db.getListRange('shouts', 0, -1); 162 | if (!sids || !sids.length) { 163 | return; 164 | } 165 | 166 | const keys = sids.map(sid => `shout:${sid}`); 167 | 168 | await Promise.all([ 169 | NodeBB.db.deleteAll(keys), 170 | NodeBB.db.delete('shouts'), 171 | NodeBB.db.setObjectField('global', 'nextSid', 0), 172 | ]); 173 | return true; 174 | }; 175 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | 3 | 'use strict'; 4 | 5 | const _ = require('lodash'); 6 | 7 | const packageInfo = require('../package.json'); 8 | const pluginInfo = require('../plugin.json'); 9 | 10 | const pluginId = pluginInfo.id.replace('nodebb-plugin-', ''); 11 | const NodeBB = require('./nodebb'); 12 | 13 | const Config = module.exports; 14 | 15 | const features = [ 16 | { 17 | name: 'Gists', 18 | id: 'gist', 19 | description: 'Easily create Gists', 20 | icon: 'fa-github-alt', 21 | button: 'Create Gist', 22 | enabled: true, 23 | }, 24 | { 25 | name: 'Bugs', 26 | id: 'bug', 27 | description: 'Report bugs quickly', 28 | icon: 'fa-bug', 29 | button: 'Report Bug', 30 | enabled: false, 31 | }, 32 | ]; 33 | 34 | const adminDefaults = { 35 | toggles: { 36 | guestsAllowed: false, 37 | headerLink: false, 38 | features: (function () { 39 | const defaults = {}; 40 | features.forEach((el) => { 41 | defaults[el.id] = el.enabled; 42 | }); 43 | 44 | return defaults; 45 | }()), 46 | }, 47 | limits: { 48 | shoutLimit: '25', 49 | }, 50 | version: '', 51 | }; 52 | 53 | const userDefaults = { 54 | 'toggles:sound': 0, 55 | 'toggles:notification': 1, 56 | 'toggles:hide': 0, 57 | muted: '', 58 | }; 59 | 60 | Config.plugin = { 61 | name: pluginInfo.name, 62 | id: pluginId, 63 | version: packageInfo.version, 64 | description: packageInfo.description, 65 | icon: 'fa-bullhorn', 66 | }; 67 | 68 | Config.init = function (callback) { 69 | Config.global = new NodeBB.Settings(Config.plugin.id, Config.plugin.version, adminDefaults, () => { 70 | callback(); 71 | }); 72 | }; 73 | 74 | Config.global = {}; 75 | 76 | Config.adminSockets = { 77 | sync: function () { 78 | Config.global.sync(); 79 | }, 80 | getDefaults: function (socket, data, callback) { 81 | callback(null, Config.global.createDefaultWrapper()); 82 | }, 83 | getRandomUser: async function (socket, data) { 84 | let done = false; 85 | let start = -49; 86 | let stop = start + 48; 87 | const oneMinuteMs = 60 * 1000; 88 | const cutoffMinutes = parseInt(data.cutoffMinutes, 10) || 30; 89 | const cutoff = Date.now() - (cutoffMinutes * oneMinuteMs); 90 | const foundShouts = []; 91 | do { 92 | const sids = await NodeBB.db.getListRange('shouts', start, stop); 93 | if (!sids.length) { 94 | done = true; 95 | } else { 96 | const allShouts = await NodeBB.db.getObjects(sids.map(sid => `shout:${sid}`)); 97 | const shouts = allShouts.filter(s => s && s.timestamp >= cutoff); 98 | const allBeforeCutoff = allShouts.every(s => s && s.timestamp < cutoff); 99 | if (allBeforeCutoff) { 100 | done = true; 101 | } 102 | foundShouts.push(...shouts); 103 | } 104 | start -= 50; 105 | stop = start + 49; 106 | } while (!done); 107 | if (!foundShouts.length) { 108 | throw new Error(`No users found in the past ${cutoffMinutes} minutes`); 109 | } 110 | const randomUid = _.sample(_.uniq(foundShouts.map(s => s.fromuid))); 111 | const now = Date.now(); 112 | await NodeBB.db.sortedSetAdd('shoutbox:random:users', now, `${randomUid}:${now}`); 113 | return await NodeBB.User.getUserFields(randomUid, ['uid', 'username', 'userslug', 'picture']); 114 | }, 115 | getPastRandomUsers: async function () { 116 | const randomUsers = await NodeBB.db.getSortedSetRevRangeWithScores( 117 | 'shoutbox:random:users', 0, -1 118 | ); 119 | let userData = await NodeBB.User.getUsersFields( 120 | randomUsers.map(u => u.value.split(':')[0]), ['uid', 'username', 'userslug', 'picture'] 121 | ); 122 | userData = userData.map((u, index) => ({ 123 | ...u, 124 | timePicked: randomUsers[index].score, 125 | })); 126 | 127 | return userData.filter(u => u && u.userslug); 128 | }, 129 | }; 130 | 131 | Config.user = {}; 132 | Config.user.sockets = {}; 133 | 134 | Config.user.get = async function (data) { 135 | if (!data) { 136 | throw new Error('[[error:invalid-data]]'); 137 | } 138 | if (!Config.global.get) { 139 | return data; 140 | } 141 | const prefix = `${Config.plugin.id}:`; 142 | if (!data.settings) { 143 | data.settings = {}; 144 | } 145 | 146 | Object.keys(userDefaults).forEach((key) => { 147 | const fullKey = prefix + key; 148 | data.settings[fullKey] = data.settings.hasOwnProperty(fullKey) ? data.settings[fullKey] : userDefaults[key]; 149 | }); 150 | 151 | data.settings['shoutbox:shoutLimit'] = parseInt(Config.global.get('limits.shoutLimit'), 10); 152 | return data; 153 | }; 154 | 155 | // get user shoutbox settings 156 | Config.user.load = async function (uid) { 157 | const settings = await NodeBB.User.getSettings(uid); 158 | const sbSettings = {}; 159 | const prefix = `${Config.plugin.id}:`; 160 | Object.keys(userDefaults).forEach((key) => { 161 | const fullKey = prefix + key; 162 | sbSettings[fullKey] = settings.hasOwnProperty(fullKey) ? settings[fullKey] : userDefaults[key]; 163 | }); 164 | sbSettings['shoutbox:shoutLimit'] = parseInt(Config.global.get('limits.shoutLimit'), 10); 165 | return sbSettings; 166 | }; 167 | 168 | Config.user.save = async function (hookData) { 169 | if (!hookData || !hookData.uid || !hookData.settings) { 170 | throw new Error('[[error:invalid-data]]'); 171 | } 172 | 173 | Object.keys(userDefaults).forEach((key) => { 174 | const fullKey = `${Config.plugin.id}:${key}`; 175 | if (hookData.data.hasOwnProperty(fullKey)) { 176 | hookData.settings[fullKey] = hookData.data[fullKey]; 177 | } 178 | }); 179 | return hookData; 180 | }; 181 | 182 | Config.user.sockets.getSettings = async function (socket) { 183 | if (!socket.uid) { 184 | throw new Error('not-logged-in'); 185 | } 186 | return { 187 | settings: await Config.user.load(socket.uid), 188 | }; 189 | }; 190 | 191 | Config.user.sockets.saveSettings = async function (socket, data) { 192 | if (!socket.uid || !data || !data.settings) { 193 | throw new Error('[[error:invalid-data]]'); 194 | } 195 | 196 | data.uid = socket.uid; 197 | await NodeBB.api.users.updateSettings(socket, data); 198 | }; 199 | 200 | Config.getTemplateData = function () { 201 | const featureConfig = Config.global.get('toggles.features'); 202 | const data = { 203 | title: '[[shoutbox:shoutbox]]', 204 | }; 205 | 206 | data.features = features.slice(0).map((item) => { 207 | item.enabled = featureConfig[item.id]; 208 | return item; 209 | }); 210 | 211 | return data; 212 | }; 213 | -------------------------------------------------------------------------------- /public/js/lib/actions/default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var DefaultActions = { 5 | typing: function (sbInstance) { 6 | this.register = function () { 7 | sbInstance.dom.container.find('.shoutbox-message-input') 8 | .off('keyup.typing').on('keyup.typing', utils.throttle(handle, 250)); 9 | }; 10 | 11 | function handle() { 12 | if ($(this).val()) { 13 | sbInstance.sockets.notifyStartTyping(); 14 | } else { 15 | sbInstance.sockets.notifyStopTyping(); 16 | } 17 | } 18 | }, 19 | overlay: function (sbInstance) { 20 | this.register = function () { 21 | sbInstance.dom.overlay 22 | .off('click.overlay', '.shoutbox-content-overlay-close') 23 | .on('click.overlay', '.shoutbox-content-overlay-close', handle); 24 | }; 25 | 26 | function handle() { 27 | sbInstance.dom.overlay.removeClass('active'); 28 | return false; 29 | } 30 | }, 31 | scrolling: function (sbInstance) { 32 | this.register = function () { 33 | var t; 34 | var shoutContent = sbInstance.dom.shoutsContainer; 35 | 36 | shoutContent.scroll(function () { 37 | clearTimeout(t); 38 | t = setTimeout(function () { 39 | handle(); 40 | }, 200); 41 | }); 42 | 43 | sbInstance.dom.overlay 44 | .off('click.overlay', '#shoutbox-content-overlay-scrolldown') 45 | .on('click.overlay', '#shoutbox-content-overlay-scrolldown', function () { 46 | shoutContent.scrollTop( 47 | shoutContent[0].scrollHeight - shoutContent.height() 48 | ); 49 | return false; 50 | }); 51 | }; 52 | 53 | function handle() { 54 | var shoutContent = sbInstance.dom.shoutsContainer; 55 | var shoutOverlay = sbInstance.dom.overlay; 56 | var scrollHeight = Shoutbox.utils.getScrollHeight(shoutContent); 57 | 58 | var overlayActive = shoutOverlay.hasClass('active'); 59 | var pastScrollBreakpoint = scrollHeight >= sbInstance.vars.scrollBreakpoint; 60 | var scrollMessageShowing = sbInstance.vars.scrollMessageShowing; 61 | 62 | if (!overlayActive && pastScrollBreakpoint && !scrollMessageShowing) { 63 | sbInstance.utils.showOverlay(sbInstance.vars.messages.scrolled); 64 | sbInstance.vars.scrollMessageShowing = true; 65 | } else if (overlayActive && !pastScrollBreakpoint && scrollMessageShowing) { 66 | shoutOverlay.removeClass('active'); 67 | sbInstance.vars.scrollMessageShowing = false; 68 | } 69 | } 70 | }, 71 | send: function (sbInstance) { 72 | this.register = function () { 73 | sbInstance.dom.textInput.off('keypress.send').on('keypress.send', function (e) { 74 | if (e.which === 13 && !e.shiftKey) { 75 | handle(); 76 | } 77 | }); 78 | 79 | sbInstance.dom.sendButton.off('click.send').on('click.send', function () { 80 | handle(); 81 | return false; 82 | }); 83 | }; 84 | 85 | function handle() { 86 | var msg = utils.stripHTMLTags(sbInstance.dom.textInput.val()); 87 | 88 | if (msg.length) { 89 | sbInstance.commands.parse(msg, function (msg) { 90 | sbInstance.sockets.sendShout({ message: msg }); 91 | }); 92 | } 93 | 94 | sbInstance.dom.textInput.val(''); 95 | sbInstance.sockets.notifyStopTyping(); 96 | } 97 | }, 98 | delete: function (sbInstance) { 99 | this.register = function () { 100 | sbInstance.dom.container 101 | .off('click.delete', '.shoutbox-shout-option-close') 102 | .on('click.delete', '.shoutbox-shout-option-close', handle); 103 | }; 104 | 105 | function handle() { 106 | var sid = $(this).parents('[data-sid]').data('sid'); 107 | 108 | sbInstance.sockets.removeShout({ sid: sid }, function (err, result) { 109 | if (result === true) { 110 | Shoutbox.alert('success', 'Successfully deleted shout!'); 111 | } else if (err) { 112 | Shoutbox.alert('error', 'Error deleting shout: ' + err.message); 113 | } 114 | }); 115 | 116 | return false; 117 | } 118 | }, 119 | edit: function (sbInstance) { 120 | var self = this; 121 | 122 | this.register = function () { 123 | function eventsOff() { 124 | sbInstance.dom.shoutsContainer 125 | .off('click.edit', '.shoutbox-shout-option-edit') 126 | .off('dblclick.edit', '[data-sid]'); 127 | 128 | sbInstance.dom.textInput.off('keyup.edit'); 129 | } 130 | 131 | function eventsOn() { 132 | sbInstance.dom.shoutsContainer 133 | .on('click.edit', '.shoutbox-shout-option-edit', function () { 134 | handle( 135 | $(this).parents('[data-sid]').data('sid') 136 | ); 137 | }).on('dblclick.edit', '[data-sid]', function () { 138 | handle( 139 | $(this).data('sid') 140 | ); 141 | }); 142 | 143 | sbInstance.dom.textInput.on('keyup.edit', function (e) { 144 | if (e.which === 38 && !$(this).val()) { 145 | handle( 146 | sbInstance.dom.shoutsContainer 147 | .find('[data-uid="' + app.user.uid + '"].shoutbox-shout:last') 148 | .data('sid') 149 | ); 150 | } 151 | }); 152 | } 153 | 154 | sbInstance.dom.textInput.off('textComplete:show').on('textComplete:show', function () { 155 | eventsOff(); 156 | }); 157 | 158 | sbInstance.dom.textInput.off('textComplete:hide').on('textComplete:hide', function () { 159 | eventsOn(); 160 | }); 161 | 162 | eventsOff(); 163 | eventsOn(); 164 | }; 165 | 166 | function handle(sid) { 167 | var shout = sbInstance.dom.shoutsContainer.find('[data-sid="' + sid + '"]'); 168 | 169 | if (shout.data('uid') === app.user.uid || app.user.isAdmin || app.user.isGlobalMod) { 170 | sbInstance.vars.editing = sid; 171 | 172 | sbInstance.sockets.getOriginalShout({ sid: sid }, function (err, orig) { 173 | if (err) { 174 | return Shoutbox.alert('error', err); 175 | } 176 | orig = orig[0].content; 177 | 178 | sbInstance.dom.sendButton.off('click.send').on('click.send', function () { 179 | edit(orig); 180 | }).text('Edit'); 181 | 182 | sbInstance.dom.textInput.off('keyup.edit').off('keypress.send').on('keypress.send', function (e) { 183 | if (e.which === 13 && !e.shiftKey) { 184 | edit(orig); 185 | } 186 | }).on('keyup.edit', function (e) { 187 | if (e.currentTarget.value.length === 0) { 188 | self.finish(); 189 | } 190 | }) 191 | .val(orig) 192 | .focus() 193 | .putCursorAtEnd() 194 | .parents('.input-group') 195 | .addClass('has-warning'); 196 | }); 197 | } 198 | 199 | function edit(orig) { 200 | var msg = utils.stripHTMLTags(sbInstance.dom.textInput.val()); 201 | 202 | if (msg === orig || msg === '' || msg === null) { 203 | return self.finish(); 204 | } 205 | 206 | sbInstance.sockets.editShout({ sid: sid, edited: msg }, function (err, result) { 207 | if (result === true) { 208 | Shoutbox.alert('success', 'Successfully edited shout!'); 209 | } else if (err) { 210 | Shoutbox.alert('error', 'Error editing shout: ' + err.message, 3000); 211 | } 212 | self.finish(); 213 | }); 214 | } 215 | 216 | return false; 217 | } 218 | 219 | this.finish = function () { 220 | sbInstance.dom.textInput.val('').parents('.input-group').removeClass('has-warning'); 221 | sbInstance.dom.sendButton.text('Send').removeClass('hide'); 222 | 223 | sbInstance.actions.send.register(); 224 | sbInstance.actions.edit.register(); 225 | 226 | sbInstance.vars.editing = 0; 227 | sbInstance.sockets.notifyStopTyping(); 228 | }; 229 | }, 230 | }; 231 | 232 | $(window).on('action:app.load', function () { 233 | for (var a in DefaultActions) { 234 | if (DefaultActions.hasOwnProperty(a)) { 235 | Shoutbox.actions.register(a, DefaultActions[a]); 236 | } 237 | } 238 | }); 239 | }(window.Shoutbox)); 240 | -------------------------------------------------------------------------------- /public/js/lib/base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (Shoutbox) { 4 | var Instance = function (container, options) { 5 | var self = this; 6 | 7 | this.options = options || {}; 8 | 9 | setupDom.apply(this, [container]); 10 | setupVars.apply(this); 11 | setupDependencies.apply(this); 12 | 13 | this.settings.load(); 14 | this.createAutoComplete(); 15 | 16 | const shoutsPerPage = config.shoutbox.settings['shoutbox:shoutLimit']; 17 | 18 | if (this.dom.shoutsContainer) { 19 | const container = $(this.dom.shoutsContainer); 20 | $(this.dom.shoutsContainer).on('scroll', utils.debounce(function () { 21 | const st = container.scrollTop(); 22 | if (st < 150) { 23 | const first = container.find('.shoutbox-shout[data-index]'); 24 | if (first.length) { 25 | const index = parseInt(first.attr('data-index'), 10) - shoutsPerPage; 26 | getShouts(index, 'before'); 27 | } 28 | } 29 | }, 500)); 30 | } 31 | 32 | getShouts(-shoutsPerPage); 33 | 34 | window.sb = this; 35 | 36 | function getShouts(start, direction) { 37 | self.sockets.getShouts({ 38 | start: start, 39 | }, function (err, shouts) { 40 | if (err) { 41 | return Shoutbox.alert('error', err); 42 | } 43 | shouts = shouts.filter(function (el) { 44 | return el !== null; 45 | }); 46 | 47 | if (shouts.length === 0 && direction !== 'before') { 48 | self.utils.showOverlay(self.vars.messages.empty); 49 | } else { 50 | self.addShouts(shouts, direction); 51 | } 52 | }); 53 | } 54 | 55 | $('[component="shoutbox/random-user"]').on('click', function () { 56 | const cutoff = $(this).attr('data-cutoff'); 57 | socket.emit('admin.plugins.shoutbox.getRandomUser', { cutoffMinutes: cutoff }, async function (err, user) { 58 | if (err) { 59 | return Shoutbox.alert('error', err); 60 | } 61 | const bootbox = await app.require('bootbox'); 62 | bootbox.alert(`Picked user ${user.username}`); 63 | }); 64 | }); 65 | 66 | $('[component="shoutbox/random-user-log"]').on('click', function () { 67 | socket.emit('admin.plugins.shoutbox.getPastRandomUsers', {}, async function (err, users) { 68 | if (err) { 69 | return Shoutbox.alert('error', err); 70 | } 71 | const bootbox = await app.require('bootbox'); 72 | const html = users.map(u => ` 73 |
  • 74 |
    75 | ${u.username} 76 | ${new Date(u.timePicked).toLocaleString()} 77 |
    78 |
  • 79 | `).join(''); 80 | const dialog = bootbox.dialog({ 81 | title: 'Past Winners', 82 | message: `
      ${html}
    `, 83 | onEscape: true, 84 | }); 85 | dialog.on('click', 'a', function () { 86 | dialog.modal('hide'); 87 | }); 88 | }); 89 | }); 90 | }; 91 | 92 | function setupDependencies() { 93 | this.utils = Shoutbox.utils.init(this); 94 | this.sockets = Shoutbox.sockets.init(this); 95 | this.settings = Shoutbox.settings.init(this); 96 | this.actions = Shoutbox.actions.init(this); 97 | this.commands = Shoutbox.commands.init(this); 98 | } 99 | 100 | Instance.prototype.addShouts = function (shouts, direction = 'after') { 101 | if (!shouts.length) { 102 | return; 103 | } 104 | var self = this; 105 | var lastUid = this.vars.lastUid; 106 | var lastSid = this.vars.lastSid; 107 | var uid; 108 | var sid; 109 | 110 | 111 | for (let i = shouts.length - 1; i > 0; i -= 1) { 112 | var s = shouts[i]; 113 | var prev = shouts[i - 1]; 114 | if (parseInt(s.fromuid, 10) === parseInt(prev.fromuid, 10)) { 115 | prev.timestamp = s.timestamp; 116 | } 117 | } 118 | 119 | shouts = shouts.map(function (el) { 120 | uid = parseInt(el.fromuid, 10); 121 | sid = parseInt(el.sid, 10); 122 | 123 | // Own shout 124 | el.isOwn = parseInt(app.user.uid, 10) === uid; 125 | 126 | // Permissions 127 | el.user.isMod = el.isOwn || app.user.isAdmin || app.user.isGlobalMod; 128 | 129 | // Add shout chain information to shout 130 | el.isChained = lastUid === uid; 131 | 132 | // Add timeString to shout 133 | // jQuery.timeago only works properly with ISO timestamps 134 | el.timeString = (new Date(parseInt(el.timestamp, 10)).toISOString()); 135 | 136 | // Extra classes 137 | el.typeClasses = el.isOwn ? 'shoutbox-shout-self ' : ''; 138 | el.typeClasses += el.user.isAdmin ? 'shoutbox-shout-admin ' : ''; 139 | 140 | lastUid = uid; 141 | lastSid = sid; 142 | 143 | return el; 144 | }); 145 | 146 | this.vars.lastUid = lastUid; 147 | this.vars.lastSid = lastSid; 148 | 149 | app.parseAndTranslate('shoutbox/shouts', { 150 | shouts: shouts, 151 | }, function (html) { 152 | if (direction === 'before') { 153 | self.dom.shoutsContainer.prepend(html); 154 | } else { 155 | self.dom.shoutsContainer.append(html); 156 | if (shouts.length === 1 && shouts[0].isChained) { 157 | const timeagoEl = self.dom.shoutsContainer 158 | .children('.shoutbox-user') 159 | .last() 160 | .find('.shoutbox-shout-timestamp .timeago'); 161 | timeagoEl.attr('title', shouts[0].timeString); 162 | timeagoEl.timeago('update', shouts[0].timeString); 163 | } 164 | self.utils.scrollToBottom(shouts.length > 1); 165 | } 166 | html.find('.timeago').timeago(); 167 | }); 168 | }; 169 | 170 | Instance.prototype.updateUserStatus = function (uid, status) { 171 | var self = this; 172 | var setStatus = function (uid, status) { 173 | self.dom.shoutsContainer.find('[data-uid="' + uid + '"].shoutbox-avatar').removeClass().addClass('shoutbox-avatar ' + status); 174 | }; 175 | 176 | var getStatus = function (uid) { 177 | self.sockets.getUserStatus(uid, function (err, data) { 178 | if (err) { 179 | return Shoutbox.alert('error', err); 180 | } 181 | setStatus(uid, data.status); 182 | }); 183 | }; 184 | 185 | if (!uid) { 186 | uid = []; 187 | 188 | self.dom.shoutsContainer.find('[data-uid].shoutbox-avatar').each(function (index, el) { 189 | uid.push($(el).data('uid')); 190 | }); 191 | 192 | uid = uid.filter(function (el, index) { 193 | return uid.indexOf(el) === index; 194 | }); 195 | } 196 | 197 | if (!status) { 198 | if (typeof uid === 'number') { 199 | getStatus(uid); 200 | } else if (Array.isArray(uid)) { 201 | for (let i = 0, l = uid.length; i < l; i++) { 202 | getStatus(uid[i]); 203 | } 204 | } 205 | } else if (typeof uid === 'number') { 206 | setStatus(uid, status); 207 | } else if (Array.isArray(uid)) { 208 | for (let i = 0, l = uid.length; i < l; i++) { 209 | setStatus(uid[i], status); 210 | } 211 | } 212 | }; 213 | 214 | Instance.prototype.createAutoComplete = function () { 215 | if (!this.dom.textInput) { 216 | return; 217 | } 218 | const element = $(this.dom.textInput); 219 | require(['composer/autocomplete'], function (autocomplete) { 220 | const data = { 221 | element: element, 222 | strategies: [], 223 | options: { 224 | style: { 225 | 'z-index': 20000, 226 | flex: 0, 227 | top: 'inherit', 228 | }, 229 | placement: 'top', 230 | }, 231 | }; 232 | 233 | $(window).trigger('chat:autocomplete:init', data); 234 | if (data.strategies.length) { 235 | const autoComplete = autocomplete.setup(data); 236 | $(window).one('action:ajaxify.start', () => { 237 | autoComplete.destroy(); 238 | }); 239 | } 240 | }); 241 | }; 242 | 243 | function setupDom(container) { 244 | this.dom = {}; 245 | this.dom.container = container; 246 | this.dom.overlay = container.find('.shoutbox-content-overlay'); 247 | this.dom.overlayMessage = this.dom.overlay.find('.shoutbox-content-overlay-message'); 248 | this.dom.shoutsContainer = container.find('.shoutbox-content'); 249 | this.dom.settingsMenu = container.find('.shoutbox-settings-menu'); 250 | this.dom.textInput = container.find('.shoutbox-message-input'); 251 | this.dom.sendButton = container.find('.shoutbox-message-send-btn'); 252 | } 253 | 254 | function setupVars() { 255 | this.vars = { 256 | lastUid: -1, 257 | lastSid: -1, 258 | scrollBreakpoint: 50, 259 | messages: { 260 | alert: '[ %u ] - new shout!', 261 | empty: 'The shoutbox is empty, start shouting!', 262 | scrolled: 'Scroll down', 263 | }, 264 | userCheck: 0, 265 | }; 266 | } 267 | 268 | Shoutbox.base = { 269 | init: function (container, options) { 270 | return new Instance(container, options); 271 | }, 272 | }; 273 | }(window.Shoutbox)); 274 | 275 | --------------------------------------------------------------------------------