/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ /*global Settings, Utils, Attachment, MozActivity, MessageManager, SubjectComposer, Promise, EventDispatcher, DOMError, OptionMenu */ /*exported Compose */ 'use strict'; /** * Handle UI specifics of message composition. Namely, * resetting (auto manages placeholder text), getting * message content, and message size */ var Compose = (function() { const TYPES = ['image', 'audio', 'video']; // delay between 2 counter updates while composing a message const UPDATE_DELAY = 500; // Min available chars count that triggers available chars counter const MIN_AVAILABLE_CHARS_COUNT = 20; var placeholderClass = 'placeholder'; var attachmentClass = 'attachment-container'; var attachments = new Map(); // will be defined in init var dom = { form: null, message: null, sendButton: null, attachButton: null, counter: null }; var state = { empty: true, maxLength: null, size: null, lastScrollPosition: 0, resizing: false, // 'sms' or 'mms' type: 'sms', lock: { canSend: null, canEdit: null, forceType: null }, segmentInfo: { segments: 0, charsAvailableInLastSegment: 0 } }; var subject = null; // Given a DOM element, we will extract an array of the // relevant nodes as attachment or text function getContentFromDOM(domElement) { var content = []; var node; for (node = domElement.firstChild; node; node = node.nextSibling) { // hunt for an attachment in the Map and append it var attachment = attachments.get(node); if (attachment) { content.push(attachment); continue; } var last = content.length - 1; var text = node.textContent; // Bug 877141 - contenteditable wil insert non-break spaces when // multiple consecutive spaces are entered, we don't want them. if (text) { text = text.replace(/\u00A0/g, ' '); } if (node.nodeName == 'BR') { if (node === domElement.lastChild) { continue; } text = '\n'; } // append (if possible) text to the last entry if (text.length) { if (typeof content[last] === 'string') { content[last] += text; } else { content.push(text); } } } return content; } // anytime content changes - takes a parameter to check for image resizing function onContentChanged(duck) { // if the duck is an image attachment or object, handle resizes if ((duck instanceof Attachment && duck.type === 'img') || (duck && duck.containsImage)) { return imageAttachmentsHandling(); } var isEmptyMessage = !dom.message.textContent.length && !hasAttachment(); if (isEmptyMessage) { var brs = dom.message.getElementsByTagName('br'); // firefox will keep an extra
in there if (brs.length > 1) { isEmptyMessage = false; } } // Placeholder management var placeholding = dom.message.classList.contains(placeholderClass); if (placeholding && !isEmptyMessage) { dom.message.classList.remove(placeholderClass); } if (!placeholding && isEmptyMessage) { dom.message.classList.add(placeholderClass); } state.emptyMessage = isEmptyMessage; Compose.updateEmptyState(); Compose.updateType(); Compose.updateSendButton(); Compose.updateAttachButton(); updateSegmentInfoThrottled(); Compose.emit('input'); } function onSubjectChanged() { Compose.updateEmptyState(); Compose.updateType(); Compose.updateSendButton(); Compose.updateAttachButton(); Compose.emit('subject-change'); } function onSubjectVisibilityChanged() { if (subject.isVisible()) { dom.form.classList.add('subject-composer-visible'); } else { dom.message.focus(); dom.form.classList.remove('subject-composer-visible'); } } function hasAttachment() { return !!dom.message.querySelector('iframe'); } function hasSubject() { return subject.isVisible() && !!subject.getValue(); } function composeKeyEvents(e) { // If message can't be edited anymore and no-backspace pressed, cancel. if (!compose.canEdit() && e.which !== 8) { e.preventDefault(); } else { // Trigger a recompute of size on the keypress events. state.size = null; } } function insert(item) { var fragment = document.createDocumentFragment(); if (!item) { return null; } // trigger recalc on insert state.size = null; if (item.render) { // it's an Attachment var node = item.render(); attachments.set(node, item); fragment.appendChild(node); } else if (item.nodeName === 'IFRAME') { // this iframe is generated by us fragment.appendChild(item); } else if (typeof item === 'string') { item.split('\n').forEach(function(line) { fragment.appendChild(document.createTextNode(line)); fragment.appendChild(document.createElement('br')); }); fragment.lastElementChild.remove(); } return fragment; } function imageAttachmentsHandling() { // There is need to resize image attachment if total compose // size doen't exceed mms size limitation. if (Compose.size < Settings.mmsSizeLimitation) { onContentChanged(); return; } var imgNodes = []; attachments.forEach((attachment, node) => { if (attachment.type === 'img') { imgNodes.push(node); } }); var done = 0; // Total number of images < 3 // => Set max image size to 2/5 message size limitation. // Total number of images >= 3 // => Set max image size to 1/5 message size limitation. var images = imgNodes.length; var limit = images > 2 ? Settings.mmsSizeLimitation * 0.2 : Settings.mmsSizeLimitation * 0.4; function imageSized() { if (++done === images) { state.resizing = false; onContentChanged(); } else { resizedImg(imgNodes[done]); } } function resizedImg(node) { var item = attachments.get(node); if (item.blob.size < limit) { imageSized(); } else { Utils.getResizedImgBlob(item.blob, limit, function(resizedBlob) { // trigger recalc when resized state.size = null; item.blob = resizedBlob; item.updateFileSize(); imageSized(); }); } } state.resizing = true; resizedImg(imgNodes[0]); onContentChanged(); } var segmentInfoTimeout = null; function updateSegmentInfoThrottled() { // we need to call updateSegmentInfo even in MMS mode if we have only text: // if we're in MMS mode because we have a long message, then we need to // check when we go back to SMS mode by having a shorter message. // A possible solution is to do it only when the user deletes characters in // MMS mode. if (hasAttachment()) { return resetSegmentInfo(); } if (segmentInfoTimeout === null) { segmentInfoTimeout = setTimeout(updateSegmentInfo, UPDATE_DELAY); } } function updateSegmentInfo() { segmentInfoTimeout = null; var value = Compose.getText(); // saving one IPC call when we clear the composer var segmentInfoPromise = value ? MessageManager.getSegmentInfo(value) : Promise.reject(); segmentInfoPromise.then( function(segmentInfo) { state.segmentInfo = segmentInfo; }, resetSegmentInfo ).then(compose.updateType.bind(Compose)) .then(compose.emit.bind(compose, 'segmentinfochange')); } function resetSegmentInfo() { state.segmentInfo = { segments: 0, charsAvailableInLastSegment: 0 }; } function disposeAttachmentNode(attachmentNode) { var thumbnailURL = attachmentNode.dataset.thumbnail; if (thumbnailURL) { window.URL.revokeObjectURL(thumbnailURL); } attachments.delete(attachmentNode); } var compose = { init: function composeInit(formId) { dom.form = document.getElementById(formId); dom.message = document.getElementById('messages-input'); dom.sendButton = document.getElementById('messages-send-button'); dom.attachButton = document.getElementById('messages-attach-button'); dom.counter = dom.form.querySelector('.js-message-counter'); dom.messagesAttach = dom.form.querySelector('.messages-attach-container'); dom.composerButton = dom.form.querySelector('.composer-button-container'); subject = new SubjectComposer( dom.form.querySelector('.js-subject-composer') ); subject.on('change', onSubjectChanged); subject.on('visibility-change', onSubjectChanged); subject.on('visibility-change', onSubjectVisibilityChanged); // update the placeholder, send button and Compose.type dom.message.addEventListener('input', onContentChanged); // we need to bind to keydown & keypress because of #870120 dom.message.addEventListener('keydown', composeKeyEvents); dom.message.addEventListener('keypress', composeKeyEvents); dom.message.addEventListener('click', this.onAttachmentClick.bind(this)); dom.attachButton.addEventListener('click', this.onAttachClick.bind(this)); this.offAll(); this.clear(); this.on('type', this.onTypeChange.bind(this)); this.on('type', this.updateMessageCounter.bind(this)); this.on('segmentinfochange', this.updateMessageCounter.bind(this)); var onInteracted = this.emit.bind(this, 'interact'); dom.message.addEventListener('focus', onInteracted); dom.message.addEventListener('click', onInteracted); dom.messagesAttach.addEventListener('click', onInteracted); dom.composerButton.addEventListener('click', onInteracted); subject.on('focus', onInteracted); return this; }, getContent: function() { return getContentFromDOM(dom.message); }, getSubject: function() { return subject.isVisible() ? subject.getValue() : null; }, setSubject: function(value) { return subject.setValue(value); }, isSubjectMaxLength: function() { return subject.getValue().length >= subject.getMaxLength(); }, showSubject: function() { subject.show(); subject.focus(); }, hideSubject: function() { subject.hide(); }, /** Render draft * * @param {Draft} draft Draft to be loaded into the composer. * */ fromDraft: function(draft) { // Clear out the composer this.clear(); // If we don't have a draft, return only having cleared the composer if (!draft) { return; } if (draft.subject) { this.setSubject(draft.subject); subject.show(); } if (!draft.content) { return; } if (!Array.isArray(draft.content)) { throw new Error('If present, the `content` property must be an array.'); } // draft content is an array draft.content.forEach(function(fragment) { // If the fragment is an attachment // use the stored content to instantiate a new Attachment object // to be properly rendered after a cold start for the app if (fragment.blob) { fragment = new Attachment(fragment.blob, { name: fragment.name, isDraft: true }); } // Append each fragment in order to the composer Compose.append(fragment); }, Compose); }, getText: function() { var out = this.getContent().filter(function(elem) { return (typeof elem === 'string'); }); return out.join(''); }, isEmpty: function() { return state.empty; }, setupLock({ canSend, canEdit, forceType }) { state.lock.canSend = canSend; state.lock.canEdit = canEdit; state.lock.forceType = forceType; }, refresh() { this.updateType(); this.updateSendButton(); this.updateAttachButton(); }, scrollToTarget: function(target) { // target can be an element or a selection range var targetRect = target.getBoundingClientRect(); // put the middle of the target at the middle of the container box var containerRect = dom.message.getBoundingClientRect(); var offset = (targetRect.top + targetRect.height / 2) - (containerRect.top + containerRect.height / 2); // we += because the scrollTop that was set is already compensated // with the getBoundingClientRect() dom.message.scrollTop += offset; }, scrollMessageContent: function() { if (document.activeElement === dom.message) { // we just got the focus: ensure the caret is visible, and clone // the range to avoid any unnecessary selectionchange event fired // by modifying the range associated with current selection var range = window.getSelection().getRangeAt(0).cloneRange(); if (range.collapsed) { // We can't get the bounding client rect of a collapsed range, // so let's insert a temporary node to get the caret position. range.insertNode(document.createElement('span')); this.scrollToTarget(range); range.deleteContents(); } else { this.scrollToTarget(range); } state.lastScrollPosition = dom.message.scrollTop; } else { // we just lost the focus: restore the last scroll position dom.message.scrollTop = state.lastScrollPosition; } }, /** Writes node to composition element * @param {mixed} item Html, DOMNode, or attachment to add * to composition element. * @param {Boolean} position True to append, false to prepend or * undefined/null for auto (at cursor). */ prepend: function(item) { var fragment = insert(item); if (!fragment) { return this; } // If the first element is a
, it needs to stay first // insert after it but before everyting else if (dom.message.firstChild && dom.message.firstChild.nodeName === 'BR') { dom.message.insertBefore(fragment, dom.message.childNodes[1]); } else { dom.message.insertBefore(fragment, dom.message.childNodes[0]); } onContentChanged(item); return this; }, // if item is an array,ignore calling onContentChanged for each item append: function(item, options) { options = options || {}; if (Array.isArray(item)) { var containsImage = false; item.forEach(function(content) { if (content.type === 'img') { containsImage = true; } this.append(content, {ignoreChange: true}); }, this); onContentChanged({containsImage: containsImage}); } else { var fragment = insert(item); if (!fragment) { return this; } if (document.activeElement === dom.message) { // insert element at caret position var range = window.getSelection().getRangeAt(0); var firstNodes = fragment.firstChild; range.deleteContents(); range.insertNode(fragment); this.scrollToTarget(range); dom.message.focus(); range.setStartAfter(firstNodes); } else { // insert element at the end of the Compose area dom.message.insertBefore(fragment, dom.message.lastChild); this.scrollToTarget(dom.message.lastChild); } if (!options.ignoreChange) { onContentChanged(item); } } return this; }, clear: function() { // changing the type here prevents the "type" event from being fired state.type = 'sms'; this.onTypeChange(); // Dispose attachments attachments.forEach((attachment, node) => disposeAttachmentNode(node)); dom.message.innerHTML = '
'; subject.reset(); state.resizing = false; state.size = 0; resetSegmentInfo(); segmentInfoTimeout = null; onContentChanged(); return this; }, focus: function() { dom.message.focus(); // Put the cursor at the end of the message var selection = window.getSelection(); var range = document.createRange(); var lastChild = dom.message.lastChild; if (lastChild.tagName === 'BR') { range.setStartBefore(lastChild); } else { range.setStartAfter(lastChild); } selection.removeAllRanges(); selection.addRange(range); return this; }, updateType: function() { var newType = this.getType(); if (newType !== state.type) { state.type = newType; this.emit('type'); } }, getType() { var typeForcedByLock = state.lock.forceType && state.lock.forceType(); if (typeForcedByLock) { return typeForcedByLock; } var isTextTooLong = state.segmentInfo.segments > Settings.maxConcatenatedMessages; return isTextTooLong || hasSubject() || hasAttachment() ? 'mms' : 'sms'; }, updateEmptyState: function() { state.empty = state.emptyMessage && !hasSubject(); }, updateSendButton: function() { dom.sendButton.disabled = !this.canSend(); }, updateAttachButton: function() { dom.attachButton.disabled = !this.canEdit(); }, canSend() { var isSendAllowedByLock = !state.lock.canSend || state.lock.canSend(); var sizeExceedsLimit = this.type === 'mms' && Settings.mmsSizeLimitation && this.size > Settings.mmsSizeLimitation; // We can't send if message is empty, we're in the process of resizing or // message is already too big. return isSendAllowedByLock && !sizeExceedsLimit && !this.isEmpty() && !this.isResizing; }, canEdit() { var isEditAllowedByLock = !state.lock.canEdit || state.lock.canEdit(); var sizeExceedsOrEqualToLimit = this.type === 'mms' && Settings.mmsSizeLimitation && this.size >= Settings.mmsSizeLimitation; // User can continue to type message if it's either SMS, MMS which size // doesn't exceed maximum allowed size or maximum allowed size is not set. return isEditAllowedByLock && !sizeExceedsOrEqualToLimit; }, _onAttachmentRequestError: function c_onAttachmentRequestError(err) { var errId = err instanceof DOMError ? err.name : err.message; if (errId === 'file too large') { Utils.alert({ id: 'attached-files-too-large', args: { n: 1, mmsSize: (Settings.mmsSizeLimitation / 1024).toFixed(0) } }); //'pick cancelled' is the error thrown when the pick activity app is // canceled normally } else if (errId !== 'ActivityCanceled' && errId !== 'pick cancelled') { console.warn('Unhandled error: ', err); } }, onAttachClick: function c_onAttachClick(event) { this.requestAttachment().then( this.append.bind(this), this._onAttachmentRequestError ); }, onAttachmentClick: function c_onAttachmentClick(event) { if (!event.target.classList.contains(attachmentClass) || state.resizing) { return; } var currentAttachmentDOM = event.target; var currentAttachment = attachments.get(event.target); var name = currentAttachment.name; var blob = currentAttachment.blob; var options = { header: name.substr(name.lastIndexOf('/') + 1) }; // Localize the name of the file type var mimeFirstPart = blob.type.substr(0, blob.type.indexOf('/')); var fileType = (TYPES.indexOf(mimeFirstPart) > -1) ? mimeFirstPart: 'other'; var onView = () => { currentAttachment.view(); }; function onRemove() { dom.message.removeChild(currentAttachmentDOM); disposeAttachmentNode(currentAttachmentDOM); state.size = null; onContentChanged(); } var onReplace = () => { this.requestAttachment().then((newAttachment) => { var fragment = insert(newAttachment); dom.message.insertBefore(fragment, currentAttachmentDOM); dom.message.removeChild(currentAttachmentDOM); disposeAttachmentNode(currentAttachmentDOM); onContentChanged(newAttachment); }, this._onAttachmentRequestError); }; options.items = [ { l10nId: `view-attachment-${fileType}`, method: onView }, { l10nId: `remove-attachment-${fileType}`, method: onRemove }, { l10nId: `replace-attachment-${fileType}`, method: onReplace }, { l10nId: 'cancel' } ]; new OptionMenu(options).show(); }, onTypeChange: function c_onTypeChange() { if (this.type === 'sms') { dom.message.setAttribute('x-inputmode', '-moz-sms'); } else { dom.message.removeAttribute('x-inputmode'); } dom.form.dataset.messageType = this.type; }, updateMessageCounter: function c_updateMessageCounter() { var counterValue = ''; if (this.type === 'sms') { var segments = state.segmentInfo.segments; var availableChars = state.segmentInfo.charsAvailableInLastSegment; if (segments && (segments > 1 || availableChars <= MIN_AVAILABLE_CHARS_COUNT)) { counterValue = availableChars + '/' + segments; } } if (counterValue !== dom.counter.textContent) { dom.counter.textContent = counterValue; } }, /** Initiates a 'pick' MozActivity allowing the user to create an * attachment * @return {Promise} A Promise for the underlying DOMRequest API. * Resolve will be triggered when activity returns * successfully with result blob size smaller than limit. * Reject will be triggered when activity returns error * or blob size exceeds limit. */ requestAttachment: function() { var activityData = { type: ['image/*', 'audio/*', 'video/*', 'text/vcard'] }; var activity; var defer = Utils.Promise.defer(); if (Settings.mmsSizeLimitation) { activityData.maxFileSizeBytes = Settings.mmsSizeLimitation; } activity = new MozActivity({ name: 'pick', data: activityData }); activity.onsuccess = function() { var result = activity.result; if (Settings.mmsSizeLimitation && result.blob.size > Settings.mmsSizeLimitation && Utils.typeFromMimeType(result.blob.type) !== 'img') { defer.reject(new Error('file too large')); } defer.resolve(new Attachment(result.blob, { name: result.name, isDraft: true })); }; // Re-throw Gecko-level errors activity.onerror = () => { defer.reject(activity.error); }; return defer.promise; } }; Object.defineProperty(compose, 'type', { get: function composeGetType() { return state.type; } }); Object.defineProperty(compose, 'size', { get: function composeGetSize() { if (state.size === null) { state.size = this.getContent().reduce(function(sum, content) { if (typeof content === 'string') { return sum + content.length; } else { return sum + content.size; } }, 0); } return state.size; } }); Object.defineProperty(compose, 'segmentInfo', { get: function composeGetSegmentInfo() { return state.segmentInfo; } }); Object.defineProperty(compose, 'isResizing', { get: function composeGetResizeState() { return state.resizing; } }); Object.defineProperty(compose, 'isSubjectVisible', { get: function composeIsSubjectVisible() { return subject.isVisible(); } }); return EventDispatcher.mixin(compose, [ 'input', 'type', 'segmentinfochange', 'interact', 'subject-change' ]); }());