From 3e712fe320999994a747b3f41928067f2b0d67ce Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 2 Mar 2017 12:03:32 +0100 Subject: [PATCH] Implemented input/output processing for comments See #2222 --- com.woltlab.wcf/userGroupOption.xml | 4 + wcfsetup/install/files/js/WCF.Comment.js | 47 +-- .../js/WoltLabSuite/Core/Ui/Comment/Add.js | 318 ++++++++++++++++++ .../files/lib/data/comment/Comment.class.php | 21 +- .../lib/data/comment/CommentAction.class.php | 56 ++- 5 files changed, 410 insertions(+), 36 deletions(-) create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Comment/Add.js diff --git a/com.woltlab.wcf/userGroupOption.xml b/com.woltlab.wcf/userGroupOption.xml index 0bf90ccde5..56358406bc 100644 --- a/com.woltlab.wcf/userGroupOption.xml +++ b/com.woltlab.wcf/userGroupOption.xml @@ -568,6 +568,10 @@ pdf 0 0 + diff --git a/wcfsetup/install/files/js/WCF.Comment.js b/wcfsetup/install/files/js/WCF.Comment.js index 5ca29ca3f4..fa5b4c472e 100644 --- a/wcfsetup/install/files/js/WCF.Comment.js +++ b/wcfsetup/install/files/js/WCF.Comment.js @@ -131,7 +131,16 @@ WCF.Comment.Handler = Class.extend({ // add new comment if (this._container.data('canAdd')) { - this._initAddComment(); + if (elBySel('.commentListAddComment .wysiwygTextarea', this._container[0]) === null) { + console.debug("Missing WYSIWYG implementation, adding comments is not available."); + } + else { + require(['WoltLabSuite/Core/Ui/Comment/Add'], (function (UICommentAdd) { + new UICommentAdd(elBySel('.jsCommentAdd', this._container[0]), { + + }); + }).bind(this)); + } } WCF.DOMNodeInsertedHandler.execute(); @@ -328,14 +337,6 @@ WCF.Comment.Handler = Class.extend({ } }, - /** - * Initializes the UI components to add a comment. - */ - _initAddComment: function() { - var button = elBySel('.jsCommentAdd .formSubmit button[data-type="save"]', this._commentAdd[0]); - if (button) button.addEventListener(WCF_CLICK_EVENT, this._save.bind(this)); - }, - /** * Initializes the UI elements to add a response. * @@ -438,6 +439,10 @@ WCF.Comment.Handler = Class.extend({ * @param jQuery input */ _save: function(event, isResponse, input) { + if (!isResponse) { + throw new Error("Adding comments through `_save()` is no longer supported."); + } + var $input = (event === null) ? input : $(event.currentTarget).parent().children('textarea'); $input.next('small.innerError').remove(); var $value = $.trim($input.val()); @@ -447,16 +452,12 @@ WCF.Comment.Handler = Class.extend({ return; } - var $actionName = 'addComment'; var $data = { + commentID: $input.data('commentID'), message: $value, objectID: this._container.data('objectID'), objectTypeID: this._container.data('objectTypeID') }; - if (isResponse === true) { - $actionName = 'addResponse'; - $data.commentID = $input.data('commentID'); - } if (!WCF.User.userID) { this._commentData = $data; @@ -479,7 +480,7 @@ WCF.Comment.Handler = Class.extend({ new WCF.Action.Proxy({ autoSend: true, data: { - actionName: $actionName, + actionName: 'addResponse', className: 'wcf\\data\\comment\\CommentAction', parameters: { data: $data @@ -561,20 +562,6 @@ WCF.Comment.Handler = Class.extend({ */ _success: function(data, textStatus, jqXHR) { switch (data.actionName) { - case 'addComment': - if (data.returnValues.guestDialog) { - this._createGuestDialog(data.returnValues.guestDialog, data.returnValues.useCaptcha); - } - else { - this._commentAdd.find('textarea').val('').blur().trigger('updateHeight'); - $(data.returnValues.template).insertAfter(this._commentAdd).wcfFadeIn(); - - if (!WCF.User.userID) { - this._guestDialog.wcfDialog('close'); - } - } - break; - case 'addResponse': if (data.returnValues.guestDialog) { this._createGuestDialog(data.returnValues.guestDialog, data.returnValues.useCaptcha); @@ -782,7 +769,7 @@ WCF.Comment.Handler = Class.extend({ */ _submit: function(event) { var $requestData = { - actionName: this._commentData.commentID ? 'addResponse' : 'addComment', + actionName: 'addResponse', className: 'wcf\\data\\comment\\CommentAction' }; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Comment/Add.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Comment/Add.js new file mode 100644 index 0000000000..cf6816df23 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Comment/Add.js @@ -0,0 +1,318 @@ +/** + * Handles the comment add feature. + * + * @author Alexander Ebert + * @copyright 2001-2017 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Ui/Comment/Add + */ +define(['Ajax', 'Core', 'EventHandler', 'Language', 'Dom/ChangeListener', 'Dom/Util', 'Dom/Traverse', 'Ui/Dialog', 'Ui/Notification', 'WoltLabSuite/Core/Ui/Scroll', 'EventKey', 'User', 'WoltLabSuite/Core/Controller/Captcha'], + function(Ajax, Core, EventHandler, Language, DomChangeListener, DomUtil, DomTraverse, UiDialog, UiNotification, UiScroll, EventKey, User, ControllerCaptcha) { + "use strict"; + + /** + * @constructor + */ + function UiCommentAdd(container, options) { this.init(container, options); } + UiCommentAdd.prototype = { + /** + * Initializes a new quick reply field. + * + * @param {Element} container container element + * @param {Object} options configuration options + */ + init: function(container, options) { + this._options = Core.extend({ + ajax: { + className: '' + }, + successMessage: 'wcf.global.success.add' + }, options); + + this._container = container; + this._content = elBySel('.commentListAddComment', this._container); + this._textarea = elBySel('.wysiwygTextarea', this._container); + this._editor = null; + this._loadingOverlay = null; + + // handle submit button + var submitButton = elBySel('button[data-type="save"]', this._container); + submitButton.addEventListener(WCF_CLICK_EVENT, this._submit.bind(this)); + }, + + /** + * Submits the guest dialog. + * + * @param {Event} event + * @protected + */ + _submitGuestDialog: function(event) { + // only submit when enter key is pressed + if (event.type === 'keypress' && !EventKey.Enter(event)) { + return; + } + + var usernameInput = elBySel('input[name=username]', event.currentTarget.closest('.dialogContent')); + if (usernameInput.value === '') { + var error = DomTraverse.nextByClass(usernameInput, 'innerError'); + if (!error) { + error = elCreate('small'); + error.className = 'innerError'; + error.innerText = Language.get('wcf.global.form.error.empty'); + + DomUtil.insertAfter(error, usernameInput); + + usernameInput.closest('dl').classList.add('formError'); + } + + return; + } + + var parameters = { + parameters: { + data: { + username: usernameInput.value + } + } + }; + + //noinspection JSCheckFunctionSignatures + var captchaId = elData(event.currentTarget, 'captcha-id'); + if (ControllerCaptcha.has(captchaId)) { + parameters = Core.extend(parameters, ControllerCaptcha.getData(captchaId)); + } + + this._submit(undefined, parameters); + }, + + /** + * Validates the message and submits it to the server. + * + * @param {Event?} event event object + * @param {Object?} additionalParameters additional parameters sent to the server + * @protected + */ + _submit: function(event, additionalParameters) { + if (event) { + event.preventDefault(); + } + + if (!this._validate()) { + // validation failed, bail out + return; + } + + this._showLoadingOverlay(); + + // build parameters + var commentList = this._container.closest('.commentList'); + var parameters = { + data: { + message: this._getEditor().code.get(), + objectID: elData(commentList, 'object-id'), + objectTypeID: elData(commentList, 'object-type-id') + } + }; + + EventHandler.fire('com.woltlab.wcf.redactor2', 'submit_text', parameters.data); + + if (!User.userId && !additionalParameters) { + parameters.requireGuestDialog = true; + } + + Ajax.api(this, Core.extend({ + parameters: parameters + }, additionalParameters)); + }, + + /** + * Validates the message and invokes listeners to perform additional validation. + * + * @return {boolean} validation result + * @protected + */ + _validate: function() { + // remove all existing error elements + var errorMessages = elByClass('innerError', this._container); + while (errorMessages.length) { + elRemove(errorMessages[0]); + } + + // check if editor contains actual content + if (this._getEditor().utils.isEmpty()) { + this.throwError(this._textarea, Language.get('wcf.global.form.error.empty')); + return false; + } + + var data = { + api: this, + editor: this._getEditor(), + message: this._getEditor().code.get(), + valid: true + }; + + EventHandler.fire('com.woltlab.wcf.redactor2', 'validate_text', data); + + return (data.valid !== false); + }, + + /** + * Throws an error by adding an inline error to target element. + * + * @param {Element} element erroneous element + * @param {string} message error message + */ + throwError: function(element, message) { + var error = elCreate('small'); + error.className = 'innerError'; + error.textContent = (message === 'empty' ? Language.get('wcf.global.form.error.empty') : message); + + DomUtil.insertAfter(error, element); + }, + + /** + * Displays a loading spinner while the request is processed by the server. + * + * @protected + */ + _showLoadingOverlay: function() { + if (this._loadingOverlay === null) { + this._loadingOverlay = elCreate('div'); + this._loadingOverlay.className = 'commentLoadingOverlay'; + this._loadingOverlay.innerHTML = ''; + } + + this._content.classList.add('loading'); + this._content.appendChild(this._loadingOverlay); + }, + + /** + * Hides the loading spinner. + * + * @protected + */ + _hideLoadingOverlay: function() { + this._content.classList.remove('loading'); + + var loadingOverlay = elBySel('.commentLoadingOverlay', this._content); + if (loadingOverlay !== null) { + loadingOverlay.parentNode.removeChild(loadingOverlay); + } + }, + + /** + * Resets the editor contents and notifies event listeners. + * + * @protected + */ + _reset: function() { + this._getEditor().code.set('

