Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,564 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<meta name="theme-group" content="theme-communications">
<meta name="theme-color" content="var(--header-background)">
<title>Messages</title>
<!--App Style root.css-->
<link rel="stylesheet" type="text/css" href="/views/shared/style/root.css">
<!-- Stable Building blocks -->
<link rel="stylesheet" type="text/css" href="/shared/style/animations.css">
<link rel="stylesheet" type="text/css" href="/shared/style/action_menu.css">
<link rel="stylesheet" type="text/css" href="/shared/style/option_menu.css">
<link rel="stylesheet" type="text/css" href="/shared/style/buttons.css">
<link rel="stylesheet" type="text/css" href="/shared/style/confirm.css">
<link rel="stylesheet" type="text/css" href="/shared/style/edit_mode.css">
<link rel="stylesheet" type="text/css" href="/shared/style/input_areas.css">
<link rel="stylesheet" type="text/css" href="/shared/style/switches.css">
<link rel="stylesheet" type="text/css" href="/shared/style/lists.css">
<link rel="stylesheet" type="text/css" href="/shared/style/progress_activity.css">
<link rel="stylesheet" type="text/css" href="/shared/style/status.css">
<link rel="stylesheet" type="text/css" href="/shared/style/tabs.css" >
<link rel="stylesheet" type="text/css" href="/shared/elements/gaia-icons/gaia-icons.css">
<!-- App styles -->
<link rel="stylesheet" type="text/css" href="/views/shared/style/sms.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/panel.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/edit-mode.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/notification.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/common.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/composer.css">
<link rel="stylesheet" type="text/css" href="/views/conversation/style/message.css">
<link rel="stylesheet" type="text/css" href="/views/conversation/style/report_view.css">
<link rel="stylesheet" type="text/css" href="/views/conversation/style/attachment.css">
<link rel="stylesheet" type="text/css" href="/views/conversation/style/conversation.css">
<link rel="stylesheet" type="text/css" href="/views/conversation/style/edit-mode.css">
<link rel="stylesheet" type="text/css" href="/views/new_message/style/new_message.css">
<link rel="stylesheet" type="text/css" href="/views/new_message/style/recipients.css">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/sms.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<link rel="localization" href="/shared/locales/intl/unitformat.{locale}.properties">
<script defer src="/mocks/navigator_moz_icc_manager.js"></script>
<!-- Firefox Desktop shims -->
<script defer src="/desktop-mock/navigator_moz_system_messages.js"></script>
<script defer src="/desktop-mock/navigator_moz_settings.js"></script>
<script defer src="/desktop-mock/navigator_moz_mobilemessage.js"></script>
<script defer src="/desktop-mock/navigator_moz_contacts.js"></script>
<!-- Web Components -->
<script defer src="/shared/elements/config.js"></script>
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<script async src="/shared/elements/gaia-header/dist/gaia-header.js"></script>
<link rel="localization" href="/shared/elements/gaia_menu/locales/gaia_menu.{locale}.properties">
<link rel="localization" href="/shared/elements/gaia_sim_picker/locales/gaia_sim_picker.{locale}.properties">
<!--
<script defer src="/shared/js/component_utils.js"></script>
<script defer src="/shared/elements/gaia_menu/script.js"></script>
<script defer src="/shared/elements/gaia_sim_picker/script.js"></script>
<link rel="stylesheet" type="text/css" href="/shared/elements/gaia_menu/style.css">
<link rel="stylesheet" type="text/css" href="/shared/elements/gaia_sim_picker/style.css">
-->
<!-- Shared code -->
<script defer src="/shared/js/intl/l20n.js"></script>
<!-- Should be removed while gecko support navigator.mozHour12 API -->
<script defer src="/shared/js/date_time_helper.js"></script>
<script defer src="/shared/js/template.js"></script>
<script defer src="/shared/js/async_storage.js"></script>
<script defer src="/shared/js/lazy_loader.js"></script>
<script defer src="/shared/js/contact_photo_helper.js"></script>
<script defer src="/shared/js/fb/fb_request.js"></script>
<script defer src="/shared/js/fb/fb_data_reader.js"></script>
<script defer src="/shared/js/fb/fb_reader_utils.js"></script>
<script defer src="/shared/js/event_dispatcher.js"></script>
<script defer src="/views/shared/js/utils.js"></script>
<script defer src="/views/shared/js/navigation.js"></script>
<script defer src="/views/shared/js/inter_instance_event_dispatcher.js"></script>
<script defer src="/views/shared/js/time_headers.js"></script>
<script defer src="/views/shared/js/contacts.js"></script>
<script defer src="/services/js/drafts.js"></script>
<script defer src="/services/js/threads.js"></script>
<script defer src="/views/shared/js/settings.js"></script>
<script defer src="/services/js/message_manager.js"></script>
<script defer src="/views/shared/js/app.js"></script>
<script defer src="/shared/js/multi_sim_action_button.js"></script>
<script defer src="/views/shared/js/task_runner.js"></script>
<script defer src="/views/shared/js/wbmp.js"></script>
<script defer src="/views/shared/js/smil.js"></script>
<script defer src="/views/shared/js/shared_components.js"></script>
<script defer src="/views/conversation/js/link_helper.js"></script>
<script defer src="/shared/js/image_utils.js"></script>
<script defer src="/views/conversation/js/attachment.js"></script>
<script defer src="/views/conversation/js/attachment_renderer.js"></script>
<script defer src="/views/conversation/js/subject_composer.js"></script>
<script defer src="/views/conversation/js/compose.js"></script>
<script defer src="/shared/js/gesture_detector.js"></script>
<script defer src="/lib/bridge/client.js"></script>
<script defer src="/services/js/activity/activity_client.js"></script>
<script defer src="/views/conversation/js/recipients.js"></script>
<script defer src="/views/conversation/js/conversation.js"></script>
<script defer src="/views/conversation/js/startup.js"></script>
<script defer src="/views/shared/js/bootstrap.js"></script>
<!-- Lazy load scripts
<script defer src="/shared/js/settings_url.js"></script>
<script defer src="/shared/js/mime_mapper.js"></script>
<script defer src="/shared/js/mobile_operator.js"></script>
<script defer src="/shared/js/settings_listener.js"></script>
<script defer src="/shared/js/option_menu.js"></script>
<script defer src="/views/shared/js/errors.js"></script>
<script defer src="/views/shared/js/shared_components.js"></script>
<script defer src="/views/shared/js/dialog.js"></script>
<script defer src="/views/shared/js/error_dialog.js"></script>
<script defer src="/views/shared/js/waiting_screen.js"></script>
<script defer src="/views/shared/js/activity_picker.js"></script>
<script defer src="/views/conversation/js/link_action_handler.js"></script>
<script defer src="/views/shared/js/contact_renderer.js"></script>
<script defer src="/views/shared/js/localization_helper.js"></script>
<script defer src="/views/conversation/js/information.js"></script>
<script defer src="/services/js/messaging/messaging_client.js"></script>
<script defer src="/services/js/moz_mobile_connections/moz_mobile_connections_client.js"></script>
-->
</head>
<body role="application" class="skin-comms theme-communications">
<iframe src="/views/shared/shim_host.html" class="shim-host" hidden></iframe>
<article id="main-wrapper" class="wrapper">
<section class="panel panel-ConversationView editable-select-mode" role="region" aria-hidden="true">
<gaia-header id="messages-header" class="view-header regular-header" action="back" no-font-fit>
<h1 id="messages-header-text"></h1>
<button id="messages-call-number-button" data-icon="call" class="hide" data-l10n-id="callButton"></button>
<button id="messages-options-button" data-icon="more" data-l10n-id="actionsButton"></button>
</gaia-header>
<div id="messages-subheader" class="subheader">
<section id="messages-to-field">
<section data-l10n-id="to" id="to-label">
To:
</section>
<!-- When overflow occurs in the recipients list, scrolling is
commonly triggered automatically through focus and keyboard events.
In these cases, white space described by CSS `padding-bottom` will
not be automatically scrolled into view (because the element will
only scroll far enough to bring the cursor into view). A container
element is necessary to consistently render this vertical space
(instead realized with CSS `margin-bottom`). -->
<div id="messages-recipients-list-container" class="bb-editable"
role="group" aria-labelledby="to-label">
<section id="messages-recipients-list" class="singleline">
</section>
</div>
<menu type="toolbar">
<a id="messages-contact-pick-button">
<span class="icon icon-user"></span>
</a>
</menu>
</section>
<div id="contact-carrier" class="carrier-wrapper"></div>
<!-- Notifications in this container will be rendered on top of the
Compose field. These notifications should be hidden automatically so
they do not prevent the user from inputting text. -->
<div class="overlay">
<section id="messages-subject-max-length-notice"
role="notification"
class="hide">
<p data-l10n-id='messages-max-subject-length-text'></p>
</section>
<section id="messages-max-length-notice"
role="notification"
class="hide">
<p></p>
</section>
<section id="messages-sms-counter-notice"
role="notification"
class="hide">
<p></p>
</section>
<section id="messages-convert-notice"
role="notification"
class="hide">
<p></p>
</section>
<section id="messages-resize-notice"
role="notification"
class="hide">
<p data-l10n-id="resize-image">Resizing the attached image</p>
</section>
<section id="messages-new-message-notice"
role="notification"
class="hide">
<div class="text-container">
<div data-l10n-id="new-message-notice-text">You have new messages</div>
<div dir="auto" class="contact">Contact</div>
</div>
<div class="button-container">
<button class="notice-button" data-l10n-id="new-message-notice-a-text">read it</button>
</div>
</section>
</div>
</div>
<article id="composer-container" class="view-body">
<div class="flex-composer">
<div id="messages-container" data-type="list" class="article-list"></div>
<form id="messages-compose-form" role="search" class="bottom" data-message-type="sms">
<section class="mms-label-line">
<section class="subject-composer js-subject-composer hide">
<div contentEditable="true" class="subject-composer-input"
role="textbox"
aria-multiline="false">
</div>
<div class="subject-composer-placeholder"
data-l10n-id="messagesSubjectInput_placeholder">
</div>
</section>
<span class="mms-label"
data-l10n-id="mms-label">MMS</span>
</section>
<section class="content-composer">
<div class="messages-attach-container">
<button id="messages-attach-button" aria-label="Add attachment" data-l10n-id="attach-button" type="button"></button>
</div>
<div class="composer-message-input-container">
<div id="messages-input" contentEditable="true" name="message"
x-inputmode="-moz-sms" data-l10n-id="messagesInput"
role="textbox" aria-multiline="true">
</div>
</div>
<div class="composer-button-container">
<span class="message-counter js-message-counter"></span>
<button id="messages-send-button" class="icon" aria-label="Send" data-l10n-id="send-button" type="submit">
<div class="js-sim-indication sim-indication" data-l10n-id="send-button-sim-indication"></div>
</button>
</div>
</section>
</form>
</div>
</article>
<form role="dialog" id="messages-edit-form" data-type="edit" >
<section role="region" class="theme-settings">
<gaia-header id="messages-edit-header" action="close" no-font-fit>
<h1 id="messages-edit-mode" data-l10n-id="deleteMessages-title"></h1>
<button id="messages-check-uncheck-all-button" class="edit-button" data-l10n-id="select-all"></button>
</gaia-header>
</section>
<div class="bottom-tab" role="region" >
<ul class="bb-tablist">
<li role="presentation">
<button id="messages-delete-button" class="disable-when-dialog-animates" data-action="delete-button"></button>
</li>
</ul>
</div>
</form>
<section id="messages-recipient-suggestions" class="article-list hide" data-type="list">
<ul class="contact-list recipient-suggestions-list" role="listbox"></ul>
</section>
</section>
<section class="panel panel-GroupView" role="region" aria-hidden="true">
<gaia-header id="information-group-header" class="header" action="back" no-font-fit>
<h1 data-l10n-id="participant" class="header-text"></h1>
</gaia-header>
<article class="view-body article-list" data-type="list">
<ul class="contact-list"></ul>
</article>
</section>
<section class="panel panel-ReportView" role="region" aria-hidden="true">
<gaia-header id="information-report-header" class="header" action="close" no-font-fit>
<h1 data-l10n-id="message-report"></h1>
</gaia-header>
<article class="view-body article-list report-view container" data-type="list">
<div class="description">
<p class="type"></p>
<p class="subject">
<span data-l10n-id="message-subject"></span>
<span class="detail"></span>
</p>
<p class="sent-block">
<span class="sent-title"></span>
<span class="sent-timestamp l10n-contains-date detail"></span>
</p>
<p class="received-block">
<span data-l10n-id="message-received"></span>
<span class="received-timestamp l10n-contains-date detail"></span>
</p>
<p class="lateness-block">
<span data-l10n-id="message-lateness"></span>
<span class="lateness">
<span class="lateness-month detail"></span>
<span class="lateness-day detail"></span>
<span class="lateness-hour detail"></span>
<span class="lateness-minute detail"></span>
<span class="lateness-second detail"></span>
</span>
</p>
<p class="size-block">
<span data-l10n-id="message-size-was"></span>
<span class="size detail"></span>
</p>
<p class="sim-info hide">
<span data-l10n-id="message-via"></span>
<span class="sim-detail detail"></span>
</p>
</div>
<span class="contact-title"></span>
<ul class="contact-list"></ul>
<button class="resend-btn" data-l10n-id="message-resend">
</button>
</article>
</section>
</article>
<article id="loading">
<form role="dialog" data-type="confirm" class="loading-container">
<section>
<p class="loading-header" data-l10n-id="deleting-messages">Deleting messages...</p>
<div role="status">
<progress class="small"></progress>
</div>
</section>
</form>
</article>
<!--
- Templates
-->
<div id="contact-suggestion-tmpl" class="hide">
<!--
<li role="presentation">
<a class="suggestion js-contact-info" role="option"
data-number="${number}" data-source="contacts" data-name="${name}"
data-type="${type}" data-carrier="${carrier}">
${photoHTML}
<p class="name"><bdi class="ellipsis-dir-fix">${nameHTML}</bdi></p>
<p class="number">${phoneDetailsHTML}</p>
</a>
</li>
-->
</div>
<div id="contact-report-tmpl" class="hide">
<!--
<li>
<div class="js-contact-info">
${photoHTML}
<p class="name"><bdi class="ellipsis-dir-fix">${nameHTML}</bdi></p>
<p class="number">${phoneDetailsHTML}</p>
</div>
</li>
-->
</div>
<div id="contact-suggestion-unknown-tmpl" class="hide">
<!--
<li role="presentation">
<a class="suggestion" role="option"
data-number="${number}" data-source="contacts" data-name="${name}"
data-type="${type}" data-carrier="${carrier}">
${photoHTML}
<p class="name">${phoneDetailsHTML}</p>
<p class="number">
<span data-l10n-id="unknown-contact-title">Unknown Contact</span>
</p>
</a>
</li>
-->
</div>
<div id="contact-prompt-tmpl" class="hide">
<!--
<div class="contact-header">
${photoHTML}
<p class="name"><bdi class="ellipsis-dir-fix">${name}</bdi></p>
<p class="details">${phoneDetailsHTML}</p>
</div>
-->
</div>
<div id="contact-photo-tmpl" class="hide">
<!--
<aside role="img" data-l10n-id="contact-photo" aria-label="Contact photo" class="pack-end">
<span class="contact-photo" data-type="img"></span>
</aside>
-->
</div>
<div id="contact-highlight-tmpl" class="hide">
<!--
<span class="highlight">${str}</span>
-->
</div>
<div id="messages-number-tmpl" class="hide">
<!--
<a class="suggestion js-contact-info" data-number="${number}" data-source="manual" data-name="${number}">
<p class="name"><bdi class="ellipsis-dir-fix">${number}</bdi></p>
</a>
-->
</div>
<div id="information-report-tmpl" class="hide">
<!--
<span class="icon"></span>
<span data-l10n-id="${titleL10n}"></span>
<span class="timestamp l10n-contains-date"
data-l10n-date="${timestamp}"
data-l10n-date-format="${messageL10nDateFormat}"
>${reportDateL10n}</span>
-->
</div>
<div id="messages-not-downloaded-tmpl" class="hide">
<!--
<span class="not-downloaded-message l10n-contains-date"
data-l10n-id="${messageL10nId}"
data-l10n-args="${messageL10nArgs}"
data-l10n-date="${messageL10nDate}"
data-l10n-date-format="${messageL10nDateFormat}"></span>
<button type="button" class="download" data-l10n-id="${downloadL10nId}"></button>
-->
</div>
<div id="messages-date-group-tmpl">
<!--
<div id="${id}" class="messages-date-group" data-timestamp="${timestamp}">
<header
class="messages-date-group-header"
data-time-update="${headerTimeUpdate}"
data-time="${headerTime}"
data-date-only="${headerDateOnly}">
</header>
<ul class="message-list"></ul>
</div>
-->
</div>
<div id="messages-message-tmpl" class="hide">
<!--
<label class="pack-checkbox danger">
<input type="checkbox" value="${id}">
<span></span>
</label>
<section class="bubble">
<h1 class="message-subject">
${subject}
</h1>
<div class="message-content">
<p class="message-content-body" tabindex="-1">${bodyHTML}</p>
<div class="message-details">
${simInformationHTML}
<time data-time-update="true" data-time-only="true" data-time="${timestamp}"></time>
${messageStatusHTML}
</div>
</div>
</section>
-->
</div>
<div id="messages-message-sim-information-tmpl" class="hide">
<!--
<span class="message-sim-information"
data-l10n-id="sim-id-label"
data-l10n-args="${simNumberL10nArgs}">
</span>
-->
</div>
<div id="messages-message-status-tmpl" class="hide">
<!--
<div class="message-status" data-l10n-id="${statusL10nId}"
aria-label="Message status"
role="status">
</div>
-->
</div>
<div id="messages-late-arrival-notice-tmpl" class="hide">
<!--
<div class="late-arrival-icon" data-l10n-id="late-notice-label" aria-label="Late arrival"></div>
<div class="late-arrival-notice"></div>
-->
</div>
<div id="messages-recipient-tmpl" class="hide">
<!--
<span class="${className}" x-inputmode="verbatim"
contenteditable="${editable}" role="${role}"
data-number="${number}" data-source="${source}" data-name="${name}"
data-carrier="${carrier}" data-type="${type}" dir="auto">
${name}
</span>
-->
</div>
<div id="attachment-nopreview-tmpl" class="hide">
<!--
<div class="attachment ${errorClass}" data-attachment-type="${type}">
<div class="thumbnail-placeholder ${type}-placeholder">
</div>
<div class="js-size-indicator size-indicator" data-l10n-id="${sizeL10nId}" data-l10n-args="${sizeL10nArgs}"></div>
</div>
<div class="file-name" dir="auto"></div>
-->
</div>
<div id="attachment-preview-tmpl" class="hide">
<!--
<div class="attachment">
<div class="thumbnail"></div>
<div class="js-size-indicator size-indicator" data-l10n-id="${sizeL10nId}" data-l10n-args="${sizeL10nArgs}"></div>
</div>
<div class="file-name" dir="auto"></div>
-->
</div>
<div id="attachment-draft-tmpl" class="hide">
<!--
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<base href="${baseURL}">
<link rel="stylesheet" type="text/css" href="views/conversation/style/attachment_draft.css">
<link rel="stylesheet" type="text/css" href="views/conversation/style/attachment.css">
</head>
<body class="attachment-draft ${cssClass}">${baseHTML}</body>
-->
</div>
<!--
Please, don't change template markup alignment, it's done this way to
remove any whitespace symbols between inline elements.
-->
<div id="phone-details-tmpl" class="hide">
<!--
<span class="${hasTypeClass} ${hasCarrierClass}"><span
dir="auto" class="phone-type"
data-l10n-id="${type}">${type}</span><span
class="phone-type-separator"
data-l10n-id="phone-type-separator"></span><span
class="phone-number" dir="ltr">${number}</span><span
class="phone-carrier-separator"
data-l10n-id="phone-carrier-separator"></span><span
class="phone-carrier" dir="auto">${carrier}</span>
</span>
-->
</div>
<div id="messages-group-header-tmpl" class="hide">
<!--
<span class="group-header-title"><bdi class="ellipsis-dir-fix">${name}</bdi>&nbsp;<bdi dir="ltr">(+${participantCount})</bdi></span>
-->
</div>
<div id="messages-header-tmpl" class="hide">
<!--
<bdi class="ellipsis-dir-fix">${name}</bdi>
-->
</div>
<article>
<gaia-sim-picker id="sim-picker" tabindex="-1"></gaia-sim-picker>
</article>
</body>
</html>

View file

