diff --git a/webcompat/static/js/lib/bugform.js b/webcompat/static/js/lib/bugform.js index f8daca676..9ceb9feff 100644 --- a/webcompat/static/js/lib/bugform.js +++ b/webcompat/static/js/lib/bugform.js @@ -5,6 +5,24 @@ /*eslint new-cap: ["error", { "capIsNewExceptions": ["Deferred"] }]*/ function BugForm() { + // Set up listener for message events from screenshot-enabled add-ons + // Runs only when the DOM is ready + var me = this; + window.addEventListener( + "message", + function(event) { + $(function() { + me.onReceiveMessage(event); + }); + }, + false + ); + + // the initialization of the rest of the form happens when the DOM is ready + $(BugForm.prototype.onDOMReadyInit.bind(this)); +} + +BugForm.prototype.onDOMReadyInit = function() { this.clickedButton = null; this.detailsInput = $("#details:hidden"); this.errorLabel = $(".js-error-upload"); @@ -49,7 +67,7 @@ function BugForm() { valid: true, helpText: "Image must be one of the following: jpe, jpg, jpeg, png, gif, or bmp.", - altHelpText: "Please choose a smaller image (< 4MB)" + altHelpText: "Please choose a smaller image (< 4MB)" }, browser: { el: $("#browser"), @@ -84,627 +102,611 @@ function BugForm() { this.stepsToReproduceField = this.inputs.steps_reproduce.el; this.contactField = this.inputs.contact.el; - this.init = function() { - this.checkURLValidity = this.checkURLValidity.bind(this); - this.checkDescriptionValidity = this.checkDescriptionValidity.bind(this); - this.checkProblemTypeValidity = this.checkProblemTypeValidity.bind(this); - this.checkImageTypeValidity = this.checkImageTypeValidity.bind(this); - this.checkGitHubUsername = this.checkGitHubUsername.bind(this); - this.checkOptionalNonEmpty = this.checkOptionalNonEmpty.bind(this); - this.storeClickedButton = this.storeClickedButton.bind(this); - this.onFormSubmit = this.onFormSubmit.bind(this); - this.onReceiveMessage = this.onReceiveMessage.bind(this); - this.preventSubmitByEnter = this.preventSubmitByEnter.bind(this); - - // Make sure we're not getting a report - // about our own site before checking params. - if (!this.isSelfReport()) { - this.checkParams(); - } - - this.disableSubmits(); - this.urlField.on("blur input", this.checkURLValidity); - this.descField.on("blur input", this.checkDescriptionValidity); - this.problemType.on("change", this.checkProblemTypeValidity); - this.uploadField.on("change", this.checkImageTypeValidity); - this.osField.on( - "blur", - this.checkOptionalNonEmpty.bind(this, this.osField) - ); - this.browserField.on( - "blur", - this.checkOptionalNonEmpty.bind(this, this.browserField) - ); - this.contactField.on("blur input", this.checkGitHubUsername); - this.submitButtons.on("click", this.storeClickedButton); - this.form.on("submit", this.onFormSubmit); - - // prevent submit by hitting enter key for single line input fields - this.form.on("keypress", ":input:not(textarea)", this.preventSubmitByEnter); - - // See if the user already has a valid form - // (after a page refresh, back button, etc.) - this.checkForm(); - - // Set up listener for message events from screenshot-enabled add-ons - window.addEventListener("message", this.onReceiveMessage, false); - }; - - this.onReceiveMessage = function(event) { - // We're getting a report about our own site, so let's bail. - if (this.isSelfReport()) { - return false; - } - - // Make sure the data is coming from a trusted source. - // (i.e., our add-on or some other priviledged code sent it) - if (location.origin === event.origin) { - // See https://github.com/webcompat/webcompat.com/issues/1252 to track - // the work of only accepting blobs, which should simplify things. - if (event.data instanceof Blob) { - // convertToDataURI sends the resulting string to the upload - // callback. - this.convertToDataURI(event.data, this.showUploadPreview); - } else { - // ...the data is already a data URI string - this.showUploadPreview(event.data); - } - } - }; - - this.preventSubmitByEnter = function(event) { - if (event.key === "Enter") { - event.preventDefault(); - } - }; - - this.showUploadPreview = function(dataURI) { - // The final size of Base64-encoded binary data is ~equal to - // 1.37 times the original data size + 814 bytes (for headers). - // so, bytes = (encoded_string.length - 814) / 1.37) - // see https://en.wikipedia.org/wiki/Base64#MIME - if (String(dataURI).length - 814 / 1.37 > this.UPLOAD_LIMIT) { - this.downsampleImage( - dataURI, - _.bind(function(downsampledData) { - // Recurse until it's small enough for us to upload. - this.showUploadPreview(downsampledData); - }, this) - ); - } else { - this.makeValid("image"); - this.addPreviewBackground(dataURI); - } - }; - - this.downsampleImage = function(dataURI, callback) { - var img = document.createElement("img"); - var canvas = document.createElement("canvas"); - var ctx = canvas.getContext("2d"); - - img.onload = function() { - // scale the tmp canvas to 50% - canvas.width = Math.floor(img.width / 2); - canvas.height = Math.floor(img.height / 2); - ctx.scale(0.5, 0.5); - // draw back in the screenshot (at 50% scale) - // and re-serialize to data URI - ctx.drawImage(img, 0, 0); - // Note: this will convert GIFs to JPEG, which breaks - // animated GIFs. However, this only will happen if they - // were above the upload limit size. So... sorry? - var screenshotData = canvas.toDataURL("image/jpeg", 0.8); - (img = null), (canvas = null); - - callback(screenshotData); - }; - - img.src = dataURI; - }; - - // Is the user trying to report a site against webcompat.com itself? - this.isSelfReport = function(href) { - href = href || location.href; - var url = href.match(this.urlParamRegExp); - if (url !== null) { - if (_.includes(decodeURIComponent(url[0]), location.origin)) { - return true; - } - } + return this.init(); +}; + +BugForm.prototype.init = function() { + // Make sure we're not getting a report + // about our own site before checking params. + if (!this.isSelfReport()) { + this.checkParams(); + } + + this.disableSubmits(); + this.urlField.on("blur input", this.checkURLValidity.bind(this)); + this.descField.on("blur input", this.checkDescriptionValidity.bind(this)); + this.problemType.on("change", this.checkProblemTypeValidity.bind(this)); + this.uploadField.on("change", this.checkImageTypeValidity.bind(this)); + this.osField.on("blur", this.checkOptionalNonEmpty.bind(this, this.osField)); + this.browserField.on( + "blur", + this.checkOptionalNonEmpty.bind(this, this.browserField) + ); + this.contactField.on("blur input", this.checkGitHubUsername.bind(this)); + this.submitButtons.on("click", this.storeClickedButton.bind(this)); + this.form.on("submit", this.onFormSubmit.bind(this)); + + // prevent submit by hitting enter key for single line input fields + this.form.on( + "keypress", + ":input:not(textarea)", + this.preventSubmitByEnter.bind(this) + ); + + // See if the user already has a valid form + // (after a page refresh, back button, etc.) + this.checkForm(); +}; + +BugForm.prototype.onReceiveMessage = function(event) { + // We're getting a report about our own site, so let's bail. + if (this.isSelfReport()) { return false; - }; - - // Do some extra work based on the GET params that come with the request - this.checkParams = function() { - // Don't bother doing any work for bare requests. - if (!location.search) { - return; - } - - var url = location.href.match(this.urlParamRegExp); - if (url !== null) { - url = this.trimWyciwyg(decodeURIComponent(url[1])); - this.urlField.val(url); - this.makeValid("url"); - } - - // If we have a problem_type param, and it matches the value, select it for - // the user. see https://github.com/webcompat/webcompat.com/blob/34c3b6b1a1116b401a9a442685131ae747045f67/webcompat/form.py#L38 - // for possible matching values - var problemType = location.href.match(/problem_type=([^&]*)/); - if (problemType !== null) { - $("[value=" + problemType[1] + "]").click(); - } - - // If we have details, put it inside a hidden input and append it to the - // form. - var details = location.href.match(/details=([^&]*)/); - if (details) { - this.addDetails(details[1]); - } - }; - - this.addDetails = function(detailsParam) { - // The content of the details param may be encoded via - // application/x-www-form-urlencoded, so we need to change the - // + (SPACE) to %20 before decoding - this.detailsInput.val( - decodeURIComponent(detailsParam.replace(/\+/g, "%20")) - ); - }; - - this.storeClickedButton = function(event) { - this.clickedButton = event.target.name; - }; - - this.trimWyciwyg = function(url) { - // Trim wyciwyg://N/ from URL, if found. - // See https://bugzilla.mozilla.org/show_bug.cgi?id=1098037 & - // https://en.wikipedia.org/wiki/WYCIWYG - var wyciwygRe = /(wyciwyg:\/\/\d+\/)/i; - if (url.search(wyciwygRe) !== 0) { - return url; - } else { - return url.replace(wyciwygRe, ""); - } - }; - - this.disableSubmits = function() { - this.submitButtons.prop("disabled", true); - this.submitButtons.addClass("is-disabled"); - }; - - this.enableSubmits = function() { - this.submitButtons.prop("disabled", false); - this.submitButtons.removeClass("is-disabled"); - }; - - this.checkProblemTypeValidity = function() { - if (!$("[name=problem_category]:checked").length) { - this.makeInvalid("problem_type"); - } else { - this.makeValid("problem_type"); - } - }; - - this.checkImageTypeValidity = function(event) { - var splitImg = this.uploadField.val().split("."); - var ext = splitImg[splitImg.length - 1].toLowerCase(); - var allowed = ["jpg", "jpeg", "jpe", "png", "gif", "bmp"]; - // Bail if there's no image. - if (!this.uploadField.val()) { - return; - } - - if (!_.includes(allowed, ext)) { - this.makeInvalid("image"); - } else { - this.makeValid("image"); - if (event) { - // We can just grab the 0th one, because we only allow uploading - // a single image at a time (for now) - this.convertToDataURI(event.target.files[0], this.showUploadPreview); - } - } - - // null out input.value so we get a consistent - // change event across browsers - event.target.value = null; - }; - - this.isReportableURL = function(url) { - var ok = url && (_.startsWith(url, "http:") || _.startsWith(url, "https:")); - ok &= !(_.startsWith(url, "http:// ") || _.startsWith(url, "https:// ")); - return ok; - }; - - /* Check to see that the URL input element is not empty, - or if it's a non-webby scheme. */ - this.checkURLValidity = function() { - var val = this.urlField.val(); - if ($.trim(val) === "" || !this.isReportableURL(val)) { - this.makeInvalid("url"); - } else { - this.makeValid("url"); - } - }; - - /* Check to see that the description input element is not empty. */ - this.checkDescriptionValidity = function() { - var val = this.descField.val(); - if ($.trim(val) === "") { - this.makeInvalid("description"); - } else { - this.makeValid("description"); - } - }; - - /* Check if Browser and OS are empty or not, only - so we can set them to valid (there is no invalid state) */ - this.checkOptionalNonEmpty = function(input) { - var inputId = input.prop("id"); - - if (input.val()) { - this.makeValid(inputId); - } else { - this.makeInvalid(inputId); - } - }; - - /* - Check a string is a valid GitHub username - - maximum 39 chars - - alphanumerical characters and hyphens - - no two consecutive hyphens - */ - this.isValidGitHubUsername = function(contact) { - return this.githubRegexp.test(contact); - }; - - /* Check to see if the GitHub username has the right syntax. */ - this.checkGitHubUsername = function() { - var contact = this.contactField.val(); - if (this.isValidGitHubUsername(contact) || $.trim(contact) === "") { - this.makeValid("contact"); + } + + // Make sure the data is coming from a trusted source. + // (i.e., our add-on or some other priviledged code sent it) + if (location.origin === event.origin) { + // See https://github.com/webcompat/webcompat.com/issues/1252 to track + // the work of only accepting blobs, which should simplify things. + if (event.data instanceof Blob) { + // convertToDataURI sends the resulting string to the upload + // callback. + this.convertToDataURI(event.data, this.showUploadPreview.bind(this)); } else { - this.makeInvalid("contact"); - } - }; - - this.checkForm = function() { - // Run through and see if there's any user input in the - // required inputs - var inputs = [ - this.problemType.filter(":checked").length, - this.urlField.val(), - this.descField.val(), - this.uploadField.val() - ]; - if (_.some(inputs, Boolean)) { - // then, check validity - this.checkURLValidity(); - this.checkDescriptionValidity(); - this.checkProblemTypeValidity(); - this.checkImageTypeValidity(); - this.checkGitHubUsername(); - // and open the form, if it's not already open - if (!this.reportButton.hasClass("is-open")) { - this.reportButton.click(); - } - } - // Make sure we only do this if the inputs exist on the page - if (this.browserField.length) { - this.checkOptionalNonEmpty(this.browserField); - } - if (this.osField.length) { - this.checkOptionalNonEmpty(this.osField); - } - }; - - /* makeInvalid can take an {altHelp: true} options argument to select - alternate helpText to display */ - this.makeInvalid = function(id, opts) { - // Early return if inline help is already in place. - if (this.inputs[id].valid === false) { - return; - } - - var inlineHelp = $("", { - class: "label-icon-message form-message-error", - text: - opts && opts.altHelp - ? this.inputs[id].altHelpText - : this.inputs[id].helpText - }); - - this.inputs[id].valid = false; - this.inputs[id].el - .parents(".js-Form-group") - .removeClass("is-validated js-no-error") - .addClass("is-error js-form-error"); - - switch (id) { - case "os": - case "browser": - // remove error classes, because these inputs are optional - this.inputs[id].el - .parents(".js-Form-group") - .removeClass("is-error js-form-error"); - break; - case "url": - case "contact": - case "description": - case "problem_type": - inlineHelp.insertAfter("label[for=" + id + "]"); - break; - case "image": - // hide the error in case we already saw one - $(".form-upload-error").remove(); - - inlineHelp - .removeClass("form-message-error") - .addClass("form-upload-error") - .appendTo(".js-error-upload"); - - $(".js-label-upload").addClass("is-hidden"); - $(".js-remove-upload").addClass("is-hidden"); - $(".js-error-upload").removeClass("is-hidden"); - - $(".form-message-error").hide(); - $(".form-input-validation .error").hide(); - // "reset" the form field, because the file would get rejected - // from the server anyways. - this.uploadField.val(this.uploadField.get(0).defaultValue); - // return early because we just cleared out the input. - // someone might decide to just not select an image. - return; - } - - this.disableSubmits(); - }; - - this.makeValid = function(id) { - this.inputs[id].valid = true; - this.inputs[id].el - .parents(".js-Form-group") - .removeClass("is-error js-form-error") - .addClass("is-validated js-no-error"); - - this.inputs[id].el - .parents(".js-Form-group") - .find(".form-message-error") - .remove(); - - if ( - this.inputs["url"].valid && - this.inputs["problem_type"].valid && - this.inputs["image"].valid && - this.inputs["description"].valid - ) { - this.enableSubmits(); - } - }; - /* - If the users browser understands the FileReader API, show a preview - of the image they're about to load, then invoke the passed in callback - with the result of reading the blobOrFile as a dataURI. - */ - this.convertToDataURI = function(blobOrFile, callback) { - if (!(window.FileReader && window.File)) { - return; - } - - // One last image type validation check. - if (!blobOrFile.type.match("image.*")) { - this.makeInvalid("image"); - return; - } - - var reader = new FileReader(); - reader.onload = function(event) { - var dataURI = event.target.result; - callback(dataURI); - }; - reader.readAsDataURL(blobOrFile); - }; - - this.addPreviewBackground = function(dataURI) { - if (!_.startsWith(dataURI, "data:image/")) { - return; + // ...the data is already a data URI string + this.showUploadPreview(event.data); } + } +}; - this.previewEl.css({ - background: "url(" + dataURI + ") no-repeat center / contain" - }); - - this.hasImage = true; - this.showRemoveUpload(); - }; - /* - Allow users to remove an image from the form upload. - */ - this.showRemoveUpload = function() { - // hide upload image errors (this will no-op if the user never saw one) - $(".form-upload-error").remove(); - - this.errorLabel.addClass("is-hidden"); - this.uploadLabel.removeClass("visually-hidden"); - - this.removeBanner.removeClass("is-hidden"); - this.removeBanner.attr("tabIndex", "0"); - this.uploadLabel.addClass("visually-hidden"); - this.removeBanner.on("click", this.removeUploadPreview); - }; - - /* - Remove the upload image preview and hide the banner. - */ - this.removeUploadPreview = function() { - this.previewEl.css("background", "none"); - this.removeBanner.addClass("is-hidden"); - this.removeBanner.attr("tabIndex", "-1"); - this.uploadLabel.removeClass("visually-hidden").removeClass("is-hidden"); - this.removeBanner.off("click"); - this.removeBanner.get(0).blur(); - - this.hasImage = false; - - // clear out the input[type=file] as well - this.uploadField.val(this.uploadField.get(0).defaultValue); - }; - - this.showLoadingIndicator = function() { - this.loadingIndicator.addClass("is-active"); - }; - - this.hideLoadingIndicator = function() { - this.loadingIndicator.removeClass("is-active"); - }; - - this.onFormSubmit = function(event) { - this.showLoadingIndicator(); - this.maybeUploadImage(event).then(this.submitForm, this.handleUploadError); - }; - - /* - If we have an image, kick off the uploadImage promise, otherwise - resolve immediately. - */ - this.maybeUploadImage = function(event) { +BugForm.prototype.preventSubmitByEnter = function(event) { + if (event.key === "Enter") { event.preventDefault(); - var dfd = $.Deferred(); - - if (!this.hasImage) { - return dfd.resolve(); - } - - this.uploadImage(this.getDataURIFromPreviewEl()) - .then(this.addImageURL) - .then(dfd.resolve, dfd.reject); - - return dfd.promise(); - }; - - /* - Upload the image before form submission so we can - put an image link in the bug description. - */ - this.uploadImage = function(dataURI) { - var dfd = $.Deferred(); - this.disableSubmits(); - - $(".js-remove-upload").addClass("is-hidden"); - - var formdata = new FormData(); - formdata.append("image", dataURI); - - $.ajax({ - contentType: false, - processData: false, - data: formdata, - method: "POST", - url: "/upload/" - }).then(dfd.resolve, dfd.reject); - - return dfd.promise(); - }; - - /* - React to server-side errors related to images by showing a flash - message to the user, and clearing out the bad image and preview. - - If we're here, the attempted form submission failed. - */ - this.handleUploadError = function(response) { - if (response && response.status === 415) { - wcEvents.trigger("flash:error", { - message: this.inputs.image.helpText, - timeout: 5000 - }); - } - - if (response && response.status === 413) { - wcEvents.trigger("flash:error", { - message: - "The image is too big! Please choose something smaller than 4MB.", - timeout: 5000 - }); + } +}; + +BugForm.prototype.showUploadPreview = function(dataURI) { + // The final size of Base64-encoded binary data is ~equal to + // 1.37 times the original data size + 814 bytes (for headers). + // so, bytes = (encoded_string.length - 814) / 1.37) + // see https://en.wikipedia.org/wiki/Base64#MIME + if (String(dataURI).length - 814 / 1.37 > this.UPLOAD_LIMIT) { + this.downsampleImage( + dataURI, + _.bind(function(downsampledData) { + // Recurse until it's small enough for us to upload. + this.showUploadPreview(downsampledData); + }, this) + ); + } else { + this.makeValid("image"); + this.addPreviewBackground(dataURI); + } +}; + +BugForm.prototype.downsampleImage = function(dataURI, callback) { + var img = document.createElement("img"); + var canvas = document.createElement("canvas"); + var ctx = canvas.getContext("2d"); + + img.onload = function() { + // scale the tmp canvas to 50% + canvas.width = Math.floor(img.width / 2); + canvas.height = Math.floor(img.height / 2); + ctx.scale(0.5, 0.5); + // draw back in the screenshot (at 50% scale) + // and re-serialize to data URI + ctx.drawImage(img, 0, 0); + // Note: this will convert GIFs to JPEG, which breaks + // animated GIFs. However, this only will happen if they + // were above the upload limit size. So... sorry? + var screenshotData = canvas.toDataURL("image/jpeg", 0.8); + (img = null), (canvas = null); + + callback(screenshotData); + }; + + img.src = dataURI; +}; + +// Is the user trying to report a site against webcompat.com itself? +BugForm.prototype.isSelfReport = function(href) { + href = href || location.href; + var url = href.match(this.urlParamRegExp); + if (url !== null) { + if (_.includes(decodeURIComponent(url[0]), location.origin)) { + return true; + } + } + return false; +}; + +// Do some extra work based on the GET params that come with the request +BugForm.prototype.checkParams = function() { + // Don't bother doing any work for bare requests. + if (!location.search) { + return; + } + + var url = location.href.match(this.urlParamRegExp); + if (url !== null) { + url = this.trimWyciwyg(decodeURIComponent(url[1])); + this.urlField.val(url); + this.makeValid("url"); + } + + // If we have a problem_type param, and it matches the value, select it for + // the user. see https://github.com/webcompat/webcompat.com/blob/34c3b6b1a1116b401a9a442685131ae747045f67/webcompat/form.py#L38 + // for possible matching values + var problemType = location.href.match(/problem_type=([^&]*)/); + if (problemType !== null) { + $("[value=" + problemType[1] + "]").click(); + } + + // If we have details, put it inside a hidden input and append it to the + // form. + var details = location.href.match(/details=([^&]*)/); + if (details) { + this.addDetails(details[1]); + } +}; + +BugForm.prototype.addDetails = function(detailsParam) { + // The content of the details param may be encoded via + // application/x-www-form-urlencoded, so we need to change the + // + (SPACE) to %20 before decoding + this.detailsInput.val(decodeURIComponent(detailsParam.replace(/\+/g, "%20"))); +}; + +BugForm.prototype.storeClickedButton = function(event) { + this.clickedButton = event.target.name; +}; + +BugForm.prototype.trimWyciwyg = function(url) { + // Trim wyciwyg://N/ from URL, if found. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1098037 & + // https://en.wikipedia.org/wiki/WYCIWYG + var wyciwygRe = /(wyciwyg:\/\/\d+\/)/i; + if (url.search(wyciwygRe) !== 0) { + return url; + } else { + return url.replace(wyciwygRe, ""); + } +}; + +BugForm.prototype.disableSubmits = function() { + this.submitButtons.prop("disabled", true); + this.submitButtons.addClass("is-disabled"); +}; + +BugForm.prototype.enableSubmits = function() { + this.submitButtons.prop("disabled", false); + this.submitButtons.removeClass("is-disabled"); +}; + +BugForm.prototype.checkProblemTypeValidity = function() { + if (!$("[name=problem_category]:checked").length) { + this.makeInvalid("problem_type"); + } else { + this.makeValid("problem_type"); + } +}; + +BugForm.prototype.checkImageTypeValidity = function(event) { + var splitImg = this.uploadField.val().split("."); + var ext = splitImg[splitImg.length - 1].toLowerCase(); + var allowed = ["jpg", "jpeg", "jpe", "png", "gif", "bmp"]; + // Bail if there's no image. + if (!this.uploadField.val()) { + return; + } + + if (!_.includes(allowed, ext)) { + this.makeInvalid("image"); + } else { + this.makeValid("image"); + if (event) { + // We can just grab the 0th one, because we only allow uploading + // a single image at a time (for now) + this.convertToDataURI( + event.target.files[0], + this.showUploadPreview.bind(this) + ); } - - this.hideLoadingIndicator(); - this.removeUploadPreview(); - }; - - this.submitForm = function() { - var dfd = $.Deferred(); - var formEl = this.form.get(0); - // Calling submit() manually on the form won't contain details - // about which