\u200b

'); + + EventHandler.fire('com.woltlab.wcf.redactor2', 'reset_text'); + }, + + /** + * Handles errors occured during server processing. + * + * @param {Object} data response data + * @protected + */ + _handleError: function(data) { + //noinspection JSUnresolvedVariable + this.throwError(this._textarea, data.returnValues.errorType); + }, + + /** + * Returns the current editor instance. + * + * @return {Object} editor instance + * @protected + */ + _getEditor: function() { + if (this._editor === null) { + if (typeof window.jQuery === 'function') { + this._editor = window.jQuery(this._textarea).data('redactor'); + } + else { + throw new Error("Unable to access editor, jQuery has not been loaded yet."); + } + } + + return this._editor; + }, + + /** + * Inserts the rendered message into the post list, unless the post is on the next + * page in which case a redirect will be performed instead. + * + * @param {Object} data response data + * @protected + */ + _insertMessage: function(data) { + // insert HTML + //noinspection JSCheckFunctionSignatures + DomUtil.insertHtml(data.returnValues.template, this._container, 'after'); + + UiNotification.show(Language.get(this._options.successMessage)); + + DomChangeListener.trigger(); + }, + + /** + * @param {{returnValues:{guestDialog:string,guestDialogID:string}}} data + * @protected + */ + _ajaxSuccess: function(data) { + if (!User.userId && !data.returnValues.guestDialogID) { + throw new Error("Missing 'guestDialogID' return value for guest."); + } + + if (!User.userId && data.returnValues.guestDialog) { + UiDialog.openStatic(data.returnValues.guestDialogID, data.returnValues.guestDialog, { + closable: false, + title: Language.get('wcf.global.confirmation.title') + }); + + var dialog = UiDialog.getDialog(data.returnValues.guestDialogID); + elBySel('input[type=submit]', dialog.content).addEventListener(WCF_CLICK_EVENT, this._submitGuestDialog.bind(this)); + elBySel('input[type=text]', dialog.content).addEventListener('keypress', this._submitGuestDialog.bind(this)); + } + else { + this._insertMessage(data); + + if (!User.userId) { + UiDialog.close(data.returnValues.guestDialogID); + } + + this._reset(); + + this._hideLoadingOverlay(); + } + }, + + _ajaxFailure: function(data) { + this._hideLoadingOverlay(); + + //noinspection JSUnresolvedVariable + if (data === null || data.returnValues === undefined || data.returnValues.errorType === undefined) { + return true; + } + + this._handleError(data); + + return false; + }, + + _ajaxSetup: function() { + return { + data: { + actionName: 'addComment', + className: 'wcf\\data\\comment\\CommentAction' + }, + silent: true + }; + } + }; + + return UiCommentAdd; +}); diff --git a/wcfsetup/install/files/lib/data/comment/Comment.class.php b/wcfsetup/install/files/lib/data/comment/Comment.class.php index ab8b9163c6..2452116218 100644 --- a/wcfsetup/install/files/lib/data/comment/Comment.class.php +++ b/wcfsetup/install/files/lib/data/comment/Comment.class.php @@ -5,6 +5,7 @@ use wcf\data\IMessage; use wcf\data\TUserContent; use wcf\system\bbcode\SimpleMessageParser; use wcf\system\comment\CommentHandler; +use wcf\system\html\output\HtmlOutputProcessor; use wcf\util\StringUtil; /** @@ -51,14 +52,30 @@ class Comment extends DatabaseObject implements IMessage { * @inheritDoc */ public function getFormattedMessage() { - return SimpleMessageParser::getInstance()->parse($this->message); + $processor = new HtmlOutputProcessor(); + $processor->process($this->message, 'com.woltlab.wcf.comment', $this->commentID); + + return $processor->getHtml(); + } + + /** + * Returns a simplified version of the formatted message. + * + * @return string + */ + public function getSimplifiedFormattedMessage() { + $processor = new HtmlOutputProcessor(); + $processor->setOutputType('text/simplified-html'); + $processor->process($this->message, 'com.woltlab.wcf.comment', $this->commentID); + + return $processor->getHtml(); } /** * @inheritDoc */ public function getExcerpt($maxLength = 255) { - return StringUtil::truncateHTML($this->getFormattedMessage(), $maxLength); + return StringUtil::truncateHTML($this->getSimplifiedFormattedMessage(), $maxLength); } /** diff --git a/wcfsetup/install/files/lib/data/comment/CommentAction.class.php b/wcfsetup/install/files/lib/data/comment/CommentAction.class.php index d9c65eab36..afc33d0940 100644 --- a/wcfsetup/install/files/lib/data/comment/CommentAction.class.php +++ b/wcfsetup/install/files/lib/data/comment/CommentAction.class.php @@ -8,12 +8,14 @@ use wcf\data\comment\response\StructuredCommentResponse; use wcf\data\object\type\ObjectType; use wcf\data\object\type\ObjectTypeCache; use wcf\data\AbstractDatabaseObjectAction; +use wcf\system\bbcode\BBCodeHandler; use wcf\system\captcha\CaptchaHandler; use wcf\system\comment\manager\ICommentManager; use wcf\system\comment\CommentHandler; use wcf\system\exception\PermissionDeniedException; use wcf\system\exception\SystemException; use wcf\system\exception\UserInputException; +use wcf\system\html\input\HtmlInputProcessor; use wcf\system\like\LikeHandler; use wcf\system\user\activity\event\UserActivityEventHandler; use wcf\system\user\notification\object\type\ICommentUserNotificationObjectType; @@ -66,6 +68,11 @@ class CommentAction extends AbstractDatabaseObjectAction { */ protected $commentProcessor = null; + /** + * @var HtmlInputProcessor + */ + protected $htmlInputProcessor; + /** * response object * @var CommentResponse @@ -203,7 +210,7 @@ class CommentAction extends AbstractDatabaseObjectAction { $this->validateUsername(); $this->validateCaptcha(); - $this->validateMessage(); + $this->validateMessage(true); $objectType = $this->validateObjectType(); // validate object id and permissions @@ -232,6 +239,9 @@ class CommentAction extends AbstractDatabaseObjectAction { ]; } + /** @var HtmlInputProcessor $htmlInputProcessor */ + $htmlInputProcessor = $this->parameters['htmlInputProcessor']; + // create comment $this->createdComment = CommentEditor::create([ 'objectTypeID' => $this->parameters['data']['objectTypeID'], @@ -239,7 +249,7 @@ class CommentAction extends AbstractDatabaseObjectAction { 'time' => TIME_NOW, 'userID' => WCF::getUser()->userID ?: null, 'username' => WCF::getUser()->userID ? WCF::getUser()->username : $this->parameters['data']['username'], - 'message' => $this->parameters['data']['message'], + 'message' => $htmlInputProcessor->getHtml(), 'responses' => 0, 'responseIDs' => serialize([]) ]); @@ -533,7 +543,7 @@ class CommentAction extends AbstractDatabaseObjectAction { public function validateEdit() { $this->validatePrepareEdit(); - $this->validateMessage(); + $this->validateMessage($this->comment !== null); } /** @@ -717,7 +727,7 @@ class CommentAction extends AbstractDatabaseObjectAction { /** * Validates message parameter. */ - protected function validateMessage() { + protected function validateMessage($isComment = false) { $this->readString('message', false, 'data'); $this->parameters['data']['message'] = MessageUtil::stripCrap($this->parameters['data']['message']); @@ -726,6 +736,26 @@ class CommentAction extends AbstractDatabaseObjectAction { } CommentHandler::enforceCensorship($this->parameters['data']['message']); + + if ($isComment) { + $this->setDisallowedBBCodes(); + $htmlInputProcessor = $this->getHtmlInputProcessor($this->parameters['data']['message']); + + // search for disallowed bbcodes + $disallowedBBCodes = $htmlInputProcessor->validate(); + if (!empty($disallowedBBCodes)) { + throw new UserInputException('text', WCF::getLanguage()->getDynamicVariable('wcf.message.error.disallowedBBCodes', ['disallowedBBCodes' => $disallowedBBCodes])); + } + + if ($htmlInputProcessor->appearsToBeEmpty()) { + throw new UserInputException('message'); + } + + $this->parameters['htmlInputProcessor'] = $htmlInputProcessor; + } + else { + unset($this->parameters['htmlInputProcessor']); + } } /** @@ -818,6 +848,24 @@ class CommentAction extends AbstractDatabaseObjectAction { } } + /** + * Sets the list of disallowed bbcodes for comments. + */ + protected function setDisallowedBBCodes() { + BBCodeHandler::getInstance()->setDisallowedBBCodes(explode(',', WCF::getSession()->getPermission('user.comment.disallowedBBCodes'))); + } + + public function getHtmlInputProcessor($message = null, $objectID = 0) { + if ($message === null) { + return $this->htmlInputProcessor; + } + + $this->htmlInputProcessor = new HtmlInputProcessor(); + $this->htmlInputProcessor->process($message, 'com.woltlab.wcf.comment', $objectID); + + return $this->htmlInputProcessor; + } + /** * Returns the comment object. * -- 2.20.1