@ -0,0 +1,122 @@
/** Creates an Attachment object
* @param {String} type of attachment (image, video, etc).
* @param {String} uri Location or datauri of image to show.
* @param {Number} size Size of attachment in bytes.
* @return {Attachment} new attachment object.
*/
/*global AttachmentRenderer, MimeMapper, MozActivity, Utils*/
(function(exports) {
'use strict';
// Path to the folder that stores all saved attachments
const ATTACHMENT_FOLDER_PATH = 'sms-attachments/';
/**
* Gets actual base file name (name.extension) from its path.
* @param {String} filepath
* @return {String} actual base file name i.e name.extension
*/
function getBaseName(filePath) {
if (!filePath) {
throw new Error('Filepath is not defined!');
}
return filePath.substring(filePath.lastIndexOf('/') + 1);
}
/**
* set Property of Attachment from given blob and options object.
* @param {Object, Object} (blob object, options object)
*/
function Attachment(blob, options) {
options = options || {};
this.blob = blob;
this.isDraft = !!options.isDraft;
this.name = blob.name || options.name || '';
if (typeof this.name !== 'string') {
this.name = '';
}
// force the _renderer property to be non enumerable so that we don't try to
// store it in IndexedDB
Object.defineProperty(this, '_renderer', { writable: true });
}
Attachment.prototype = {
/* private methods */
_getAttachmentRenderer: function() {
this._renderer = this._renderer || AttachmentRenderer.for(this);
return this._renderer;
},
/* public properties */
get size() {
return this.blob.size;
},
get type() {
return Utils.typeFromMimeType(this.blob.type);
},
/* public methods */
render: function(readyCallback) {
var attachmentRenderer = this._getAttachmentRenderer();
attachmentRenderer.render().catch(function(e) {
console.error('Error occurred while rendering attachment.', e);
}).then(readyCallback);
// We still need this for the case where we render a list of attachments
// and right order is important.
return attachmentRenderer.getAttachmentContainer();
},
updateFileSize: function() {
var attachmentRenderer = this._getAttachmentRenderer();
attachmentRenderer.updateFileSize();
},
view: function(options) {
// Make sure media is openable and savable even if
// file extenion is missing or invalid but mimetype is supported.
var l10nPromise = this.name ? Promise.resolve(this.name) :
document.l10n.formatValue('unnamed-attachment');
return l10nPromise.then(name => {
// Make sure media is openable and savable even if
// blob mimetype is unsupported but file extension is valid.
var mimetype =
MimeMapper.guessTypeFromFileProperties(
name,
this.blob.type.toLowerCase()
);
var filename = MimeMapper.ensureFilenameMatchesType(name, mimetype);
// Override filename, so that every attachment that is saved via "open"
// activity will be placed in the single location.
filename = ATTACHMENT_FOLDER_PATH + getBaseName(filename);
var activity = new MozActivity({
name: 'open',
data: {
type: mimetype,
filename: filename,
blob: this.blob,
allowSave: options && options.allowSave
}
});
activity.onerror = function() {
console.error('error with open activity', this.error.name);
if (this.error.name === 'NO_PROVIDER') {
Utils.alert('attachmentOpenError');
}
};
});
}
};
exports.Attachment = Attachment;
}(this));

View file

@ -0,0 +1,300 @@
/*global Promise, Template, Utils,
ImageUtils
*/
/* exported AttachmentRenderer */
(function(exports) {
'use strict';
// do not create thumbnails for too big attachments
// (see bug 805114 for a similar issue in Gallery)
const MAX_THUMBNAIL_GENERATION_SIZE = 10 * 1024 * 1024; // 5MB
// Actual thumbnails size should be 10 rem (100px) * devicePixelRatio
const THUMBNAIL_SIZE = 100 * window.devicePixelRatio;
/**
* A <div> container suits most of the cases where we want to display an
* MMS attachment (= icon + file name + file size). However, drafts are a
* specific case because they are inside an editable area.
*
* A <div contenteditable="false"> container would be fine for drafts but
* Gecko does not support it at the moment, see bug 685445:
* https://bugzilla.mozilla.org/show_bug.cgi?id=685445
*
* Instead of a bunch of JavaScript to manage where the caret is and what
* to delete on backspace, the contentEditable region treats the iframe as
* a simple block. Outside of the Compose area, a <div> container is still
* fine -- and it's *way* faster.
*/
const RENDERERS = {
draft: {
createAttachmentContainer: function() {
var container = document.createElement('iframe');
// The attachment's iFrame requires access to the parent document's
// context so that URIs for Blobs created in the parent may resolve as
// expected.
container.setAttribute('sandbox', 'allow-same-origin');
return container;
},
/**
* Renders baseMarkup into container node (in this case iframe).
* @param baseMarkup Base attachment HTML markup. It should be safely
* escaped in advance!
* @param attachmentContainer Attachment container node.
* @returns {Promise.<Node>} Content container node is the container node
* for the attachment base HTML markup that allows consumer code to
* perform post processing DOM operations.
*/
renderTo: function(data, attachmentContainer) {
attachmentContainer.classList.add(data.cssClass);
var template = Template('attachment-draft-tmpl');
var markup = template.interpolate({
baseURL: location.protocol + '//' + location.host + '/',
baseHTML: data.markup,
cssClass: data.cssClass
}, { safe: ['baseHTML'] });
var blob = new Blob([markup], { type: 'text/html' });
var blobUrl = window.URL.createObjectURL(blob);
attachmentContainer.src = blobUrl;
return this._whenLoaded(attachmentContainer).then(function onload() {
// do some postprocessing after it's loaded
window.URL.revokeObjectURL(blobUrl);
var contentDocument = attachmentContainer.contentDocument;
var documentElement = contentDocument.documentElement;
documentElement.lang = document.documentElement.getAttribute('lang');
documentElement.dir = document.documentElement.getAttribute('dir');
document.l10n.translateFragment(documentElement);
// Attach click listeners and fire the callback when rendering is
// complete: we can't bind `readyCallback' to the `load' event
// listener because it would break our unit tests.
// Bubble click events from inside the iframe.
contentDocument.addEventListener(
'click',
() => attachmentContainer.click()
);
// Return actual content container node to allow postprocessing of
// DOM content without dealing with iframe structure.
return contentDocument.body;
});
},
_whenLoaded: function(iframe) {
var innerDocument = iframe.contentDocument;
// checking for the class is a way to check that the correct content is
// loaded. If the IFrame is inserted to the DOM before we change the
// src, its default src about:blank is loaded and we have a body, albeit
// a bad one.
if (innerDocument && innerDocument.body &&
innerDocument.body.classList.contains('attachment-draft')) {
return Promise.resolve();
}
return new Promise((resolve) => {
iframe.addEventListener('load', function onload() {
this.removeEventListener('load', onload);
resolve();
});
});
},
/**
* Returns the inner DOM node.
*/
getContentNode: function(attachmentContainer) {
return this._whenLoaded(attachmentContainer).then(
() => attachmentContainer.contentDocument.documentElement
);
},
setL10nAttributes: function(element, l10nId, l10nArgs) {
document.l10n.setAttributes(
element, l10nId, l10nArgs
);
// l10n library can't see changes inside iframes yet
document.l10n.translateFragment(element);
}
},
base: {
createAttachmentContainer: function() {
return document.createElement('div');
},
renderTo: function(data, attachmentContainer) {
attachmentContainer.classList.add(data.cssClass);
attachmentContainer.innerHTML = data.markup;
return Promise.resolve(attachmentContainer);
},
getContentNode: function(attachmentContainer) {
return Promise.resolve(attachmentContainer);
},
setL10nAttributes: function(element, l10nId, l10nArgs) {
document.l10n.setAttributes(
element, l10nId, l10nArgs
);
}
}
};
var AttachmentRenderer = function(attachment) {
this._attachment = attachment;
this._renderer = attachment.isDraft ? RENDERERS.draft : RENDERERS.base;
this._attachmentContainer = null;
};
/**
* Gets DOM node that will be container for the attachment markup. This method
* will create new container if it wasn't before.
* @returns {Node}
*/
AttachmentRenderer.prototype.getAttachmentContainer = function() {
if (!this._attachmentContainer) {
this._attachmentContainer = this._createAttachmentContainer();
}
return this._attachmentContainer;
};
AttachmentRenderer.prototype.render = function() {
var attachmentContainer = this.getAttachmentContainer();
// Currently we try to extract thumbnail for image only, for the rest of
// types (audio, video and etc.) we display default attachment placeholder.
// Video type should be revisited with:
// Bug 924609 - Video thumbnails previews are not showing in MMS when
// attaching or receiving a video.
var thumbnailPromise = this._attachment.type === 'img' &&
this._attachment.size < MAX_THUMBNAIL_GENERATION_SIZE ?
this.getThumbnail() : Promise.reject();
return thumbnailPromise.
then((thumbnail) => {
return {
markup: this._getBaseMarkup('attachment-preview-tmpl'),
cssClass: 'preview',
thumbnail: thumbnail
};
}).
catch((error) => {
return {
markup: this._getBaseMarkup('attachment-nopreview-tmpl', !!error),
cssClass: 'nopreview'
};
}).
then((data) => {
return this._renderer.renderTo(data, attachmentContainer).
then((contentContainer) => {
var thumbnailNode = contentContainer.querySelector('.thumbnail');
if (thumbnailNode) {
thumbnailNode.style.backgroundImage =
'url("' + data.thumbnail.url + data.thumbnail.fragment + '")';
// It's essential to remember real image URL to revoke it later
attachmentContainer.dataset.thumbnail = data.thumbnail.url;
}
var fileNode = contentContainer.querySelector('.file-name');
if (this._attachment.name) {
fileNode.removeAttribute('data-l10n-id');
fileNode.textContent = this._attachment.name.slice(
this._attachment.name.lastIndexOf('/') + 1
);
} else {
fileNode.setAttribute('data-l10n-id', 'unnamed-attachment');
}
});
});
};
AttachmentRenderer.prototype.updateFileSize = function() {
var attachmentContainer = this.getAttachmentContainer();
return this._renderer.getContentNode(attachmentContainer).then(
(contentNode) => {
var sizeIndicator = contentNode.querySelector('.js-size-indicator');
if (!sizeIndicator) {
throw new Error('updateFileSize should be called after a render().');
}
var sizeL10n = Utils.getSizeForL10n(this._attachment.size);
this._renderer.setL10nAttributes(
sizeIndicator, sizeL10n.l10nId, sizeL10n.l10nArgs
);
}
);
};
/**
* Extracts thumbnail for the image.
* TODO: As we started to use mozSampleSize for thumbnail generation we need
* to check if we still want to store this thumbnail data (indexedDB)
* Bug 876467 - [MMS] generate, store, and reuse image thumbnails
* @returns {Promise}
*/
AttachmentRenderer.prototype.getThumbnail = function() {
// The thumbnail format matches the blob format.
var blob = this._attachment.blob;
return ImageUtils.getSizeAndType(blob).then(
function getSizeResolve(data) {
var fragment = ImageUtils.Downsample.sizeNoMoreThan(
THUMBNAIL_SIZE / Math.min(data.width, data.height)
);
return {
url: window.URL.createObjectURL(blob),
fragment: fragment
};
}
);
};
/**
* Creates new attachment container.
* @returns {Node}
*/
AttachmentRenderer.prototype._createAttachmentContainer = function() {
var attachmentContainer = this._renderer.createAttachmentContainer();
attachmentContainer.classList.add('attachment-container');
attachmentContainer.dataset.attachmentType = this._attachment.type;
return attachmentContainer;
};
/**
* Returns HTML string that represents base attachment markup.
* @param templateId Id of the template to use.
* @param hasError Indicates whether something is wrong with attachment.
* @returns {string}
*/
AttachmentRenderer.prototype._getBaseMarkup = function(templateId, hasError) {
// interpolate the #attachment-[no]preview-tmpl template
var sizeL10n = Utils.getSizeForL10n(this._attachment.size);
return Template(templateId).interpolate({
type: this._attachment.type,
errorClass: hasError ? 'corrupted' : '',
sizeL10nId: sizeL10n.l10nId,
sizeL10nArgs: JSON.stringify(sizeL10n.l10nArgs)
});
};
exports.AttachmentRenderer = {
for: function(attachment) {
return new AttachmentRenderer(attachment);
}
};
}(this));

View file

@ -0,0 +1,848 @@
/* -*- 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 <br> 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 <br>, 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 = '<br>';
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'
]);
}());

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,582 @@
/*global ContactRenderer,
Contacts,
ConversationView,
MessageManager,
MobileOperator,
Navigation,
Settings,
Template,
Threads,
Utils
*/
/*exported Information */
(function(exports) {
'use strict';
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const LATENESS_DISPLAY_LOWER_LIMIT = 5 * MINUTE;
/*
Information view module is a subpanel belongs to TheadUI panel. This
module provide some default method for thiw view module:
show: Reuse the conversation view for information view. Hiding unrelated icon,
reset the header, and render container for showing the view.
reset: Hide the view container, clean up the contact list and resume the
icons/header for ConversationView.
renderContactList(participants, options): Rendering group-view style contact
list inside the page. Participants is an array of contact number and option
is for setting additional information for rendering other lock for contact
information.
*/
var TMPL = function createTemplate(tmpls) {
for (var key in tmpls) {
tmpls[key] = Template(tmpls[key]);
}
return tmpls;
}({
number: 'messages-number-tmpl',
report: 'information-report-tmpl'
});
/*
Summarized single status based on delivery and read status for mms.
The 1st level properties represents delivery status and 2nd level properties
represents read status.
*/
const REPORT_MAP = {
'not-applicable': {
'not-applicable': 'not-applicable',
'pending' : 'pending',
'success' : 'read',
'error' : 'error'
},
'pending' : {
'not-applicable': 'pending',
'pending': 'pending',
'success' : 'read', // should not possible
'error' : 'error'
},
'success' : {
'not-applicable': 'delivered',
'pending': 'delivered',
'success' : 'read',
'error' : 'error'
},
'error' : {
'not-applicable': 'error',
'pending': 'error',
'success' : 'error', // should not possible
'error' : 'error' // should not possible
}
};
// Register the message events we wanted for report view refresh
const MESSAGE_EVENTS = [
'message-failed-to-send',
'message-delivered',
'message-read',
'message-sent',
'message-sending'
];
const reportDateTimeFormatOptions = {
month: 'long',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: 'numeric'
};
function completeLocaleFormat(timestamp) {
var options = Object.assign(
{ hour12: navigator.mozHour12 },
reportDateTimeFormatOptions);
return new Date(+timestamp).toLocaleString(navigator.languages, options);
}
function l10nContainsDateSetup(element, timestamp) {
element.dataset.l10nDate = timestamp;
element.dataset.l10nDateFormat = JSON.stringify(reportDateTimeFormatOptions);
element.textContent = completeLocaleFormat(timestamp);
}
/**
* Generate report Div contains delivery report and read report for showing
* report information within contact list
*/
function createReportDiv(reports) {
var reportDiv = document.createElement('div');
reportDiv.className = 'network-status';
var data = {
titleL10n: '',
reportDateL10n: '',
messageL10nDateFormat: JSON.stringify(reportDateTimeFormatOptions),
timestamp: ''
};
var status;
var deliveryStatus = reports.deliveryStatus;
var readStatus = reports.readStatus;
if (!readStatus) { // sms
status = deliveryStatus === 'success' ?
'delivered' :
deliveryStatus;
} else if (deliveryStatus === 'rejected') {
// Status = 'rejected' when receiver is not allowed to download any mms
status = 'rejected';
} else if (deliveryStatus in REPORT_MAP) {
status = REPORT_MAP[deliveryStatus][readStatus];
} else {
console.error('Invalid message report status: ' + deliveryStatus);
return reportDiv;
}
reportDiv.dataset.deliveryStatus = status;
switch (status) {
case 'not-applicable':
return reportDiv;
case 'delivered':
data.timestamp = '' + reports.deliveryTimestamp;
data.reportDateL10n = completeLocaleFormat(reports.deliveryTimestamp);
break;
case 'read':
data.timestamp = '' + reports.readTimestamp;
data.reportDateL10n = completeLocaleFormat(reports.readTimestamp);
break;
}
data.titleL10n = 'report-status-' + status;
reportDiv.innerHTML = TMPL.report.interpolate(data);
return reportDiv;
}
function showSimInfo(element, iccId) {
var iccManager = navigator.mozIccManager;
// Hide the element when single SIM or no iccManager/mobileConnections
if (!(Settings.hasSeveralSim() && iccId && iccManager)) {
return;
}
var simInfoElement = element.querySelector('.sim-detail');
var simId = Settings.getServiceIdByIccId(iccId);
if (simId === null) {
document.l10n.setAttributes(
simInfoElement,
'dsds-unknown-sim'
);
} else {
var operator = getOperatorByServiceId(simId);
var icc = iccManager.getIccById(iccId);
var number = icc && icc.iccInfo.msisdn;
var data = {};
var l10nId;
var info = [operator, number].filter(function(value) {
return value;
});
var detailString = info.join(', ');
l10nId = info.length ? 'sim-detail' : 'sim-id-label';
data = { id: simId + 1, detailString: detailString };
document.l10n.setAttributes(
simInfoElement,
l10nId,
data
);
}
element.classList.remove('hide');
}
/**
* Incoming message: return array of sender number string;
* Outgoing message: return array of object(number and report div block).
*/
function createListWithMsgInfo(message) {
var list = [];
if (message.delivery === 'received' ||
message.delivery === 'not-downloaded') { // received message
if (message.type === 'mms') {
if (message.receivers) {
list.push(...message.receivers);
}
if (!list.includes(message.sender)) {
list.push(message.sender);
}
} else {
list.push(message.sender);
}
} else if (message.type === 'mms') { // sent mms message
message.deliveryInfo.forEach(function(info) {
list.push({number: info.receiver,
infoBlock: createReportDiv(info)});
});
} else { // sent sms message
var info = {};
info.deliveryStatus = message.deliveryStatus;
info.deliveryTimestamp = message.deliveryTimestamp;
list.push({number: message.receiver,
infoBlock: createReportDiv(info)});
}
return list;
}
/**
* Will return operator name depending on the iccId.
* Will return the empty string in a single SIM scenario.
*/
function getOperatorByServiceId(index) {
var conn = navigator.mozMobileConnections[index];
return MobileOperator.userFacingInfo(conn).operator;
}
var VIEWS = {
group: {
name: 'group-view',
selector: '.panel-GroupView',
contactFlavor: 'group-view',
render: function renderGroup() {
var participants = Threads.get(this.id).participants;
this.renderContactList(participants);
document.l10n.setAttributes(
this.headerText, 'participant', { n:participants.length }
);
},
setEventListener: function setEventListener() {
this.contactList.addEventListener('click', (event) => {
event.stopPropagation();
event.preventDefault();
var target = event.target;
ConversationView.promptContact({
number: target.dataset.number
}).then(
() => Navigation.toPanel('thread', { id: this.id })
);
});
},
elements: ['contact-list', 'header', 'header-text']
},
report: {
name: 'report-view',
selector: '.panel-ReportView',
contactFlavor: 'report-view',
init: function() {
this.onStatusChanged = this.onStatusChanged.bind(this);
},
beforeEnter: function() {
this.constructor.prototype.beforeEnter.apply(this, arguments);
MESSAGE_EVENTS.forEach((event) => {
MessageManager.on(event, this.onStatusChanged);
});
},
afterLeave: function() {
this.constructor.prototype.afterLeave.apply(this, arguments);
MESSAGE_EVENTS.forEach((event) => {
MessageManager.off(event, this.onStatusChanged);
});
},
render: function renderReport() {
var setL10nAttributes = document.l10n.setAttributes;
var request = MessageManager.getMessage(this.id);
// Hide these dynamic fields to avoid incorrect info displayed at first
this.subject.classList.add('hide');
this.sizeBlock.classList.add('hide');
request.onsuccess = (function() {
var message = request.result;
var type = message.type;
var delivery = message.delivery;
var isIncoming = delivery === 'received' ||
delivery === 'not-downloaded';
// Fill in the description/status/size
if (type === 'sms') {
setL10nAttributes(this.type, 'message-type-sms');
} else { //mms
setL10nAttributes(this.type, 'message-type-mms');
// subject text content
var subject = message.subject;
this.subject.classList.toggle('hide', !subject);
if (subject) {
this.subject.querySelector('.detail').textContent = subject;
}
// Message total size show/hide
if (message.attachments && message.attachments.length > 0) {
var size = message.attachments.reduce(function(size, attachment) {
return (size += attachment.content.size);
}, 0);
var params = Utils.getSizeForL10n(size);
setL10nAttributes(this.size, params.l10nId, params.l10nArgs);
this.sizeBlock.classList.remove('hide');
}
}
this.container.dataset.delivery = delivery;
this.container.classList.toggle('incoming-message', isIncoming);
this.container.classList.toggle('outgoing-message', !isIncoming);
// If incoming message is migrated from the database where sentTimestamp
// hadn't been supported yet then we won't have valid value for it.
this.container.classList.toggle(
'no-valid-sent-timestamp',
isIncoming && !message.sentTimestamp
);
setL10nAttributes(
this.contactTitle,
isIncoming ? 'report-from-title' : 'report-to-title'
);
if (isIncoming) {
l10nContainsDateSetup(this.receivedTimestamp, message.timestamp);
l10nContainsDateSetup(this.sentTimestamp, message.sentTimestamp);
setL10nAttributes(this.sentTitle, 'message-sent');
this.renderLateArrivalMsg(message);
} else {
if (delivery === 'sending' || delivery === 'sent') {
setL10nAttributes(this.sentTitle, 'message-' + delivery);
}
if (delivery === 'error' || delivery === 'sent') {
l10nContainsDateSetup(this.sentTimestamp, message.timestamp);
}
}
//show sim information for dual sim device
showSimInfo(this.simInfo, message.iccId);
// Filled in the contact list. Only outgoing message contains detailed
// report information.
this.renderContactList(createListWithMsgInfo(message));
}).bind(this);
},
renderLateArrivalMsg: function report_renderLateArrivalMsg(message) {
var latenessBlock = this.panel.querySelector('.lateness-block');
var lateness = message.timestamp - message.sentTimestamp;
var showLatenessNotice = message.timestamp &&
message.sentTimestamp &&
lateness >= LATENESS_DISPLAY_LOWER_LIMIT;
if (showLatenessNotice) {
var units = [
{ unit: 'month', unitVal: MONTH },
{ unit: 'day', unitVal: DAY },
{ unit: 'hour', unitVal: HOUR },
{ unit: 'minute', unitVal: MINUTE },
{ unit: 'second', unitVal: SECOND }
];
units.forEach(({unit, unitVal}) => {
var output;
var unitElement = this.panel.querySelector(`.lateness-${unit}`);
if (lateness >= unitVal) {
// Compute integer value for the current unit output
// and get the rest for the next smaller unit
output = Math.floor(lateness / unitVal);
lateness = lateness % unitVal;
document.l10n.setAttributes(
unitElement,
`duration-${unit}-narrow`,
{ value: output }
);
unitElement.classList.remove('hide');
} else {
unitElement.classList.add('hide');
}
});
latenessBlock.classList.remove('hide');
} else {
latenessBlock.classList.add('hide');
}
},
// Set this flag to true only when resend is triggered.
messageResending: false,
setEventListener: function report_setEventListener() {
this.resendBtn.addEventListener('click', () => {
this.messageResending = true;
ConversationView.resendMessage(this.id);
});
},
isReportForMessage: function report_isReportForMessage(id) {
return Navigation.isCurrentPanel('report-view', { id: id }) ||
(Navigation.isCurrentPanel('report-view') &&
this.id === id);
},
onStatusChanged: function report_onStatusChanged(e) {
// If we got sending status change in report view after resend clicked
// (messageResending is true), we should change report panel id, reset
// messageResending flag and refresh for new message report.
if (e.message.delivery === 'sending' && this.messageResending) {
this.id = e.message.id;
this.messageResending = false;
}
this.isReportForMessage(e.message.id) && this.refresh();
},
elements: ['contact-list', 'size', 'size-block', 'type', 'sent-title',
'sent-timestamp', 'received-timestamp', 'subject', 'sim-info',
'contact-title', 'resend-btn', 'header', 'container'
]
}
};
var Information = function(type) {
Object.assign(this, VIEWS[type]);
if (this.init) {
this.init();
}
this.panel = document.querySelector(this.selector);
this.elements.forEach(function(name) {
this[Utils.camelCase(name)] = this.panel.querySelector('.' + name);
}, this);
this.header.addEventListener(
'action', this.backOrClose.bind(this)
);
this.setEventListener && this.setEventListener();
this.reset();
};
Information.prototype = {
constructor: Information,
beforeEnter: function(args) {
this.id = +args.id;
this.header.removeAttribute('no-font-fit');
this.show();
},
afterLeave: function() {
this.reset();
this.id = null;
},
show: function() {
this.render();
},
refresh: function() {
if (Navigation.isCurrentPanel(this.name)) {
this.render();
}
},
reset: function() {
// Remove all LIs
if (this.contactList) {
this.contactList.textContent = '';
}
},
backOrClose: function() {
Navigation.back();
},
// Incrementing ID for each rendering request to avoid possible race when next
// renderContactList request earlier than list item appended to contact list
// ul. Ignoring the rendering/appending request if the rendering ID doesn't
// match the latest rendering ID.
renderingId: 0,
// Param participants could be:
// - Array of contact number string or
// - Array of object({ number: number, infoBlock: infoBlock })
// for rendering the contact list.
renderContactList: function(participants) {
var ul = this.contactList;
var renderer = ContactRenderer.flavor(this.contactFlavor);
var currentRenderingId = ++this.renderingId;
ul.textContent = '';
participants.forEach((participant) => {
var number, infoBlock, selector;
if (typeof participant === 'object') {
number = participant.number;
infoBlock = participant.infoBlock;
selector = '.js-contact-info';
} else {
number = participant;
}
Contacts.findByAddress(number).then((contacts) => {
// If the current rendering ID doesn't match the latest ID, skip current
// one and only render for ID which matches latest request ID.
if (currentRenderingId !== this.renderingId) {
return;
}
if (contacts.length > 0) {
renderer.render({
contact: contacts[0],
input: number,
infoBlock: infoBlock,
infoBlockParentSelector: selector,
target: ul
});
} else {
var li = document.createElement('li');
li.role = 'presentation';
li.innerHTML = TMPL.number.interpolate({
number: number
});
var parentBlock = li.querySelector(selector);
if (parentBlock && infoBlock) {
parentBlock.appendChild(infoBlock);
}
ul.appendChild(li);
}
});
});
}
};
Information.initDefaultViews = function() {
// Create group / report information view
exports.GroupView = new Information('group');
exports.ReportView = new Information('report');
};
exports.Information = Information;
// end global closure
}(this));

