5 |
6 |
7 |
8 |
51 |
52 |
53 |
54 |
55 |
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 |
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: `
`,
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: '',
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 |
--------------------------------------------------------------------------------