View file

@ -0,0 +1,49 @@
/*global ActivityPicker, ConversationView */
(function(exports) {
'use strict';
/*
Centralized event handling for various
data-actions url, email, phone in a message
*/
var LinkActionHandler = {
onClick: function lah_onClick(event) {
event.preventDefault();
event.stopPropagation();
var dataset = event.target.dataset;
var action = dataset.action;
if (!action) {
return;
}
if (action === 'email-link') {
ConversationView.prompt({
email: dataset.email,
inMessage: true
});
}
if (action === 'dial-link') {
ConversationView.promptContact({
number: dataset.dial,
inMessage: true
});
}
if (action === 'url-link') {
var type = action.replace('-link', '');
ActivityPicker[type](
dataset[type], this.reset, this.reset
);
}
}
};
exports.LinkActionHandler = LinkActionHandler;
}(this));

View file

@ -0,0 +1,301 @@
/*global Utils */
(function() {
'use strict';
/*
The LinkHelper exposes functionality to apply the regexp
on the input strings and replace them with corresponding
anchor links for url, phone, email.
*/
var KNOWN_TLDS = [
'aero', 'asia', 'biz', 'cat', 'com', 'coop', 'info',
'int', 'jobs', 'mobi', 'museum', 'name', 'net', 'org', 'post', 'pro',
'tel', 'travel', 'xxx', 'edu', 'gov', 'mil', 'nyc', 'ac',
'ad', 'ae', 'af', 'ag', 'ai', 'al', 'am', 'an', 'ao', 'aq', 'ar',
'as', 'at', 'au', 'aw', 'ax', 'az', 'ba', 'bb', 'bd', 'be', 'bf',
'bg', 'bh', 'bi', 'bj', 'bm', 'bn', 'bo', 'br', 'bs', 'bt', 'bv',
'no', 'bw', 'by', 'bz', 'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci',
'ck', 'cl', 'cm', 'cn', 'co', 'cr', 'cs', 'cu', 'cv', 'cx', 'cy',
'cz', 'dd', 'de', 'dj', 'dk', 'dm', 'do', 'dz', 'ec', 'ee', 'eg',
'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm', 'fo', 'fr',
'ga', 'gb', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm', 'gn',
'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn',
'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir',
'is', 'it', 'je', 'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km',
'kn', 'kp', 'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk',
'lr', 'ls', 'lt', 'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'me', 'mg',
'mh', 'mk', 'ml', 'mm', 'mn', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms',
'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'nc', 'ne', 'nf',
'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu', 'nz', 'om', 'pa', 'pe',
'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr', 'ps', 'pt', 'pw',
'py', 'qa', 're', 'ro', 'rs', 'ru', 'su', 'рф', 'rw', 'sa', 'sb',
'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'no', 'sk', 'sl', 'sm',
'sn', 'so', 'sr', 'ss', 'st', 'su', 'sv', 'sx', 'sy', 'sz', 'tc',
'td', 'tf', 'tg', 'th', 'tj', 'tk', 'tl', 'tp', 'tm', 'tn', 'to',
'tp', 'tl', 'tr', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'uk', 'us',
'gov', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf',
'ws', 'ye', 'yt', 'yu', 'za', 'zm', 'zw'
];
var ipv4part = '(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)';
var ipv4RegExp = new RegExp('^(?:' + ipv4part + '\\.){3}' + ipv4part + '$');
var leadingDigitsRegExp = /^[+0-9]+/;
// ensure that each part of the domain is long enough
function checkDomain(domain) {
// Check for a specific IPv4 address
if (ipv4RegExp.test(domain)) {
return true;
} else {
// Don't add many restrictions,
// just the tld to be non numeric and length > 1
var parts = domain.split('.');
var lastPart = parts[parts.length - 1];
// We want the last part not to be a number
return lastPart.length > 1 && !isFinite(lastPart);
}
}
// defines things that can match right before to be a "safe" link
var safeStart = /[\s.,:;\(>\u0080-\uFFFF]/;
const MINIMUM_DIGITS_IN_PHONE_NUMBER = 5;
const LEADING_DIGIT_BREAKPOINT = 7;
/**
* For each category of links:
* The key is the link type
* order matters - defined first means higher priority if two matches equal
*
* regexp: The regular expression to match potential links
* matchFilter: (optional) A function that is passed the matched part, and
* the linkSpec.
* Should return the linkSpec, or false if not to be matched
* transform: A function that converts the match data to a html link string
* Is passed the matched part and the linkSpec.
*/
var LINK_TYPES = {
phone: {
regexp: new RegExp([
// sddp: space, dot, dash or parens
'(?:\\+\\d{1,4}[ \\t.()-]{0,3}|\\()?' + // (\+<digits><sddp>|\()?
'(?:\\d{1,4}[ \\t.()-]{0,3})?' + // <digits><sdd>*
'(?:\\d[\\d \\t.()-]{0,12}\\d)' + // <digit><digit|sddp>*<digit>
'\\b' // must end on a word boundary
].join(''), 'g'),
matchFilter: function phoneMatchFilter(phone, link) {
var onlyDigits = Utils.removeNonDialables(phone);
if (onlyDigits.length < MINIMUM_DIGITS_IN_PHONE_NUMBER) {
return false;
}
if (onlyDigits.length === MINIMUM_DIGITS_IN_PHONE_NUMBER &&
phone.length !== onlyDigits.length) {
return false;
}
var leadingDigits = leadingDigitsRegExp.exec(phone);
if (leadingDigits &&
leadingDigits[0].length >= LEADING_DIGIT_BREAKPOINT) {
link.end = link.start + leadingDigits[0].length;
}
return link;
},
transform: function phoneTransform(phone, link) {
return '<a dir="ltr" data-dial="' + phone +
'" data-action="dial-link">' + phone + '</a>';
}
},
url: {
regexp: new RegExp([
// must begin at start of string, after whitespace,
// {1} match the protocol https?:// or rtsp:// (optional)
'(https?://|rtsp://)?',
// {2} match "server name": . must be followed by at least one letter
'(\\w(?:\\.?[-\\w]){0,255})',
// {3} match a . followed by one or more domain valid chars
'(\\.\\w{1,10})',
// optional :port
'(?::[0-9]{1,5})?',
// start the "query" capture group by matching an optional dot then /
'(?:\\.?/',
// anything other than a sentence ender
// a sentence ender not followed by whitespace
// match 0 - 2048 characters in the url
'(?:[^\\s.,?:;!]|[.,?:;!](?!\\s|$)){0,2048}',
// end the "query" capture group (optional)
')?'
].join(''), 'mgi'),
matchFilter: function urlMatchFilter(url, linkSpec) {
var match = linkSpec.match;
var scheme, tld;
if (!checkDomain(match[2] + match[3])) {
return false;
}
scheme = match[1];
tld = match[3] && match[3].slice(1);
// For Cases where:
//
// 1. There was no scheme (eg, "http", "https", "rtsp")
// 2. The matched tld is not a number
// 3. The matched tld is not a known tld
//
// ... Do not create a clickable link
if (!scheme && (!isFinite(tld) && KNOWN_TLDS.indexOf(tld) === -1)) {
return false;
}
// strip a trailing ) if there isn't a ( in the url
if (url.slice(-1) === ')' && url.indexOf('(') === -1) {
linkSpec.end--;
}
return linkSpec;
},
transform: function urlTransform(url, linkSpec) {
var href = url;
if (!linkSpec.match[1]) {
href = 'http://' + href;
}
return '<a data-url="' + href + '" data-action="url-link" >' + url +
'</a>';
}
},
email: {
regexp: /[\w.+-]+@[\w.-]+\.[a-z.]{2,6}/mgi,
transform: function emailTransform(email) {
return [
'<a data-email="',
'" data-action="email-link">',
'</a>'
].join(email);
}
}
};
var LINK_TYPES_KEYS = Object.keys(LINK_TYPES);
function searchForLinks(type, string) {
var linkSpecs = [];
var spec = LINK_TYPES[type];
if (!spec) {
return linkSpecs;
}
var regexp = spec.regexp;
var matchFilter = spec.matchFilter;
var match, linkSpec;
// while we match stuff...
while ((match = regexp.exec(string))) {
// if the match isn't at the begining of the string, check for a safe
// character before the match
var rest = string.slice(match.index - 1);
if (match.index && !safeStart.test(rest.charAt(0))) {
// we should only advance the regexp to the next safeStart
var nextSafe = safeStart.exec(rest);
if (nextSafe) {
regexp.lastIndex = match.index + nextSafe.index;
}
continue;
}
linkSpec = {
type: type,
start: match.index,
length: match[0].length,
end: match.index + match[0].length,
match: match
};
if (matchFilter) {
linkSpec = matchFilter(match[0], linkSpec);
}
if (linkSpec) {
linkSpecs.push(linkSpec);
regexp.lastIndex = linkSpec.end;
}
}
return linkSpecs;
}
function linkSort(a, b) {
// sort by starting position first
// then by ending position reversed
// then by the order of the type in the LINK_TYPES
return (a.start - b.start) ||
(b.end - a.end) ||
(LINK_TYPES_KEYS.indexOf(a.type) - LINK_TYPES_KEYS.indexOf(b.type));
}
function removeOverlapping(linkSpecs) {
linkSpecs.sort(linkSort);
for (var index = 0; index < linkSpecs.length - 1; index++) {
var end = linkSpecs[index].end;
// while there are more linkSpecs, and they start before we end remove the
// overlapping matches
while (linkSpecs[index + 1] && linkSpecs[index + 1].start < end) {
linkSpecs.splice(index + 1, 1);
}
}
}
var LinkHelper = window.LinkHelper = {
searchAndLinkUrl: function lh_searchAndLinkUrl(inputText) {
return LinkHelper.searchAndLinkClickableData(inputText, { url: true });
},
searchAndLinkEmail: function lh_searchAndLinkEmail(inputText) {
return LinkHelper.searchAndLinkClickableData(inputText, { email: true });
},
searchAndLinkPhone: function lh_searchAndLinkPhone(inputText) {
return LinkHelper.searchAndLinkClickableData(inputText, { phone: true });
},
// Invokes resepective functions to change url, phone and email strings
// and make them active links
// |inputText| should be already html escaped
searchAndLinkClickableData:
function lh_searchAndLinkClickableData(inputText, mode) {
// default is everything enabled
mode = mode || LINK_TYPES;
var linkSpecs = [];
var type;
for (type in mode) {
if (mode[type] && LINK_TYPES[type]) {
linkSpecs = linkSpecs.concat(searchForLinks(type, inputText));
}
}
removeOverlapping(linkSpecs);
var result = '';
var lastEnd = 0;
linkSpecs.forEach(function replaceLink(link) {
result += inputText.slice(lastEnd, link.start);
var replacing = inputText.slice(link.start, link.end);
var replaceWith = LINK_TYPES[link.type].transform(replacing, link);
result += replaceWith;
lastEnd = link.end;
});
result += inputText.slice(lastEnd);
return result;
}
};
})();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,87 @@
/* global App,
ConversationView,
Information,
InterInstanceEventDispatcher,
LazyLoader,
LocalizationHelper,
MessageManager,
MessagingClient,
MozMobileConnectionsClient,
Navigation,
Settings,
Threads,
TimeHeaders,
Utils
*/
(function(exports) {
'use strict';
const LAZY_DEPENDENCIES = [
'/services/js/messaging/messaging_client.js',
'/services/js/moz_mobile_connections/moz_mobile_connections_client.js',
'/shared/js/settings_listener.js',
'/shared/js/mime_mapper.js',
'/shared/js/option_menu.js',
'/shared/js/mobile_operator.js',
'/shared/elements/gaia_sim_picker/script.js',
'/views/shared/js/waiting_screen.js',
'/views/shared/js/errors.js',
'/views/shared/js/dialog.js',
'/views/shared/js/error_dialog.js',
'/views/conversation/js/link_action_handler.js',
'/views/shared/js/contact_renderer.js',
'/views/shared/js/activity_picker.js',
'/views/conversation/js/information.js',
'/views/shared/js/localization_helper.js'
];
function initLazyDependencies() {
return LazyLoader.load(LAZY_DEPENDENCIES).then(() => {
LocalizationHelper.init();
TimeHeaders.init();
Information.initDefaultViews();
Settings.init();
MessagingClient.init(App.instanceId);
MozMobileConnectionsClient.init(App.instanceId);
Navigation.setReady();
InterInstanceEventDispatcher.connect();
});
}
function initShims() {
exports.InboxView = Object.freeze({
markReadUnread: () => {},
updateThread: () => {}
});
var deferred = Utils.Promise.defer();
MessageManager.getThreads({
each: (thread) => Threads.set(thread.id, thread),
done: deferred.resolve
});
return deferred.promise;
}
exports.Startup = {
init() {
Utils.initializeShimHost(App.instanceId);
MessageManager.init();
ConversationView.init();
if (Navigation.isDefaultPanel()) {
ConversationView.once('visually-loaded', initLazyDependencies);
} else {
initLazyDependencies();
}
// Note that we won't be able to access report and group views directly
// because information.js is not loaded before ConversationView finishes
// loading.
initShims().then(() => Navigation.init());
}
};
})(window);

View file

@ -0,0 +1,178 @@
/* global EventDispatcher */
/* exported SubjectComposer */
(function(exports) {
'use strict';
const MAX_LENGTH = 40;
const privateMembers = new WeakMap();
function onKeyDown(e) {
/* jshint validthis: true */
var priv = privateMembers.get(this);
if (e.keyCode === e.DOM_VK_BACK_SPACE) {
if (!priv.isHoldingBackspace) {
priv.isEmptyOnBackspace = !this.getValue();
priv.isHoldingBackspace = true;
}
} else {
priv.isHoldingBackspace = false;
// Input char will be ignored when:
// - Reaching the maximum subject length. Any char input is not allowed
// - Return key(new line) input. Since new line won't work in subject
if (this.getValue().length >= MAX_LENGTH ||
e.keyCode === e.DOM_VK_RETURN) {
e.preventDefault();
e.stopPropagation();
}
}
}
function onKeyUp(e) {
/* jshint validthis: true */
var priv = privateMembers.get(this);
// User removes subject field by either:
// - Selecting options menu in top right hand corner, or
// - Selecting the delete button in the keyboard. if all
// the text is removed from the subject field and the user
// selects delete on the keyboard the subject field is removed
if (e.keyCode === e.DOM_VK_BACK_SPACE && priv.isHoldingBackspace &&
priv.isEmptyOnBackspace) {
this.hide();
}
priv.isEmptyOnBackspace = false;
priv.isHoldingBackspace = false;
}
function onInput(e) {
/* jshint validthis: true */
privateMembers.get(this).updateValue(e.target.textContent);
}
function updatePlaceholder() {
/* jshint validthis: true */
var priv = privateMembers.get(this);
if (priv.value) {
priv.placeholder.remove();
} else {
priv.node.appendChild(priv.placeholder);
}
}
function updateValue(newValue) {
/* jshint validthis: true */
var priv = privateMembers.get(this);
// Bug 877141 - contenteditable will insert non-break spaces when
// multiple consecutive spaces are entered, we don't want them.
if (newValue) {
newValue = newValue.replace(/\n\s*|\u00A0/g, ' ');
}
if (priv.value !== newValue) {
priv.value = newValue;
priv.updatePlaceholder();
this.emit('change');
}
return newValue;
}
var SubjectComposer = function(node) {
EventDispatcher.mixin(this, [
'focus',
'change',
'visibility-change'
]);
if (!node) {
throw new Error('Subject node is required');
}
var priv = {
isHoldingBackspace: false,
isEmptyOnBackspace: false,
isVisible: false,
value: '',
// nodes
node: node,
input: null,
placeholder: null,
// methods
updateValue: updateValue.bind(this),
updatePlaceholder: updatePlaceholder.bind(this),
};
privateMembers.set(this, priv);
priv.input = priv.node.querySelector('.subject-composer-input');
priv.placeholder = priv.node.querySelector(
'.subject-composer-placeholder'
);
priv.input.addEventListener('keydown', onKeyDown.bind(this));
priv.input.addEventListener('keyup', onKeyUp.bind(this));
priv.input.addEventListener('input', onInput.bind(this));
priv.input.addEventListener('focus', this.emit.bind(this, 'focus'));
};
SubjectComposer.prototype.show = function ms_show() {
this.toggle(true);
};
SubjectComposer.prototype.hide = function ms_hide() {
this.toggle(false);
};
SubjectComposer.prototype.toggle = function ms_toggle(toggle) {
var priv = privateMembers.get(this);
priv.node.classList.toggle('hide', !toggle);
priv.isVisible = toggle;
this.emit('visibility-change');
};
SubjectComposer.prototype.isVisible = function ms_is_visible() {
return privateMembers.get(this).isVisible;
};
SubjectComposer.prototype.focus = function ms_focus() {
privateMembers.get(this).input.focus();
};
SubjectComposer.prototype.getMaxLength = function ms_get_maxLength() {
return MAX_LENGTH;
};
SubjectComposer.prototype.getValue = function ms_get_value() {
return privateMembers.get(this).value;
};
SubjectComposer.prototype.setValue = function ms_set_value(value) {
var priv = privateMembers.get(this);
if (typeof value !== 'string') {
throw new Error('Value should be a valid string!');
}
priv.input.textContent = priv.updateValue(value);
};
SubjectComposer.prototype.reset = function ms_reset() {
var priv = privateMembers.get(this);
priv.value = priv.input.textContent = '';
priv.node.classList.add('hide');
priv.isVisible = false;
priv.updatePlaceholder();
};
exports.SubjectComposer = SubjectComposer;
})(window);

View file

@ -0,0 +1,214 @@
/**
* Attachment containers are either:
* - <div> elements, in the message thread;
* - <iframe> elements, in the Compose area (draft attachments).
* Both containers have an `attachment-container' class, but only <iframe>
* containers have an `attachment-draft' class.
*/
.attachment-container {
position: relative;
margin-bottom: 0.5rem;
display: block;
border: none;
height: 10rem;
width: 10rem;
-moz-user-select: none;
}
.attachment-draft {
margin: 0;
padding: 0;
font-family: sans-serif;
}
/* make sure all pointer events are sent to the main container */
.attachment-draft *,
.attachment-container * {
pointer-events: none;
}
.attachment-container.nopreview { /* 100x100 px placeholder + filename below */
height: 12.7rem; /* font-size = 1.7rem + 1rem margin */
}
/* Internal height of the iframe that contains the attachment draft */
.attachment-container.nopreview[data-attachment-type="vcard"] {
/* 40x40 px placeholder + filename below */
height: 6.7rem; /* font-size = 1.7rem + 1rem margin */
}
/* The internal height of the attachment itself */
.attachment[data-attachment-type="vcard"] {
height: 100%;
}
/* Allow to display the filename properly */
#messages-input > [data-attachment-type="vcard"] {
width: 20rem;
}
.article-list[data-type="list"] .message .attachment-container {
margin: 1rem 0;
}
/**
* Attachments that occur at the bounds of an MMS message do not need
* additional spacing.
*/
#messages-container[data-type="list"] .message .attachment-container:first-child {
margin-top: 0;
}
#messages-container[data-type="list"] .message .attachment-container:last-child {
margin-bottom: 0;
}
.attachment {
position: absolute;
top: 0;
offset-inline-start: 0;
width: 10rem;
height: 10rem;
}
.outgoing .attachment {
offset-inline-start: auto;
offset-inline-end: 0;
}
.preview .attachment {
box-sizing: border-box;
width: 100%;
height: 100%;
background-color: #ececec;
border: 2px solid #fff;
}
.preview .attachment:active,
.preview .attachment:hover {
background-color: #b2f2ff;
}
.size-indicator {
position: absolute;
bottom: 0;
right: 0;
left: 0;
padding: 0 0.4rem;
display: none;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 1.5rem;
}
.attachment-draft > :not([data-attachment-type="vcard"]) .size-indicator {
display: block;
}
.file-name {
position: absolute;
bottom: 0.5rem;
height: 2rem;
display: none;
line-height: 2rem;
font-size: 1.6rem;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* .file-name has a dir="auto". Moreover, sms.css in not included in attachment
* iframe, so we cannot leverage the general alignment rules. */
html[dir="ltr"] .file-name {
text-align: left;
left: 1px; /* Avoid issues related to being too much on the left */
right: 0;
}
html[dir="rtl"] .file-name {
text-align: right;
left: 0;
right: 1px;
}
.attachment-draft .file-name {
color: #4a4a4a;
}
.nopreview .file-name {
display: block;
unicode-bidi: -moz-plaintext;
}
.thumbnail {
width: 100%;
height: 100%;
background: none no-repeat center center / cover;
}
.thumbnail-placeholder {
width: 100%;
height: 100%;
/* BiDi-proof: CSS sprites */
background: url('images/attachments.png') no-repeat left top / 10rem;
}
.attachment-container .thumbnail,
.attachment-container .thumbnail-placeholder {
position: absolute;
top: 0
}
.attachment-draft:hover .thumbnail-placeholder:not(.vcard-placeholder),
.attachment-draft:active .thumbnail-placeholder:not(.vcard-placeholder),
.attachment-container:not([data-attachment-type="vcard"]):hover .thumbnail-placeholder,
.attachment-container:not([data-attachment-type="vcard"]):active .thumbnail-placeholder {
background-color: #33cccc;
background-blend-mode: luminosity;
}
.img-placeholder {
background-position: left -10rem; /* BiDi proof: CSS sprites */
}
.audio-placeholder {
background-position: left -20rem; /* BiDi proof: CSS sprites */
}
.video-placeholder {
background-position: left -30rem; /* BiDi proof: CSS sprites */
}
.vcard-placeholder {
background: url('images/attachment-vcard.png') no-repeat;
background-size: 4rem;
}
html[dir="ltr"] .vcard-placeholder { background-position: left top; }
html[dir="rtl"] .vcard-placeholder { background-position: right top; }
.incoming.received .vcard-placeholder {
background-image: url('images/attachment-vcard-incoming.png');
}
.attachment.corrupted .thumbnail-placeholder:after {
display: block;
position: absolute;
top: 0;
offset-inline-start: 0;
height: 100%;
width: 100%;
content: '';
background: url('icons/corrupted.png') no-repeat center center / 10rem;
}

View file

@ -0,0 +1,6 @@
html, body {
height: 100%;
width: 100%;
font-size: 10px;
}

View file

@ -0,0 +1,159 @@
#messages-container {
/* We'll set flex-basis with a shrink factor of 0 to do min-height.
* formula: flex-basis = a % + b)
* a = (gap2 - gap1) / keyboard_height
* b = gap2 - (gap2 - gap1) * (buri_height - header) / keyboard_height + subheader + time_header
*
* with :
* gap1 = 4rem
* minimal visible part of a thread when the keyboard is displayed, on buri
* gap2 = 8rem
* minimal visible part of a thread when the keyboard is hidden, on a buri
* keyboard_height = 26rem
* measured on a buri, standard english keyboard with suggestions
* header = 5rem
* subheader = 2rem
* buri_height = 48rem
* time_header = 2rem
*
* See https://bugzilla.mozilla.org/show_bug.cgi?id=1015867#c12 for more
* information
*/
flex: 1 0 calc(15.4% + 5.38rem);
overflow: auto;
}
#messages-new-message-notice {
display: flex;
flex-direction: row;
padding: 1rem;
padding-inline-start: 2rem;
padding-inline-end: 1.5rem;
overflow: hidden;
white-space: nowrap;
line-height: 2rem;
font-size: 1.8rem;
}
#messages-new-message-notice a {
margin: 0;
}
#messages-new-message-notice .contact {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#messages-new-message-notice > .text-container {
flex: auto;
overflow: hidden;
}
#messages-new-message-notice > .button-container {
flex: none;
padding: 0.2rem;
padding-inline-start: 1.5rem;
padding-inline-end: 0;
}
/* remove margin for notice-button class of button */
.notice-button {
margin: 0;
}
.group-header-title {
display: flex;
justify-content: center;
}
.carrier-wrapper {
display: none;
box-sizing: border-box;
height: 2.2rem;
padding: 0 1.5rem;
font-size: 1.4rem;
line-height: 2.2rem;
text-align: start;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
background: rgba(255, 255, 255, 0.95);
color: #333;
}
.carrier-wrapper .phone-carrier {
color: #008787;
}
.has-carrier .carrier-wrapper {
display: block;
}
#messages-container .messages-date-group-header {
position: sticky;
top: 0;
z-index: 1;
height: 2.2rem;
margin: 0;
padding: 0.6rem 3rem;
font-size: 1.4rem;
line-height: 2.2rem;
text-transform: uppercase;
background: rgba(225, 240, 236, 0.85);
color: #008787;
}
/* If has carrier change top position */
.has-carrier #messages-container .messages-date-group-header {
top: 2.2rem;
}
.has-carrier #messages-container > .messages-date-group:first-child > .messages-date-group-header {
margin-top: 2.2rem;
}
/* TODO: check whether we need this rule */
#messages-container .messages-date-group-header.hidden {
display: none;
}
.article-list[data-type="list"] .message {
z-index: 0; /* bug 970422 */
overflow: hidden;
width: 100%;
height: auto;
padding-bottom: 1rem;
border-bottom: none;
}
.article-list[data-type="list"] .message.hidden {
display: none;
}
#messages-container[data-type="list"] .message.mms .bubble img {
height: auto;
width: auto;
}
/* transition in and out edit mode, both for LTR and RTL contexts */
.message .bubble {
transition: transform var(--transition-duration) ease 0s;
}
/* RTL View */
#messages-header-text .ellipsis-dir-fix {
width: auto;
}

View file

@ -0,0 +1,33 @@
/*
* This file contains styles for Conversation view edit mode. They complement
* styles from views/shared/style/edit-mode.css that are applied as well.
* View edit mode is activated via Options -> Select messages.
*/
.edit #messages-container {
margin-bottom: 5.5rem;
}
.edit .article-list[data-type="list"] .message {
border-color: #e7e7e7;
}
.edit [data-type="list"] .message .bubble {
pointer-events: none;
}
.edit .message .bubble {
pointer-events: none;
}
html[dir="ltr"] .edit .message.incoming .bubble {
transform: translateX(2.5rem);
}
html[dir="rtl"] .edit .message.incoming .bubble {
transform: translateX(-2.5rem);
}
.edit #messages-compose-form {
display: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

View file

@ -0,0 +1,238 @@
.message.incoming {
padding-inline-start: 1.5rem;
padding-inline-end: 4rem;
}
.message.outgoing {
padding-inline-start: 4rem;
padding-inline-end: 1.5rem;
}
.message .bubble {
box-sizing: border-box;
height: 100%;
max-width: 100%;
padding: 1rem 1.5rem;
overflow: visible;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
}
.bubble .message-content-body:focus {
-moz-user-select: text;
}
.message.outgoing .bubble {
background: #fff;
}
html[dir="ltr"] .message.outgoing .bubble {
float: right;
border-radius: 1.5rem 0 1.5rem 1.5rem;
}
html[dir="rtl"] .message.outgoing .bubble {
float: left;
border-radius: 0 1.5rem 1.5rem 1.5rem;
}
.message.outgoing .bubble,
.message.outgoing a:active {
color: #333;
}
.message.incoming .bubble {
background: #39c3c2;
}
html[dir="ltr"] .message.incoming .bubble {
float: left;
border-radius: 0 1.5rem 1.5rem 1.5rem;
}
html[dir="rtl"] .message.incoming .bubble {
float: right;
border-radius: 1.5rem 0 1.5rem 1.5rem;
}
.message.incoming .bubble,
.message.incoming a:active {
color: #fff;
}
.message p.message-content-body {
white-space: pre-wrap;
outline: none;
}
/* BB overrides */
.message.outgoing p:first-of-type:last-of-type,
.message.incoming p:first-of-type:last-of-type {
padding-top: 0.5rem;
line-height: 2rem;
}
.message.incoming p:first-of-type {
color: #fff;
}
.message.sending p:first-of-type {
color: #6e6e6e;
}
/* End of BB overrides */
.outgoing.error .bubble {
background: #fae1df;
}
.incoming.error .bubble {
background: #ff9898;
}
.message.sending .bubble,
.message.sending a,
.message.sending a:active {
color: #6e6e6e;
}
.message.sending .message-subject,
.message.sending .message-content-body {
opacity: 0.5;
}
/* Message Subject */
.message .message-subject {
display: none;
margin-bottom: 1rem;
font-size: 1.4rem;
font-style: italic;
line-height: 1.7rem;
unicode-bidi: -moz-plaintext;
}
.message.has-subject .message-subject {
display: block;
}
/* Message content styles */
.message-content a {
color: #004eff;
text-decoration: underline;
}
.message-content > p {
unicode-bidi: -moz-plaintext;
}
.message-details {
display: flex;
margin-top: 0.5rem;
font-size: 1.4rem;
line-height: 2rem;
}
.message.sending time,
.message.sending .message-sim-information {
display: none;
}
.message.outgoing .message-details {
color: #858585;
}
.message.outgoing.error .message-details {
color: #e97f79;
}
.message.incoming .message-details {
color: #d1eeee;
}
.message.incoming .late-arrival-notice {
color: #026a69;
}
.message.incoming .late-arrival-icon {
margin-inline-start: 1rem;
margin-inline-end: 0.1rem;
min-width: 1.5rem;
height: 2rem;
background: no-repeat center center / 1.5rem;
background-image: url('images/icons/late_clock.png');
}
.message.incoming.error .message-details {
color: #ffe0dc;
}
.message-sim-information {
margin-inline-end: 0.4rem;
}
.message-sim-information::after {
content: ' ‧ ';
}
/* Message status aside panel */
.message .message-status {
height: 2rem;
min-width: 3rem;
background: no-repeat center center / 1.6rem;
font-size: 1.4rem;
}
.message.error .message-status {
background-image: url('images/icons/exclamation.png');
}
.message.delivered .message-status {
background-image: url('images/icons/deliveredtick.png');
}
.message.read .message-status {
background-image: url('images/icons/message_read.png');
}
.message.pending .message-status,
.message.sending .message-status {
background: none;
}
/* Message download button */
.message .download {
display: block;
min-height: initial;
width: auto;
padding: 0;
/* Eliminating container paragraph line-height */
margin: -1.5rem 0 0;
background: none;
color: #fff;
border: none;
/* We're using border-bottom instead of just underline style as line is too
close to the text and doesn't look similar to visual spec. */
border-bottom: 1px solid #fff;
}
.message .download:active {
opacity: 0.85;
}
/* hide download button via css on expired messages */
.message.expired .download {
display: none;
}
.message.pending .download {
border-color: transparent;
opacity: 0.8;
}

View file

@ -0,0 +1,167 @@
/* Message information page for report details*/
.report-view {
display: flex;
flex-direction: column;
background-color: #fff;
}
.report-view > .description {
padding-top: 2rem;
padding-inline-end: 1.5rem;
padding-bottom: 3rem;
padding-inline-start: 3rem;
}
.report-view p,
.report-view span {
font-size: 1.5rem;
line-height: 1rem;
color: #858585;
}
.report-view p {
padding-top: 1.5rem;
}
.report-view .description > .type {
padding-top: 0;
font-size: 1.6rem;
color: #ff4e00;
}
.report-view .description .detail {
color: #333;
}
.report-view .description > .type[data-l10n-id="message-type-sms"] {
color: #000;
}
/* Hiding some elements for some states */
.report-view[data-delivery="error"] .sent-title,
.report-view[data-delivery="sending"] .sent-timestamp,
.outgoing-message .received-block,
.outgoing-message .lateness-block {
display: none;
}
/* Hide sent timestamp label if we don't have valid value to display */
.report-view.no-valid-sent-timestamp .sent-block {
display: none;
}
.report-view .contact-title {
color: #008787;
font-size: 1.6rem;
padding-inline-start: 3rem;
}
.report-view > ul.contact-list {
/* Showing 4 complete contacts and part of the contact name if list
contains more than 4 contacts */
position: static;
margin-top: 0.5rem;
flex-grow: 1;
}
.report-view > .contact-list > li {
height: auto;
min-height: 9rem;
padding-top: 0.7rem;
padding-bottom: 1rem;
padding-inline-end: 0;
padding-inline-start: 1.5rem;
border-top: solid 0.1rem #e7e7e7;
border-bottom: medium none;
}
.report-view > .contact-list p,
.report-view > .contact-list span {
font-size: 1.4rem;
line-height: 1.6rem;
}
.report-view > .contact-list .name {
padding-top: 0;
font-size: 1.8rem;
font-weight: 500;
line-height: 2.6rem;
}
.report-view > .contact-list aside {
/* margin-top = (li - padding - aside) / 2 = (9rem - 2rem - 5.5rem) / 2 */
margin-top: 0.8rem;
}
.report-view > .contact-list .number {
padding-top: 0.6rem;
}
.report-view .network-status {
padding-top: 0.7rem;
}
.report-view .name + .network-status {
padding-top: 1rem;
}
.report-view .network-status,
.report-view .network-status span {
line-height: 1.4rem;
}
.contact-list p.number .phone-carrier {
color: #008787;
}
.report-view > .contact-list .icon {
width: 1.6rem;
height: 1.6rem;
margin: -0.1rem 0 0.1rem;
margin-inline-end: 0.7rem;
background: no-repeat center center / 1.6rem transparent;
}
html[dir="ltr"] .report-view > .contact-list .icon { float: left; }
html[dir="rtl"] .report-view > .contact-list .icon { float: right; }
.contact-list [data-delivery-status="delivered"] .icon {
background-image: url('images/icons/deliveredtick.png');
}
.contact-list [data-delivery-status="read"] .icon {
background-image: url('images/icons/message_read.png');
}
.contact-list [data-delivery-status="error"] .icon {
background-image: url('images/icons/exclamation.png');
}
.contact-list [data-delivery-status="not-applicable"],
.contact-list [data-delivery-status="pending"] .icon,
.contact-list [data-delivery-status="pending"] .timestamp,
.contact-list [data-delivery-status="error"] .timestamp,
.contact-list [data-delivery-status="rejected"] .icon,
.contact-list [data-delivery-status="rejected"] .timestamp{
display: none;
}
.report-view .resend-btn {
display: none;
width: calc(100% - 3rem);
flex-shrink: 0;
margin: 0 1.5rem 2rem;
background-color: #00caf2;
color: #fff;
}
.report-view[data-delivery="error"] .resend-btn {
display: block;
}

View file

@ -0,0 +1,3 @@
{
"extends": "../../../../test/unit/.jshintrc"
}

View file

@ -0,0 +1,511 @@
/*global MocksHelper, MockL10n, loadBodyHTML, Attachment, AttachmentRenderer,
AssetsHelper,
ImageUtils,
Promise
*/
'use strict';
require('/views/conversation/js/attachment_renderer.js');
require('/views/shared/js/utils.js');
require('/shared/js/image_utils.js');
require('/shared/test/unit/mocks/mock_l20n.js');
require('/views/shared/test/unit/mock_attachment.js');
require('/views/shared/test/unit/mock_utils.js');
var MocksHelperForAttachment = new MocksHelper([
'Attachment',
'Utils'
]).init();
suite('AttachmentRenderer >', function() {
MocksHelperForAttachment.attachTestHelpers();
var testImageBlob;
var testImageBlob_small;
var testImageBlob_bogus;
var testAudioBlob;
var testVideoBlob;
var testVcardBlob;
function assertThumbnailPreview(el) {
assert.ok(el.classList.contains('preview'));
assert.isFalse(el.classList.contains('nopreview'));
assert.isNull(el.querySelector('div.placeholder'));
var thumbnail = el.querySelector('div.thumbnail');
assert.ok(thumbnail);
assert.include(thumbnail.style.backgroundImage, 'blob:');
}
function assertThumbnailPlaceholder(el, type) {
assert.ok(el.classList.contains('nopreview'));
assert.isFalse(el.classList.contains('preview'));
assert.isNull(el.querySelector('div.thumbnail'));
assert.isFalse(el.hasAttribute('data-thumbnail'));
var placeholder = el.querySelector('div.thumbnail-placeholder');
assert.ok(placeholder);
assert.ok(placeholder.classList.contains(type + '-placeholder'));
}
suiteSetup(function(done) {
this.realMozL10n = document.l10n;
document.l10n = MockL10n;
// create a bogus image blob (should be rendered with a `corrupted' class)
testImageBlob_bogus = new Blob(['This is an image message'], {
type: 'image/jpeg'
});
var mediaFolder = '/views/shared/test/unit/media';
var blobPromises = [
AssetsHelper.generateImageBlob(300, 300, 'image/jpeg', 0.25).then(
(blob) => testImageBlob_small = blob
),
AssetsHelper.generateImageBlob(1400, 1400, 'image/jpeg', 1).then(
(blob) => testImageBlob = blob
),
AssetsHelper.loadFileBlob(`${mediaFolder}/audio.oga`).then(
(blob) => testAudioBlob = blob
),
AssetsHelper.loadFileBlob(`${mediaFolder}/video.ogv`).then(
(blob) => testVideoBlob = blob
),
AssetsHelper.loadFileBlob(`${mediaFolder}/contacts.vcf`).then(
(blob) => testVcardBlob = blob
)
];
Promise.all(blobPromises).then(() => {
done(() => {
var mmsSizeLimit = 300 * 1024;
assert.isTrue(
testImageBlob_small.size < mmsSizeLimit,
'Image blob should be greater than MMS size limit'
);
assert.isTrue(
testImageBlob.size > mmsSizeLimit,
'Image blob should be greater than MMS size limit'
);
});
}, done);
});
suiteTeardown(function() {
document.l10n = this.realMozL10n;
});
setup(function() {
loadBodyHTML('/index.html');
});
teardown(function() {
document.body.textContent = '';
});
suite('render >', function() {
test('bogus image attachment', function(done) {
var attachment = new Attachment(testImageBlob_bogus, {
name: 'Image attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'img');
// broken image => no thumbnail and `corrupted' class
assertThumbnailPlaceholder(attachmentContainer, 'img');
assert.ok(attachmentContainer.querySelector('div.corrupted'));
}).then(done, done);
});
test('small image attachment (thumbnail)', function(done) {
var attachment = new Attachment(testImageBlob_small, {
name: 'Image attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'img');
// image < message limit => thumbnail blob
assertThumbnailPreview(attachmentContainer);
assert.isNull(attachmentContainer.querySelector('div.corrupted'));
assert.isDefined(attachmentContainer.dataset.thumbnail);
assert.include(attachmentContainer.dataset.thumbnail, 'blob:');
}).then(done, done);
});
test('HUGE (fake) image attachment', function(done) {
var attachment = new Attachment({
size: 10 * 1024 * 1024,
type: 'image/jpeg'
}, {
name: 'Image attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'img');
// image > message limit => no thumbnail preview
assertThumbnailPlaceholder(attachmentContainer, 'img');
assert.isNull(attachmentContainer.querySelector('div.corrupted'));
}).then(done, done);
});
test('big image attachment', function(done) {
var attachment = new Attachment(testImageBlob, {
name: 'Image attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'img');
// image < message limit => thumbnail blob
assertThumbnailPreview(attachmentContainer);
assert.isNull(attachmentContainer.querySelector('div.corrupted'));
assert.isDefined(attachmentContainer.dataset.thumbnail);
assert.include(attachmentContainer.dataset.thumbnail, 'blob:');
}).then(done, done);
});
test('audio attachment', function(done) {
var attachment = new Attachment(testAudioBlob, {
name: 'Audio attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'audio');
// not an image => no thumbnail preview
assertThumbnailPlaceholder(attachmentContainer, 'audio');
assert.isNull(attachmentContainer.querySelector('div.corrupted'));
}).then(done, done);
});
test('video attachment', function(done) {
var attachment = new Attachment(testVideoBlob, {
name: 'Video attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'video');
// not an image => no thumbnail preview
assertThumbnailPlaceholder(attachmentContainer, 'video');
assert.isNull(attachmentContainer.querySelector('div.corrupted'));
}).then(done, done);
});
test('vcard attachment', function(done) {
var attachment = new Attachment(testVcardBlob, {
name: 'vcard.vcf'
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.ok(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, 'vcard');
// not an image => no thumbnail preview
assertThumbnailPlaceholder(attachmentContainer, 'vcard');
assert.isNull(attachmentContainer.querySelector('div.corrupted'));
}).then(done, done);
});
suite('draft attachments > ', function() {
test('> vcard', function(done) {
var attachment = new Attachment(testVcardBlob, {
name: 'vcard.vcf',
isDraft: true
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer = attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.equal(attachmentContainer.tagName, 'IFRAME');
assert.equal(attachmentContainer.dataset.attachmentType, 'vcard');
var doc = attachmentContainer.contentDocument;
var attachmentNode = doc.querySelector('.attachment');
assert.ok(attachmentNode);
assert.isNull(attachmentNode.querySelector('div.corrupted'));
assert.ok(attachmentNode.querySelector('.vcard-placeholder'));
var fileNameNode = doc.querySelector('.file-name');
assert.ok(fileNameNode);
assert.isNull(fileNameNode.firstElementChild);
assert.equal(fileNameNode.textContent, attachment.name);
}).then(done, done);
document.body.appendChild(attachmentContainer);
});
suite('> image', function() {
function testDraftImage(testName, attachmentName) {
test(testName + ' attachment', function(done) {
var attachment = new Attachment(testImageBlob, {
name: attachmentName,
isDraft: true
});
var attachmentRenderer = AttachmentRenderer.for(attachment),
attachmentContainer =
attachmentRenderer.getAttachmentContainer();
attachmentRenderer.render().then(() => {
assert.equal(attachmentContainer.tagName, 'IFRAME');
var doc = attachmentContainer.contentDocument;
var attachmentNode = doc.querySelector('.attachment');
assert.ok(attachmentNode);
assert.isNull(attachmentNode.querySelector('div.corrupted'));
assert.ok(attachmentNode.querySelector('.thumbnail'));
assert.isDefined(attachmentContainer.dataset.thumbnail);
assert.include(attachmentContainer.dataset.thumbnail, 'blob:');
var fileNameNode = doc.querySelector('.file-name');
assert.ok(fileNameNode);
assert.isNull(fileNameNode.firstElementChild);
assert.equal(fileNameNode.textContent, attachment.name);
}).then(done, done);
document.body.appendChild(attachmentContainer);
});
}
testDraftImage('normal', 'Image attachment');
testDraftImage(
'malicious script',
'%3Cscript%3Ealert(%22I%20am%20dangerous%22)%3C%2Fscript%3E'
);
testDraftImage(
'malicious image',
'%3Cimg%20src%3D%22http%3A%2F%2Fmalicious.server.ru%2Fpingback%22%3E'
);
});
});
});
suite('getThumbnail >', function() {
setup(function() {
this.sinon.stub(ImageUtils, 'getSizeAndType');
this.sinon.spy(ImageUtils.Downsample, 'sizeNoMoreThan');
});
test('Use ImageUtils for no resize case', function(done) {
var attachmentRenderer = AttachmentRenderer.for(new Attachment(
testImageBlob, {
name: 'auto_generated'
}
));
ImageUtils.getSizeAndType.returns(
Promise.resolve({
width: 100,
height: 100
})
);
attachmentRenderer.getThumbnail().then(function(thumbnail) {
assert.include(thumbnail.url, 'blob:');
assert.equal(thumbnail.fragment.toString(), '');
sinon.assert.calledWith(
ImageUtils.Downsample.sizeNoMoreThan,
sinon.match.number
);
}).then(done, done);
});
test('Use ImageUtils for resize needed case', function(done) {
var attachmentRenderer = AttachmentRenderer.for(new Attachment(
testImageBlob, {
name: 'auto_generated'
}
));
ImageUtils.getSizeAndType.returns(
Promise.resolve({
width: 300,
height: 300
})
);
attachmentRenderer.getThumbnail().then(function(thumbnails) {
assert.include(thumbnails.url, 'blob:');
assert.include(thumbnails.fragment.toString(), '#-moz-samplesize=');
sinon.assert.calledWith(
ImageUtils.Downsample.sizeNoMoreThan,
sinon.match.number
);
}).then(done, done);
});
test('throw error if image is corrupted', function(done) {
var attachmentRenderer = AttachmentRenderer.for(
new Attachment(testImageBlob_bogus, {
name: 'auto_generated'
})
);
var imgError = 'image error';
ImageUtils.getSizeAndType.returns(
Promise.reject(imgError)
);
attachmentRenderer.getThumbnail().catch(function(error) {
sinon.assert.calledWith(ImageUtils.getSizeAndType, testImageBlob_bogus);
assert.equal(imgError, error);
}).then(done, done);
});
});
suite('getAttachmentContainer >', function() {
test('uses only one container per attachment renderer', function() {
var attachment = new Attachment(testImageBlob_bogus, {
name: 'Image attachment'
});
var attachmentRenderer1 = AttachmentRenderer.for(attachment),
attachmentRenderer2 = AttachmentRenderer.for(attachment);
var attachmentContainer1 = attachmentRenderer1.getAttachmentContainer(),
attachmentContainer2 = attachmentRenderer2.getAttachmentContainer();
assert.isNotNull(attachmentContainer1);
assert.isNotNull(attachmentContainer2);
assert.notEqual(attachmentContainer1, attachmentContainer2);
var theSameAttachmentContainer1 =
attachmentRenderer1.getAttachmentContainer();
var theSameAttachmentContainer2 =
attachmentRenderer2.getAttachmentContainer();
assert.equal(attachmentContainer1, theSameAttachmentContainer1);
assert.equal(attachmentContainer2, theSameAttachmentContainer2);
});
test('returns correct container for non-draft attachment', function() {
var attachment = new Attachment(testImageBlob_bogus, {
name: 'Image attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment);
var attachmentContainer = attachmentRenderer.getAttachmentContainer();
assert.isNotNull(attachmentContainer);
assert.equal(attachmentContainer.nodeName.toLowerCase(), 'div');
assert.isTrue(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, attachment.type);
});
test('returns correct container for draft attachment', function() {
var attachment = new Attachment(testImageBlob_bogus, {
name: 'Image attachment',
isDraft: true
});
var attachmentRenderer = AttachmentRenderer.for(attachment);
var attachmentContainer = attachmentRenderer.getAttachmentContainer();
assert.isNotNull(attachmentContainer);
assert.equal(attachmentContainer.nodeName.toLowerCase(), 'iframe');
assert.isTrue(
attachmentContainer.classList.contains('attachment-container')
);
assert.equal(attachmentContainer.dataset.attachmentType, attachment.type);
assert.equal(
attachmentContainer.getAttribute('sandbox'),
'allow-same-origin'
);
});
});
suite('updateFileSize >', function() {
test('updates size in the same container', function(done) {
var attachment = new Attachment(testImageBlob, {
name: 'Image attachment'
});
var attachmentRenderer = AttachmentRenderer.for(attachment);
var attachmentContainer = attachmentRenderer.getAttachmentContainer();
var currentFileSize, sizeInfo;
attachmentRenderer.render().then(() => {
sizeInfo = attachmentContainer.querySelector('.size-indicator');
currentFileSize = sizeInfo.dataset.l10nArgs;
attachment.blob = testImageBlob_small;
return attachmentRenderer.updateFileSize();
}).then(() => {
var newFileSize = sizeInfo.dataset.l10nArgs;
assert.notEqual(newFileSize, currentFileSize);
}).then(done, done);
});
test('updates size in iframes too', function(done) {
this.sinon.spy(document.l10n, 'translateFragment');
var attachment = new Attachment(testImageBlob, {
name: 'Image attachment',
isDraft: true
});
var attachmentRenderer = AttachmentRenderer.for(attachment);
var attachmentContainer = attachmentRenderer.getAttachmentContainer();
document.body.appendChild(attachmentContainer);
var currentFileSize, sizeInfo, node;
attachmentRenderer.render().then(() => {
node = attachmentContainer.contentDocument.documentElement;
sizeInfo = node.querySelector('.size-indicator');
currentFileSize = sizeInfo.dataset.l10nArgs;
attachment.blob = testImageBlob_small;
return attachmentRenderer.updateFileSize();
}).then(() => {
var newFileSize = sizeInfo.dataset.l10nArgs;
assert.notEqual(newFileSize, currentFileSize);
sinon.assert.calledWith(document.l10n.translateFragment, node);
}).then(done, done);
});
});
});

View file

@ -0,0 +1,267 @@
/*global MocksHelper, MockL10n, Attachment,
AttachmentRenderer, MimeMapper, MockMozActivity, Promise, Utils,
AssetsHelper
*/
'use strict';
require('/views/conversation/js/attachment.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_attachment_renderer.js');
require('/shared/test/unit/mocks/mock_l20n.js');
require('/views/shared/test/unit/mock_utils.js');
require('/views/shared/test/unit/mock_moz_activity.js');
require('/views/shared/test/unit/mock_mime_mapper.js');
var MocksHelperForAttachment = new MocksHelper([
'Utils',
'MozActivity',
'MimeMapper',
'AttachmentRenderer'
]).init();
suite('attachment_test.js', function() {
MocksHelperForAttachment.attachTestHelpers();
var testImageBlob;
suiteSetup(function(done) {
// this sometimes takes longer because we fetch 4 assets via XHR
this.realMozL10n = document.l10n;
document.l10n = MockL10n;
AssetsHelper.generateImageBlob(1400, 1400, 'image/jpeg', 1).then((blob) => {
done(() => {
assert.isTrue(
blob.size > 300 * 1024,
'Image blob should be greater than MMS size limit'
);
testImageBlob = blob;
});
}, done);
});
suiteTeardown(function() {
document.l10n = this.realMozL10n;
});
test('Name property defaults to a string value', function() {
var attachment = new Attachment(new Blob());
assert.typeOf(attachment.name, 'string');
});
test('Filename is not a string', function() {
var attachment = new Attachment(testImageBlob, {});
assert.equal(attachment.name, '');
});
test('correct enumerable properties', function() {
var expectedProperties = ['name', 'blob', 'isDraft'];
var attachment = new Attachment(new Blob());
attachment.render();
expectedProperties.forEach((prop) => assert.ok(prop in attachment));
var keys = Object.keys(attachment);
assert.equal(keys.length, expectedProperties.length);
});
suite('render attachment', function() {
var attachmentRendererMock;
var attachmentContainer;
setup(function() {
attachmentContainer = document.createElement('x-container');
this.sinon.stub(AttachmentRenderer.prototype, 'getAttachmentContainer');
AttachmentRenderer.prototype.getAttachmentContainer.returns(
attachmentContainer
);
attachmentRendererMock = new AttachmentRenderer();
this.sinon.stub(AttachmentRenderer, 'for').returns(
attachmentRendererMock
);
});
test('returns attachment container', function() {
var attachment = new Attachment(new Blob(), {
name: 'Image attachment'
});
assert.equal(attachment.render(), attachmentContainer);
});
test('calls ready callback in fail and success cases', function(done) {
var attachmentRendererResult = Promise.resolve();
this.sinon.stub(attachmentRendererMock, 'render', function() {
return attachmentRendererResult;
});
var attachment = new Attachment(new Blob(), {
name: 'Image attachment'
});
var successfulDeferred = Utils.Promise.defer();
attachment.render(function() {
successfulDeferred.resolve();
});
attachmentRendererResult = Promise.reject();
var failedDeferred = Utils.Promise.defer();
attachment.render(function() {
failedDeferred.resolve();
});
successfulDeferred.promise.then(() => failedDeferred.promise).
then(done, done);
});
test('updateFileSize delegates to the renderer', function() {
var firstAttachmentRenderer = new AttachmentRenderer();
AttachmentRenderer.for.onCall(0).returns(
firstAttachmentRenderer
);
this.sinon.spy(AttachmentRenderer.prototype, 'render');
this.sinon.spy(AttachmentRenderer.prototype, 'updateFileSize');
var attachment = new Attachment(new Blob());
attachment.render();
attachment.updateFileSize();
sinon.assert.calledOnce(AttachmentRenderer.for);
sinon.assert.alwaysCalledOn(
firstAttachmentRenderer.render, firstAttachmentRenderer
);
sinon.assert.alwaysCalledOn(
firstAttachmentRenderer.updateFileSize, firstAttachmentRenderer
);
});
});
suite('view attachment with open activity', function() {
setup(function() {
this.sinon.spy(MimeMapper, 'guessTypeFromFileProperties');
this.sinon.spy(MimeMapper, 'ensureFilenameMatchesType');
});
test('Open normal image attachment', function(done) {
var fileName = 'jpeg_image.jpg';
var attachment = new Attachment(testImageBlob, {
name: fileName
});
var typeSpy = MimeMapper.guessTypeFromFileProperties;
var matchSpy = MimeMapper.ensureFilenameMatchesType;
attachment.view().then(() => {
assert.ok(typeSpy.calledWith(fileName, 'image/jpeg'));
assert.ok(matchSpy.calledWith(fileName, typeSpy.returnValues[0]));
assert.equal(MockMozActivity.calls.length, 1);
}).then(done, done);
});
test('Filename has no extension', function(done) {
var fileName = 'jpeg_image';
var attachment = new Attachment(testImageBlob, {
name: fileName
});
var typeSpy = MimeMapper.guessTypeFromFileProperties;
var matchSpy = MimeMapper.ensureFilenameMatchesType;
attachment.view().then(() => {
assert.ok(typeSpy.calledWith(fileName, 'image/jpeg'));
assert.ok(matchSpy.calledWith(fileName, typeSpy.returnValues[0]));
assert.equal(MockMozActivity.calls.length, 1);
}).then(done, done);
});
test('Filename is overridden using single attachment folder',
function(done) {
var attachment1 = new Attachment(testImageBlob, {
name: '/some_path/.hidden_folder/attachment1.jpg'
});
attachment1.view().then(() => {
assert.equal(MockMozActivity.calls.length, 1);
assert.equal(
MockMozActivity.calls[0].data.filename,
'sms-attachments/attachment1.jpg'
);
var attachment2 = new Attachment(testImageBlob, {
name: 'attachment2.jpg'
});
return attachment2.view();
}).then(() => {
assert.equal(MockMozActivity.calls.length, 2);
assert.equal(
MockMozActivity.calls[1].data.filename,
'sms-attachments/attachment2.jpg'
);
}).then(done, done);
});
test('Filename is undefined',
function(done) {
var attachment1 = new Attachment(testImageBlob, {
name: undefined
});
attachment1.view().then(() => {
assert.equal(MockMozActivity.calls.length, 1);
assert.equal(
MockMozActivity.calls[0].data.filename,
'sms-attachments/unnamed-attachment.jpg'
);
}).then(done, done);
});
suite('Activity errors >', function() {
var activity;
setup(function(done) {
this.sinon.spy(window, 'MozActivity');
this.sinon.stub(Utils, 'alert').returns(Promise.resolve());
var attachment = new Attachment(testImageBlob, {
name: 'jpeg_image.jpg'
});
attachment.view().then(() => {
activity = window.MozActivity.firstCall.thisValue;
}).then(done, done);
});
test('No handler for this image', function() {
activity.onerror.call({
error: { name: 'NO_PROVIDER' }
});
sinon.assert.calledWith(Utils.alert, 'attachmentOpenError');
});
test('Activity is canceled', function() {
activity.onerror.call({
error: { name: 'ActivityCanceled' }
});
sinon.assert.notCalled(Utils.alert);
});
test('Activity is canceled (on some other environment)', function() {
activity.onerror.call({
error: { name: 'USER_ABORT' }
});
sinon.assert.notCalled(Utils.alert);
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,555 @@
/*global MocksHelper, MockL10n, ConversationView,
loadBodyHTML, Compose, MessageManager, Navigation */
'use strict';
if (typeof GestureDetector === 'undefined') {
require('/shared/js/gesture_detector.js');
}
require('/shared/js/event_dispatcher.js');
require('/shared/test/unit/mocks/mock_gesture_detector.js');
require('/shared/test/unit/mocks/mock_l20n.js');
require('/views/shared/test/unit/mock_contact.js');
require('/services/test/unit/mock_message_manager.js');
require('/views/shared/test/unit/mock_moz_activity.js');
require('/views/shared/test/unit/mock_information.js');
require('/views/shared/test/unit/mock_navigation.js');
require('/views/shared/js/utils.js');
require('/views/shared/js/settings.js');
require('/views/conversation/js/subject_composer.js');
require('/views/conversation/js/compose.js');
require('/views/shared/js/contacts.js');
require('/views/conversation/js/recipients.js');
require('/views/inbox/js/inbox.js');
require('/views/conversation/js/conversation.js');
require('/views/conversation/js/attachment.js');
require('/views/shared/js/contact_renderer.js');
require('/views/shared/js/navigation.js');
require('/services/test/unit/mock_threads.js');
require('/services/test/unit/activity/mock_activity_client.js');
var mHelperIntegration = new MocksHelper([
'MessageManager',
'MozActivity',
'Information',
'Navigation',
'Threads',
'ActivityClient'
]).init();
suite('ConversationView Integration', function() {
var realMozL10n;
var recipients;
var children;
var fixture;
var sendButton;
var input;
mHelperIntegration.attachTestHelpers();
if (typeof loadBodyHTML === 'undefined') {
require('/shared/test/unit/load_body_html_helper.js');
}
suiteSetup(function() {
realMozL10n = document.l10n;
document.l10n = MockL10n;
loadBodyHTML('/index.html');
Navigation.init();
ConversationView.init();
});
suiteTeardown(function() {
document.l10n = realMozL10n;
});
setup(function() {
sendButton = document.getElementById('messages-send-button');
input = document.getElementById('messages-input');
fixture = {
name: 'foo',
number: '999',
email: 'a@b.com',
source: 'none',
// Mapped to node attr, not true boolean
editable: 'true'
};
ConversationView.recipients = null;
ConversationView.initRecipients();
});
teardown(function() {
recipients = null;
fixture = null;
});
suite('Search Contact Events', function() {
setup(function() {
this.sinon.spy(ConversationView, 'searchContact');
});
test('toFieldInput handler, successful', function() {
var fakeEvent = {
target: {
isPlaceholder: true,
textContent: 'abc'
}
};
ConversationView.toFieldInput.call(ConversationView, fakeEvent);
sinon.assert.calledOnce(ConversationView.searchContact);
sinon.assert.calledWith(ConversationView.searchContact, 'abc');
});
test('toFieldInput handler, unsuccessful', function() {
var fakeEvent = {
target: {
isPlaceholder: true,
textContent: ''
}
};
ConversationView.toFieldInput.call(ConversationView, fakeEvent);
sinon.assert.called(ConversationView.searchContact);
sinon.assert.calledWith(ConversationView.searchContact, '');
fakeEvent.target.textContent = 'abd';
fakeEvent.target.isPlaceholder = false;
ConversationView.toFieldInput.call(ConversationView, fakeEvent);
assert.isFalse(ConversationView.searchContact.calledTwice);
});
});
suite('Recipient List Display', function() {
test('Always begins in singleline mode', function() {
// Assert initial state: #messages-recipients-list is singleline
assert.isFalse(
ConversationView.recipientsList.classList.contains('multiline')
);
assert.isTrue(
ConversationView.recipientsList.classList.contains('singleline')
);
// Modify state
ConversationView.recipients.visible('multiline');
// Assert modified state: #messages-recipients-list is multiline
assert.isTrue(
ConversationView.recipientsList.classList.contains('multiline')
);
assert.isFalse(
ConversationView.recipientsList.classList.contains('singleline')
);
// Reset state
ConversationView.initRecipients();
// Assert initial/reset state: #messages-recipients-list is singleline
assert.isFalse(
ConversationView.recipientsList.classList.contains('multiline')
);
assert.isTrue(
ConversationView.recipientsList.classList.contains('singleline')
);
});
test('Typing in list will switch to singline mode', function(done) {
var toField = ConversationView.toField;
var recipientsList = ConversationView.recipientsList;
children = recipientsList.children;
recipients = ConversationView.recipients;
// Fill the recipients list with enough recipients to
// enable the "pan" event
recipients.add({
number: '999999999999999999999999999999'
});
recipients.add({
number: '888888888888888888888888888888'
});
// Since the offsetHeight is lazily captured during any
// first event, trigger an event to ensure that the
// "pan" event will have a height to calculate with.
//
toField.click();
// Simulate list growth
recipientsList.style.height = '100px';
// Listen for the pan event, simulated immediately following.
toField.addEventListener('pan', function panTest() {
done(function() {
assert.isTrue(toField.classList.contains('multiline'));
toField.removeEventListener('pan', panTest, false);
// Once the pan event has occured, the following steps
// will simulate the user actions necessary for testing
// the return to single line mode
// 1. Enter text into the editable "placeholder"
children[2].textContent = '0';
// 2. Trigger a "keyup" event
children[2].dispatchEvent(
new CustomEvent('keyup', {
bubbles: true
})
);
// toField should now be "singleline" again
assert.isTrue(toField.classList.contains('singleline'));
});
});
// Simulate pan event
toField.dispatchEvent(
new CustomEvent('pan', {
detail: {
absolute: {
dy: 1
}
}
})
);
});
test('Clicking in list will switch to singline mode', function(done) {
var toField = ConversationView.toField;
var recipientsList = ConversationView.recipientsList;
children = recipientsList.children;
recipients = ConversationView.recipients;
// Fill the recipients list with enough recipients to
// enable the "multiline" view
recipients.add({
number: '999999999999999999999999999999'
});
recipients.add({
number: '888888888888888888888888888888'
});
// Simulate list growth
recipientsList.style.height = '100px';
// Listen for the pan event, simulated immediately following.
toField.addEventListener('click', function clickTest() {
done(function() {
toField.removeEventListener('click', clickTest, false);
// toField should now be "singleline" again
assert.isTrue(toField.classList.contains('singleline'));
});
});
// Artificially set the list to multiline view
recipients.visible('multiline');
// Click "anywhere"
toField.click();
});
});
suite('Recipient Input Behaviours', function() {
var is = {
corresponding: function(recipient, avatar, value) {
return is.recipient(recipient, value) &&
is.avatar(avatar, value);
},
recipient: function(candidate, value) {
return (candidate.name === value || candidate.number === value);
},
avatar: function(candidate, value) {
return !is.placeholder(candidate) &&
!is.editable(candidate) && candidate.textContent.trim() === value;
},
placeholder: function(candidate, opts) {
opts = opts || { isEmpty: true };
return candidate.isPlaceholder &&
((opts.isEmpty && candidate.textContent.trim() === '') || true);
},
editable: function(candidate) {
return candidate.contentEditable === false;
}
};
setup(function() {
this.sinon.stub(Navigation, 'isCurrentPanel').returns(false);
Navigation.isCurrentPanel.withArgs('composer').returns(true);
this.sinon.stub(MessageManager, 'sendSMS');
});
test('Assimilate stranded recipients (message input)', function() {
ConversationView.recipients.add({
number: '999'
});
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// There are one recipients...
assert.equal(recipients.length, 1);
// And two displayed children,
// (the recipient "avatars" and a
// placeholder for the next entry)
assert.equal(children.length, 2);
assert.ok(is.corresponding(recipients.list[0], children[0], '999'));
assert.ok(is.placeholder(children[1]));
// Set text in the placeholder, as if the user has typed
// something before jumping to the input field
children[1].textContent = '000';
// Simulate input field focus/entry
input.dispatchEvent(new CustomEvent('focus'));
// There are now two recipients...
assert.equal(recipients.length, 2);
// And three displayed children,
// (the recipient "avatars" and a
// placeholder for the next entry)
assert.equal(children.length, 3);
assert.ok(is.corresponding(recipients.list[0], children[0], '999'));
assert.ok(is.corresponding(recipients.list[1], children[1], '000'));
assert.ok(is.placeholder(children[2]));
});
test('Assimilate stranded recipients (attachButton)', function() {
ConversationView.recipients.add({
number: '777'
});
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// There are one recipients...
assert.equal(recipients.length, 1);
// And two displayed children,
// (the recipient "avatars" and a
// placeholder for the next entry)
assert.equal(children.length, 2);
assert.ok(is.corresponding(recipients.list[0], children[0], '777'));
assert.ok(is.placeholder(children[1]));
// Set text in the placeholder, as if the user has typed
// something before jumping to the input field
children[1].textContent = '555';
// Simulate input field focus/entry
document.getElementById('messages-attach-button').click();
// There are now two recipients...
assert.equal(recipients.length, 2);
// And three displayed children,
// (the recipient "avatars" and a
// placeholder for the next entry)
assert.equal(children.length, 3);
assert.ok(is.corresponding(recipients.list[0], children[0], '777'));
assert.ok(is.corresponding(recipients.list[1], children[1], '555'));
assert.ok(is.placeholder(children[2]));
});
test('Assimilate stranded recipients (sendButton)', function() {
// To ensure the onSendClick handler will succeed:
// 1. Add some content to the message
Compose.append('foo');
// 2. Create a recipient
ConversationView.recipients.add({
number: '999'
});
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// There are one recipients...
assert.equal(recipients.length, 1);
// And two displayed children,
// (the recipient "avatars" and a
// placeholder for the next entry)
assert.equal(children.length, 2);
assert.ok(is.corresponding(recipients.list[0], children[0], '999'));
assert.ok(is.placeholder(children[1]));
// Set text in the placeholder, as if the user has typed
// something before jumping to the input field
children[1].textContent = '000';
// Simulate sendButton click
ConversationView.onSendClick();
ConversationView.simSelectedCallback(undefined, 0);
// Ensure that the "unaccepted" recipient was assimilated
// and included in the recipients list when message was sent
sinon.assert.calledWithMatch(MessageManager.sendSMS, {
recipients: ['999', '000'],
content: 'foo'
});
});
/* Bug:909641 test fails on ci
test('Assimilate stranded recipients (contactPickButton)', function(done) {
// To ensure the recipient wrapped before picker return:
// 1. Add some content to the message
Compose.append('foo');
// 2. Create a recipient
ConversationView.recipients.add({
number: '111'
});
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// Set text in the placeholder, as if the user has typed
// something before jumping to the input field
children[1].textContent = '222';
// Simulate contact pick
ConversationView.requestContact();
// Simulate the picker activity success
setTimeout(function onsuccess() {
ConversationView.recipients.add({
number: '333'
});
// There are now three recipients after picker activity success
assert.equal(recipients.length, 3);
// And four displayed children,
// (the recipient "avatars" and a
// placeholder for the next entry)
assert.equal(children.length, 4);
assert.ok(is.corresponding(recipients.list[0], children[0], '111'));
assert.ok(is.corresponding(recipients.list[1], children[1], '222'));
assert.ok(is.corresponding(recipients.list[2], children[2], '333'));
assert.ok(is.placeholder(children[3]));
done();
});
});
*/
test('Lone ";" are not recipients', function() {
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// Set ";" in the placeholder, as if the user has typed
children[0].textContent = ';';
// Simulate input field focus/entry
input.click();
// There are no recipients...
assert.equal(recipients.length, 0);
// And one displayed child...
assert.equal(children.length, 1);
// The the ";" has been removed from the
// recipients list
assert.equal(
ConversationView.recipientsList.children[0].textContent, ''
);
});
test('Taps on in-progress recipients do nothing special', function() {
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// Set text in the placeholder, as if the user has typed
children[0].textContent = '9999999999';
// Simulate a tap on the placeholder
children[0].click();
// There should be no recipients created
assert.equal(recipients.length, 0);
// And only the placeholder is displayed
assert.equal(children.length, 1);
assert.isTrue(children[0].isPlaceholder);
});
/**
* The code in this test works exactly as it should
* when run as "standalone".
*
* The test runner appears to cause a loss of
* node references that are expected to exist
test('<delete> removes "known" contact to the left', function() {
function backspace(target) {
var doc = target.ownerDocument;
var view = doc.defaultView;
var event = doc.createEvent('KeyboardEvent');
event.initKeyEvent(
'keypress', true, true, view,
false, false, false, false,
KeyEvent.DOM_VK_BACK_SPACE, 0
);
target.dispatchEvent(event);
return event;
}
ConversationView.recipients = new Recipients({
outer: 'messages-to-field',
inner: 'messages-recipients-list',
template: new Template('messages-recipient-tmpl')
});
ConversationView.recipients.add({
source: 'contacts',
name: 'Rick',
number: '99999'
});
children = ConversationView.recipientsList.children;
recipients = ConversationView.recipients;
// Simulate backspace on the current placeholder
backspace(children[1]);
// There are no recipients...
assert.equal(recipients.length, 0);
// And one displayed placeholder
assert.equal(children.length, 1);
});
*/
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
/*global MocksHelper, MockL10n, LinkActionHandler, ActivityPicker,
ConversationView,
Contacts
*/
'use strict';
require('/shared/test/unit/mocks/mock_l20n.js');
require('/shared/test/unit/mocks/mock_option_menu.js');
require('/views/conversation/js/link_action_handler.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_activity_picker.js');
require('/views/shared/test/unit/mock_contacts.js');
require('/views/shared/test/unit/mock_moz_activity.js');
require('/views/shared/test/unit/mock_conversation.js');
require('/views/shared/test/unit/mock_utils.js');
var mocksHelperLAH = new MocksHelper([
'ActivityPicker',
'Contacts',
'MozActivity',
'OptionMenu',
'ConversationView',
'Utils'
]).init();
suite('LinkActionHandler', function() {
var realMozL10n, events;
suiteSetup(function() {
realMozL10n = document.l10n;
document.l10n = MockL10n;
mocksHelperLAH.suiteSetup();
events = {
phone: {
target: {
dataset: {
action: 'dial-link',
dial: '999'
}
}
},
email: {
target: {
dataset: {
action: 'email-link',
email: 'a@b.com'
}
}
},
url: {
target: {
dataset: {
action: 'url-link',
url: 'http://mozilla.com'
}
}
}
};
});
suiteTeardown(function() {
document.l10n = realMozL10n;
mocksHelperLAH.suiteTeardown();
});
setup(function() {
Object.keys(events).forEach(function(type) {
events[type].preventDefault = this.sinon.spy();
events[type].stopPropagation = this.sinon.spy();
}, this);
mocksHelperLAH.setup();
});
teardown(function() {
mocksHelperLAH.teardown();
});
suite('onClick', function() {
setup(function() {
mocksHelperLAH.setup();
});
teardown(function() {
mocksHelperLAH.teardown();
});
test('dial-link: (known) delegates to promptContact ', function() {
this.sinon.stub(ConversationView, 'promptContact');
this.sinon.stub(Contacts, 'findByPhoneNumber')
.callsArgWith(1, [{
name: ['Huey'],
tel: {
value: '999'
}
}]);
LinkActionHandler.onClick(events.phone);
assert.deepEqual(ConversationView.promptContact.args[0][0], {
number: '999',
inMessage: true
});
assert.ok(events.phone.preventDefault.called);
assert.ok(events.phone.stopPropagation.called);
});
test('dial-link: (unknown) delegates to promptContact ', function() {
this.sinon.stub(ConversationView, 'promptContact');
this.sinon.stub(Contacts, 'findByPhoneNumber')
.callsArgWith(1, []);
LinkActionHandler.onClick(events.phone);
assert.deepEqual(ConversationView.promptContact.args[0][0], {
number: '999',
inMessage: true
});
assert.ok(events.phone.preventDefault.called);
assert.ok(events.phone.stopPropagation.called);
});
test('email-link: delegates to prompt ', function() {
this.sinon.stub(ConversationView, 'prompt');
LinkActionHandler.onClick(events.email);
assert.ok(ConversationView.prompt.called);
assert.deepEqual(ConversationView.prompt.args[0][0], {
email: 'a@b.com',
inMessage: true
});
});
test('url-link: go directly to the action ', function() {
this.sinon.stub(ActivityPicker, 'url');
LinkActionHandler.onClick(events.url);
assert.isTrue(ActivityPicker.url.called);
assert.ok(events.url.preventDefault.called);
assert.ok(events.url.stopPropagation.called);
});
});
});

View file

@ -0,0 +1,377 @@
/*global LinkHelper, FixturePhones */
/*
Link Helper Tests
*/
'use strict';
require('/views/conversation/js/link_helper.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_utils.js');
suite('link_helper_test.js', function() {
suite('LinkHelper URL replacements', function() {
function url2msg(url, addprefix) {
var link = url;
if (addprefix) {
url = 'http://' + url;
}
return '<a data-url="' + url + '"' +
' data-action="url-link" >' + link + '</a>';
}
function testURLMatch(url, match, addprefix) {
var expected = url.replace(match, url2msg(match, addprefix));
var result = LinkHelper.searchAndLinkClickableData(url);
assert.equal(result, expected);
}
function testURLOK(url, addprefix) {
var expected = url2msg(url, addprefix);
var result = LinkHelper.searchAndLinkClickableData(url);
assert.equal(result, expected);
}
function testURLNOK(url) {
// we only test url here to make sure it doesn't match
var result = LinkHelper.searchAndLinkUrl(url);
assert.equal(result, url);
}
suite('Matches', function() {
test('Simple URL', function() {
testURLOK('http://www.mozilla.org/');
});
test('Simple URL .co.uk', function() {
testURLOK('http://www.mozilla.co.uk/');
});
test('Simple short URL', function() {
testURLOK('mzl.la', true);
});
test('Simple short URL with parens', function() {
testURLMatch('(mzl.la)', 'mzl.la', true);
});
test('Simple short URL with trailing dot', function() {
testURLMatch('mzl.la.', 'mzl.la', true);
});
test('Simple short URL with trailing dot and port', function() {
testURLMatch('mzl.la.:527', 'mzl.la', true);
});
test('Simple short URL with value', function() {
testURLOK('mzl.la/ac', true);
});
test('Simple URL https', function() {
testURLOK('https://www.mozilla.org');
});
test('Simple URL https without www.', function() {
testURLOK('https://mozilla.org');
});
test('Simple URL https with trailing dot on domain.', function() {
testURLOK('https://mozilla.org./blah');
});
test('Simple URL http with port', function() {
testURLOK('http://www.mozilla.org:8080/');
});
test('Simple URL http without www. and with port', function() {
testURLOK('http://mozilla.org:8080/');
});
test('Simple URL with IPv4', function() {
testURLOK('http://8.8.8.8/');
});
test('Simple URL with IPv4 with zero', function() {
testURLOK('http://8.0.8.8/');
});
test('Simple URL rtsp', function() {
testURLOK('rtsp://www.mozilla.org/video.mov');
});
test('<br>prefix', function() {
testURLMatch('<br>mozilla.org', 'mozilla.org', true);
});
test('Trailing question mark', function() {
testURLMatch('OMG mozilla.org? it is cool!', 'mozilla.org', true);
});
test('Trailing colon', function() {
testURLMatch('OMG mozilla.org: it is cool!', 'mozilla.org', true);
});
test('Trailing semi-colon', function() {
testURLMatch('OMG mozilla.org; it is cool!', 'mozilla.org', true);
});
test('Trailing bang', function() {
testURLMatch('OMG mozilla.org! it is cool!', 'mozilla.org', true);
});
test('Trailing period', function() {
testURLMatch('Check out mozilla.org.', 'mozilla.org', true);
});
test('Trailing comma', function() {
testURLMatch('Check out mozilla.org, or not', 'mozilla.org', true);
});
test('Trailing parens', function() {
testURLMatch('(Check out mozilla.org)', 'mozilla.org', true);
});
test('Trailing question mark on path', function() {
testURLMatch('OMG mzl.la/huh? it is cool!', 'mzl.la/huh', true);
});
test('Trailing colon on path', function() {
testURLMatch('OMG mzl.la/huh: it is cool!', 'mzl.la/huh', true);
});
test('Trailing semi-colon on path', function() {
testURLMatch('OMG mzl.la/huh; it is cool!', 'mzl.la/huh', true);
});
test('Trailing bang on path', function() {
testURLMatch('OMG mzl.la/huh! it is cool!', 'mzl.la/huh', true);
});
test('Trailing parens on path', function() {
testURLMatch('(Check out mzl.la/ac)', 'mzl.la/ac', true);
});
test('Trailing period on path', function() {
testURLMatch('Check out mzl.la/ac.', 'mzl.la/ac', true);
});
test('Trailing comma on path', function() {
testURLMatch('Check out mzl.la/ac, or not', 'mzl.la/ac', true);
});
test('wiki link that has paren', function() {
testURLOK('http://en.wikipedia.org/wiki/Arete_(disambiguation)');
});
test('Crazy URL from #887146', function() {
testURLOK('http://sani.tiz.ed.com/wap/videos/files_mm/' +
'3GPP%20MPEG4%20-%20176×144_7fps_15kpbs_AACmono_8KHz_5kbps.3gp');
});
test('One letter second-level', function() {
testURLOK('http://x.com');
});
test('URL with phone number in the middle', function() {
testURLOK('http://somesite.com/q,12288296666/');
});
test('Full width char prefix', function() {
testURLMatch(':mozilla.org', 'mozilla.org', true);
});
test('non ascii prefix', function() {
testURLMatch('àmozilla.org', 'mozilla.org', true);
});
test('Bug 941763: Add dot as URL prefix', function() {
testURLMatch('.website.com', 'website.com', true);
testURLMatch('https://.website.com', 'website.com', true);
testURLMatch('htt://www.mozilla.org', 'mozilla.org', true);
});
});
suite('Failures', function() {
test('Simple invalid URL with slashes', function() {
testURLNOK('http://www.a/b/d.com');
});
test('Simple invalid TLD URL', function() {
testURLNOK('http://www.mozilla.o');
});
test('Simple ellipse', function() {
testURLNOK('aaa...ccc');
});
test('Simple URL with username', function() {
testURLNOK('http://user@mozilla.org');
});
test('Simple URL with credentials', function() {
testURLNOK('http://user:pass@mozilla.org');
});
test('Simple URL with IPv6', function() {
testURLNOK('http://[::1]/');
});
test('Bug 890342 1', function() {
testURLNOK('2.74');
});
test('Bug 890342 2', function() {
testURLNOK('21.72');
});
test('Bug 923739', function() {
testURLNOK('foo.zz');
testURLNOK('foo.foo');
testURLNOK('foo.quux');
testURLOK('http://whatever.stuff');
testURLOK('https://whatever.stuff');
});
});
});
suite('LinkHelper Email replacements', function() {
function email2msg(email) {
return '<a data-email="' + email + '"' +
' data-action="email-link">' + email + '</a>';
}
function testEmailOK(email) {
var exp = email2msg(email);
var res = LinkHelper.searchAndLinkClickableData(email);
assert.equal(res, exp);
}
function testEmailNOK(email) {
var res = LinkHelper.searchAndLinkEmail(email);
assert.equal(res, email);
}
test('Simple email', function() {
testEmailOK('user@hostname.tld');
});
test('Simple email with alias', function() {
testEmailOK('user+alias@hostname.tld');
});
test('Simple email with subdomain', function() {
testEmailOK('user@cs.subdhostname.tld');
});
test('Invalid email no hostname', function() {
testEmailNOK('user@');
});
test('Invalid email no user', function() {
testEmailNOK('@hostname.tld');
});
});
suite('LinkHelper Phone replacements', function() {
function testPhoneMatch(text, match) {
var expected = text.replace(match, phone2msg(match));
var result = LinkHelper.searchAndLinkClickableData(text);
assert.equal(result, expected);
}
function phone2msg(phone) {
return '<a dir="ltr" data-dial="' + phone + '"' +
' data-action="dial-link">' + phone + '</a>';
}
function testPhoneOK(phone) {
var exp = phone2msg(phone);
var res = LinkHelper.searchAndLinkClickableData(phone);
assert.equal(res, exp);
}
function testPhoneNOK(phone) {
var res = LinkHelper.searchAndLinkPhone(phone);
assert.equal(res, phone);
}
suite('Matches', function() {
test('Simple french mobile nat.', function() {
testPhoneOK('0612345678');
});
test('Simple french mobile intl', function() {
testPhoneOK('+33612345678');
});
test('Phone from #887737', function() {
testPhoneOK('+5511 98907-6047');
});
test('word before trailing number', function() {
testPhoneMatch('Test1 600123123', '600123123');
});
test('word after preceding number', function() {
testPhoneMatch('600123123 1Test', '600123123');
});
[
// Bug 900413 - detect 5 digit short codes
'24242',
'10086',
'+1234',
// bug 941976 - 7 digit 4 digit with symbols should still work
'123-1234 1234'
].forEach(function(phone) {
test(phone, testPhoneOK.bind(null, phone));
});
});
suite('Failures', function() {
[
'0.34',
'1200',
'5000',
'0.122',
'0921',
// Bug 900413 - 5 digit codes with separators not allowed
'1-10-13',
'12-3-13',
'1-800-A',
'1(234)5',
'1234..5',
'1 2 3 45',
'1\t23\t45',
'12+34'
].forEach(function(phone) {
test(phone, testPhoneNOK.bind(null, phone));
});
});
suite('Varied cases', function() {
FixturePhones.forEach(function(fixture) {
if (fixture.isTestable) {
suite(fixture.title, function() {
fixture.values.forEach(function(value) {
test(value, function() {
testPhoneOK(value);
});
});
});
}
});
});
suite('Tricky Problems', function() {
test('Two 9 digit numbers separated by space (#892480)', function() {
var test = '123456789 987654321';
var expected = test.split(' ').map(phone2msg).join(' ');
var result = LinkHelper.searchAndLinkClickableData(test);
assert.equal(result, expected);
});
test('Two 6 digit numbers separated by newline (#892480)', function() {
var test = '222333\n333222';
var expected = test.split('\n').map(phone2msg).join('\n');
var result = LinkHelper.searchAndLinkClickableData(test);
assert.equal(result, expected);
});
test('7 digit and 9 digit (#941976)', function() {
var test = '1234567 987654321';
var expected = test.split(' ').map(phone2msg).join(' ');
var result = LinkHelper.searchAndLinkClickableData(test);
assert.equal(result, expected);
});
test('7 digit and 5 digit (#941976)', function() {
var test = '1234567 12345';
var expected = test.split(' ').map(phone2msg).join(' ');
var result = LinkHelper.searchAndLinkClickableData(test);
assert.equal(result, expected);
});
test('7 digit and 5 digit(with plus) (#941976)', function() {
var test = '1234567 +1234';
var expected = test.split(' ').map(phone2msg).join(' ');
var result = LinkHelper.searchAndLinkClickableData(test);
assert.equal(result, expected);
});
test('7 digit and 4 digit (#941976)', function() {
testPhoneMatch('1234567 1234', '1234567');
});
});
});
suite('Multiple in the same string', function() {
test('Hits every case', function() {
var test = 'Hey, check out http://stackoverflow.com/q/12882966/ and ' +
'call me at +18155551212 or (e-mail user@hostname.tld)';
var expected = 'Hey, check out ' +
'<a data-url="http://stackoverflow.com/q/12882966/" ' +
'data-action="url-link" >http://stackoverflow.com/q/12882966/</a>' +
' and call me at ' +
'<a dir="ltr" data-dial="+18155551212" data-action="dial-link">' +
'+18155551212</a> or (e-mail <a data-email="user@hostname.tld"' +
' data-action="email-link">user@hostname.tld</a>)';
assert.equal(LinkHelper.searchAndLinkClickableData(test), expected);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
/*global App,
ConversationView,
Information,
InterInstanceEventDispatcher,
LazyLoader,
LocalizationHelper,
MessageManager,
MessagingClient,
MozMobileConnectionsClient,
MocksHelper,
Navigation,
Settings,
Startup,
TimeHeaders,
Utils
*/
'use strict';
require('/views/shared/test/unit/mock_time_headers.js');
require('/views/shared/test/unit/mock_navigation.js');
require('/views/shared/test/unit/mock_settings.js');
require('/views/shared/test/unit/mock_inter_instance_event_dispatcher.js');
require('/views/shared/test/unit/mock_conversation.js');
require('/views/shared/test/unit/mock_information.js');
require('/views/shared/test/unit/mock_localization_helper.js');
require('/views/shared/test/unit/mock_app.js');
require('/shared/test/unit/mocks/mock_lazy_loader.js');
require('/services/test/unit/messaging/mock_messaging_client.js');
require('/services/test/unit/moz_mobile_connections/' +
'mock_moz_mobile_connections_client.js');
require('/services/test/unit/mock_message_manager.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_utils.js');
require('/views/conversation/js/startup.js');
var MocksHelperForInboxStartup = new MocksHelper([
'App',
'ConversationView',
'Information',
'InterInstanceEventDispatcher',
'LazyLoader',
'LocalizationHelper',
'MessageManager',
'MessagingClient',
'MozMobileConnectionsClient',
'Navigation',
'TimeHeaders',
'Settings',
'Utils'
]).init();
suite('ConversationView Startup,', function() {
MocksHelperForInboxStartup.attachTestHelpers();
setup(function() {
this.sinon.spy(MessageManager, 'init');
this.sinon.spy(Navigation, 'init');
this.sinon.spy(ConversationView, 'init');
this.sinon.spy(LocalizationHelper, 'init');
this.sinon.spy(Information, 'initDefaultViews');
this.sinon.spy(TimeHeaders, 'init');
this.sinon.spy(Settings, 'init');
this.sinon.spy(Navigation, 'setReady');
this.sinon.spy(InterInstanceEventDispatcher, 'connect');
this.sinon.stub(ConversationView, 'once');
this.sinon.stub(LazyLoader, 'load').returns(Promise.resolve());
this.sinon.stub(MessageManager, 'getThreads');
this.sinon.stub(Navigation, 'isDefaultPanel');
this.sinon.spy(MessagingClient, 'init');
this.sinon.spy(MozMobileConnectionsClient, 'init');
this.sinon.spy(Utils, 'initializeShimHost');
});
suite('In default panel,', function() {
setup(function() {
Navigation.isDefaultPanel.returns(true);
Startup.init();
});
test('correctly initializes dependencies', function(done) {
sinon.assert.calledWith(Utils.initializeShimHost, App.instanceId);
sinon.assert.calledOnce(MessageManager.init);
sinon.assert.calledOnce(ConversationView.init);
// Workaround to deal with temporary shim code.
MessageManager.getThreads.lastCall.args[0].done();
Promise.resolve().then(
() => sinon.assert.calledOnce(Navigation.init)
).then(done, done);
});
test('starts non-critical initializations only once view is visually ready',
function() {
// Lazy loading is not started yet and headers are not processed either.
sinon.assert.notCalled(LazyLoader.load);
ConversationView.once.withArgs('visually-loaded').yield();
sinon.assert.calledOnce(LazyLoader.load);
});
test('correctly initializes lazy dependencies', function(done) {
ConversationView.once.withArgs('visually-loaded').yield();
LazyLoader.load.lastCall.returnValue.then(() => {
sinon.assert.calledOnce(LocalizationHelper.init);
sinon.assert.calledOnce(TimeHeaders.init);
sinon.assert.calledOnce(Settings.init);
sinon.assert.calledOnce(Navigation.setReady);
sinon.assert.calledOnce(Information.initDefaultViews);
sinon.assert.calledOnce(InterInstanceEventDispatcher.connect);
}).then(done, done);
});
});
suite('In a subpanel,', function() {
setup(function() {
Navigation.isDefaultPanel.returns(false);
Startup.init();
});
test('correctly initializes dependencies', function(done) {
sinon.assert.calledWith(Utils.initializeShimHost, App.instanceId);
sinon.assert.calledOnce(MessageManager.init);
sinon.assert.calledOnce(ConversationView.init);
// Workaround to deal with temporary shim code.
MessageManager.getThreads.lastCall.args[0].done();
Promise.resolve().then(
() => sinon.assert.calledOnce(Navigation.init)
).then(done, done);
});
test('starts non-critical initializations right away', function() {
sinon.assert.notCalled(ConversationView.once);
sinon.assert.calledOnce(LazyLoader.load);
});
test('correctly initializes lazy dependencies', function(done) {
LazyLoader.load.lastCall.returnValue.then(() => {
sinon.assert.calledOnce(LocalizationHelper.init);
sinon.assert.calledOnce(TimeHeaders.init);
sinon.assert.calledOnce(Settings.init);
sinon.assert.calledOnce(Navigation.setReady);
sinon.assert.calledOnce(Information.initDefaultViews);
sinon.assert.calledOnce(InterInstanceEventDispatcher.connect);
}).then(done, done);
});
});
});

View file

@ -0,0 +1,367 @@
/*global loadBodyHTML,
FocusEvent,
InputEvent,
KeyEvent,
KeyboardEvent,
SubjectComposer
*/
'use strict';
require('/shared/js/event_dispatcher.js');
require('/views/conversation/js/subject_composer.js');
suite('SubjectComposer >', function() {
var node, subjectComposer, input;
setup(function() {
loadBodyHTML('/index.html');
node = document.querySelector('.js-subject-composer');
subjectComposer = new SubjectComposer(node);
input = node.querySelector('.subject-composer-input');
});
teardown(function() {
subjectComposer.reset();
});
test('correctly instantiate', function() {
assert.throws(
() => new SubjectComposer(),
'Subject node is required'
);
assert.ok(new SubjectComposer(node));
assert.isTrue(
node.classList.contains('hide'),
'Should be hidden by default'
);
});
suite('visibility >', function() {
teardown(function() {
subjectComposer.reset();
});
test('show and hide work correctly', function() {
var visibilityChangeStub = sinon.stub();
subjectComposer.on('visibility-change', visibilityChangeStub);
subjectComposer.show();
assert.isFalse(node.classList.contains('hide'));
sinon.assert.called(visibilityChangeStub);
subjectComposer.hide();
assert.isTrue(node.classList.contains('hide'));
sinon.assert.called(visibilityChangeStub);
});
test('keeps value between show and hide', function() {
subjectComposer.show();
subjectComposer.setValue('my value');
assert.equal(subjectComposer.getValue(), 'my value');
assert.equal(input.textContent, 'my value');
// Hide subject and show again
subjectComposer.hide();
subjectComposer.show();
assert.equal(subjectComposer.getValue(), 'my value');
assert.equal(input.textContent, 'my value');
});
test('isVisible tracks visibility correctly', function() {
assert.isFalse(subjectComposer.isVisible());
subjectComposer.show();
assert.isTrue(subjectComposer.isVisible());
subjectComposer.hide();
assert.isFalse(subjectComposer.isVisible());
});
});
suite('value management >', function() {
setup(function() {
subjectComposer.show();
});
teardown(function() {
subjectComposer.reset();
});
test('correctly sets and gets value', function() {
assert.throws(
() => subjectComposer.setValue(), 'Value should be a valid string'
);
assert.equal(subjectComposer.getValue(), '');
subjectComposer.setValue('test');
assert.equal(subjectComposer.getValue(), 'test');
assert.equal(input.textContent, 'test');
input.innerHTML = 'test#2<br><br>';
input.dispatchEvent(new InputEvent('input'));
assert.equal(subjectComposer.getValue(), 'test#2');
assert.equal(input.textContent, 'test#2');
subjectComposer.setValue('test#3');
assert.equal(subjectComposer.getValue(), 'test#3');
assert.equal(input.textContent, 'test#3');
input.innerHTML = '<br>test#4<br><br>';
input.dispatchEvent(new InputEvent('input'));
assert.equal(subjectComposer.getValue(), 'test#4');
assert.equal(input.textContent, 'test#4');
});
test('correctly pre-process value', function() {
var content = 'Line\u00A01<br>\n Line 2<br><br><br><br>\nLine\n 3<br>';
subjectComposer.setValue(content);
assert.equal(
subjectComposer.getValue(),
'Line 1<br> Line 2<br><br><br><br> Line 3<br>'
);
subjectComposer.setValue('');
input.innerHTML = content;
input.dispatchEvent(new InputEvent('input'));
assert.equal(subjectComposer.getValue(), 'Line 1 Line 2 Line 3');
subjectComposer.setValue('');
input.textContent = content;
input.dispatchEvent(new InputEvent('input'));
assert.equal(
subjectComposer.getValue(),
'Line 1<br> Line 2<br><br><br><br> Line 3<br>'
);
});
test('fires "change" only if value actually changes via setValue',
function() {
var onChange = sinon.stub();
subjectComposer.on('change', onChange);
subjectComposer.setValue('test');
sinon.assert.calledOnce(onChange);
subjectComposer.setValue('test');
sinon.assert.calledOnce(onChange);
subjectComposer.setValue('test#2');
sinon.assert.calledTwice(onChange);
});
test('fires "change" only if value actually changes via innerHTML',
function() {
var onChange = sinon.stub();
subjectComposer.on('change', onChange);
input.innerHTML = 'test';
input.dispatchEvent(new InputEvent('input'));
sinon.assert.calledOnce(onChange);
input.innerHTML = 'test';
input.dispatchEvent(new InputEvent('input'));
sinon.assert.calledOnce(onChange);
input.innerHTML = 'test#2';
input.dispatchEvent(new InputEvent('input'));
sinon.assert.calledTwice(onChange);
});
test('correctly manages placeholder', function() {
var placeholder = node.querySelector(
'.subject-composer-placeholder'
);
assert.isNotNull(placeholder, 'Placeholder should be presented');
assert.equal(
placeholder.getAttribute('data-l10n-id'),
'messagesSubjectInput_placeholder'
);
subjectComposer.setValue('a');
assert.isNull(
node.querySelector('.subject-composer-placeholder'),
'Placeholder should be removed'
);
input.textContent = '';
input.dispatchEvent(new InputEvent('input'));
assert.isNotNull(
node.querySelector('.subject-composer-placeholder'),
'Placeholder should be presented'
);
});
});
suite('Backspace key handling >', function() {
var downEvent, upEvent, backspace;
setup(function() {
downEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
keyCode: KeyEvent.DOM_VK_BACK_SPACE
});
upEvent = new KeyboardEvent('keyup', {
bubbles: true,
cancelable: true,
keyCode: KeyEvent.DOM_VK_BACK_SPACE
});
backspace = function() {
input.dispatchEvent(downEvent);
input.dispatchEvent(upEvent);
};
subjectComposer.show();
subjectComposer.setValue('Howdy!');
});
teardown(function() {
subjectComposer.reset();
});
test('<delete> in empty subject hides field', function() {
// 1. Assert the correct state condition updates have occurred,
// as described in step 1
assert.isTrue(subjectComposer.isVisible());
// 2. To simulate the user "deleting" the subject,
// set the value to an empty string.
input.textContent = '';
input.dispatchEvent(new InputEvent('input'));
// 3. Simulate backspace on the subject field
backspace();
// 4. Confirm that the state of the compose
// area has updated properly.
assert.isFalse(subjectComposer.isVisible());
});
test('<delete> in non-empty subject does not hide field', function() {
// 1. Assert the correct state condition updates have occurred,
// as described in step 1
assert.isTrue(subjectComposer.isVisible());
// 2. Simulate backspace on the subject field
backspace();
// 3. Confirm that the state of the compose area not changed.
assert.isTrue(subjectComposer.isVisible());
});
test('<delete> holding subject does not hide field', function() {
// 1. Assert the correct state condition updates have occurred,
// as described in step 1
assert.isTrue(subjectComposer.isVisible());
// 2. Simulate holding backspace on the subject field
for (var i = 0; i < 5; i++) {
input.dispatchEvent(downEvent);
}
// 3. This is the "release" from a holding state
input.dispatchEvent(upEvent);
// 4. Confirm that the state of the compose area not changed.
assert.isTrue(subjectComposer.isVisible());
});
test('<delete> holding subject, release and tap hides field', function() {
// 1. Assert the correct state condition updates have occurred,
// as described in step 1
assert.isTrue(subjectComposer.isVisible());
// 2. Simulate holding backspace on the subject field
for (var i = 0; i < 5; i++) {
input.dispatchEvent(downEvent);
}
// 3. This is the "release" from a holding state
input.dispatchEvent(upEvent);
// 4. To simulate the user "deleting" the subject,
// set the value to an empty string.
input.textContent = '';
input.dispatchEvent(new InputEvent('input'));
// 5. Simulate backspace on the subject field.
backspace();
// 6. Confirm that the state of the compose area not changed.
assert.isFalse(subjectComposer.isVisible());
});
});
suite('Return key handling >', function() {
test('Return key should be ignored at key down', function() {
subjectComposer.show();
subjectComposer.setValue('Howdy!');
var event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
keyCode: KeyEvent.DOM_VK_RETURN
});
this.sinon.spy(event, 'stopPropagation');
input.dispatchEvent(event);
assert.ok(event.defaultPrevented);
sinon.assert.called(event.stopPropagation);
});
});
test('correctly resets subject', function() {
var onChangeStub = sinon.stub();
subjectComposer.show();
assert.equal(subjectComposer.getValue(), '');
subjectComposer.setValue('test');
assert.equal(subjectComposer.getValue(), 'test');
assert.equal(input.textContent, 'test');
subjectComposer.on('visibility-change', onChangeStub);
subjectComposer.on('change', onChangeStub);
subjectComposer.reset();
assert.equal(subjectComposer.getValue(), '');
assert.equal(input.textContent, '');
assert.isFalse(subjectComposer.isVisible());
// Visibility change and change events should not be fired on reset
sinon.assert.notCalled(onChangeStub);
});
test('fires "focus" event', function() {
subjectComposer.show();
var onFocusStub = sinon.stub();
subjectComposer.on('focus', onFocusStub);
input.dispatchEvent(new FocusEvent('focus'));
sinon.assert.calledOnce(onFocusStub);
});
});

View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<meta name="theme-group" content="theme-communications">
<meta name="theme-color" content="var(--header-background)">
<title>Inbox</title>
<!--App Style root.css-->
<link rel="stylesheet" type="text/css" href="/views/shared/style/root.css">
<!-- Stable Building blocks -->
<link rel="stylesheet" type="text/css" href="/shared/style/animations.css">
<link rel="stylesheet" type="text/css" href="/shared/style/action_menu.css">
<link rel="stylesheet" type="text/css" href="/shared/style/option_menu.css">
<link rel="stylesheet" type="text/css" href="/shared/style/buttons.css">
<link rel="stylesheet" type="text/css" href="/shared/style/confirm.css">
<link rel="stylesheet" type="text/css" href="/shared/style/edit_mode.css">
<link rel="stylesheet" type="text/css" href="/shared/style/switches.css">
<link rel="stylesheet" type="text/css" href="/shared/style/lists.css">
<link rel="stylesheet" type="text/css" href="/shared/style/tabs.css" >
<link rel="stylesheet" type="text/css" href="/shared/style/progress_activity.css">
<link rel="stylesheet" type="text/css" href="/shared/style/status.css">
<link rel="stylesheet" type="text/css" href="/shared/elements/gaia-icons/gaia-icons.css">
<!-- App styles -->
<link rel="stylesheet" type="text/css" href="/views/shared/style/sms.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/panel.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/edit-mode.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/notification.css">
<link rel="stylesheet" type="text/css" href="/views/shared/style/common.css">
<link rel="stylesheet" type="text/css" href="/views/inbox/style/inbox.css">
<link rel="stylesheet" type="text/css" href="/views/inbox/style/edit-mode.css">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/sms.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<script defer src="/mocks/navigator_moz_icc_manager.js"></script>
<!-- Firefox Desktop shims -->
<script defer src="/desktop-mock/navigator_moz_system_messages.js"></script>
<script defer src="/desktop-mock/navigator_moz_settings.js"></script>
<script defer src="/desktop-mock/navigator_moz_mobilemessage.js"></script>
<script defer src="/desktop-mock/navigator_moz_contacts.js"></script>
<!-- Web Components -->
<script defer src="/shared/elements/config.js"></script>
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<script async src="/shared/elements/gaia-header/dist/gaia-header.js"></script>
<!-- Shared code -->
<script defer src="/shared/js/intl/l20n.js"></script>
<!-- Should be removed while gecko support navigator.mozHour12 API -->
<script defer src="/shared/js/date_time_helper.js"></script>
<script defer src="/shared/js/template.js"></script>
<script defer src="/shared/js/async_storage.js"></script>
<script defer src="/shared/js/lazy_loader.js"></script>
<script defer src="/shared/js/sticky_header.js"></script>
<script defer src="/shared/js/contact_photo_helper.js"></script>
<script defer src="/shared/js/fb/fb_request.js"></script>
<script defer src="/shared/js/fb/fb_data_reader.js"></script>
<script defer src="/shared/js/fb/fb_reader_utils.js"></script>
<script defer src="/shared/js/event_dispatcher.js"></script>
<script defer src="/views/shared/js/utils.js"></script>
<script defer src="/views/shared/js/navigation.js"></script>
<script defer src="/views/shared/js/inter_instance_event_dispatcher.js"></script>
<script defer src="/views/shared/js/time_headers.js"></script>
<script defer src="/views/shared/js/contacts.js"></script>
<script defer src="/services/js/drafts.js"></script>
<script defer src="/services/js/threads.js"></script>
<script defer src="/views/inbox/js/inbox.js"></script>
<script defer src="/views/shared/js/settings.js"></script>
<script defer src="/lib/bridge/client.js"></script>
<script defer src="/lib/bridge/plugins/stream/client.js"></script>
<script defer src="/services/js/conversation/conversation_client.js"></script>
<script defer src="/services/js/message_manager.js"></script>
<script defer src="/views/shared/js/app.js"></script>
<script defer src="/views/inbox/js/startup.js"></script>
<script defer src="/views/shared/js/bootstrap.js"></script>
<!-- Lazy load scripts
<script defer src="/shared/js/option_menu.js"></script>
<script defer src="/views/shared/js/dialog.js"></script>
<script defer src="/views/shared/js/waiting_screen.js"></script>
<script defer src="/views/shared/js/activity_picker.js"></script>
-->
</head>
<body role="application" class="skin-comms theme-communications">
<iframe src="/views/shared/shim_host.html" class="shim-host" hidden></iframe>
<article id="main-wrapper" class="wrapper">
<section class="panel panel-InboxView" role="region" aria-hidden="true">
<!-- title-start and title-end indicates the button widths respectively
at the start and end of the line -->
<!-- see https://github.com/gaia-components/gaia-header/ for more information -->
<!-- WARNING: the actual value depends on the actual CSS being used in gaia-header -->
<gaia-header class="view-header regular-header" title-start="0" title-end="100">
<h1 data-l10n-id="messages"></h1>
<a href="#/composer" id="threads-composer-link" data-icon="compose" data-l10n-id="composeLink"></a>
<a id="threads-options-button" data-icon="more" data-l10n-id="actionsButton"></a>
<a id="threads-settings-button" data-icon="settings" data-l10n-id="settingsButton"></a>
</gaia-header>
<article id="threads-container" class="view-body edit-container" data-type="list">
</article>
<div id="threads-no-messages">
<div id="no-result-message">
<p data-l10n-id="noMessages-title">No messages</p>
<p data-l10n-id="noMessages-text-2">Start a conversation now</p>
</div>
</div>
<form id="threads-edit-form" role="dialog" data-type="edit">
<section role="region" class="theme-settings">
<gaia-header id="threads-edit-header" action="close" no-font-fit>
<h1 id="threads-edit-mode" data-l10n-id="selectThreads-title"></h1>
<button id="threads-check-uncheck-all-button" class="edit-button" data-l10n-id="select-all"></button>
</gaia-header>
</section>
<div class="bottom-tab" role="region" >
<ul class="bb-tablist">
<li role="presentation">
<button id="threads-delete-button" class="disable-when-dialog-animates" data-action="delete-button"></button>
</li>
<li role="presentation">
<button id="threads-read-unread-button" data-action="mark-as-read"></button>
</li>
</ul>
</div>
</form>
<section id="threads-draft-saved-banner" class="hide" role="status">
<p data-l10n-id="message-draft-saved">Message saved as draft</p>
</section>
<div id="sticky"></div>
</section>
</article>
<article id="loading">
<form role="dialog" data-type="confirm" class="loading-container">
<section>
<p class="loading-header" data-l10n-id="deleting-messages">Deleting messages...</p>
<div role="status">
<progress class="small"></progress>
</div>
</section>
</form>
</article>
<!--
- Templates
-->
<div id="messages-thread-tmpl" class="hide">
<!--
<label class="pack-checkbox negative">
<input type="checkbox" value="${id}" data-mode="${mode}">
<span></span>
</label>
<a href="${hash}">
<aside role="note" class="icon icon-unread icon-draft js-conversation-state"></aside>
<aside class="pack-end threadlist-item-picture"
role="img" aria-label="Contact photo"
data-l10n-id="contact-photo">
<span data-type="img"></span>
</aside>
<p class="threadlist-item-title"><bdi class="ellipsis-dir-fix"></bdi></p>
<p class="summary">
<time data-time-update="true" data-time-only="true" data-time="${timestamp}"></time>
<span class="mms-icon" data-l10n-id="mms-label">MMS</span>
<span dir="auto" class="body-text js-conversation-body ellipsis-dir-fix"></span>
</p>
</a>
-->
</div>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
/* global App,
ConversationClient,
Drafts,
InboxView,
InterInstanceEventDispatcher,
LazyLoader,
MessageManager,
Navigation,
Settings,
TimeHeaders,
Utils
*/
(function(exports) {
'use strict';
const LAZY_DEPENDENCIES = [
'/shared/js/option_menu.js',
'/views/shared/js/waiting_screen.js',
'/views/shared/js/dialog.js',
'/views/shared/js/error_dialog.js',
'/views/shared/js/activity_picker.js'
];
function initLazyDependencies() {
return LazyLoader.load(LAZY_DEPENDENCIES).then(() => {
TimeHeaders.init();
Settings.init();
Navigation.setReady();
InterInstanceEventDispatcher.connect();
});
}
exports.Startup = {
init() {
Utils.initializeShimHost(App.instanceId);
ConversationClient.init(App.instanceId);
MessageManager.init();
Navigation.init();
Drafts.init();
InboxView.init();
InboxView.once('visually-loaded', () => {
initLazyDependencies();
});
InboxView.once('fully-loaded', () => {
App.setReady();
});
InboxView.renderThreads();
}
};
})(window);

View file

@ -0,0 +1,36 @@
/*
* This file contains styles for Inbox view edit mode. They complement styles
* from views/shared/style/edit-mode.css that are applied as well.
* View edit mode is activated via Options -> Select threads.
*/
.bb-tablist > li button[data-action="mark-as-read"] {
background-image: url("images/icons/ico_in_markasread_30x30.png");
}
.bb-tablist > li button[data-action="mark-as-unread"] {
background-image: url("images/icons/ico_in_markasunread_30x30.png");
}
.edit #threads-container[data-type="list"] {
height: calc(100% - 10rem);
}
html[dir="ltr"] .edit [data-type="list"] li > a > aside.icon-unread {
transform: translateX(5rem);
}
html[dir="rtl"] .edit [data-type="list"] li > a > aside.icon-unread {
transform: translateX(-5rem);
}
.edit #threads-container aside.pack-end {
margin-inline-start: 4rem;
}
html[dir="ltr"] .edit #threads-container[data-type="list"] aside.pack-end span {
transform: translateX(1.5rem);
}
html[dir="rtl"] .edit #threads-container[data-type="list"] aside.pack-end span {
transform: translateX(-1.5rem);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

View file

@ -0,0 +1,191 @@
#sticky {
position: absolute;
/* The sticky element is positioned at the beginning of the list. */
top: 5rem;
/* The width is not 100% so the scrollbar is visible. */
width: calc(100% - 2rem);
/* The height needs to be kept in sync with the header height. */
height: 3.1rem;
/* Leaving 1rem margin for both side to makes scrollbar visible in LTR/RTL */
margin: 0 1rem;
/* The sticky element needs to be above the list. */
z-index: 2;
/* Hide sticky header when content is not ready */
visibility: hidden;
/* Those style will affect the backgroundImage: -moz-element. */
background: white no-repeat center top;
}
#sticky.has-content {
/* Set sticky header visible only when content is ready */
visibility: visible;
}
#threads-container[data-type="list"] header {
background: #fff;
}
#threads-container[data-type="list"] ul {
padding: 0 1.0rem;
background-color: #fff;
}
#threads-container[data-type="list"] .mms-icon {
display: none;
font-size: 1.5rem;
color: #ff6622;
}
#threads-container[data-type="list"] [data-last-message-type="mms"] .mms-icon {
display: inline;
}
#threads-container[data-type="list"] [data-last-message-type="mms"] .body-text {
display: none;
}
.threadlist-item-picture {
display: none;
}
.threadlist-item-picture.has-picture {
display: block;
}
.threadlist-item .default-picture > span[data-type=img],
.threadlist-item .group-picture > span[data-type=img] {
/* Please keep this image in sync with what is used in js/inbox.js */
background: url('images/default_contact_image.png') top left; /* BiDi proof: CSS sprites */
}
.threadlist-item:nth-child(even) .default-picture > span[data-type=img],
.threadlist-item:nth-child(even) .group-picture > span[data-type=img] {
background-position: bottom right; /* BiDi proof: CSS sprites */
}
.threadlist-item:nth-child(3n+1) .default-picture > span[data-type=img],
.threadlist-item:nth-child(3n+1) .group-picture > span[data-type=img] {
background-position: top right; /* BiDi proof: CSS sprites */
}
.threadlist-item:nth-child(4n+4) .default-picture > span[data-type=img],
.threadlist-item:nth-child(4n+4) .group-picture > span[data-type=img] {
background-position: center center;
}
.threadlist-item-picture.group-picture > span[data-type=img] {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 2.5rem;
}
/**
* Bug 967878, 979018 -[Messages] Background doesn't
* scroll with the threads causing expensive over-invalidation
*/
#threads-container > div {
background-color: #fff;
}
[data-type="list"] aside.icon-unread {
margin: 0;
width: 1.9rem;
visibility: hidden;
}
[data-type="list"] .unread aside.icon-unread {
background: url("images/unread.png") no-repeat;
background-size: 1rem;
visibility: visible;
}
[data-type="list"] .threadlist-item a * {
pointer-events: none;
}
[data-type="list"] .summary {
display: flex;
}
[data-type="list"] .draft .icon-draft {
background: url("images/draft.png") no-repeat;
background-size: 1.2rem;
visibility: visible;
}
[data-type="list"] .is-draft .pack-end {
opacity: 0.5;
}
[data-type="list"] .is-draft .threadlist-item-title {
font-style: italic;
color: #5f5f5f;
}
/* Override time margin for flex layout */
[data-type="list"] .summary time {
display: inline-block;
margin-inline-end: 1rem;
}
[data-type="list"] .unread .summary time {
color: #1D8399;
}
/* Empty Inbox layout */
#threads-no-messages {
background: url("images/SMS_200x200_bubble.png")
no-repeat center;
background-size: 20rem;
overflow-y: auto;
overflow-x: hidden;
height: calc(100% - 5rem);
width: 100%;
}
#no-result-message {
position: relative;
top: 50%;
width: 27rem;
padding: 2rem 0.5rem;
margin: -8rem auto;
border-top: .1rem solid rgba(96,96,96,.5);
line-height: 3rem;
text-align: start;
font-weight: 300;
font-size: 2.2rem;
color: #606060;
}
#threads-no-messages, .threadlist-is-empty #threads-container {
display: none;
}
/* Using !important to override scoped style in gaia-header component. */
#threads-settings-button, .threadlist-is-empty #threads-options-button {
display: none !important;
}
/* Using flex to center the content. */
.threadlist-is-empty #threads-settings-button {
display: flex !important;
}
.threadlist-is-empty #threads-no-messages {
display: block;
}

View file

@ -0,0 +1,3 @@
{
"extends": "../../../../test/unit/.jshintrc"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
/*global App,
ConversationClient,
Drafts,
InboxView,
InterInstanceEventDispatcher,
LazyLoader,
MessageManager,
MocksHelper,
Navigation,
Settings,
Startup,
TimeHeaders,
Utils
*/
'use strict';
require('/views/shared/test/unit/mock_time_headers.js');
require('/views/shared/test/unit/mock_navigation.js');
require('/views/shared/test/unit/mock_settings.js');
require('/views/shared/test/unit/mock_inter_instance_event_dispatcher.js');
require('/views/shared/test/unit/mock_inbox.js');
require('/views/shared/test/unit/mock_app.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_utils.js');
require('/shared/test/unit/mocks/mock_lazy_loader.js');
require('/services/test/unit/mock_message_manager.js');
require('/services/test/unit/mock_drafts.js');
require('/services/test/unit/conversation/mock_conversation_client.js');
require('/views/inbox/js/startup.js');
var MocksHelperForInboxStartup = new MocksHelper([
'App',
'ConversationClient',
'Drafts',
'InboxView',
'InterInstanceEventDispatcher',
'LazyLoader',
'MessageManager',
'Navigation',
'Settings',
'TimeHeaders',
'Utils'
]).init();
suite('InboxView Startup', function() {
MocksHelperForInboxStartup.attachTestHelpers();
setup(function() {
this.sinon.spy(App, 'setReady');
this.sinon.spy(MessageManager, 'init');
this.sinon.spy(Navigation, 'init');
this.sinon.spy(InboxView, 'init');
this.sinon.spy(InboxView, 'renderThreads');
this.sinon.spy(TimeHeaders, 'init');
this.sinon.spy(Settings, 'init');
this.sinon.spy(Navigation, 'setReady');
this.sinon.spy(InterInstanceEventDispatcher, 'connect');
this.sinon.stub(InboxView, 'once');
this.sinon.stub(LazyLoader, 'load').returns(Promise.resolve());
this.sinon.stub(ConversationClient, 'init');
this.sinon.spy(Utils, 'initializeShimHost');
this.sinon.stub(Drafts, 'init');
Startup.init();
});
test('correctly initializes dependencies', function() {
sinon.assert.calledWith(Utils.initializeShimHost, App.instanceId);
sinon.assert.calledWith(ConversationClient.init, App.instanceId);
sinon.assert.calledOnce(MessageManager.init);
sinon.assert.calledOnce(Navigation.init);
sinon.assert.calledOnce(Drafts.init);
sinon.assert.calledOnce(InboxView.init);
sinon.assert.calledOnce(InboxView.renderThreads);
});
test('starts non-critical initializations only once view is visually ready',
function() {
// Lazy loading is not started yet and headers are not processed either.
sinon.assert.notCalled(LazyLoader.load);
InboxView.once.withArgs('visually-loaded').yield();
sinon.assert.calledOnce(LazyLoader.load);
});
test('correctly initializes lazy dependencies', function(done) {
InboxView.once.withArgs('visually-loaded').yield();
LazyLoader.load.lastCall.returnValue.then(() => {
sinon.assert.calledOnce(TimeHeaders.init);
sinon.assert.calledOnce(Settings.init);
sinon.assert.calledOnce(Navigation.setReady);
sinon.assert.calledOnce(InterInstanceEventDispatcher.connect);
}).then(done, done);
});
test('app is marked as ready only on fully loaded', function() {
InboxView.once.withArgs('visually-loaded').yield();
sinon.assert.notCalled(App.setReady);
InboxView.once.withArgs('fully-loaded').yield();
sinon.assert.calledOnce(App.setReady);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

View file

@ -0,0 +1,134 @@
/*
Styles for new message
*/
section[role="region"].new > gaia-header h1 {
pointer-events: none;
}
.new .article-list {
z-index: 5;
padding: 0;
}
.new .flex-composer {
position: relative;
top: 4rem; /* 4rem is the default size of the recipient panel */
height: calc(100% - 4rem);
}
.subheader menu {
position: relative;
flex-shrink: 0;
width: 5rem;
height: 4rem;
}
/* Render this container outside of the document flow so that it does not
* effect the height of the containing subheader, and therefore does not effect
* the height of the Compose field. */
.subheader .overlay {
position: absolute;
width: 100%;
}
.multiline-recipients-mode.new .flex-composer {
top: 5.5rem;
height: calc(100% - 5.5rem);
}
.new #messages-container {
flex-basis: 0;
}
/* TODO: Remove this once panel markup split */
#messages-contact-pick-button {
display: none;
}
.new #messages-contact-pick-button {
display: block;
width: 5rem;
height: 4rem;
}
#messages-contact-pick-button .icon-user {
position: absolute;
top: 0.6rem;
offset-inline-end: 1rem;
display: block;
width: 2.8rem;
height: 2.8rem;
border-radius: 50%;
color: #575757;
background-color: #FFF;
}
#messages-contact-pick-button .icon-user:after {
content: "+";
position: absolute;
display: block;
width: 2.8rem;
height: 2.8rem;
line-height: 2.8rem;
font-size: 2.1rem;
font-weight: 500;
text-align: center;
}
#messages-contact-pick-button .icon-user:active {
color: #FFF;
background-color: #29bbb5;
}
section[role="region"].new .subheader {
/* The subheader contains the recipients part and because of the fact that it
* has "overflow: visible" we can see it. We should revise it.
*/
height: 0;
}
/** To be seen below the To: field when we are creating a new message **/
.new .overlay {
/* recipient's panel default size; be careful as this value depends on To:
* field height */
top: 4rem;
}
.new.multiline-recipients-mode .overlay {
top: 5.5rem;
}
#messages-recipient-suggestions {
position: absolute;
/* 9rem (5 rem from app header + 4rem from "To" panel heights) */
top: 9rem;
overflow-y: auto;
max-height: calc(100% - 9rem);
width: 100%;
}
.recipient-suggestions-list {
border-bottom: 0.1rem solid #E7E7E7;
}
.multiline-recipients-mode #messages-recipient-suggestions {
top: 10.5rem;
max-height: calc(100% - 10.5rem);
}
#messages-recipient-suggestions li {
pointer-events: auto;
}

View file

@ -0,0 +1,180 @@
html[dir="ltr"] [dir] { text-align: left; }
html[dir="rtl"] [dir] { text-align: right; }
#messages-to-field {
position: relative;
/* To be above .article-list's with z-index = 5 */
z-index: 6;
display: none;
min-height: 5.5rem;
color: black;
font-size: 1.465rem;
background-color: #f2f2f2;
transition: transform 0.2s ease-in-out 0s;
transform: translateY(calc(-100% + 4rem));
}
.multiline-recipients-mode #messages-to-field {
transform: translateY(calc(-100% + 5.5rem));
}
section[role="region"].new #messages-to-field {
display: flex;
align-items: flex-end;
}
section[role="region"].new #messages-to-field.multiline {
transform: translateY(0);
}
#to-label {
flex-shrink: 0;
height: 4rem;
padding-inline-start: 1.5rem;
padding-inline-end: 0.7rem;
line-height: 4rem;
font-weight: 200;
font-size: 1.465rem;
color: #000;
}
#messages-recipients-list-container {
min-height: 4rem;
min-width: 0;
box-sizing: border-box;
flex-grow: 1;
}
#messages-recipients-list {
box-sizing: border-box;
min-height: 4rem;
padding-top: 0;
padding-inline-end: 0.6rem;
padding-bottom: 0.6rem;
padding-inline-start: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
}
/*
Used by Recipients.View to display multi or single line.
Do not remove.
*/
#messages-recipients-list.singleline {
overflow-y: hidden;
}
#messages-recipients-list.multiline {
/* max-height = viewport - header - minimum composer - list margin */
max-height: calc(100vh - 5rem - 4rem - 0.6rem);
margin-bottom: 0.6rem;
padding-bottom: 0;
overflow-y: scroll;
}
/**
* BiDi note: all [contenteditable] blocks must carry a `dir="auto"` attribute,
* so we cannot use *-inline-{start,end} properties on them, because the
* direction of a dir="auto" element (and therefore the position of start/end)
* depends on its content. We want our margin/padding to depend on the document
* direction, not element's content.
*/
.recipient[contenteditable] {
overflow: hidden;
margin: 0.6rem 0 0;
display: block;
/* override BB selector */
height: auto;
width: auto;
}
html[dir="ltr"] .recipient[contenteditable] { margin-right: 0.6rem; }
html[dir="rtl"] .recipient[contenteditable] { margin-left: 0.6rem; }
.recipient[contenteditable].email {
background: #fff url(images/icons/icon_sms_compose_email.png) no-repeat;
background-size: 1.7rem;
}
/* see BiDi note above */
html[dir="ltr"] .recipient[contenteditable].email {
padding-left: 3.8rem;
background-position: left 1.5rem center;
}
html[dir="rtl"] .recipient[contenteditable].email {
padding-right: 3.8rem;
background-position: right 1.5rem center;
}
#messages-recipients-list > .recipient[contenteditable=true] {
flex-grow: 1;
height: 2.8rem;
line-height: 2.8rem;
border-radius: 0;
word-wrap: unset;
/* Building blocks override (bug xxx) */
background: none;
color: #000;
}
#messages-recipients-list > .recipient[contenteditable=true]:focus {
/**
* To workaround bug 1103011, we just collapse editable recipient on focus so
* that caret position looks correct for the user, while in fact it is not
* true - caret is still in wrong position, but zero-width editable element is
* aligned correctly inside parent.
*/
flex-grow: unset;
}
.recipient[contenteditable=true]:empty:not(:last-of-type) {
display: none;
}
/**
* Disable padding and give negative margin-end on empty contenteditable
* recipients when they do not have focus. This allows the Recipients List
* to intelligently collapse when the final row contains no "assimilated"
* (i.e. non-contenteditable) recipients. */
.recipient[contenteditable=true]:empty {
padding: 0;
}
/**
* We don't use margin-inline-end here because of the bug 1103011, as currently
* directionality of empty contenteditable isn't inherited from parent, but
* always LTR, so margin-inline-end equals to margin-right in both LTR and RTL
* modes. So to align it correctly we should override margin depending on text
* direction of parent element manually.
*/
html[dir="ltr"] .recipient[contenteditable=true]:empty {
margin-right: -0.5rem;
}
html[dir="rtl"] .recipient[contenteditable=true]:empty {
margin-left: -0.5rem;
}
/* see BiDi note above */
html[dir="ltr"] .recipient[contenteditable=true]:focus,
html[dir="ltr"] .recipient[contenteditable=true]:hover,
html[dir="ltr"] .recipient[contenteditable=true]:empty:focus {
margin-right: 0.5rem;
padding-right: 0.3rem;
}
html[dir="rtl"] .recipient[contenteditable=true]:focus,
html[dir="rtl"] .recipient[contenteditable=true]:hover,
html[dir="rtl"] .recipient[contenteditable=true]:empty:focus {
margin-left: 0.5rem;
padding-left: 0.3rem;
}

Some files were not shown because too many files have changed in this diff Show more