merge m-c to fx-team; a=merge
@ -310,10 +310,16 @@ function onSearchSubmit(aEvent)
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
aEvent.preventDefault();
|
||||
gSearchSuggestionController.addInputValueToFormHistory();
|
||||
|
||||
if (aEvent) {
|
||||
aEvent.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let gSearchSuggestionController;
|
||||
|
||||
function setupSearchEngine()
|
||||
{
|
||||
// The "autofocus" attribute doesn't focus the form element
|
||||
@ -341,6 +347,12 @@ function setupSearchEngine()
|
||||
searchText.placeholder = searchEngineName;
|
||||
}
|
||||
|
||||
if (!gSearchSuggestionController) {
|
||||
gSearchSuggestionController =
|
||||
new SearchSuggestionUIController(searchText, searchText.parentNode,
|
||||
onSearchSubmit);
|
||||
}
|
||||
gSearchSuggestionController.engineName = searchEngineName;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,10 +24,14 @@
|
||||
<link rel="icon" type="image/png" id="favicon"
|
||||
href="chrome://branding/content/icon32.png"/>
|
||||
<link rel="stylesheet" type="text/css" media="all"
|
||||
href="chrome://browser/content/searchSuggestionUI.css"/>
|
||||
<link rel="stylesheet" type="text/css" media="all" defer="defer"
|
||||
href="chrome://browser/content/abouthome/aboutHome.css"/>
|
||||
|
||||
<script type="text/javascript;version=1.8"
|
||||
src="chrome://browser/content/abouthome/aboutHome.js"/>
|
||||
<script type="text/javascript;version=1.8"
|
||||
src="chrome://browser/content/searchSuggestionUI.js"/>
|
||||
</head>
|
||||
|
||||
<body dir="&locale.dir;">
|
||||
|
@ -216,9 +216,37 @@ let AboutHomeListener = {
|
||||
AboutHomeListener.init(this);
|
||||
|
||||
|
||||
// An event listener for custom "WebChannelMessageToChrome" events on pages
|
||||
addEventListener("WebChannelMessageToChrome", function (e) {
|
||||
// if target is window then we want the document principal, otherwise fallback to target itself.
|
||||
let principal = e.target.nodePrincipal ? e.target.nodePrincipal : e.target.document.nodePrincipal;
|
||||
|
||||
if (e.detail) {
|
||||
sendAsyncMessage("WebChannelMessageToChrome", e.detail, null, principal);
|
||||
} else {
|
||||
Cu.reportError("WebChannel message failed. No message detail.");
|
||||
}
|
||||
}, true, true);
|
||||
|
||||
// Add message listener for "WebChannelMessageToContent" messages from chrome scripts
|
||||
addMessageListener("WebChannelMessageToContent", function (e) {
|
||||
if (e.data) {
|
||||
content.dispatchEvent(new content.CustomEvent("WebChannelMessageToContent", {
|
||||
detail: Cu.cloneInto({
|
||||
id: e.data.id,
|
||||
message: e.data.message,
|
||||
}, content),
|
||||
}));
|
||||
} else {
|
||||
Cu.reportError("WebChannel message failed. No message data.");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
let ContentSearchMediator = {
|
||||
|
||||
whitelist: new Set([
|
||||
"about:home",
|
||||
"about:newtab",
|
||||
]),
|
||||
|
||||
@ -247,7 +275,7 @@ let ContentSearchMediator = {
|
||||
},
|
||||
|
||||
get _contentWhitelisted() {
|
||||
return this.whitelist.has(content.document.documentURI.toLowerCase());
|
||||
return this.whitelist.has(content.document.documentURI);
|
||||
},
|
||||
|
||||
_sendMsg: function (type, data=null) {
|
||||
@ -258,12 +286,14 @@ let ContentSearchMediator = {
|
||||
},
|
||||
|
||||
_fireEvent: function (type, data=null) {
|
||||
content.dispatchEvent(new content.CustomEvent("ContentSearchService", {
|
||||
let event = Cu.cloneInto({
|
||||
detail: {
|
||||
type: type,
|
||||
data: data,
|
||||
},
|
||||
}));
|
||||
}, content);
|
||||
content.dispatchEvent(new content.CustomEvent("ContentSearchService",
|
||||
event));
|
||||
},
|
||||
};
|
||||
ContentSearchMediator.init(this);
|
||||
|
@ -392,3 +392,8 @@ input[type=button] {
|
||||
.newtab-search-panel-engine[selected] {
|
||||
background: url("chrome://global/skin/menu/shared-menu-check.png") center left 4px no-repeat transparent;
|
||||
}
|
||||
|
||||
.searchSuggestionTable {
|
||||
font: message-box;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
@ -5,6 +5,7 @@
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
|
||||
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://browser/content/searchSuggestionUI.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://browser/content/newtab/newTab.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://browser/skin/newtab/newTab.css" type="text/css"?>
|
||||
|
||||
@ -97,4 +98,6 @@
|
||||
|
||||
<xul:script type="text/javascript;version=1.8"
|
||||
src="chrome://browser/content/newtab/newTab.js"/>
|
||||
<xul:script type="text/javascript;version=1.8"
|
||||
src="chrome://browser/content/searchSuggestionUI.js"/>
|
||||
</xul:window>
|
||||
|
@ -30,7 +30,9 @@ let gSearch = {
|
||||
},
|
||||
|
||||
search: function (event) {
|
||||
event.preventDefault();
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
let searchStr = this._nodes.text.value;
|
||||
if (this.currentEngineName && searchStr.length) {
|
||||
this._send("Search", {
|
||||
@ -39,6 +41,7 @@ let gSearch = {
|
||||
whence: "newtab",
|
||||
});
|
||||
}
|
||||
this._suggestionController.addInputValueToFormHistory();
|
||||
},
|
||||
|
||||
manageEngines: function () {
|
||||
@ -47,7 +50,10 @@ let gSearch = {
|
||||
},
|
||||
|
||||
handleEvent: function (event) {
|
||||
this["on" + event.detail.type](event.detail.data);
|
||||
let methodName = "on" + event.detail.type;
|
||||
if (this.hasOwnProperty(methodName)) {
|
||||
this[methodName](event.detail.data);
|
||||
}
|
||||
},
|
||||
|
||||
onState: function (data) {
|
||||
@ -183,5 +189,14 @@ let gSearch = {
|
||||
this._nodes.logo.hidden = true;
|
||||
this._nodes.text.placeholder = engine.name;
|
||||
}
|
||||
|
||||
// Set up the suggestion controller.
|
||||
if (!this._suggestionController) {
|
||||
let parent = document.getElementById("newtab-scrollbox");
|
||||
this._suggestionController =
|
||||
new SearchSuggestionUIController(this._nodes.text, parent,
|
||||
() => this.search());
|
||||
}
|
||||
this._suggestionController.engineName = engine.name;
|
||||
},
|
||||
};
|
||||
|
48
browser/base/content/searchSuggestionUI.css
Normal file
@ -0,0 +1,48 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
.searchSuggestionTable {
|
||||
background-color: hsla(0,0%,100%,.99);
|
||||
border: 1px solid;
|
||||
border-color: hsla(210,54%,20%,.15) hsla(210,54%,20%,.17) hsla(210,54%,20%,.2);
|
||||
border-spacing: 0;
|
||||
border-top: 0;
|
||||
box-shadow: 0 1px 0 hsla(210,65%,9%,.02) inset,
|
||||
0 0 2px hsla(210,65%,9%,.1) inset,
|
||||
0 1px 0 hsla(0,0%,100%,.2);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
text-align: start;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.searchSuggestionRow {
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
max-width: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.searchSuggestionRow.formHistory {
|
||||
color: hsl(210,100%,40%);
|
||||
}
|
||||
|
||||
.searchSuggestionRow.selected {
|
||||
background-color: hsl(210,100%,40%);
|
||||
color: hsl(0,0%,100%);
|
||||
}
|
||||
|
||||
.searchSuggestionEntry {
|
||||
margin: 0;
|
||||
max-width: inherit;
|
||||
overflow: hidden;
|
||||
padding: 6px 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchSuggestionEntry > span.typed {
|
||||
font-weight: bold;
|
||||
}
|
379
browser/base/content/searchSuggestionUI.js
Normal file
@ -0,0 +1,379 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
this.SearchSuggestionUIController = (function () {
|
||||
|
||||
const MAX_DISPLAYED_SUGGESTIONS = 6;
|
||||
const SUGGESTION_ID_PREFIX = "searchSuggestion";
|
||||
const CSS_URI = "chrome://browser/content/searchSuggestionUI.css";
|
||||
|
||||
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
||||
|
||||
/**
|
||||
* Creates a new object that manages search suggestions and their UI for a text
|
||||
* box.
|
||||
*
|
||||
* The UI consists of an html:table that's inserted into the DOM after the given
|
||||
* text box and styled so that it appears as a dropdown below the text box.
|
||||
*
|
||||
* @param inputElement
|
||||
* Search suggestions will be based on the text in this text box.
|
||||
* Assumed to be an html:input. xul:textbox is untested but might work.
|
||||
* @param tableParent
|
||||
* The suggestion table is appended as a child to this element. Since
|
||||
* the table is absolutely positioned and its top and left values are set
|
||||
* to be relative to the top and left of the page, either the parent and
|
||||
* all its ancestors should not be positioned elements (i.e., their
|
||||
* positions should be "static"), or the parent's position should be the
|
||||
* top left of the page.
|
||||
* @param onClick
|
||||
* A function that's called when a search suggestion is clicked. Ideally
|
||||
* we could call submit() on inputElement's ancestor form, but that
|
||||
* doesn't trigger submit listeners.
|
||||
* @param idPrefix
|
||||
* The IDs of elements created by the object will be prefixed with this
|
||||
* string.
|
||||
*/
|
||||
function SearchSuggestionUIController(inputElement, tableParent, onClick=null,
|
||||
idPrefix="") {
|
||||
this.input = inputElement;
|
||||
this.onClick = onClick;
|
||||
this._idPrefix = idPrefix;
|
||||
|
||||
let tableID = idPrefix + "searchSuggestionTable";
|
||||
this.input.autocomplete = "off";
|
||||
this.input.setAttribute("aria-autocomplete", "true");
|
||||
this.input.setAttribute("aria-controls", tableID);
|
||||
tableParent.appendChild(this._makeTable(tableID));
|
||||
|
||||
this.input.addEventListener("keypress", this);
|
||||
this.input.addEventListener("input", this);
|
||||
this.input.addEventListener("focus", this);
|
||||
this.input.addEventListener("blur", this);
|
||||
window.addEventListener("ContentSearchService", this);
|
||||
|
||||
this._stickyInputValue = "";
|
||||
this._hideSuggestions();
|
||||
}
|
||||
|
||||
SearchSuggestionUIController.prototype = {
|
||||
|
||||
// The timeout (ms) of the remote suggestions. Corresponds to
|
||||
// SearchSuggestionController.remoteTimeout. Uses
|
||||
// SearchSuggestionController's default timeout if falsey.
|
||||
remoteTimeout: undefined,
|
||||
|
||||
get engineName() {
|
||||
return this._engineName;
|
||||
},
|
||||
|
||||
set engineName(val) {
|
||||
this._engineName = val;
|
||||
if (val && document.activeElement == this.input) {
|
||||
this._speculativeConnect();
|
||||
}
|
||||
},
|
||||
|
||||
get selectedIndex() {
|
||||
for (let i = 0; i < this._table.children.length; i++) {
|
||||
let row = this._table.children[i];
|
||||
if (row.classList.contains("selected")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
|
||||
set selectedIndex(idx) {
|
||||
// Update the table's rows, and the input when there is a selection.
|
||||
this._table.removeAttribute("aria-activedescendant");
|
||||
for (let i = 0; i < this._table.children.length; i++) {
|
||||
let row = this._table.children[i];
|
||||
if (i == idx) {
|
||||
row.classList.add("selected");
|
||||
row.firstChild.setAttribute("aria-selected", "true");
|
||||
this._table.setAttribute("aria-activedescendant", row.firstChild.id);
|
||||
this.input.value = this.suggestionAtIndex(i);
|
||||
}
|
||||
else {
|
||||
row.classList.remove("selected");
|
||||
row.firstChild.setAttribute("aria-selected", "false");
|
||||
}
|
||||
}
|
||||
|
||||
// Update the input when there is no selection.
|
||||
if (idx < 0) {
|
||||
this.input.value = this._stickyInputValue;
|
||||
}
|
||||
},
|
||||
|
||||
get numSuggestions() {
|
||||
return this._table.children.length;
|
||||
},
|
||||
|
||||
suggestionAtIndex: function (idx) {
|
||||
let row = this._table.children[idx];
|
||||
return row ? row.textContent : null;
|
||||
},
|
||||
|
||||
deleteSuggestionAtIndex: function (idx) {
|
||||
// Only form history suggestions can be deleted.
|
||||
if (this.isFormHistorySuggestionAtIndex(idx)) {
|
||||
let suggestionStr = this.suggestionAtIndex(idx);
|
||||
this._sendMsg("RemoveFormHistoryEntry", suggestionStr);
|
||||
this._table.children[idx].remove();
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
},
|
||||
|
||||
isFormHistorySuggestionAtIndex: function (idx) {
|
||||
let row = this._table.children[idx];
|
||||
return row && row.classList.contains("formHistory");
|
||||
},
|
||||
|
||||
addInputValueToFormHistory: function () {
|
||||
this._sendMsg("AddFormHistoryEntry", this.input.value);
|
||||
},
|
||||
|
||||
handleEvent: function (event) {
|
||||
this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
|
||||
},
|
||||
|
||||
_onInput: function () {
|
||||
if (this.input.value) {
|
||||
this._getSuggestions();
|
||||
}
|
||||
else {
|
||||
this._stickyInputValue = "";
|
||||
this._hideSuggestions();
|
||||
}
|
||||
this.selectedIndex = -1;
|
||||
},
|
||||
|
||||
_onKeypress: function (event) {
|
||||
let selectedIndexDelta = 0;
|
||||
switch (event.keyCode) {
|
||||
case event.DOM_VK_UP:
|
||||
if (this.numSuggestions) {
|
||||
selectedIndexDelta = -1;
|
||||
}
|
||||
break;
|
||||
case event.DOM_VK_DOWN:
|
||||
if (this.numSuggestions) {
|
||||
selectedIndexDelta = 1;
|
||||
}
|
||||
else {
|
||||
this._getSuggestions();
|
||||
}
|
||||
break;
|
||||
case event.DOM_VK_RIGHT:
|
||||
// Allow normal caret movement until the caret is at the end of the input.
|
||||
if (this.input.selectionStart != this.input.selectionEnd ||
|
||||
this.input.selectionEnd != this.input.value.length) {
|
||||
return;
|
||||
}
|
||||
// else, fall through
|
||||
case event.DOM_VK_RETURN:
|
||||
if (this.selectedIndex >= 0) {
|
||||
this.input.value = this.suggestionAtIndex(this.selectedIndex);
|
||||
}
|
||||
this._stickyInputValue = this.input.value;
|
||||
this._hideSuggestions();
|
||||
break;
|
||||
case event.DOM_VK_DELETE:
|
||||
if (this.selectedIndex >= 0) {
|
||||
this.deleteSuggestionAtIndex(this.selectedIndex);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndexDelta) {
|
||||
// Update the selection.
|
||||
let newSelectedIndex = this.selectedIndex + selectedIndexDelta;
|
||||
if (newSelectedIndex < -1) {
|
||||
newSelectedIndex = this.numSuggestions - 1;
|
||||
}
|
||||
else if (this.numSuggestions <= newSelectedIndex) {
|
||||
newSelectedIndex = -1;
|
||||
}
|
||||
this.selectedIndex = newSelectedIndex;
|
||||
|
||||
// Prevent the input's caret from moving.
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
_onFocus: function () {
|
||||
this._speculativeConnect();
|
||||
},
|
||||
|
||||
_onBlur: function () {
|
||||
this._hideSuggestions();
|
||||
},
|
||||
|
||||
_onMousemove: function (event) {
|
||||
// It's important to listen for mousemove, not mouseover or mouseenter. The
|
||||
// latter two are triggered when the user is typing and the mouse happens to
|
||||
// be over the suggestions popup.
|
||||
this.selectedIndex = this._indexOfTableRowOrDescendent(event.target);
|
||||
},
|
||||
|
||||
_onMousedown: function (event) {
|
||||
let idx = this._indexOfTableRowOrDescendent(event.target);
|
||||
let suggestion = this.suggestionAtIndex(idx);
|
||||
this._stickyInputValue = suggestion;
|
||||
this.input.value = suggestion;
|
||||
this._hideSuggestions();
|
||||
if (this.onClick) {
|
||||
this.onClick.call(null);
|
||||
}
|
||||
},
|
||||
|
||||
_onContentSearchService: function (event) {
|
||||
let methodName = "_onMsg" + event.detail.type;
|
||||
if (methodName in this) {
|
||||
this[methodName](event.detail.data);
|
||||
}
|
||||
},
|
||||
|
||||
_onMsgSuggestions: function (suggestions) {
|
||||
// Ignore the suggestions if their search string or engine doesn't match
|
||||
// ours. Due to the async nature of message passing, this can easily happen
|
||||
// when the user types quickly.
|
||||
if (this._stickyInputValue != suggestions.searchString ||
|
||||
this.engineName != suggestions.engineName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty the table.
|
||||
while (this._table.firstElementChild) {
|
||||
this._table.firstElementChild.remove();
|
||||
}
|
||||
|
||||
// Position and size the table.
|
||||
let { left, bottom } = this.input.getBoundingClientRect();
|
||||
this._table.style.left = (left + window.scrollX) + "px";
|
||||
this._table.style.top = (bottom + window.scrollY) + "px";
|
||||
this._table.style.minWidth = this.input.offsetWidth + "px";
|
||||
this._table.style.maxWidth = (window.innerWidth - left - 40) + "px";
|
||||
|
||||
// Add the suggestions to the table.
|
||||
let searchWords =
|
||||
new Set(suggestions.searchString.trim().toLowerCase().split(/\s+/));
|
||||
for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) {
|
||||
let type, idx;
|
||||
if (i < suggestions.formHistory.length) {
|
||||
[type, idx] = ["formHistory", i];
|
||||
}
|
||||
else {
|
||||
let j = i - suggestions.formHistory.length;
|
||||
if (j < suggestions.remote.length) {
|
||||
[type, idx] = ["remote", j];
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._table.appendChild(this._makeTableRow(type, suggestions[type][idx],
|
||||
i, searchWords));
|
||||
}
|
||||
|
||||
this._table.hidden = false;
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
},
|
||||
|
||||
_speculativeConnect: function () {
|
||||
if (this.engineName) {
|
||||
this._sendMsg("SpeculativeConnect", this.engineName);
|
||||
}
|
||||
},
|
||||
|
||||
_makeTableRow: function (type, suggestionStr, currentRow, searchWords) {
|
||||
let row = document.createElementNS(HTML_NS, "tr");
|
||||
row.classList.add("searchSuggestionRow");
|
||||
row.classList.add(type);
|
||||
row.setAttribute("role", "presentation");
|
||||
row.addEventListener("mousemove", this);
|
||||
row.addEventListener("mousedown", this);
|
||||
|
||||
let entry = document.createElementNS(HTML_NS, "td");
|
||||
entry.classList.add("searchSuggestionEntry");
|
||||
entry.setAttribute("role", "option");
|
||||
entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow;
|
||||
entry.setAttribute("aria-selected", "false");
|
||||
|
||||
let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/);
|
||||
for (let i = 0; i < suggestionWords.length; i++) {
|
||||
let word = suggestionWords[i];
|
||||
let wordSpan = document.createElementNS(HTML_NS, "span");
|
||||
if (searchWords.has(word)) {
|
||||
wordSpan.classList.add("typed");
|
||||
}
|
||||
wordSpan.textContent = word;
|
||||
entry.appendChild(wordSpan);
|
||||
if (i < suggestionWords.length - 1) {
|
||||
entry.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
}
|
||||
|
||||
row.appendChild(entry);
|
||||
return row;
|
||||
},
|
||||
|
||||
_getSuggestions: function () {
|
||||
this._stickyInputValue = this.input.value;
|
||||
if (this.engineName) {
|
||||
this._sendMsg("GetSuggestions", {
|
||||
engineName: this.engineName,
|
||||
searchString: this.input.value,
|
||||
remoteTimeout: this.remoteTimeout,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_hideSuggestions: function () {
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this._table.hidden = true;
|
||||
while (this._table.firstElementChild) {
|
||||
this._table.firstElementChild.remove();
|
||||
}
|
||||
this.selectedIndex = -1;
|
||||
},
|
||||
|
||||
_indexOfTableRowOrDescendent: function (row) {
|
||||
while (row && row.localName != "tr") {
|
||||
row = row.parentNode;
|
||||
}
|
||||
if (!row) {
|
||||
throw new Error("Element is not a row");
|
||||
}
|
||||
return row.rowIndex;
|
||||
},
|
||||
|
||||
_makeTable: function (id) {
|
||||
this._table = document.createElementNS(HTML_NS, "table");
|
||||
this._table.id = id;
|
||||
this._table.hidden = true;
|
||||
this._table.dir = "auto";
|
||||
this._table.classList.add("searchSuggestionTable");
|
||||
this._table.setAttribute("role", "listbox");
|
||||
return this._table;
|
||||
},
|
||||
|
||||
_sendMsg: function (type, data=null) {
|
||||
dispatchEvent(new CustomEvent("ContentSearchClient", {
|
||||
detail: {
|
||||
type: type,
|
||||
data: data,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
return SearchSuggestionUIController;
|
||||
})();
|
@ -1913,6 +1913,7 @@
|
||||
<body>
|
||||
<![CDATA[
|
||||
if (aTab.closing ||
|
||||
aTab._pendingPermitUnload ||
|
||||
this._windowIsClosing)
|
||||
return false;
|
||||
|
||||
@ -1920,10 +1921,17 @@
|
||||
|
||||
if (!aTabWillBeMoved) {
|
||||
let ds = browser.docShell;
|
||||
if (ds &&
|
||||
ds.contentViewer &&
|
||||
!ds.contentViewer.permitUnload()) {
|
||||
return false;
|
||||
if (ds && ds.contentViewer) {
|
||||
// We need to block while calling permitUnload() because it
|
||||
// processes the event queue and may lead to another removeTab()
|
||||
// call before permitUnload() even returned.
|
||||
aTab._pendingPermitUnload = true;
|
||||
let permitUnload = ds.contentViewer.permitUnload();
|
||||
delete aTab._pendingPermitUnload;
|
||||
|
||||
if (!permitUnload) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,9 +11,11 @@ support-files =
|
||||
browser_bug678392-1.html
|
||||
browser_bug678392-2.html
|
||||
browser_bug970746.xhtml
|
||||
browser_fxa_oauth.html
|
||||
browser_registerProtocolHandler_notification.html
|
||||
browser_star_hsts.sjs
|
||||
browser_tab_dragdrop2_frame1.xul
|
||||
browser_web_channel.html
|
||||
bug564387.html
|
||||
bug564387_video1.ogv
|
||||
bug564387_video1.ogv^headers^
|
||||
@ -64,6 +66,8 @@ support-files =
|
||||
page_style_sample.html
|
||||
print_postdata.sjs
|
||||
redirect_bug623155.sjs
|
||||
searchSuggestionEngine.sjs
|
||||
searchSuggestionEngine.xml
|
||||
test-mixedcontent-securityerrors.html
|
||||
test_bug435035.html
|
||||
test_bug462673.html
|
||||
@ -295,6 +299,7 @@ skip-if = true # browser_drag.js is disabled, as it needs to be updated for the
|
||||
[browser_findbarClose.js]
|
||||
skip-if = e10s # Bug ?????? - test directly manipulates content (tries to grab an iframe directly from content)
|
||||
[browser_fullscreen-window-open.js]
|
||||
[browser_fxa_oauth.js]
|
||||
skip-if = buildapp == 'mulet' || e10s || os == "linux" # Bug 933103 - mochitest's EventUtils.synthesizeMouse functions not e10s friendly. Linux: Intermittent failures - bug 941575.
|
||||
[browser_gestureSupport.js]
|
||||
skip-if = e10s # Bug 863514 - no gesture support.
|
||||
@ -371,6 +376,10 @@ skip-if = buildapp == 'mulet' || e10s # e10s: Bug 933103 - mochitest's EventUtil
|
||||
[browser_save_video.js]
|
||||
skip-if = buildapp == 'mulet' || e10s # Bug ?????? - test directly manipulates content (event.target)
|
||||
[browser_scope.js]
|
||||
[browser_searchSuggestionUI.js]
|
||||
support-files =
|
||||
searchSuggestionUI.html
|
||||
searchSuggestionUI.js
|
||||
[browser_selectTabAtIndex.js]
|
||||
skip-if = e10s # Bug ?????? - no idea! "Accel+9 selects expected tab - Got 0, expected 9"
|
||||
[browser_star_hsts.js]
|
||||
@ -428,6 +437,7 @@ skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
|
||||
[browser_visibleTabs_contextMenu.js]
|
||||
skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
|
||||
[browser_visibleTabs_tabPreview.js]
|
||||
[browser_web_channel.js]
|
||||
skip-if = (os == "win" && !debug) || e10s # Bug 1007418 / Bug 698371 - thumbnail captures need e10s love (tabPreviews_capture fails with Argument 1 of CanvasRenderingContext2D.drawWindow does not implement interface Window.)
|
||||
[browser_windowopen_reflows.js]
|
||||
skip-if = buildapp == 'mulet'
|
||||
|
@ -97,7 +97,9 @@ let gTests = [
|
||||
setup: function () { },
|
||||
run: function () {
|
||||
// Skip this test on Linux.
|
||||
if (navigator.platform.indexOf("Linux") == 0) { return; }
|
||||
if (navigator.platform.indexOf("Linux") == 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
|
||||
@ -372,6 +374,53 @@ let gTests = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
// See browser_searchSuggestionUI.js for comprehensive content search
|
||||
// suggestion UI tests.
|
||||
desc: "Search suggestion smoke test",
|
||||
setup: function() {},
|
||||
run: function()
|
||||
{
|
||||
return Task.spawn(function* () {
|
||||
// Add a test engine that provides suggestions and switch to it.
|
||||
let engine = yield promiseNewEngine("searchSuggestionEngine.xml");
|
||||
let promise = promiseBrowserAttributes(gBrowser.selectedTab);
|
||||
Services.search.currentEngine = engine;
|
||||
yield promise;
|
||||
|
||||
// Avoid intermittent failures.
|
||||
gBrowser.contentWindow.wrappedJSObject.gSearchSuggestionController.remoteTimeout = 5000;
|
||||
|
||||
// Type an X in the search input.
|
||||
let input = gBrowser.contentDocument.getElementById("searchText");
|
||||
input.focus();
|
||||
EventUtils.synthesizeKey("x", {});
|
||||
|
||||
// Wait for the search suggestions to become visible.
|
||||
let table =
|
||||
gBrowser.contentDocument.getElementById("searchSuggestionTable");
|
||||
let deferred = Promise.defer();
|
||||
let observer = new MutationObserver(() => {
|
||||
if (input.getAttribute("aria-expanded") == "true") {
|
||||
observer.disconnect();
|
||||
ok(!table.hidden, "Search suggestion table unhidden");
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
observer.observe(input, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-expanded"],
|
||||
});
|
||||
yield deferred.promise;
|
||||
|
||||
// Empty the search input, causing the suggestions to be hidden.
|
||||
EventUtils.synthesizeKey("a", { accelKey: true });
|
||||
EventUtils.synthesizeKey("VK_DELETE", {});
|
||||
ok(table.hidden, "Search suggestion table hidden");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
function test()
|
||||
@ -547,3 +596,21 @@ function waitForLoad(cb) {
|
||||
cb();
|
||||
}, true);
|
||||
}
|
||||
|
||||
function promiseNewEngine(basename) {
|
||||
info("Waiting for engine to be added: " + basename);
|
||||
let addDeferred = Promise.defer();
|
||||
let url = getRootDirectory(gTestPath) + basename;
|
||||
Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
|
||||
onSuccess: function (engine) {
|
||||
info("Search engine added: " + basename);
|
||||
registerCleanupFunction(() => Services.search.removeEngine(engine));
|
||||
addDeferred.resolve(engine);
|
||||
},
|
||||
onError: function (errCode) {
|
||||
ok(false, "addEngine failed with error code " + errCode);
|
||||
addDeferred.reject();
|
||||
},
|
||||
});
|
||||
return addDeferred.promise;
|
||||
}
|
||||
|
28
browser/base/content/test/general/browser_fxa_oauth.html
Normal file
@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>fxa_oauth_test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.onload = function(){
|
||||
var event = new window.CustomEvent("WebChannelMessageToChrome", {
|
||||
detail: {
|
||||
id: "oauth_client_id",
|
||||
message: {
|
||||
command: "oauth_complete",
|
||||
data: {
|
||||
state: "state",
|
||||
code: "code1",
|
||||
closeWindow: "signin",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
75
browser/base/content/test/general/browser_fxa_oauth.js
Normal file
@ -0,0 +1,75 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/
|
||||
*/
|
||||
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthClient",
|
||||
"resource://gre/modules/FxAccountsOAuthClient.jsm");
|
||||
|
||||
const HTTP_PATH = "http://example.com";
|
||||
const HTTP_ENDPOINT = "/browser/browser/base/content/test/general/browser_fxa_oauth.html";
|
||||
|
||||
let gTests = [
|
||||
{
|
||||
desc: "FxA OAuth - should open a new tab, complete OAuth flow",
|
||||
run: function* () {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let tabOpened = false;
|
||||
let properUrl = "http://example.com/browser/browser/base/content/test/general/browser_fxa_oauth.html?" +
|
||||
"webChannelId=oauth_client_id&scope=&client_id=client_id&action=signin&state=state";
|
||||
waitForTab(function (tab) {
|
||||
Assert.ok("Tab successfully opened");
|
||||
Assert.equal(gBrowser.currentURI.spec, properUrl);
|
||||
tabOpened = true;
|
||||
});
|
||||
|
||||
let client = new FxAccountsOAuthClient({
|
||||
parameters: {
|
||||
state: "state",
|
||||
client_id: "client_id",
|
||||
oauth_uri: HTTP_PATH,
|
||||
content_uri: HTTP_PATH,
|
||||
},
|
||||
authorizationEndpoint: HTTP_ENDPOINT
|
||||
});
|
||||
|
||||
client.onComplete = function(tokenData) {
|
||||
Assert.ok(tabOpened);
|
||||
Assert.equal(tokenData.code, "code1");
|
||||
Assert.equal(tokenData.state, "state");
|
||||
resolve();
|
||||
};
|
||||
|
||||
client.launchWebFlow();
|
||||
});
|
||||
}
|
||||
}
|
||||
]; // gTests
|
||||
|
||||
function waitForTab(aCallback) {
|
||||
let container = gBrowser.tabContainer;
|
||||
container.addEventListener("TabOpen", function tabOpener(event) {
|
||||
container.removeEventListener("TabOpen", tabOpener, false);
|
||||
gBrowser.addEventListener("load", function listener() {
|
||||
gBrowser.removeEventListener("load", listener, true);
|
||||
let tab = event.target;
|
||||
aCallback(tab);
|
||||
}, true);
|
||||
}, false);
|
||||
}
|
||||
|
||||
function test() {
|
||||
waitForExplicitFinish();
|
||||
|
||||
Task.spawn(function () {
|
||||
for (let test of gTests) {
|
||||
info("Running: " + test.desc);
|
||||
yield test.run();
|
||||
}
|
||||
}).then(finish, ex => {
|
||||
Assert.ok(false, "Unexpected Exception: " + ex);
|
||||
finish();
|
||||
});
|
||||
}
|
305
browser/base/content/test/general/browser_searchSuggestionUI.js
Normal file
@ -0,0 +1,305 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
const TEST_PAGE_BASENAME = "searchSuggestionUI.html";
|
||||
const TEST_CONTENT_SCRIPT_BASENAME = "searchSuggestionUI.js";
|
||||
const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
|
||||
|
||||
const TEST_MSG = "SearchSuggestionUIControllerTest";
|
||||
|
||||
add_task(function* emptyInput() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_BACK_SPACE");
|
||||
checkState(state, "", [], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* blur() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("blur");
|
||||
checkState(state, "x", [], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* arrowKeys() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Cycle down the suggestions starting from no selection.
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Cycle up starting from no selection.
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
// The right arrow and return key function the same.
|
||||
function rightArrowOrReturn(keyName) {
|
||||
return function* rightArrowOrReturnTest() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
// This should make the xfoo suggestion sticky. To make sure it sticks,
|
||||
// trigger suggestions again and cycle through them by pressing Down until
|
||||
// nothing is selected again.
|
||||
state = yield msg("key", keyName);
|
||||
checkState(state, "xfoo", [], -1);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoofoo", ["xfoofoo", "xfoobar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoobar", ["xfoofoo", "xfoobar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
};
|
||||
}
|
||||
|
||||
add_task(rightArrowOrReturn("VK_RIGHT"));
|
||||
add_task(rightArrowOrReturn("VK_RETURN"));
|
||||
|
||||
add_task(function* mouse() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Mouse over the first suggestion.
|
||||
state = yield msg("mousemove", 0);
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
// Mouse over the second suggestion.
|
||||
state = yield msg("mousemove", 1);
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
// Click the second suggestion. This should make it sticky. To make sure it
|
||||
// sticks, trigger suggestions again and cycle through them by pressing Down
|
||||
// until nothing is selected again.
|
||||
state = yield msg("mousedown", 1);
|
||||
checkState(state, "xbar", [], -1);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbarfoo", ["xbarfoo", "xbarbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbarbar", ["xbarfoo", "xbarbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* formHistory() {
|
||||
yield setUp();
|
||||
|
||||
// Type an X and add it to form history.
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
yield msg("addInputValueToFormHistory");
|
||||
|
||||
// Wait for Satchel to say it's been added to form history.
|
||||
let deferred = Promise.defer();
|
||||
Services.obs.addObserver(function onAdd(subj, topic, data) {
|
||||
if (data == "formhistory-add") {
|
||||
executeSoon(() => deferred.resolve());
|
||||
}
|
||||
}, "satchel-storage-changed", false);
|
||||
yield deferred.promise;
|
||||
|
||||
// Reset the input.
|
||||
state = yield msg("reset");
|
||||
checkState(state, "", [], -1);
|
||||
|
||||
// Type an X again. The form history entry should appear.
|
||||
state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
|
||||
-1);
|
||||
|
||||
// Select the form history entry and delete it.
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
|
||||
0);
|
||||
|
||||
state = yield msg("key", "VK_DELETE");
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Wait for Satchel.
|
||||
deferred = Promise.defer();
|
||||
Services.obs.addObserver(function onAdd(subj, topic, data) {
|
||||
if (data == "formhistory-remove") {
|
||||
executeSoon(() => deferred.resolve());
|
||||
}
|
||||
}, "satchel-storage-changed", false);
|
||||
yield deferred.promise;
|
||||
|
||||
// Reset the input.
|
||||
state = yield msg("reset");
|
||||
checkState(state, "", [], -1);
|
||||
|
||||
// Type an X again. The form history entry should still be gone.
|
||||
state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
|
||||
let gDidInitialSetUp = false;
|
||||
|
||||
function setUp() {
|
||||
return Task.spawn(function* () {
|
||||
if (!gDidInitialSetUp) {
|
||||
yield promiseNewEngine(TEST_ENGINE_BASENAME);
|
||||
yield promiseTab();
|
||||
gDidInitialSetUp = true;
|
||||
}
|
||||
yield msg("focus");
|
||||
});
|
||||
}
|
||||
|
||||
function msg(type, data=null) {
|
||||
gMsgMan.sendAsyncMessage(TEST_MSG, {
|
||||
type: type,
|
||||
data: data,
|
||||
});
|
||||
let deferred = Promise.defer();
|
||||
gMsgMan.addMessageListener(TEST_MSG, function onMsg(msg) {
|
||||
gMsgMan.removeMessageListener(TEST_MSG, onMsg);
|
||||
deferred.resolve(msg.data);
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function checkState(actualState, expectedInputVal, expectedSuggestions,
|
||||
expectedSelectedIdx) {
|
||||
expectedSuggestions = expectedSuggestions.map(sugg => {
|
||||
return typeof(sugg) == "object" ? sugg : {
|
||||
str: sugg,
|
||||
type: "remote",
|
||||
};
|
||||
});
|
||||
|
||||
let expectedState = {
|
||||
selectedIndex: expectedSelectedIdx,
|
||||
numSuggestions: expectedSuggestions.length,
|
||||
suggestionAtIndex: expectedSuggestions.map(s => s.str),
|
||||
isFormHistorySuggestionAtIndex:
|
||||
expectedSuggestions.map(s => s.type == "formHistory"),
|
||||
|
||||
tableHidden: expectedSuggestions.length == 0,
|
||||
tableChildrenLength: expectedSuggestions.length,
|
||||
tableChildren: expectedSuggestions.map((s, i) => {
|
||||
let expectedClasses = new Set([s.type]);
|
||||
if (i == expectedSelectedIdx) {
|
||||
expectedClasses.add("selected");
|
||||
}
|
||||
return {
|
||||
textContent: s.str,
|
||||
classes: expectedClasses,
|
||||
};
|
||||
}),
|
||||
|
||||
inputValue: expectedInputVal,
|
||||
ariaExpanded: expectedSuggestions.length == 0 ? "false" : "true",
|
||||
};
|
||||
|
||||
SimpleTest.isDeeply(actualState, expectedState, "State");
|
||||
}
|
||||
|
||||
var gMsgMan;
|
||||
|
||||
function promiseTab() {
|
||||
let deferred = Promise.defer();
|
||||
let tab = gBrowser.addTab();
|
||||
registerCleanupFunction(() => gBrowser.removeTab(tab));
|
||||
gBrowser.selectedTab = tab;
|
||||
let pageURL = getRootDirectory(gTestPath) + TEST_PAGE_BASENAME;
|
||||
tab.linkedBrowser.addEventListener("load", function onLoad(event) {
|
||||
tab.linkedBrowser.removeEventListener("load", onLoad, true);
|
||||
gMsgMan = tab.linkedBrowser.messageManager;
|
||||
gMsgMan.sendAsyncMessage("ContentSearch", {
|
||||
type: "AddToWhitelist",
|
||||
data: [pageURL],
|
||||
});
|
||||
promiseMsg("ContentSearch", "AddToWhitelistAck", gMsgMan).then(() => {
|
||||
let jsURL = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
|
||||
gMsgMan.loadFrameScript(jsURL, false);
|
||||
deferred.resolve();
|
||||
});
|
||||
}, true, true);
|
||||
openUILinkIn(pageURL, "current");
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function promiseMsg(name, type, msgMan) {
|
||||
let deferred = Promise.defer();
|
||||
info("Waiting for " + name + " message " + type + "...");
|
||||
msgMan.addMessageListener(name, function onMsg(msg) {
|
||||
info("Received " + name + " message " + msg.data.type + "\n");
|
||||
if (msg.data.type == type) {
|
||||
msgMan.removeMessageListener(name, onMsg);
|
||||
deferred.resolve(msg);
|
||||
}
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function promiseNewEngine(basename) {
|
||||
info("Waiting for engine to be added: " + basename);
|
||||
let addDeferred = Promise.defer();
|
||||
let url = getRootDirectory(gTestPath) + basename;
|
||||
Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
|
||||
onSuccess: function (engine) {
|
||||
info("Search engine added: " + basename);
|
||||
registerCleanupFunction(() => Services.search.removeEngine(engine));
|
||||
addDeferred.resolve(engine);
|
||||
},
|
||||
onError: function (errCode) {
|
||||
ok(false, "addEngine failed with error code " + errCode);
|
||||
addDeferred.reject();
|
||||
},
|
||||
});
|
||||
return addDeferred.promise;
|
||||
}
|
89
browser/base/content/test/general/browser_web_channel.html
Normal file
@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>web_channel_test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
var testName = window.location.search.replace(/^\?/, "");
|
||||
|
||||
switch(testName) {
|
||||
case "generic":
|
||||
test_generic();
|
||||
break;
|
||||
case "twoway":
|
||||
test_twoWay();
|
||||
break;
|
||||
case "multichannel":
|
||||
test_multichannel();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function test_generic() {
|
||||
var event = new window.CustomEvent("WebChannelMessageToChrome", {
|
||||
detail: {
|
||||
id: "generic",
|
||||
message: {
|
||||
something: {
|
||||
nested: "hello",
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function test_twoWay() {
|
||||
var firstMessage = new window.CustomEvent("WebChannelMessageToChrome", {
|
||||
detail: {
|
||||
id: "twoway",
|
||||
message: {
|
||||
command: "one",
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("WebChannelMessageToContent", function(e) {
|
||||
var secondMessage = new window.CustomEvent("WebChannelMessageToChrome", {
|
||||
detail: {
|
||||
id: "twoway",
|
||||
message: {
|
||||
command: "two",
|
||||
detail: e.detail.message,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!e.detail.message.error) {
|
||||
window.dispatchEvent(secondMessage);
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.dispatchEvent(firstMessage);
|
||||
}
|
||||
|
||||
function test_multichannel() {
|
||||
var event1 = new window.CustomEvent("WebChannelMessageToChrome", {
|
||||
detail: {
|
||||
id: "wrongchannel",
|
||||
message: {},
|
||||
}
|
||||
});
|
||||
|
||||
var event2 = new window.CustomEvent("WebChannelMessageToChrome", {
|
||||
detail: {
|
||||
id: "multichannel",
|
||||
message: {},
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(event1);
|
||||
window.dispatchEvent(event2);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
91
browser/base/content/test/general/browser_web_channel.js
Normal file
@ -0,0 +1,91 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/
|
||||
*/
|
||||
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
|
||||
"resource://gre/modules/WebChannel.jsm");
|
||||
|
||||
const HTTP_PATH = "http://example.com";
|
||||
const HTTP_ENDPOINT = "/browser/browser/base/content/test/general/browser_web_channel.html";
|
||||
|
||||
let gTests = [
|
||||
{
|
||||
desc: "WebChannel generic message",
|
||||
run: function* () {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let tab;
|
||||
let channel = new WebChannel("generic", Services.io.newURI(HTTP_PATH, null, null));
|
||||
channel.listen(function (id, message, target) {
|
||||
is(id, "generic");
|
||||
is(message.something.nested, "hello");
|
||||
channel.stopListening();
|
||||
gBrowser.removeTab(tab);
|
||||
resolve();
|
||||
});
|
||||
|
||||
tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?generic");
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
desc: "WebChannel two way communication",
|
||||
run: function* () {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let tab;
|
||||
let channel = new WebChannel("twoway", Services.io.newURI(HTTP_PATH, null, null));
|
||||
|
||||
channel.listen(function (id, message, sender) {
|
||||
is(id, "twoway");
|
||||
ok(message.command);
|
||||
|
||||
if (message.command === "one") {
|
||||
channel.send({ data: { nested: true } }, sender);
|
||||
}
|
||||
|
||||
if (message.command === "two") {
|
||||
is(message.detail.data.nested, true);
|
||||
channel.stopListening();
|
||||
gBrowser.removeTab(tab);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?twoway");
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
desc: "WebChannel multichannel",
|
||||
run: function* () {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let tab;
|
||||
let channel = new WebChannel("multichannel", Services.io.newURI(HTTP_PATH, null, null));
|
||||
|
||||
channel.listen(function (id, message, sender) {
|
||||
is(id, "multichannel");
|
||||
gBrowser.removeTab(tab);
|
||||
resolve();
|
||||
});
|
||||
|
||||
tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?multichannel");
|
||||
});
|
||||
}
|
||||
}
|
||||
]; // gTests
|
||||
|
||||
function test() {
|
||||
waitForExplicitFinish();
|
||||
|
||||
Task.spawn(function () {
|
||||
for (let test of gTests) {
|
||||
info("Running: " + test.desc);
|
||||
yield test.run();
|
||||
}
|
||||
}).then(finish, ex => {
|
||||
ok(false, "Unexpected Exception: " + ex);
|
||||
finish();
|
||||
});
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
function handleRequest(req, resp) {
|
||||
let suffixes = ["foo", "bar"];
|
||||
let data = [req.queryString, suffixes.map(s => req.queryString + s)];
|
||||
resp.setHeader("Content-Type", "application/json", false);
|
||||
resp.write(JSON.stringify(data));
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Any copyright is dedicated to the Public Domain.
|
||||
- http://creativecommons.org/publicdomain/zero/1.0/ -->
|
||||
|
||||
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
|
||||
<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
|
||||
<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/base/content/test/general/searchSuggestionEngine.sjs?{searchTerms}"/>
|
||||
<Url type="text/html" method="GET" template="http://browser-searchSuggestionEngine.com/searchSuggestionEngine" rel="searchform"/>
|
||||
</SearchPlugin>
|
20
browser/base/content/test/general/searchSuggestionUI.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Any copyright is dedicated to the Public Domain.
|
||||
- http://creativecommons.org/publicdomain/zero/1.0/ -->
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script type="application/javascript;version=1.8"
|
||||
src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js">
|
||||
</script>
|
||||
<script type="application/javascript;version=1.8"
|
||||
src="chrome://browser/content/searchSuggestionUI.js">
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<input>
|
||||
|
||||
</body>
|
||||
</html>
|
138
browser/base/content/test/general/searchSuggestionUI.js
Normal file
@ -0,0 +1,138 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
|
||||
const TEST_MSG = "SearchSuggestionUIControllerTest";
|
||||
const ENGINE_NAME = "browser_searchSuggestionEngine searchSuggestionEngine.xml";
|
||||
|
||||
let input = content.document.querySelector("input");
|
||||
let gController =
|
||||
new content.SearchSuggestionUIController(input, input.parentNode);
|
||||
gController.engineName = ENGINE_NAME;
|
||||
gController.remoteTimeout = 5000;
|
||||
|
||||
addMessageListener(TEST_MSG, msg => {
|
||||
messageHandlers[msg.data.type](msg.data.data);
|
||||
});
|
||||
|
||||
let messageHandlers = {
|
||||
|
||||
key: function (arg) {
|
||||
let keyName = typeof(arg) == "string" ? arg : arg.key;
|
||||
content.synthesizeKey(keyName, {});
|
||||
let wait = arg.waitForSuggestions ? waitForSuggestions : cb => cb();
|
||||
wait(ack);
|
||||
},
|
||||
|
||||
focus: function () {
|
||||
gController.input.focus();
|
||||
ack();
|
||||
},
|
||||
|
||||
blur: function () {
|
||||
gController.input.blur();
|
||||
ack();
|
||||
},
|
||||
|
||||
mousemove: function (suggestionIdx) {
|
||||
// Copied from widget/tests/test_panel_mouse_coords.xul and
|
||||
// browser/base/content/test/newtab/head.js
|
||||
let row = gController._table.children[suggestionIdx];
|
||||
let rect = row.getBoundingClientRect();
|
||||
let left = content.mozInnerScreenX + rect.left;
|
||||
let x = left + rect.width / 2;
|
||||
let y = content.mozInnerScreenY + rect.top + rect.height / 2;
|
||||
|
||||
let utils = content.SpecialPowers.getDOMWindowUtils(content);
|
||||
let scale = utils.screenPixelsPerCSSPixel;
|
||||
|
||||
let widgetToolkit = content.SpecialPowers.
|
||||
Cc["@mozilla.org/xre/app-info;1"].
|
||||
getService(content.SpecialPowers.Ci.nsIXULRuntime).
|
||||
widgetToolkit;
|
||||
let nativeMsg = widgetToolkit == "cocoa" ? 5 : // NSMouseMoved
|
||||
widgetToolkit == "windows" ? 1 : // MOUSEEVENTF_MOVE
|
||||
3; // GDK_MOTION_NOTIFY
|
||||
|
||||
row.addEventListener("mousemove", function onMove() {
|
||||
row.removeEventListener("mousemove", onMove);
|
||||
ack();
|
||||
});
|
||||
utils.sendNativeMouseEvent(x * scale, y * scale, nativeMsg, 0, null);
|
||||
},
|
||||
|
||||
mousedown: function (suggestionIdx) {
|
||||
gController.onClick = () => {
|
||||
gController.onClick = null;
|
||||
ack();
|
||||
};
|
||||
let row = gController._table.children[suggestionIdx];
|
||||
content.sendMouseEvent({ type: "mousedown" }, row);
|
||||
},
|
||||
|
||||
addInputValueToFormHistory: function () {
|
||||
gController.addInputValueToFormHistory();
|
||||
ack();
|
||||
},
|
||||
|
||||
reset: function () {
|
||||
// Reset both the input and suggestions by select all + delete.
|
||||
gController.input.focus();
|
||||
content.synthesizeKey("a", { accelKey: true });
|
||||
content.synthesizeKey("VK_DELETE", {});
|
||||
ack();
|
||||
},
|
||||
};
|
||||
|
||||
function ack() {
|
||||
sendAsyncMessage(TEST_MSG, currentState());
|
||||
}
|
||||
|
||||
function waitForSuggestions(cb) {
|
||||
let observer = new content.MutationObserver(() => {
|
||||
if (gController.input.getAttribute("aria-expanded") == "true") {
|
||||
observer.disconnect();
|
||||
cb();
|
||||
}
|
||||
});
|
||||
observer.observe(gController.input, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-expanded"],
|
||||
});
|
||||
}
|
||||
|
||||
function currentState() {
|
||||
let state = {
|
||||
selectedIndex: gController.selectedIndex,
|
||||
numSuggestions: gController.numSuggestions,
|
||||
suggestionAtIndex: [],
|
||||
isFormHistorySuggestionAtIndex: [],
|
||||
|
||||
tableHidden: gController._table.hidden,
|
||||
tableChildrenLength: gController._table.children.length,
|
||||
tableChildren: [],
|
||||
|
||||
inputValue: gController.input.value,
|
||||
ariaExpanded: gController.input.getAttribute("aria-expanded"),
|
||||
};
|
||||
|
||||
for (let i = 0; i < gController.numSuggestions; i++) {
|
||||
state.suggestionAtIndex.push(gController.suggestionAtIndex(i));
|
||||
state.isFormHistorySuggestionAtIndex.push(
|
||||
gController.isFormHistorySuggestionAtIndex(i));
|
||||
}
|
||||
|
||||
for (let child of gController._table.children) {
|
||||
state.tableChildren.push({
|
||||
textContent: child.textContent,
|
||||
classes: new Set(child.className.split(/\s+/)),
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
})();
|
@ -34,6 +34,8 @@ support-files =
|
||||
searchEngine1xLogo.xml
|
||||
searchEngine2xLogo.xml
|
||||
searchEngine1x2xLogo.xml
|
||||
../general/searchSuggestionEngine.xml
|
||||
../general/searchSuggestionEngine.sjs
|
||||
[browser_newtab_sponsored_icon_click.js]
|
||||
[browser_newtab_tabsync.js]
|
||||
[browser_newtab_undo.js]
|
||||
|
@ -8,6 +8,7 @@ const ENGINE_NO_LOGO = "searchEngineNoLogo.xml";
|
||||
const ENGINE_1X_LOGO = "searchEngine1xLogo.xml";
|
||||
const ENGINE_2X_LOGO = "searchEngine2xLogo.xml";
|
||||
const ENGINE_1X_2X_LOGO = "searchEngine1x2xLogo.xml";
|
||||
const ENGINE_SUGGESTIONS = "searchSuggestionEngine.xml";
|
||||
|
||||
const SERVICE_EVENT_NAME = "ContentSearchService";
|
||||
|
||||
@ -141,6 +142,50 @@ function runTests() {
|
||||
promiseClick(manageBox),
|
||||
]).then(TestRunner.next);
|
||||
|
||||
// Add the engine that provides search suggestions and switch to it.
|
||||
let suggestionEngine = null;
|
||||
yield promiseNewSearchEngine(ENGINE_SUGGESTIONS, 0).then(engine => {
|
||||
suggestionEngine = engine;
|
||||
TestRunner.next();
|
||||
});
|
||||
Services.search.currentEngine = suggestionEngine;
|
||||
yield promiseSearchEvents(["CurrentEngine"]).then(TestRunner.next);
|
||||
yield checkCurrentEngine(ENGINE_SUGGESTIONS, false, false);
|
||||
|
||||
// Avoid intermittent failures.
|
||||
gSearch()._suggestionController.remoteTimeout = 5000;
|
||||
|
||||
// Type an X in the search input. This is only a smoke test. See
|
||||
// browser_searchSuggestionUI.js for comprehensive content search suggestion
|
||||
// UI tests.
|
||||
let input = $("text");
|
||||
input.focus();
|
||||
EventUtils.synthesizeKey("x", {});
|
||||
let suggestionsPromise = promiseSearchEvents(["Suggestions"]);
|
||||
|
||||
// Wait for the search suggestions to become visible and for the Suggestions
|
||||
// message.
|
||||
let table = getContentDocument().getElementById("searchSuggestionTable");
|
||||
info("Waiting for suggestions table to open");
|
||||
let observer = new MutationObserver(() => {
|
||||
if (input.getAttribute("aria-expanded") == "true") {
|
||||
observer.disconnect();
|
||||
ok(!table.hidden, "Search suggestion table unhidden");
|
||||
TestRunner.next();
|
||||
}
|
||||
});
|
||||
observer.observe(input, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-expanded"],
|
||||
});
|
||||
yield undefined;
|
||||
yield suggestionsPromise.then(TestRunner.next);
|
||||
|
||||
// Empty the search input, causing the suggestions to be hidden.
|
||||
EventUtils.synthesizeKey("a", { accelKey: true });
|
||||
EventUtils.synthesizeKey("VK_DELETE", {});
|
||||
ok(table.hidden, "Search suggestion table hidden");
|
||||
|
||||
// Done. Revert the current engine and remove the new engines.
|
||||
Services.search.currentEngine = oldCurrentEngine;
|
||||
yield promiseSearchEvents(["CurrentEngine"]).then(TestRunner.next);
|
||||
@ -264,6 +309,11 @@ function checkCurrentEngine(basename, has1xLogo, has2xLogo) {
|
||||
ok(/^url\("blob:/.test(logo.style.backgroundImage), "Logo URI"); //"
|
||||
}
|
||||
|
||||
if (logo.hidden) {
|
||||
executeSoon(TestRunner.next);
|
||||
return;
|
||||
}
|
||||
|
||||
// "selected" attributes of engines in the panel
|
||||
let panel = searchPanel();
|
||||
promisePanelShown(panel).then(() => {
|
||||
@ -283,7 +333,7 @@ function checkCurrentEngine(basename, has1xLogo, has2xLogo) {
|
||||
}
|
||||
TestRunner.next();
|
||||
});
|
||||
panel.openPopup(logoImg());
|
||||
panel.openPopup(logo);
|
||||
}
|
||||
|
||||
function promisePanelShown(panel) {
|
||||
|
@ -118,6 +118,8 @@ browser.jar:
|
||||
* content/browser/sanitize.xul (content/sanitize.xul)
|
||||
* content/browser/sanitizeDialog.js (content/sanitizeDialog.js)
|
||||
content/browser/sanitizeDialog.css (content/sanitizeDialog.css)
|
||||
content/browser/searchSuggestionUI.js (content/searchSuggestionUI.js)
|
||||
content/browser/searchSuggestionUI.css (content/searchSuggestionUI.css)
|
||||
content/browser/tabbrowser.css (content/tabbrowser.css)
|
||||
* content/browser/tabbrowser.xml (content/tabbrowser.xml)
|
||||
* content/browser/urlbarBindings.xml (content/urlbarBindings.xml)
|
||||
|
@ -19,7 +19,7 @@ add_task(function() {
|
||||
let historyButton = document.getElementById("history-panelmenu");
|
||||
let historySubview = document.getElementById("PanelUI-history");
|
||||
let subviewShownPromise = subviewShown(historySubview);
|
||||
EventUtils.synthesizeMouseAtCenter(historyButton, {});
|
||||
historyButton.click();
|
||||
yield subviewShownPromise;
|
||||
|
||||
let tabsFromOtherComputers = document.getElementById("sync-tabs-menuitem2");
|
||||
@ -34,7 +34,7 @@ add_task(function() {
|
||||
yield PanelUI.show({type: "command"});
|
||||
|
||||
subviewShownPromise = subviewShown(historySubview);
|
||||
EventUtils.synthesizeMouseAtCenter(historyButton, {});
|
||||
historyButton.click();
|
||||
yield subviewShownPromise;
|
||||
|
||||
is(tabsFromOtherComputers.hidden, false, "The Tabs From Other Computers menuitem should be shown when sync is enabled.");
|
||||
|
@ -31,6 +31,7 @@
|
||||
<script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
|
||||
|
||||
<script type="text/javascript" src="loop/shared/js/utils.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/models.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/router.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/views.js"></script>
|
||||
|
@ -48,26 +48,6 @@ loop.conversation = (function(OT, mozL10n) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Used for adding different styles to the panel
|
||||
* @returns {String} Corresponds to the client platform
|
||||
* */
|
||||
_getTargetPlatform: function() {
|
||||
var platform="unknown_platform";
|
||||
|
||||
if (navigator.platform.indexOf("Win") !== -1) {
|
||||
platform = "windows";
|
||||
}
|
||||
if (navigator.platform.indexOf("Mac") !== -1) {
|
||||
platform = "mac";
|
||||
}
|
||||
if (navigator.platform.indexOf("Linux") !== -1) {
|
||||
platform = "linux";
|
||||
}
|
||||
|
||||
return platform;
|
||||
},
|
||||
|
||||
_handleAccept: function() {
|
||||
this.props.model.trigger("accept");
|
||||
},
|
||||
@ -97,7 +77,8 @@ loop.conversation = (function(OT, mozL10n) {
|
||||
var btnClassAccept = "btn btn-success btn-accept";
|
||||
var btnClassBlock = "btn btn-error btn-block";
|
||||
var btnClassDecline = "btn btn-error btn-decline";
|
||||
var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
|
||||
var conversationPanelClass = "incoming-call " +
|
||||
loop.shared.utils.getTargetPlatform();
|
||||
var cx = React.addons.classSet;
|
||||
var declineDropdownMenuClasses = cx({
|
||||
"native-dropdown-menu": true,
|
||||
|
@ -48,26 +48,6 @@ loop.conversation = (function(OT, mozL10n) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Used for adding different styles to the panel
|
||||
* @returns {String} Corresponds to the client platform
|
||||
* */
|
||||
_getTargetPlatform: function() {
|
||||
var platform="unknown_platform";
|
||||
|
||||
if (navigator.platform.indexOf("Win") !== -1) {
|
||||
platform = "windows";
|
||||
}
|
||||
if (navigator.platform.indexOf("Mac") !== -1) {
|
||||
platform = "mac";
|
||||
}
|
||||
if (navigator.platform.indexOf("Linux") !== -1) {
|
||||
platform = "linux";
|
||||
}
|
||||
|
||||
return platform;
|
||||
},
|
||||
|
||||
_handleAccept: function() {
|
||||
this.props.model.trigger("accept");
|
||||
},
|
||||
@ -97,7 +77,8 @@ loop.conversation = (function(OT, mozL10n) {
|
||||
var btnClassAccept = "btn btn-success btn-accept";
|
||||
var btnClassBlock = "btn btn-error btn-block";
|
||||
var btnClassDecline = "btn btn-error btn-decline";
|
||||
var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
|
||||
var conversationPanelClass = "incoming-call " +
|
||||
loop.shared.utils.getTargetPlatform();
|
||||
var cx = React.addons.classSet;
|
||||
var declineDropdownMenuClasses = cx({
|
||||
"native-dropdown-menu": true,
|
||||
|
@ -77,22 +77,22 @@ loop.panel = (function(_, mozL10n) {
|
||||
__("display_name_available_status");
|
||||
|
||||
return (
|
||||
React.DOM.div( {className:"footer component-spacer"},
|
||||
React.DOM.div( {className:"do-not-disturb"},
|
||||
React.DOM.p( {className:"dnd-status", onClick:this.showDropdownMenu},
|
||||
React.DOM.span(null, availabilityText),
|
||||
React.DOM.i( {className:availabilityStatus})
|
||||
),
|
||||
React.DOM.ul( {className:availabilityDropdown,
|
||||
onMouseLeave:this.hideDropdownMenu},
|
||||
React.DOM.li( {onClick:this.changeAvailability("available"),
|
||||
className:"dnd-menu-item dnd-make-available"},
|
||||
React.DOM.i( {className:"status status-available"}),
|
||||
React.DOM.div({className: "footer component-spacer"},
|
||||
React.DOM.div({className: "do-not-disturb"},
|
||||
React.DOM.p({className: "dnd-status", onClick: this.showDropdownMenu},
|
||||
React.DOM.span(null, availabilityText),
|
||||
React.DOM.i({className: availabilityStatus})
|
||||
),
|
||||
React.DOM.ul({className: availabilityDropdown,
|
||||
onMouseLeave: this.hideDropdownMenu},
|
||||
React.DOM.li({onClick: this.changeAvailability("available"),
|
||||
className: "dnd-menu-item dnd-make-available"},
|
||||
React.DOM.i({className: "status status-available"}),
|
||||
React.DOM.span(null, __("display_name_available_status"))
|
||||
),
|
||||
React.DOM.li( {onClick:this.changeAvailability("do-not-disturb"),
|
||||
className:"dnd-menu-item dnd-make-unavailable"},
|
||||
React.DOM.i( {className:"status status-dnd"}),
|
||||
),
|
||||
React.DOM.li({onClick: this.changeAvailability("do-not-disturb"),
|
||||
className: "dnd-menu-item dnd-make-unavailable"},
|
||||
React.DOM.i({className: "status status-dnd"}),
|
||||
React.DOM.span(null, __("display_name_dnd_status"))
|
||||
)
|
||||
)
|
||||
@ -115,10 +115,10 @@ loop.panel = (function(_, mozL10n) {
|
||||
|
||||
if (this.state.seenToS == "unseen") {
|
||||
navigator.mozLoop.setLoopCharPref('seenToS', 'seen');
|
||||
return React.DOM.p( {className:"terms-service",
|
||||
dangerouslySetInnerHTML:{__html: tosHTML}});
|
||||
return React.DOM.p({className: "terms-service",
|
||||
dangerouslySetInnerHTML: {__html: tosHTML}});
|
||||
} else {
|
||||
return React.DOM.div(null );
|
||||
return React.DOM.div(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -130,11 +130,11 @@ loop.panel = (function(_, mozL10n) {
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
React.DOM.div( {className:"component-spacer share generate-url"},
|
||||
React.DOM.div( {className:"description"},
|
||||
React.DOM.p( {className:"description-content"}, this.props.summary)
|
||||
),
|
||||
React.DOM.div( {className:"action"},
|
||||
React.DOM.div({className: "component-spacer share generate-url"},
|
||||
React.DOM.div({className: "description"},
|
||||
React.DOM.p({className: "description-content"}, this.props.summary)
|
||||
),
|
||||
React.DOM.div({className: "action"},
|
||||
this.props.children
|
||||
)
|
||||
)
|
||||
@ -201,10 +201,10 @@ loop.panel = (function(_, mozL10n) {
|
||||
// from the react lib.
|
||||
var cx = React.addons.classSet;
|
||||
return (
|
||||
PanelLayout( {summary:__("share_link_header_text")},
|
||||
React.DOM.div( {className:"invite"},
|
||||
React.DOM.input( {type:"url", value:this.state.callUrl, readOnly:"true",
|
||||
className:cx({'pending': this.state.pending})} )
|
||||
PanelLayout({summary: __("share_link_header_text")},
|
||||
React.DOM.div({className: "invite"},
|
||||
React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true",
|
||||
className: cx({'pending': this.state.pending})})
|
||||
)
|
||||
)
|
||||
);
|
||||
@ -223,10 +223,10 @@ loop.panel = (function(_, mozL10n) {
|
||||
render: function() {
|
||||
return (
|
||||
React.DOM.div(null,
|
||||
CallUrlResult( {client:this.props.client,
|
||||
notifier:this.props.notifier} ),
|
||||
ToSView(null ),
|
||||
AvailabilityDropdown(null )
|
||||
CallUrlResult({client: this.props.client,
|
||||
notifier: this.props.notifier}),
|
||||
ToSView(null),
|
||||
AvailabilityDropdown(null)
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -293,8 +293,8 @@ loop.panel = (function(_, mozL10n) {
|
||||
var client = new loop.Client({
|
||||
baseServerUrl: navigator.mozLoop.serverUrl
|
||||
});
|
||||
this.loadReactComponent(PanelView( {client:client,
|
||||
notifier:this._notifier} ));
|
||||
this.loadReactComponent(PanelView({client: client,
|
||||
notifier: this._notifier}));
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -96,7 +96,20 @@ h1, h2, h3 {
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: .4em 1.6em;
|
||||
/* Dimensions from spec
|
||||
* https://people.mozilla.org/~dhenein/labs/loop-link-spec/#call-start */
|
||||
padding: .5em;
|
||||
font-size: 18px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Left / Right padding elements
|
||||
* used to center components
|
||||
* */
|
||||
.flex-padding-1 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
@ -209,6 +222,8 @@ h1, h2, h3 {
|
||||
.button-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-content: space-between;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
@ -271,6 +286,26 @@ h1, h2, h3 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-large .icon {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-size: 20px;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: top;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.icon-video {
|
||||
background-image: url("../img/video-inverse-14x14.png");
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
.icon-video {
|
||||
background-image: url("../img/video-inverse-14x14@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Platform specific styles
|
||||
* The UI should match the user OS
|
||||
|
BIN
browser/components/loop/content/shared/img/firefox-logo.png
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
browser/components/loop/content/shared/img/mozilla-logo.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
35
browser/components/loop/content/shared/js/utils.js
Normal file
@ -0,0 +1,35 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/* global loop:true */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.shared = loop.shared || {};
|
||||
loop.shared.utils = (function() {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Used for adding different styles to the panel
|
||||
* @returns {String} Corresponds to the client platform
|
||||
* */
|
||||
function getTargetPlatform() {
|
||||
var platform="unknown_platform";
|
||||
|
||||
if (navigator.platform.indexOf("Win") !== -1) {
|
||||
platform = "windows";
|
||||
}
|
||||
if (navigator.platform.indexOf("Mac") !== -1) {
|
||||
platform = "mac";
|
||||
}
|
||||
if (navigator.platform.indexOf("Linux") !== -1) {
|
||||
platform = "linux";
|
||||
}
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return {
|
||||
getTargetPlatform: getTargetPlatform
|
||||
};
|
||||
})();
|
@ -42,6 +42,7 @@ browser.jar:
|
||||
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
|
||||
content/browser/loop/shared/js/router.js (content/shared/js/router.js)
|
||||
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
|
||||
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
|
||||
|
||||
# Shared libs
|
||||
content/browser/loop/shared/libs/react-0.10.0.js (content/shared/libs/react-0.10.0.js)
|
||||
|
@ -2,10 +2,125 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
html,
|
||||
body,
|
||||
#main {
|
||||
/* Required for `justify-content: space-between` to divide space equally
|
||||
* based on the full height of the page */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
/* prevent the video convsersation elements to occupy the whole available
|
||||
width hence the height while keeping aspect ratio */
|
||||
max-width: 730px;
|
||||
margin: 0 auto;
|
||||
background: #fbfbfb;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
font-family: Open Sans,sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 1rem 5rem;
|
||||
border: 1px solid #E7E7E7;
|
||||
box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/*
|
||||
* Top/Bottom spacing
|
||||
**/
|
||||
header {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer,
|
||||
.footer a,
|
||||
.terms-service,
|
||||
.terms-service a {
|
||||
font-size: .6rem;
|
||||
font-weight: 400;
|
||||
color: #adadad;
|
||||
}
|
||||
|
||||
.terms-service a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.terms-service a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.terms-service a {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.footer-external-links a {
|
||||
padding: .2rem .7rem;
|
||||
margin: 0 .5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-external-links a:hover {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
width: 100px;
|
||||
margin: 0 auto;
|
||||
height: 30px;
|
||||
background-size: contain;
|
||||
background-image: url("../../content/shared/img/mozilla-logo.png");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.call-url {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loop-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 1rem auto;
|
||||
background-image: url("../../content/shared/img/firefox-logo.png");
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.large-font {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.light-weight-font {
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.light-color-font {
|
||||
opacity: .4;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
@ -9,15 +9,9 @@
|
||||
<link rel="stylesheet" type="text/css" href="shared/css/common.css">
|
||||
<link rel="stylesheet" type="text/css" href="shared/css/conversation.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/webapp.css">
|
||||
<link rel="prefetch" type="application/l10n" href="l10n/data.ini">
|
||||
<link type="application/l10n" href="l10n/data.ini">
|
||||
</head>
|
||||
<body onload="loop.webapp.init();">
|
||||
|
||||
<header>
|
||||
<h1>Loop</h1>
|
||||
</header>
|
||||
|
||||
<div id="messages"></div>
|
||||
<body>
|
||||
|
||||
<div id="main"></div>
|
||||
|
||||
@ -39,6 +33,7 @@
|
||||
|
||||
<!-- app scripts -->
|
||||
<script type="text/javascript" src="config.js"></script>
|
||||
<script type="text/javascript" src="shared/js/utils.js"></script>
|
||||
<script type="text/javascript" src="shared/js/models.js"></script>
|
||||
<script type="text/javascript" src="shared/js/views.js"></script>
|
||||
<script type="text/javascript" src="shared/js/router.js"></script>
|
||||
@ -46,11 +41,10 @@
|
||||
<script type="text/javascript" src="js/webapp.js"></script>
|
||||
|
||||
<script>
|
||||
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
|
||||
window.addEventListener('localized', function() {
|
||||
document.documentElement.lang = document.webL10n.getLanguage();
|
||||
document.documentElement.dir = document.webL10n.getDirection();
|
||||
}, false);
|
||||
// Wait for all the localization notes to load
|
||||
window.addEventListener('localized', function() {
|
||||
loop.webapp.init();
|
||||
}, false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -75,6 +75,27 @@ loop.StandaloneClient = (function($) {
|
||||
cb(err);
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes a request for url creation date for standalone UI
|
||||
*
|
||||
* @param {String} loopToken The loopToken representing the call
|
||||
* @param {Function} cb Callback(err, callUrlInfo)
|
||||
*
|
||||
**/
|
||||
requestCallUrlInfo: function(loopToken, cb) {
|
||||
if (!loopToken) {
|
||||
throw new Error("Missing required parameter loopToken");
|
||||
}
|
||||
if (!cb) {
|
||||
throw new Error("Missing required callback function");
|
||||
}
|
||||
|
||||
$.get(this.settings.baseServerUrl + "/calls/" + loopToken)
|
||||
.done(function(callUrlInfo) {
|
||||
cb(null, callUrlInfo);
|
||||
}).fail(this._failureHandler.bind(this, cb));
|
||||
},
|
||||
|
||||
/**
|
||||
* Posts a call request to the server for a call represented by the
|
||||
* loopToken. Will return the session data for the call.
|
||||
|
@ -82,23 +82,56 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
}
|
||||
});
|
||||
|
||||
var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
|
||||
render: function() {
|
||||
var cx = React.addons.classSet;
|
||||
var conversationUrl = location.href;
|
||||
|
||||
var urlCreationDateClasses = cx({
|
||||
"light-color-font": true,
|
||||
"call-url-date": true, /* Used as a handler in the tests */
|
||||
/*hidden until date is available*/
|
||||
"hide": !this.props.urlCreationDateString.length
|
||||
});
|
||||
|
||||
var callUrlCreationDateString = __("call_url_creation_date_label", {
|
||||
"call_url_creation_date": this.props.urlCreationDateString
|
||||
});
|
||||
|
||||
return (
|
||||
/* jshint ignore:start */
|
||||
React.DOM.header({className: "container-box"},
|
||||
React.DOM.h1({className: "light-weight-font"},
|
||||
React.DOM.strong(null, __("brandShortname")), " ", __("clientShortname")
|
||||
),
|
||||
React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}),
|
||||
React.DOM.h3({className: "call-url"},
|
||||
conversationUrl
|
||||
),
|
||||
React.DOM.h4({className: urlCreationDateClasses},
|
||||
callUrlCreationDateString
|
||||
)
|
||||
)
|
||||
/* jshint ignore:end */
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
var ConversationFooter = React.createClass({displayName: 'ConversationFooter',
|
||||
render: function() {
|
||||
return (
|
||||
React.DOM.div({className: "footer container-box"},
|
||||
React.DOM.div({title: "Mozilla Logo", className: "footer-logo"})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Conversation launcher view. A ConversationModel is associated and attached
|
||||
* as a `model` property.
|
||||
*/
|
||||
var ConversationFormView = sharedViews.BaseView.extend({
|
||||
template: _.template([
|
||||
'<form>',
|
||||
' <p>',
|
||||
' <button class="btn btn-success" data-l10n-id="start_call"></button>',
|
||||
' </p>',
|
||||
'</form>'
|
||||
].join("")),
|
||||
|
||||
events: {
|
||||
"submit": "initiate"
|
||||
},
|
||||
|
||||
var ConversationFormView = React.createClass({displayName: 'ConversationFormView',
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
@ -106,55 +139,115 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
* - {loop.shared.model.ConversationModel} model Conversation model.
|
||||
* - {loop.shared.views.NotificationListView} notifier Notifier component.
|
||||
*
|
||||
* @param {Object} options Options object.
|
||||
*/
|
||||
initialize: function(options) {
|
||||
options = options || {};
|
||||
|
||||
if (!options.model) {
|
||||
throw new Error("missing required model");
|
||||
}
|
||||
this.model = options.model;
|
||||
getInitialState: function() {
|
||||
return {
|
||||
urlCreationDateString: '',
|
||||
disableCallButton: false
|
||||
};
|
||||
},
|
||||
|
||||
if (!options.notifier) {
|
||||
throw new Error("missing required notifier");
|
||||
}
|
||||
this.notifier = options.notifier;
|
||||
propTypes: {
|
||||
model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
|
||||
.isRequired,
|
||||
// XXX Check more tightly here when we start injecting window.loop.*
|
||||
notifier: React.PropTypes.object.isRequired,
|
||||
client: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
this.listenTo(this.model, "session:error", this._onSessionError);
|
||||
componentDidMount: function() {
|
||||
this.props.model.listenTo(this.props.model, "session:error",
|
||||
this._onSessionError);
|
||||
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
|
||||
this._setConversationTimestamp);
|
||||
// XXX DOM element does not exist before React view gets instantiated
|
||||
// We should turn the notifier into a react component
|
||||
this.props.notifier.$el = $("#messages");
|
||||
},
|
||||
|
||||
_onSessionError: function(error) {
|
||||
console.error(error);
|
||||
this.notifier.errorL10n("unable_retrieve_call_info");
|
||||
},
|
||||
|
||||
/**
|
||||
* Disables this form to prevent multiple submissions.
|
||||
*
|
||||
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=991126
|
||||
*/
|
||||
disableForm: function() {
|
||||
this.$("button").attr("disabled", "disabled");
|
||||
this.props.notifier.errorL10n("unable_retrieve_call_info");
|
||||
},
|
||||
|
||||
/**
|
||||
* Initiates the call.
|
||||
*
|
||||
* @param {SubmitEvent} event
|
||||
*/
|
||||
initiate: function(event) {
|
||||
event.preventDefault();
|
||||
this.model.initiate({
|
||||
_initiate: function() {
|
||||
this.props.model.initiate({
|
||||
client: new loop.StandaloneClient({
|
||||
baseServerUrl: baseServerUrl
|
||||
}),
|
||||
outgoing: true,
|
||||
// For now, we assume both audio and video as there is no
|
||||
// other option to select.
|
||||
callType: "audio-video"
|
||||
callType: "audio-video",
|
||||
loopServer: loop.config.serverUrl
|
||||
});
|
||||
this.disableForm();
|
||||
|
||||
this.setState({disableCallButton: true});
|
||||
},
|
||||
|
||||
_setConversationTimestamp: function(err, callUrlInfo) {
|
||||
if (err) {
|
||||
this.props.notifier.errorL10n("unable_retrieve_call_info");
|
||||
} else {
|
||||
var date = (new Date(callUrlInfo.urlCreationDate * 1000));
|
||||
var options = {year: "numeric", month: "long", day: "numeric"};
|
||||
var timestamp = date.toLocaleDateString(navigator.language, options);
|
||||
|
||||
this.setState({urlCreationDateString: timestamp});
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var tos_link_name = __("terms_of_use_link_text");
|
||||
var privacy_notice_name = __("privacy_notice_link_text");
|
||||
|
||||
var tosHTML = __("legal_text_and_links", {
|
||||
"terms_of_use_url": "<a target=_blank href='" +
|
||||
"https://accounts.firefox.com/legal/terms'>" + tos_link_name + "</a>",
|
||||
"privacy_notice_url": "<a target=_blank href='" +
|
||||
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
|
||||
});
|
||||
|
||||
var callButtonClasses = "btn btn-success btn-large " +
|
||||
loop.shared.utils.getTargetPlatform();
|
||||
|
||||
return (
|
||||
/* jshint ignore:start */
|
||||
React.DOM.div({className: "container"},
|
||||
React.DOM.div({className: "container-box"},
|
||||
|
||||
ConversationHeader({
|
||||
urlCreationDateString: this.state.urlCreationDateString}),
|
||||
|
||||
React.DOM.p({className: "large-font light-weight-font"},
|
||||
__("initiate_call_button_label")
|
||||
),
|
||||
|
||||
React.DOM.div({id: "messages"}),
|
||||
|
||||
React.DOM.div({className: "button-group"},
|
||||
React.DOM.div({className: "flex-padding-1"}),
|
||||
React.DOM.button({ref: "submitButton", onClick: this._initiate,
|
||||
className: callButtonClasses,
|
||||
disabled: this.state.disableCallButton},
|
||||
__("initiate_call_button"),
|
||||
React.DOM.i({className: "icon icon-video"})
|
||||
),
|
||||
React.DOM.div({className: "flex-padding-1"})
|
||||
),
|
||||
|
||||
React.DOM.p({className: "terms-service",
|
||||
dangerouslySetInnerHTML: {__html: tosHTML}})
|
||||
),
|
||||
|
||||
ConversationFooter(null)
|
||||
)
|
||||
/* jshint ignore:end */
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -250,9 +343,12 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
this._conversation.endSession();
|
||||
}
|
||||
this._conversation.set("loopToken", loopToken);
|
||||
this.loadView(new ConversationFormView({
|
||||
this.loadReactComponent(ConversationFormView({
|
||||
model: this._conversation,
|
||||
notifier: this._notifier
|
||||
notifier: this._notifier,
|
||||
client: new loop.StandaloneClient({
|
||||
baseServerUrl: loop.config.serverUrl
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
@ -308,6 +404,9 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
} else if (!OT.checkSystemRequirements()) {
|
||||
router.navigate("unsupportedBrowser", {trigger: true});
|
||||
}
|
||||
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
|
||||
document.documentElement.lang = document.webL10n.getLanguage();
|
||||
document.documentElement.dir = document.webL10n.getDirection();
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -82,23 +82,56 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
}
|
||||
});
|
||||
|
||||
var ConversationHeader = React.createClass({
|
||||
render: function() {
|
||||
var cx = React.addons.classSet;
|
||||
var conversationUrl = location.href;
|
||||
|
||||
var urlCreationDateClasses = cx({
|
||||
"light-color-font": true,
|
||||
"call-url-date": true, /* Used as a handler in the tests */
|
||||
/*hidden until date is available*/
|
||||
"hide": !this.props.urlCreationDateString.length
|
||||
});
|
||||
|
||||
var callUrlCreationDateString = __("call_url_creation_date_label", {
|
||||
"call_url_creation_date": this.props.urlCreationDateString
|
||||
});
|
||||
|
||||
return (
|
||||
/* jshint ignore:start */
|
||||
<header className="container-box">
|
||||
<h1 className="light-weight-font">
|
||||
<strong>{__("brandShortname")}</strong> {__("clientShortname")}
|
||||
</h1>
|
||||
<div className="loop-logo" title="Firefox WebRTC! logo"></div>
|
||||
<h3 className="call-url">
|
||||
{conversationUrl}
|
||||
</h3>
|
||||
<h4 className={urlCreationDateClasses} >
|
||||
{callUrlCreationDateString}
|
||||
</h4>
|
||||
</header>
|
||||
/* jshint ignore:end */
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
var ConversationFooter = React.createClass({
|
||||
render: function() {
|
||||
return (
|
||||
<div className="footer container-box">
|
||||
<div title="Mozilla Logo" className="footer-logo"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Conversation launcher view. A ConversationModel is associated and attached
|
||||
* as a `model` property.
|
||||
*/
|
||||
var ConversationFormView = sharedViews.BaseView.extend({
|
||||
template: _.template([
|
||||
'<form>',
|
||||
' <p>',
|
||||
' <button class="btn btn-success" data-l10n-id="start_call"></button>',
|
||||
' </p>',
|
||||
'</form>'
|
||||
].join("")),
|
||||
|
||||
events: {
|
||||
"submit": "initiate"
|
||||
},
|
||||
|
||||
var ConversationFormView = React.createClass({
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
@ -106,55 +139,115 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
* - {loop.shared.model.ConversationModel} model Conversation model.
|
||||
* - {loop.shared.views.NotificationListView} notifier Notifier component.
|
||||
*
|
||||
* @param {Object} options Options object.
|
||||
*/
|
||||
initialize: function(options) {
|
||||
options = options || {};
|
||||
|
||||
if (!options.model) {
|
||||
throw new Error("missing required model");
|
||||
}
|
||||
this.model = options.model;
|
||||
getInitialState: function() {
|
||||
return {
|
||||
urlCreationDateString: '',
|
||||
disableCallButton: false
|
||||
};
|
||||
},
|
||||
|
||||
if (!options.notifier) {
|
||||
throw new Error("missing required notifier");
|
||||
}
|
||||
this.notifier = options.notifier;
|
||||
propTypes: {
|
||||
model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
|
||||
.isRequired,
|
||||
// XXX Check more tightly here when we start injecting window.loop.*
|
||||
notifier: React.PropTypes.object.isRequired,
|
||||
client: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
this.listenTo(this.model, "session:error", this._onSessionError);
|
||||
componentDidMount: function() {
|
||||
this.props.model.listenTo(this.props.model, "session:error",
|
||||
this._onSessionError);
|
||||
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
|
||||
this._setConversationTimestamp);
|
||||
// XXX DOM element does not exist before React view gets instantiated
|
||||
// We should turn the notifier into a react component
|
||||
this.props.notifier.$el = $("#messages");
|
||||
},
|
||||
|
||||
_onSessionError: function(error) {
|
||||
console.error(error);
|
||||
this.notifier.errorL10n("unable_retrieve_call_info");
|
||||
},
|
||||
|
||||
/**
|
||||
* Disables this form to prevent multiple submissions.
|
||||
*
|
||||
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=991126
|
||||
*/
|
||||
disableForm: function() {
|
||||
this.$("button").attr("disabled", "disabled");
|
||||
this.props.notifier.errorL10n("unable_retrieve_call_info");
|
||||
},
|
||||
|
||||
/**
|
||||
* Initiates the call.
|
||||
*
|
||||
* @param {SubmitEvent} event
|
||||
*/
|
||||
initiate: function(event) {
|
||||
event.preventDefault();
|
||||
this.model.initiate({
|
||||
_initiate: function() {
|
||||
this.props.model.initiate({
|
||||
client: new loop.StandaloneClient({
|
||||
baseServerUrl: baseServerUrl
|
||||
}),
|
||||
outgoing: true,
|
||||
// For now, we assume both audio and video as there is no
|
||||
// other option to select.
|
||||
callType: "audio-video"
|
||||
callType: "audio-video",
|
||||
loopServer: loop.config.serverUrl
|
||||
});
|
||||
this.disableForm();
|
||||
|
||||
this.setState({disableCallButton: true});
|
||||
},
|
||||
|
||||
_setConversationTimestamp: function(err, callUrlInfo) {
|
||||
if (err) {
|
||||
this.props.notifier.errorL10n("unable_retrieve_call_info");
|
||||
} else {
|
||||
var date = (new Date(callUrlInfo.urlCreationDate * 1000));
|
||||
var options = {year: "numeric", month: "long", day: "numeric"};
|
||||
var timestamp = date.toLocaleDateString(navigator.language, options);
|
||||
|
||||
this.setState({urlCreationDateString: timestamp});
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var tos_link_name = __("terms_of_use_link_text");
|
||||
var privacy_notice_name = __("privacy_notice_link_text");
|
||||
|
||||
var tosHTML = __("legal_text_and_links", {
|
||||
"terms_of_use_url": "<a target=_blank href='" +
|
||||
"https://accounts.firefox.com/legal/terms'>" + tos_link_name + "</a>",
|
||||
"privacy_notice_url": "<a target=_blank href='" +
|
||||
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
|
||||
});
|
||||
|
||||
var callButtonClasses = "btn btn-success btn-large " +
|
||||
loop.shared.utils.getTargetPlatform();
|
||||
|
||||
return (
|
||||
/* jshint ignore:start */
|
||||
<div className="container">
|
||||
<div className="container-box">
|
||||
|
||||
<ConversationHeader
|
||||
urlCreationDateString={this.state.urlCreationDateString} />
|
||||
|
||||
<p className="large-font light-weight-font">
|
||||
{__("initiate_call_button_label")}
|
||||
</p>
|
||||
|
||||
<div id="messages"></div>
|
||||
|
||||
<div className="button-group">
|
||||
<div className="flex-padding-1"></div>
|
||||
<button ref="submitButton" onClick={this._initiate}
|
||||
className={callButtonClasses}
|
||||
disabled={this.state.disableCallButton}>
|
||||
{__("initiate_call_button")}
|
||||
<i className="icon icon-video"></i>
|
||||
</button>
|
||||
<div className="flex-padding-1"></div>
|
||||
</div>
|
||||
|
||||
<p className="terms-service"
|
||||
dangerouslySetInnerHTML={{__html: tosHTML}}></p>
|
||||
</div>
|
||||
|
||||
<ConversationFooter />
|
||||
</div>
|
||||
/* jshint ignore:end */
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -250,9 +343,12 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
this._conversation.endSession();
|
||||
}
|
||||
this._conversation.set("loopToken", loopToken);
|
||||
this.loadView(new ConversationFormView({
|
||||
this.loadReactComponent(ConversationFormView({
|
||||
model: this._conversation,
|
||||
notifier: this._notifier
|
||||
notifier: this._notifier,
|
||||
client: new loop.StandaloneClient({
|
||||
baseServerUrl: loop.config.serverUrl
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
@ -308,6 +404,9 @@ loop.webapp = (function($, _, OT, webL10n) {
|
||||
} else if (!OT.checkSystemRequirements()) {
|
||||
router.navigate("unsupportedBrowser", {trigger: true});
|
||||
}
|
||||
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
|
||||
document.documentElement.lang = document.webL10n.getLanguage();
|
||||
document.documentElement.dir = document.webL10n.getDirection();
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -23,6 +23,18 @@ call_url_unavailable_notification_heading=Oops!
|
||||
call_url_unavailable_notification_message=This URL is unavailable.
|
||||
promote_firefox_hello_heading=Download Firefox to make free audio and video calls!
|
||||
get_firefox_button=Get Firefox
|
||||
call_url_unavailable_notification=This URL is unavailable.
|
||||
initiate_call_button_label=Click Call to start a video chat
|
||||
initiate_call_button=Call
|
||||
## LOCALIZATION NOTE (legal_text_and_links): In this item, don't translate the
|
||||
## part between {{..}}
|
||||
legal_text_and_links=By using this product you agree to the {{terms_of_use_url}} and {{privacy_notice_url}}
|
||||
terms_of_use_link_text=Terms of use
|
||||
privacy_notice_link_text=Privacy notice
|
||||
brandShortname=Firefox
|
||||
clientShortname=WebRTC!
|
||||
## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
|
||||
call_url_creation_date_label=(from {{call_url_creation_date}})
|
||||
|
||||
[fr]
|
||||
call_has_ended=L'appel est terminé.
|
||||
|
@ -32,6 +32,7 @@
|
||||
</script>
|
||||
|
||||
<!-- App scripts -->
|
||||
<script src="../../content/shared/js/utils.js"></script>
|
||||
<script src="../../content/shared/js/models.js"></script>
|
||||
<script src="../../content/shared/js/router.js"></script>
|
||||
<script src="../../content/shared/js/views.js"></script>
|
||||
|
@ -31,6 +31,7 @@
|
||||
mocha.setup('bdd');
|
||||
</script>
|
||||
<!-- App scripts -->
|
||||
<script src="../../content/shared/js/utils.js"></script>
|
||||
<script src="../../content/shared/js/models.js"></script>
|
||||
<script src="../../content/shared/js/views.js"></script>
|
||||
<script src="../../content/shared/js/router.js"></script>
|
||||
|
@ -40,6 +40,65 @@ describe("loop.StandaloneClient", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#requestCallUrlInfo", function() {
|
||||
var client, fakeServerErrorDescription;
|
||||
|
||||
beforeEach(function() {
|
||||
client = new loop.StandaloneClient(
|
||||
{baseServerUrl: "http://fake.api"}
|
||||
);
|
||||
});
|
||||
|
||||
describe("should make the requests to the server", function() {
|
||||
|
||||
it("should throw if loopToken is missing", function() {
|
||||
expect(client.requestCallUrlInfo).to
|
||||
.throw(/Missing required parameter loopToken/);
|
||||
});
|
||||
|
||||
it("should make a GET request for the call url creation date", function() {
|
||||
client.requestCallUrlInfo("fakeCallUrlToken", function() {});
|
||||
|
||||
expect(requests).to.have.length.of(1);
|
||||
expect(requests[0].url)
|
||||
.to.eql("http://fake.api/calls/fakeCallUrlToken");
|
||||
expect(requests[0].method).to.eql("GET");
|
||||
});
|
||||
|
||||
it("should call the callback with (null, serverResponse)", function() {
|
||||
var successCallback = sandbox.spy(function() {});
|
||||
var serverResponse = {
|
||||
calleeFriendlyName: "Andrei",
|
||||
urlCreationDate: 0
|
||||
};
|
||||
|
||||
client.requestCallUrlInfo("fakeCallUrlToken", successCallback);
|
||||
requests[0].respond(200, {"Content-Type": "application/json"},
|
||||
JSON.stringify(serverResponse));
|
||||
|
||||
sinon.assert.calledWithExactly(successCallback,
|
||||
null,
|
||||
serverResponse);
|
||||
});
|
||||
|
||||
it("should log the error if the requests fails", function() {
|
||||
sinon.stub(console, "error");
|
||||
var serverResponse = {error: true};
|
||||
var error = JSON.stringify(serverResponse);
|
||||
|
||||
client.requestCallUrlInfo("fakeCallUrlToken", sandbox.stub());
|
||||
requests[0].respond(404, {"Content-Type": "application/json"},
|
||||
error);
|
||||
|
||||
sinon.assert.calledOnce(console.error);
|
||||
sinon.assert.calledWithExactly(console.error, "Server error",
|
||||
"HTTP 404 Not Found", serverResponse);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("requestCallInfo", function() {
|
||||
var client, fakeServerErrorDescription;
|
||||
|
||||
|
@ -76,13 +76,13 @@ describe("loop.webapp", function() {
|
||||
sdk: {},
|
||||
pendingCallTimeout: 1000
|
||||
});
|
||||
sandbox.stub(loop.webapp.WebappRouter.prototype, "loadReactComponent");
|
||||
router = new loop.webapp.WebappRouter({
|
||||
helper: {},
|
||||
conversation: conversation,
|
||||
notifier: notifier
|
||||
});
|
||||
sandbox.stub(router, "loadView");
|
||||
sandbox.stub(router, "loadReactComponent");
|
||||
sandbox.stub(router, "navigate");
|
||||
});
|
||||
|
||||
@ -165,9 +165,12 @@ describe("loop.webapp", function() {
|
||||
it("should load the ConversationFormView", function() {
|
||||
router.initiate("fakeToken");
|
||||
|
||||
sinon.assert.calledOnce(router.loadView);
|
||||
sinon.assert.calledWith(router.loadView,
|
||||
sinon.match.instanceOf(loop.webapp.ConversationFormView));
|
||||
sinon.assert.calledOnce(router.loadReactComponent);
|
||||
sinon.assert.calledWithExactly(router.loadReactComponent,
|
||||
sinon.match(function(value) {
|
||||
return React.addons.TestUtils.isComponentOfType(
|
||||
value, loop.webapp.ConversationFormView);
|
||||
}));
|
||||
});
|
||||
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=991118
|
||||
@ -295,47 +298,68 @@ describe("loop.webapp", function() {
|
||||
});
|
||||
|
||||
describe("#initiate", function() {
|
||||
var conversation, initiate, view, fakeSubmitEvent;
|
||||
var conversation, initiate, view, fakeSubmitEvent, requestCallUrlInfo;
|
||||
|
||||
beforeEach(function() {
|
||||
conversation = new sharedModels.ConversationModel({}, {
|
||||
sdk: {},
|
||||
pendingCallTimeout: 1000
|
||||
});
|
||||
view = new loop.webapp.ConversationFormView({
|
||||
model: conversation,
|
||||
notifier: notifier
|
||||
});
|
||||
|
||||
fakeSubmitEvent = {preventDefault: sinon.spy()};
|
||||
initiate = sinon.stub(conversation, "initiate");
|
||||
|
||||
var standaloneClientStub = {
|
||||
requestCallUrlInfo: function(token, cb) {
|
||||
cb(null, {urlCreationDate: 0});
|
||||
},
|
||||
settings: {baseServerUrl: loop.webapp.baseServerUrl}
|
||||
}
|
||||
|
||||
view = React.addons.TestUtils.renderIntoDocument(
|
||||
loop.webapp.ConversationFormView({
|
||||
model: conversation,
|
||||
notifier: notifier,
|
||||
client: standaloneClientStub
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should start the conversation establishment process", function() {
|
||||
conversation.set("loopToken", "fake");
|
||||
var button = view.getDOMNode().querySelector("button");
|
||||
React.addons.TestUtils.Simulate.click(button);
|
||||
|
||||
view.initiate(fakeSubmitEvent);
|
||||
|
||||
sinon.assert.calledOnce(fakeSubmitEvent.preventDefault);
|
||||
sinon.assert.calledOnce(initiate);
|
||||
sinon.assert.calledWith(initiate, sinon.match(function (value) {
|
||||
return !!value.outgoing &&
|
||||
(value.client instanceof loop.StandaloneClient) &&
|
||||
value.client.settings.baseServerUrl === loop.webapp.baseServerUrl;
|
||||
}, "{client: <properly constructed client>, outgoing: true}"));
|
||||
(value.client.settings.baseServerUrl === loop.webapp.baseServerUrl)
|
||||
}, "outgoing: true && correct baseServerUrl"));
|
||||
});
|
||||
|
||||
it("should disable current form once session is initiated", function() {
|
||||
sandbox.stub(view, "disableForm");
|
||||
conversation.set("loopToken", "fake");
|
||||
|
||||
view.initiate(fakeSubmitEvent);
|
||||
var button = view.getDOMNode().querySelector("button");
|
||||
React.addons.TestUtils.Simulate.click(button);
|
||||
|
||||
sinon.assert.calledOnce(view.disableForm);
|
||||
expect(button.disabled).to.eql(true);
|
||||
});
|
||||
|
||||
it("should set state.urlCreationDateString to a locale date string",
|
||||
function() {
|
||||
// wrap in a jquery object because text is broken up
|
||||
// into several span elements
|
||||
var date = new Date(0);
|
||||
var options = {year: "numeric", month: "long", day: "numeric"};
|
||||
var timestamp = date.toLocaleDateString(navigator.language, options);
|
||||
|
||||
expect(view.state.urlCreationDateString).to.eql(timestamp);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("Events", function() {
|
||||
var conversation, view;
|
||||
var conversation, view, StandaloneClient, requestCallUrlInfo;
|
||||
|
||||
beforeEach(function() {
|
||||
conversation = new sharedModels.ConversationModel({
|
||||
@ -344,10 +368,30 @@ describe("loop.webapp", function() {
|
||||
sdk: {},
|
||||
pendingCallTimeout: 1000
|
||||
});
|
||||
view = new loop.webapp.ConversationFormView({
|
||||
model: conversation,
|
||||
notifier: notifier
|
||||
});
|
||||
|
||||
sandbox.spy(conversation, "listenTo");
|
||||
requestCallUrlInfo = sandbox.stub();
|
||||
|
||||
view = React.addons.TestUtils.renderIntoDocument(
|
||||
loop.webapp.ConversationFormView({
|
||||
model: conversation,
|
||||
notifier: notifier,
|
||||
client: {requestCallUrlInfo: requestCallUrlInfo}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call requestCallUrlInfo", function() {
|
||||
sinon.assert.calledOnce(requestCallUrlInfo);
|
||||
sinon.assert.calledWithExactly(requestCallUrlInfo,
|
||||
sinon.match.string,
|
||||
sinon.match.func);
|
||||
});
|
||||
|
||||
it("should listen for session:error events", function() {
|
||||
sinon.assert.calledOnce(conversation.listenTo);
|
||||
sinon.assert.calledWithExactly(conversation.listenTo, conversation,
|
||||
"session:error", sinon.match.func);
|
||||
});
|
||||
|
||||
it("should trigger a notication when a session:error model event is " +
|
||||
|
@ -262,18 +262,15 @@ SessionStartup.prototype = {
|
||||
* Get the session state as a jsval
|
||||
*/
|
||||
get state() {
|
||||
this._ensureInitialized();
|
||||
return this._initialState;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines whether there is a pending session restore. Should only be
|
||||
* called after initialization has completed.
|
||||
* @throws Error if initialization is not complete yet.
|
||||
* @returns bool
|
||||
*/
|
||||
doRestore: function sss_doRestore() {
|
||||
this._ensureInitialized();
|
||||
return this._willRestore();
|
||||
},
|
||||
|
||||
@ -324,7 +321,6 @@ SessionStartup.prototype = {
|
||||
* Get the type of pending session store, if any.
|
||||
*/
|
||||
get sessionType() {
|
||||
this._ensureInitialized();
|
||||
return this._sessionType;
|
||||
},
|
||||
|
||||
@ -332,19 +328,9 @@ SessionStartup.prototype = {
|
||||
* Get whether the previous session crashed.
|
||||
*/
|
||||
get previousSessionCrashed() {
|
||||
this._ensureInitialized();
|
||||
return this._previousSessionCrashed;
|
||||
},
|
||||
|
||||
// Ensure that initialization is complete. If initialization is not complete
|
||||
// yet, something is attempting to use the old synchronous initialization,
|
||||
// throw an error.
|
||||
_ensureInitialized: function sss__ensureInitialized() {
|
||||
if (!this._initialized) {
|
||||
throw new Error("Session Store is not initialized.");
|
||||
}
|
||||
},
|
||||
|
||||
/* ........ QueryInterface .............. */
|
||||
QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver,
|
||||
Ci.nsISupportsWeakReference,
|
||||
|
@ -11,6 +11,9 @@ function ifWebGLSupported() {
|
||||
|
||||
yield getPrograms(front, 2);
|
||||
|
||||
// Wait a frame to ensure rendering
|
||||
yield front.waitForFrame();
|
||||
|
||||
let pixel = yield front.getPixel({ selector: "#canvas1", position: { x: 0, y: 0 }});
|
||||
is(pixel.r, 255, "correct `r` value for first canvas.")
|
||||
is(pixel.g, 255, "correct `g` value for first canvas.")
|
||||
|
@ -13,6 +13,14 @@ const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
|
||||
"resource://gre/modules/FormHistory.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
|
||||
"resource://gre/modules/PrivateBrowsingUtils.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
|
||||
"resource://gre/modules/SearchSuggestionController.jsm");
|
||||
|
||||
const INBOUND_MESSAGE = "ContentSearch";
|
||||
const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
|
||||
@ -26,30 +34,45 @@ const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
|
||||
*
|
||||
* Inbound messages have the following types:
|
||||
*
|
||||
* AddFormHistoryEntry
|
||||
* Adds an entry to the search form history.
|
||||
* data: the entry, a string
|
||||
* GetSuggestions
|
||||
* Retrieves an array of search suggestions given a search string.
|
||||
* data: { engineName, searchString, [remoteTimeout] }
|
||||
* GetState
|
||||
* Retrieves the current search engine state.
|
||||
* data: null
|
||||
* Retrieves the current search engine state.
|
||||
* data: null
|
||||
* ManageEngines
|
||||
* Opens the search engine management window.
|
||||
* data: null
|
||||
* Opens the search engine management window.
|
||||
* data: null
|
||||
* RemoveFormHistoryEntry
|
||||
* Removes an entry from the search form history.
|
||||
* data: the entry, a string
|
||||
* Search
|
||||
* Performs a search.
|
||||
* data: an object { engineName, searchString, whence }
|
||||
* Performs a search.
|
||||
* data: { engineName, searchString, whence }
|
||||
* SetCurrentEngine
|
||||
* Sets the current engine.
|
||||
* data: the name of the engine
|
||||
* Sets the current engine.
|
||||
* data: the name of the engine
|
||||
* SpeculativeConnect
|
||||
* Speculatively connects to an engine.
|
||||
* data: the name of the engine
|
||||
*
|
||||
* Outbound messages have the following types:
|
||||
*
|
||||
* CurrentEngine
|
||||
* Sent when the current engine changes.
|
||||
* Broadcast when the current engine changes.
|
||||
* data: see _currentEngineObj
|
||||
* CurrentState
|
||||
* Sent when the current search state changes.
|
||||
* Broadcast when the current search state changes.
|
||||
* data: see _currentStateObj
|
||||
* State
|
||||
* Sent in reply to GetState.
|
||||
* data: see _currentStateObj
|
||||
* Suggestions
|
||||
* Sent in reply to GetSuggestions.
|
||||
* data: see _onMessageGetSuggestions
|
||||
*/
|
||||
|
||||
this.ContentSearch = {
|
||||
@ -60,6 +83,10 @@ this.ContentSearch = {
|
||||
_eventQueue: [],
|
||||
_currentEvent: null,
|
||||
|
||||
// This is used to handle search suggestions. It maps xul:browsers to objects
|
||||
// { controller, previousFormHistoryResult }. See _onMessageGetSuggestions.
|
||||
_suggestionMap: new WeakMap(),
|
||||
|
||||
init: function () {
|
||||
Cc["@mozilla.org/globalmessagemanager;1"].
|
||||
getService(Ci.nsIMessageListenerManager).
|
||||
@ -72,10 +99,15 @@ this.ContentSearch = {
|
||||
// the event queue. If the message's source docshell changes browsers in
|
||||
// the meantime, then we need to update msg.target. event.detail will be
|
||||
// the docshell's new parent <xul:browser> element.
|
||||
msg.handleEvent = function (event) {
|
||||
this.target.removeEventListener("SwapDocShells", this, true);
|
||||
this.target = event.detail;
|
||||
this.target.addEventListener("SwapDocShells", this, true);
|
||||
msg.handleEvent = event => {
|
||||
let browserData = this._suggestionMap.get(msg.target);
|
||||
if (browserData) {
|
||||
this._suggestionMap.delete(msg.target);
|
||||
this._suggestionMap.set(event.detail, browserData);
|
||||
}
|
||||
msg.target.removeEventListener("SwapDocShells", msg, true);
|
||||
msg.target = event.detail;
|
||||
msg.target.addEventListener("SwapDocShells", msg, true);
|
||||
};
|
||||
msg.target.addEventListener("SwapDocShells", msg, true);
|
||||
|
||||
@ -106,6 +138,9 @@ this.ContentSearch = {
|
||||
try {
|
||||
yield this["_on" + this._currentEvent.type](this._currentEvent.data);
|
||||
}
|
||||
catch (err) {
|
||||
Cu.reportError(err);
|
||||
}
|
||||
finally {
|
||||
this._currentEvent = null;
|
||||
this._processEventQueue();
|
||||
@ -128,17 +163,11 @@ this.ContentSearch = {
|
||||
},
|
||||
|
||||
_onMessageSearch: function (msg, data) {
|
||||
let expectedDataProps = [
|
||||
this._ensureDataHasProperties(data, [
|
||||
"engineName",
|
||||
"searchString",
|
||||
"whence",
|
||||
];
|
||||
for (let prop of expectedDataProps) {
|
||||
if (!(prop in data)) {
|
||||
Cu.reportError("Message data missing required property: " + prop);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]);
|
||||
let browserWin = msg.target.ownerDocument.defaultView;
|
||||
let engine = Services.search.getEngineByName(data.engineName);
|
||||
browserWin.BrowserSearch.recordSearchInHealthReport(engine, data.whence);
|
||||
@ -158,6 +187,92 @@ this.ContentSearch = {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
_onMessageGetSuggestions: Task.async(function* (msg, data) {
|
||||
this._ensureDataHasProperties(data, [
|
||||
"engineName",
|
||||
"searchString",
|
||||
]);
|
||||
|
||||
let engine = Services.search.getEngineByName(data.engineName);
|
||||
if (!engine) {
|
||||
throw new Error("Unknown engine name: " + data.engineName);
|
||||
}
|
||||
|
||||
let browserData = this._suggestionDataForBrowser(msg.target, true);
|
||||
let { controller } = browserData;
|
||||
let ok = SearchSuggestionController.engineOffersSuggestions(engine);
|
||||
controller.maxLocalResults = ok ? 2 : 6;
|
||||
controller.maxRemoteResults = ok ? 6 : 0;
|
||||
controller.remoteTimeout = data.remoteTimeout || undefined;
|
||||
let priv = PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow);
|
||||
// fetch() rejects its promise if there's a pending request, but since we
|
||||
// process our event queue serially, there's never a pending request.
|
||||
let suggestions = yield controller.fetch(data.searchString, priv, engine);
|
||||
|
||||
// Keep the form history result so RemoveFormHistoryEntry can remove entries
|
||||
// from it. Keeping only one result isn't foolproof because the client may
|
||||
// try to remove an entry from one set of suggestions after it has requested
|
||||
// more but before it's received them. In that case, the entry may not
|
||||
// appear in the new suggestions. But that should happen rarely.
|
||||
browserData.previousFormHistoryResult = suggestions.formHistoryResult;
|
||||
|
||||
this._reply(msg, "Suggestions", {
|
||||
engineName: data.engineName,
|
||||
searchString: suggestions.term,
|
||||
formHistory: suggestions.local,
|
||||
remote: suggestions.remote,
|
||||
});
|
||||
}),
|
||||
|
||||
_onMessageAddFormHistoryEntry: function (msg, entry) {
|
||||
// There are some tests that use about:home and newtab that trigger a search
|
||||
// and then immediately close the tab. In those cases, the browser may have
|
||||
// been destroyed by the time we receive this message, and as a result
|
||||
// contentWindow is undefined.
|
||||
if (!msg.target.contentWindow ||
|
||||
PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
let browserData = this._suggestionDataForBrowser(msg.target, true);
|
||||
FormHistory.update({
|
||||
op: "bump",
|
||||
fieldname: browserData.controller.formHistoryParam,
|
||||
value: entry,
|
||||
}, {
|
||||
handleCompletion: () => {},
|
||||
handleError: err => {
|
||||
Cu.reportError("Error adding form history entry: " + err);
|
||||
},
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
_onMessageRemoveFormHistoryEntry: function (msg, entry) {
|
||||
let browserData = this._suggestionDataForBrowser(msg.target);
|
||||
if (browserData && browserData.previousFormHistoryResult) {
|
||||
let { previousFormHistoryResult } = browserData;
|
||||
for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
|
||||
if (previousFormHistoryResult.getValueAt(i) == entry) {
|
||||
previousFormHistoryResult.removeValueAt(i, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
_onMessageSpeculativeConnect: function (msg, engineName) {
|
||||
let engine = Services.search.getEngineByName(engineName);
|
||||
if (!engine) {
|
||||
throw new Error("Unknown engine name: " + engineName);
|
||||
}
|
||||
if (msg.target.contentWindow) {
|
||||
engine.speculativeConnect({
|
||||
window: msg.target.contentWindow,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_onObserve: Task.async(function* (data) {
|
||||
if (data == "engine-current") {
|
||||
let engine = yield this._currentEngineObj();
|
||||
@ -171,6 +286,20 @@ this.ContentSearch = {
|
||||
}
|
||||
}),
|
||||
|
||||
_suggestionDataForBrowser: function (browser, create=false) {
|
||||
let data = this._suggestionMap.get(browser);
|
||||
if (!data && create) {
|
||||
// Since one SearchSuggestionController instance is meant to be used per
|
||||
// autocomplete widget, this means that we assume each xul:browser has at
|
||||
// most one such widget.
|
||||
data = {
|
||||
controller: new SearchSuggestionController(),
|
||||
};
|
||||
this._suggestionMap.set(browser, data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
_reply: function (msg, type, data) {
|
||||
// We reply asyncly to messages, and by the time we reply the browser we're
|
||||
// responding to may have been destroyed. messageManager is null then.
|
||||
@ -241,6 +370,14 @@ this.ContentSearch = {
|
||||
return deferred.promise;
|
||||
},
|
||||
|
||||
_ensureDataHasProperties: function (data, requiredProperties) {
|
||||
for (let prop of requiredProperties) {
|
||||
if (!(prop in data)) {
|
||||
throw new Error("Message data missing required property: " + prop);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_initService: function () {
|
||||
if (!this._initServicePromise) {
|
||||
let deferred = Promise.defer();
|
||||
|
@ -9,6 +9,8 @@ support-files =
|
||||
support-files =
|
||||
contentSearch.js
|
||||
contentSearchBadImage.xml
|
||||
contentSearchSuggestions.sjs
|
||||
contentSearchSuggestions.xml
|
||||
[browser_NetworkPrioritizer.js]
|
||||
skip-if = e10s # Bug 666804 - Support NetworkPrioritizer in e10s
|
||||
[browser_SignInToWebsite.js]
|
||||
|
@ -171,6 +171,92 @@ add_task(function* badImage() {
|
||||
yield waitForTestMsg("CurrentState");
|
||||
});
|
||||
|
||||
add_task(function* GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() {
|
||||
yield addTab();
|
||||
|
||||
// Add the test engine that provides suggestions.
|
||||
let vals = yield waitForNewEngine("contentSearchSuggestions.xml", 0);
|
||||
let engine = vals[0];
|
||||
|
||||
let searchStr = "browser_ContentSearch.js-suggestions-";
|
||||
|
||||
// Add a form history suggestion and wait for Satchel to notify about it.
|
||||
gMsgMan.sendAsyncMessage(TEST_MSG, {
|
||||
type: "AddFormHistoryEntry",
|
||||
data: searchStr + "form",
|
||||
});
|
||||
let deferred = Promise.defer();
|
||||
Services.obs.addObserver(function onAdd(subj, topic, data) {
|
||||
if (data == "formhistory-add") {
|
||||
executeSoon(() => deferred.resolve());
|
||||
}
|
||||
}, "satchel-storage-changed", false);
|
||||
yield deferred.promise;
|
||||
|
||||
// Send GetSuggestions using the test engine. Its suggestions should appear
|
||||
// in the remote suggestions in the Suggestions response below.
|
||||
gMsgMan.sendAsyncMessage(TEST_MSG, {
|
||||
type: "GetSuggestions",
|
||||
data: {
|
||||
engineName: engine.name,
|
||||
searchString: searchStr,
|
||||
remoteTimeout: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
// Check the Suggestions response.
|
||||
let msg = yield waitForTestMsg("Suggestions");
|
||||
checkMsg(msg, {
|
||||
type: "Suggestions",
|
||||
data: {
|
||||
engineName: engine.name,
|
||||
searchString: searchStr,
|
||||
formHistory: [searchStr + "form"],
|
||||
remote: [searchStr + "foo", searchStr + "bar"],
|
||||
},
|
||||
});
|
||||
|
||||
// Delete the form history suggestion and wait for Satchel to notify about it.
|
||||
gMsgMan.sendAsyncMessage(TEST_MSG, {
|
||||
type: "RemoveFormHistoryEntry",
|
||||
data: searchStr + "form",
|
||||
});
|
||||
deferred = Promise.defer();
|
||||
Services.obs.addObserver(function onRemove(subj, topic, data) {
|
||||
if (data == "formhistory-remove") {
|
||||
executeSoon(() => deferred.resolve());
|
||||
}
|
||||
}, "satchel-storage-changed", false);
|
||||
yield deferred.promise;
|
||||
|
||||
// Send GetSuggestions again.
|
||||
gMsgMan.sendAsyncMessage(TEST_MSG, {
|
||||
type: "GetSuggestions",
|
||||
data: {
|
||||
engineName: engine.name,
|
||||
searchString: searchStr,
|
||||
remoteTimeout: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
// The formHistory suggestions in the Suggestions response should be empty.
|
||||
msg = yield waitForTestMsg("Suggestions");
|
||||
checkMsg(msg, {
|
||||
type: "Suggestions",
|
||||
data: {
|
||||
engineName: engine.name,
|
||||
searchString: searchStr,
|
||||
formHistory: [],
|
||||
remote: [searchStr + "foo", searchStr + "bar"],
|
||||
},
|
||||
});
|
||||
|
||||
// Finally, clean up by removing the test engine.
|
||||
Services.search.removeEngine(engine);
|
||||
yield waitForTestMsg("CurrentState");
|
||||
});
|
||||
|
||||
|
||||
function checkMsg(actualMsg, expectedMsgData) {
|
||||
SimpleTest.isDeeply(actualMsg.data, expectedMsgData, "Checking message");
|
||||
}
|
||||
@ -226,7 +312,7 @@ function addTab() {
|
||||
let tab = gBrowser.addTab();
|
||||
gBrowser.selectedTab = tab;
|
||||
tab.linkedBrowser.addEventListener("load", function load() {
|
||||
tab.removeEventListener("load", load, true);
|
||||
tab.linkedBrowser.removeEventListener("load", load, true);
|
||||
let url = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
|
||||
gMsgMan = tab.linkedBrowser.messageManager;
|
||||
gMsgMan.sendAsyncMessage(CONTENT_SEARCH_MSG, {
|
||||
|
9
browser/modules/test/contentSearchSuggestions.sjs
Normal file
@ -0,0 +1,9 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
function handleRequest(req, resp) {
|
||||
let suffixes = ["foo", "bar"];
|
||||
let data = [req.queryString, suffixes.map(s => req.queryString + s)];
|
||||
resp.setHeader("Content-Type", "application/json", false);
|
||||
resp.write(JSON.stringify(data));
|
||||
}
|
6
browser/modules/test/contentSearchSuggestions.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
|
||||
<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName>
|
||||
<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/contentSearchSuggestions.sjs?{searchTerms}"/>
|
||||
<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/>
|
||||
</SearchPlugin>
|
@ -1,4 +1,5 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
@ -32,7 +33,7 @@ using dom::ConstrainLongRange;
|
||||
NS_IMPL_ISUPPORTS(MediaEngineTabVideoSource, nsIDOMEventListener, nsITimerCallback)
|
||||
|
||||
MediaEngineTabVideoSource::MediaEngineTabVideoSource()
|
||||
: mMonitor("MediaEngineTabVideoSource")
|
||||
: mMonitor("MediaEngineTabVideoSource"), mTabSource(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
@ -42,7 +43,9 @@ MediaEngineTabVideoSource::StartRunnable::Run()
|
||||
mVideoSource->Draw();
|
||||
mVideoSource->mTimer = do_CreateInstance(NS_TIMER_CONTRACTID);
|
||||
mVideoSource->mTimer->InitWithCallback(mVideoSource, mVideoSource->mTimePerFrame, nsITimer:: TYPE_REPEATING_SLACK);
|
||||
mVideoSource->mTabSource->NotifyStreamStart(mVideoSource->mWindow);
|
||||
if (mVideoSource->mTabSource) {
|
||||
mVideoSource->mTabSource->NotifyStreamStart(mVideoSource->mWindow);
|
||||
}
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
@ -55,7 +58,9 @@ MediaEngineTabVideoSource::StopRunnable::Run()
|
||||
mVideoSource->mTimer->Cancel();
|
||||
mVideoSource->mTimer = nullptr;
|
||||
}
|
||||
mVideoSource->mTabSource->NotifyStreamStop(mVideoSource->mWindow);
|
||||
if (mVideoSource->mTabSource) {
|
||||
mVideoSource->mTabSource->NotifyStreamStop(mVideoSource->mWindow);
|
||||
}
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
@ -76,18 +81,25 @@ nsresult
|
||||
MediaEngineTabVideoSource::InitRunnable::Run()
|
||||
{
|
||||
mVideoSource->mData = (unsigned char*)malloc(mVideoSource->mBufW * mVideoSource->mBufH * 4);
|
||||
if (mVideoSource->mWindowId != -1) {
|
||||
nsCOMPtr<nsPIDOMWindow> window = nsGlobalWindow::GetOuterWindowWithId(mVideoSource->mWindowId);
|
||||
if (window) {
|
||||
mVideoSource->mWindow = window;
|
||||
}
|
||||
}
|
||||
if (!mVideoSource->mWindow) {
|
||||
nsresult rv;
|
||||
mVideoSource->mTabSource = do_GetService(NS_TABSOURCESERVICE_CONTRACTID, &rv);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
nsresult rv;
|
||||
mVideoSource->mTabSource = do_GetService(NS_TABSOURCESERVICE_CONTRACTID, &rv);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
nsCOMPtr<nsIDOMWindow> win;
|
||||
rv = mVideoSource->mTabSource->GetTabToStream(getter_AddRefs(win));
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
if (!win)
|
||||
return NS_OK;
|
||||
|
||||
nsCOMPtr<nsIDOMWindow> win;
|
||||
rv = mVideoSource->mTabSource->GetTabToStream(getter_AddRefs(win));
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
if (!win)
|
||||
return NS_OK;
|
||||
|
||||
mVideoSource->mWindow = win;
|
||||
mVideoSource->mWindow = win;
|
||||
}
|
||||
nsCOMPtr<nsIRunnable> start(new StartRunnable(mVideoSource));
|
||||
start->Run();
|
||||
return NS_OK;
|
||||
@ -113,13 +125,26 @@ MediaEngineTabVideoSource::Allocate(const VideoTrackConstraintsN& aConstraints,
|
||||
ConstrainLongRange cWidth(aConstraints.mRequired.mWidth);
|
||||
ConstrainLongRange cHeight(aConstraints.mRequired.mHeight);
|
||||
|
||||
mWindowId = aConstraints.mBrowserWindow.WasPassed() ? aConstraints.mBrowserWindow.Value() : -1;
|
||||
bool haveScrollWithPage = aConstraints.mScrollWithPage.WasPassed();
|
||||
mScrollWithPage = haveScrollWithPage ? aConstraints.mScrollWithPage.Value() : true;
|
||||
|
||||
if (aConstraints.mAdvanced.WasPassed()) {
|
||||
const auto& advanced = aConstraints.mAdvanced.Value();
|
||||
for (uint32_t i = 0; i < advanced.Length(); i++) {
|
||||
if (cWidth.mMax >= advanced[i].mWidth.mMin && cWidth.mMin <= advanced[i].mWidth.mMax &&
|
||||
cHeight.mMax >= advanced[i].mHeight.mMin && cHeight.mMin <= advanced[i].mHeight.mMax) {
|
||||
cWidth.mMin = std::max(cWidth.mMin, advanced[i].mWidth.mMin);
|
||||
cHeight.mMin = std::max(cHeight.mMin, advanced[i].mHeight.mMin);
|
||||
cHeight.mMax >= advanced[i].mHeight.mMin && cHeight.mMin <= advanced[i].mHeight.mMax) {
|
||||
cWidth.mMin = std::max(cWidth.mMin, advanced[i].mWidth.mMin);
|
||||
cHeight.mMin = std::max(cHeight.mMin, advanced[i].mHeight.mMin);
|
||||
}
|
||||
|
||||
if (mWindowId == -1 && advanced[i].mBrowserWindow.WasPassed()) {
|
||||
mWindowId = advanced[i].mBrowserWindow.Value();
|
||||
}
|
||||
|
||||
if (!haveScrollWithPage && advanced[i].mScrollWithPage.WasPassed()) {
|
||||
mScrollWithPage = advanced[i].mScrollWithPage.Value();
|
||||
haveScrollWithPage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -140,7 +165,6 @@ MediaEngineTabVideoSource::Allocate(const VideoTrackConstraintsN& aConstraints,
|
||||
}
|
||||
|
||||
mTimePerFrame = aPrefs.mFPS ? 1000 / aPrefs.mFPS : aPrefs.mFPS;
|
||||
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
@ -227,6 +251,13 @@ MediaEngineTabVideoSource::Draw() {
|
||||
rect->GetWidth(&width);
|
||||
rect->GetHeight(&height);
|
||||
|
||||
if (mScrollWithPage) {
|
||||
nsPoint point;
|
||||
utils->GetScrollXY(false, &point.x, &point.y);
|
||||
left += point.x;
|
||||
top += point.y;
|
||||
}
|
||||
|
||||
if (width == 0 || height == 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -63,6 +63,8 @@ protected:
|
||||
private:
|
||||
int mBufW;
|
||||
int mBufH;
|
||||
int64_t mWindowId;
|
||||
bool mScrollWithPage;
|
||||
int mTimePerFrame;
|
||||
ScopedFreePtr<unsigned char> mData;
|
||||
nsCOMPtr<nsIDOMWindow> mWindow;
|
||||
|
@ -100,7 +100,12 @@ struct VideoTrackConstraintsN :
|
||||
Triage(Kind::Width).mWidth = mWidth;
|
||||
Triage(Kind::Height).mHeight = mHeight;
|
||||
Triage(Kind::FrameRate).mFrameRate = mFrameRate;
|
||||
|
||||
if (mBrowserWindow.WasPassed()) {
|
||||
Triage(Kind::BrowserWindow).mBrowserWindow.Construct(mBrowserWindow.Value());
|
||||
}
|
||||
if (mScrollWithPage.WasPassed()) {
|
||||
Triage(Kind::ScrollWithPage).mScrollWithPage.Construct(mScrollWithPage.Value());
|
||||
}
|
||||
// treat MediaSource special because it's always required
|
||||
mRequired.mMediaSource = mMediaSource;
|
||||
}
|
||||
|
@ -1530,6 +1530,23 @@ MediaManager::GetUserMedia(bool aPrivileged,
|
||||
}
|
||||
#endif
|
||||
|
||||
if (c.mVideo.IsMediaTrackConstraints() && !aPrivileged) {
|
||||
auto& tc = c.mVideo.GetAsMediaTrackConstraints();
|
||||
// only allow privileged content to set the window id
|
||||
if (tc.mBrowserWindow.WasPassed()) {
|
||||
tc.mBrowserWindow.Construct(-1);
|
||||
}
|
||||
|
||||
if (tc.mAdvanced.WasPassed()) {
|
||||
uint32_t length = tc.mAdvanced.Value().Length();
|
||||
for (uint32_t i = 0; i < length; i++) {
|
||||
if (tc.mAdvanced.Value()[i].mBrowserWindow.WasPassed()) {
|
||||
tc.mAdvanced.Value()[i].mBrowserWindow.Construct(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass callbacks and MediaStreamListener along to GetUserMediaRunnable.
|
||||
nsRefPtr<GetUserMediaRunnable> runnable;
|
||||
if (c.mFake) {
|
||||
@ -1548,12 +1565,16 @@ MediaManager::GetUserMedia(bool aPrivileged,
|
||||
auto& tc = c.mVideo.GetAsMediaTrackConstraints();
|
||||
// deny screensharing request if support is disabled
|
||||
if (tc.mMediaSource != dom::MediaSourceEnum::Camera) {
|
||||
if (!Preferences::GetBool("media.getusermedia.screensharing.enabled", false)) {
|
||||
if (tc.mMediaSource == dom::MediaSourceEnum::Browser) {
|
||||
if (!Preferences::GetBool("media.getusermedia.browser.enabled", false)) {
|
||||
return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
|
||||
}
|
||||
} else if (!Preferences::GetBool("media.getusermedia.screensharing.enabled", false)) {
|
||||
return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
|
||||
}
|
||||
/* Deny screensharing if the requesting document is not from a host
|
||||
on the whitelist. */
|
||||
if (!HostHasPermission(*docURI)) {
|
||||
if (!aPrivileged && !HostHasPermission(*docURI)) {
|
||||
return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,9 @@ enum SupportedVideoConstraints {
|
||||
"width",
|
||||
"height",
|
||||
"frameRate",
|
||||
"mediaSource"
|
||||
"mediaSource",
|
||||
"browserWindow",
|
||||
"scrollWithPage"
|
||||
};
|
||||
|
||||
enum SupportedAudioConstraints {
|
||||
@ -27,6 +29,8 @@ dictionary MediaTrackConstraintSet {
|
||||
ConstrainDoubleRange frameRate;
|
||||
ConstrainVideoFacingMode facingMode;
|
||||
ConstrainMediaSource mediaSource = "camera";
|
||||
long long browserWindow;
|
||||
boolean scrollWithPage;
|
||||
};
|
||||
|
||||
// TODO: Bug 995352 can't nest unions
|
||||
|
@ -21,8 +21,9 @@ import java.util.Locale;
|
||||
public final class GeckoLoader {
|
||||
private static final String LOGTAG = "GeckoLoader";
|
||||
|
||||
// This matches AppConstants, but we're built earlier.
|
||||
// These match AppConstants, but we're built earlier.
|
||||
private static final String ANDROID_PACKAGE_NAME = "@ANDROID_PACKAGE_NAME@";
|
||||
private static final String MOZ_APP_ABI = "@MOZ_APP_ABI@";
|
||||
|
||||
private static volatile Intent sIntent;
|
||||
private static File sCacheFile;
|
||||
@ -264,6 +265,8 @@ public final class GeckoLoader {
|
||||
final StringBuilder message = new StringBuilder("LOAD ");
|
||||
message.append(lib);
|
||||
|
||||
// These might differ. If so, we know why the library won't load!
|
||||
message.append(": ABI: " + MOZ_APP_ABI + ", " + android.os.Build.CPU_ABI);
|
||||
message.append(": Data: " + context.getApplicationInfo().dataDir);
|
||||
try {
|
||||
final boolean appLibExists = new File("/data/app-lib/" + ANDROID_PACKAGE_NAME + "/lib" + lib + ".so").exists();
|
||||
@ -336,9 +339,17 @@ public final class GeckoLoader {
|
||||
|
||||
// Attempt 2: use nativeLibraryDir, which should also work.
|
||||
final String libDir = context.getApplicationInfo().nativeLibraryDir;
|
||||
if (attemptLoad(libDir + "/lib" + lib + ".so")) {
|
||||
// Success!
|
||||
return null;
|
||||
final String libPath = libDir + "/lib" + lib + ".so";
|
||||
|
||||
// Does it even exist?
|
||||
if (new File(libPath).exists()) {
|
||||
if (attemptLoad(libPath)) {
|
||||
// Success!
|
||||
return null;
|
||||
}
|
||||
Log.wtf(LOGTAG, "Library exists but couldn't load!");
|
||||
} else {
|
||||
Log.wtf(LOGTAG, "Library doesn't exist when it should.");
|
||||
}
|
||||
|
||||
// We failed. Return the original cause.
|
||||
|
@ -79,8 +79,12 @@ MOZ_NATIVE_DEVICES=
|
||||
# Mark as WebGL conformant
|
||||
MOZ_WEBGL_CONFORMANT=1
|
||||
|
||||
# Don't enable the Search Activity.
|
||||
# MOZ_ANDROID_SEARCH_ACTIVITY=1
|
||||
# Enable the Search Activity in nightly.
|
||||
if test "$NIGHTLY_BUILD"; then
|
||||
MOZ_ANDROID_SEARCH_ACTIVITY=1
|
||||
else
|
||||
MOZ_ANDROID_SEARCH_ACTIVITY=
|
||||
fi
|
||||
|
||||
# Don't enable the Mozilla Location Service stumbler.
|
||||
# MOZ_ANDROID_MLS_STUMBLER=1
|
||||
|
@ -192,6 +192,8 @@ let TabMirror = function(deviceId, window) {
|
||||
let constraints = {
|
||||
video: {
|
||||
mediaSource: "browser",
|
||||
browserWindow: windowId,
|
||||
scrollWithPage: true,
|
||||
advanced: [
|
||||
{ width: { min: videoWidth, max: videoWidth },
|
||||
height: { min: videoHeight, max: videoHeight }
|
||||
|
@ -18,6 +18,7 @@ import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.ProgressBar;
|
||||
|
||||
import org.mozilla.gecko.AppConstants;
|
||||
import org.mozilla.gecko.Telemetry;
|
||||
import org.mozilla.gecko.TelemetryContract;
|
||||
|
||||
@ -100,6 +101,7 @@ public class PostSearchFragment extends Fragment {
|
||||
TelemetryContract.Method.CONTENT, "search-result");
|
||||
view.stopLoading();
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.BROWSER_INTENT_CLASS_NAME);
|
||||
i.setData(Uri.parse(url));
|
||||
startActivity(i);
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import android.support.v4.app.LoaderManager;
|
||||
import android.support.v4.content.AsyncTaskLoader;
|
||||
import android.support.v4.content.Loader;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@ -136,8 +137,12 @@ public class SearchFragment extends Fragment implements AcceptsJumpTaps {
|
||||
|
||||
@Override
|
||||
public void onSubmit(String text) {
|
||||
transitionToWaiting();
|
||||
searchListener.onSearch(text);
|
||||
// Don't submit an empty query.
|
||||
final String trimmedQuery = text.trim();
|
||||
if (!TextUtils.isEmpty(trimmedQuery)) {
|
||||
transitionToWaiting();
|
||||
searchListener.onSearch(trimmedQuery);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
<activity
|
||||
android:name="org.mozilla.search.MainActivity"
|
||||
android:icon="@drawable/search_launcher"
|
||||
android:label="@string/search_app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:screenOrientation="portrait">
|
||||
@ -37,6 +38,7 @@
|
||||
|
||||
<activity
|
||||
android:name="org.mozilla.search.SearchPreferenceActivity"
|
||||
android:logo="@drawable/search_launcher"
|
||||
android:label="@string/search_pref_title"
|
||||
android:parentActivityName="org.mozilla.search.MainActivity"
|
||||
android:theme="@style/SettingsTheme" >
|
||||
|
BIN
mobile/android/search/res/drawable-hdpi/search_launcher.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
mobile/android/search/res/drawable-mdpi/search_launcher.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
mobile/android/search/res/drawable-xhdpi/search_launcher.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
mobile/android/search/res/drawable-xxhdpi/search_launcher.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
mobile/android/search/res/drawable-xxxhdpi/search_launcher.png
Normal file
After Width: | Height: | Size: 23 KiB |
@ -311,6 +311,7 @@ pref("media.navigator.video.h264.max_br", 0);
|
||||
pref("media.navigator.video.h264.max_mbps", 0);
|
||||
pref("media.peerconnection.video.h264_enabled", false);
|
||||
pref("media.getusermedia.aec", 1);
|
||||
pref("media.getusermedia.browser.enabled", true);
|
||||
#endif
|
||||
pref("media.peerconnection.video.min_bitrate", 200);
|
||||
pref("media.peerconnection.video.start_bitrate", 300);
|
||||
|
204
services/fxaccounts/FxAccountsOAuthClient.jsm
Normal file
@ -0,0 +1,204 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* Firefox Accounts OAuth browser login helper.
|
||||
* Uses the WebChannel component to receive OAuth messages and complete login flows.
|
||||
*/
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["FxAccountsOAuthClient"];
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
|
||||
"resource://gre/modules/WebChannel.jsm");
|
||||
Cu.importGlobalProperties(["URL"]);
|
||||
|
||||
/**
|
||||
* Create a new FxAccountsOAuthClient for browser some service.
|
||||
*
|
||||
* @param {Object} options Options
|
||||
* @param {Object} options.parameters
|
||||
* Opaque alphanumeric token to be included in verification links
|
||||
* @param {String} options.parameters.client_id
|
||||
* OAuth id returned from client registration
|
||||
* @param {String} options.parameters.state
|
||||
* A value that will be returned to the client as-is upon redirection
|
||||
* @param {String} options.parameters.oauth_uri
|
||||
* The FxA OAuth server uri
|
||||
* @param {String} options.parameters.content_uri
|
||||
* The FxA Content server uri
|
||||
* @param {String} [options.parameters.scope]
|
||||
* Optional. A colon-separated list of scopes that the user has authorized
|
||||
* @param {String} [options.parameters.action]
|
||||
* Optional. If provided, should be either signup or signin.
|
||||
* @param [authorizationEndpoint] {String}
|
||||
* Optional authorization endpoint for the OAuth server
|
||||
* @constructor
|
||||
*/
|
||||
this.FxAccountsOAuthClient = function(options) {
|
||||
this._validateOptions(options);
|
||||
this.parameters = options.parameters;
|
||||
this._configureChannel();
|
||||
|
||||
let authorizationEndpoint = options.authorizationEndpoint || "/authorization";
|
||||
|
||||
try {
|
||||
this._fxaOAuthStartUrl = new URL(this.parameters.oauth_uri + authorizationEndpoint + "?");
|
||||
} catch (e) {
|
||||
throw new Error("Invalid OAuth Url");
|
||||
}
|
||||
|
||||
let params = this._fxaOAuthStartUrl.searchParams;
|
||||
params.append("client_id", this.parameters.client_id);
|
||||
params.append("state", this.parameters.state);
|
||||
params.append("scope", this.parameters.scope || "");
|
||||
params.append("action", this.parameters.action || "signin");
|
||||
params.append("webChannelId", this._webChannelId);
|
||||
|
||||
};
|
||||
|
||||
this.FxAccountsOAuthClient.prototype = {
|
||||
/**
|
||||
* Function that gets called once the OAuth flow is successfully complete.
|
||||
*/
|
||||
onComplete: null,
|
||||
/**
|
||||
* Configuration object that stores all OAuth parameters.
|
||||
*/
|
||||
parameters: null,
|
||||
/**
|
||||
* WebChannel that is used to communicate with content page.
|
||||
*/
|
||||
_channel: null,
|
||||
/**
|
||||
* Boolean to indicate if this client has completed an OAuth flow.
|
||||
*/
|
||||
_complete: false,
|
||||
/**
|
||||
* The url that opens the Firefox Accounts OAuth flow.
|
||||
*/
|
||||
_fxaOAuthStartUrl: null,
|
||||
/**
|
||||
* WebChannel id.
|
||||
*/
|
||||
_webChannelId: null,
|
||||
/**
|
||||
* WebChannel origin, used to validate origin of messages.
|
||||
*/
|
||||
_webChannelOrigin: null,
|
||||
/**
|
||||
* Opens a tab at "this._fxaOAuthStartUrl".
|
||||
* Registers a WebChannel listener and sets up a callback if needed.
|
||||
*/
|
||||
launchWebFlow: function () {
|
||||
if (!this._channelCallback) {
|
||||
this._registerChannel();
|
||||
}
|
||||
|
||||
if (this._complete) {
|
||||
throw new Error("This client already completed the OAuth flow");
|
||||
} else {
|
||||
let opener = Services.wm.getMostRecentWindow("navigator:browser").gBrowser;
|
||||
opener.selectedTab = opener.addTab(this._fxaOAuthStartUrl.href);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Release all resources that are in use.
|
||||
*/
|
||||
tearDown: function() {
|
||||
this.onComplete = null;
|
||||
this._complete = true;
|
||||
this._channel.stopListening();
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures WebChannel id and origin
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_configureChannel: function() {
|
||||
this._webChannelId = "oauth_" + this.parameters.client_id;
|
||||
|
||||
// if this.parameters.content_uri is present but not a valid URI, then this will throw an error.
|
||||
try {
|
||||
this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri, null, null);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new channel with the WebChannelBroker, setup a callback listener
|
||||
* @private
|
||||
*/
|
||||
_registerChannel: function() {
|
||||
/**
|
||||
* Processes messages that are called back from the FxAccountsChannel
|
||||
*
|
||||
* @param webChannelId {String}
|
||||
* Command webChannelId
|
||||
* @param message {Object}
|
||||
* Command message
|
||||
* @param target {EventTarget}
|
||||
* Channel message event target
|
||||
* @private
|
||||
*/
|
||||
let listener = function (webChannelId, message, target) {
|
||||
if (message) {
|
||||
let command = message.command;
|
||||
let data = message.data;
|
||||
|
||||
switch (command) {
|
||||
case "oauth_complete":
|
||||
// validate the state parameter and call onComplete
|
||||
if (this.onComplete && data.code && this.parameters.state === data.state) {
|
||||
log.debug("OAuth flow completed.");
|
||||
this.onComplete({
|
||||
code: data.code,
|
||||
state: data.state
|
||||
});
|
||||
// onComplete will be called for this client only once
|
||||
// calling onComplete again will result in a failure of the OAuth flow
|
||||
this.tearDown();
|
||||
}
|
||||
|
||||
// if the message asked to close the tab
|
||||
if (data.closeWindow && target && target.contentWindow) {
|
||||
target.contentWindow.close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._channelCallback = listener.bind(this);
|
||||
this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
|
||||
this._channel.listen(this._channelCallback);
|
||||
log.debug("Channel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Validates the required FxA OAuth parameters
|
||||
*
|
||||
* @param options {Object}
|
||||
* OAuth client options
|
||||
* @private
|
||||
*/
|
||||
_validateOptions: function (options) {
|
||||
if (!options || !options.parameters) {
|
||||
throw new Error("Missing 'parameters' configuration option");
|
||||
}
|
||||
|
||||
["oauth_uri", "client_id", "content_uri", "state"].forEach(option => {
|
||||
if (!options.parameters[option]) {
|
||||
throw new Error("Missing 'parameters." + option + "' parameter");
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
@ -12,7 +12,8 @@ EXTRA_JS_MODULES += [
|
||||
'Credentials.jsm',
|
||||
'FxAccounts.jsm',
|
||||
'FxAccountsClient.jsm',
|
||||
'FxAccountsCommon.js'
|
||||
'FxAccountsCommon.js',
|
||||
'FxAccountsOAuthClient.jsm',
|
||||
]
|
||||
|
||||
# For now, we will only be using the FxA manager in B2G.
|
||||
|
46
services/fxaccounts/tests/xpcshell/test_oauth_client.js
Normal file
@ -0,0 +1,46 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
|
||||
|
||||
function run_test() {
|
||||
validationHelper(undefined,
|
||||
"Error: Missing 'parameters' configuration option");
|
||||
|
||||
validationHelper({},
|
||||
"Error: Missing 'parameters' configuration option");
|
||||
|
||||
validationHelper({ parameters: {} },
|
||||
"Error: Missing 'parameters.oauth_uri' parameter");
|
||||
|
||||
validationHelper({ parameters: {
|
||||
oauth_uri: "http://oauth.test/v1"
|
||||
}},
|
||||
"Error: Missing 'parameters.client_id' parameter");
|
||||
|
||||
validationHelper({ parameters: {
|
||||
oauth_uri: "http://oauth.test/v1",
|
||||
client_id: "client_id"
|
||||
}},
|
||||
"Error: Missing 'parameters.content_uri' parameter");
|
||||
|
||||
validationHelper({ parameters: {
|
||||
oauth_uri: "http://oauth.test/v1",
|
||||
client_id: "client_id",
|
||||
content_uri: "http://content.test"
|
||||
}},
|
||||
"Error: Missing 'parameters.state' parameter");
|
||||
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
function validationHelper(params, expected) {
|
||||
try {
|
||||
new FxAccountsOAuthClient(params);
|
||||
} catch (e) {
|
||||
return do_check_eq(e.toString(), expected);
|
||||
}
|
||||
throw new Error("Validation helper error");
|
||||
}
|
@ -8,3 +8,4 @@ tail =
|
||||
[test_manager.js]
|
||||
run-if = appname == 'b2g'
|
||||
reason = FxAccountsManager is only available for B2G for now
|
||||
[test_oauth_client.js]
|
||||
|
@ -46,6 +46,16 @@ this.SearchSuggestionController.prototype = {
|
||||
*/
|
||||
maxRemoteResults: 10,
|
||||
|
||||
/**
|
||||
* The maximum time (ms) to wait before giving up on a remote suggestions.
|
||||
*/
|
||||
remoteTimeout: REMOTE_TIMEOUT,
|
||||
|
||||
/**
|
||||
* The additional parameter used when searching form history.
|
||||
*/
|
||||
formHistoryParam: DEFAULT_FORM_HISTORY_PARAM,
|
||||
|
||||
// Private properties
|
||||
/**
|
||||
* The last form history result used to improve the performance of subsequent searches.
|
||||
@ -168,7 +178,7 @@ this.SearchSuggestionController.prototype = {
|
||||
this._remoteResultTimer = Cc["@mozilla.org/timer;1"].
|
||||
createInstance(Ci.nsITimer);
|
||||
this._remoteResultTimer.initWithCallback(this._onRemoteTimeout.bind(this),
|
||||
REMOTE_TIMEOUT,
|
||||
this.remoteTimeout || REMOTE_TIMEOUT,
|
||||
Ci.nsITimer.TYPE_ONE_SHOT);
|
||||
}
|
||||
|
||||
@ -199,7 +209,8 @@ this.SearchSuggestionController.prototype = {
|
||||
|
||||
let formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"].
|
||||
createInstance(Ci.nsIAutoCompleteSearch);
|
||||
formHistory.startSearch(searchTerm, DEFAULT_FORM_HISTORY_PARAM, this._formHistoryResult,
|
||||
formHistory.startSearch(searchTerm, this.formHistoryParam || DEFAULT_FORM_HISTORY_PARAM,
|
||||
this._formHistoryResult,
|
||||
acSearchObserver);
|
||||
return deferredFormHistory;
|
||||
},
|
||||
@ -347,3 +358,13 @@ this.SearchSuggestionController.prototype = {
|
||||
this._searchString = null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the given engine offers search suggestions.
|
||||
*
|
||||
* @param {nsISearchEngine} engine - The search engine
|
||||
* @return {boolean} True if the engine offers suggestions and false otherwise.
|
||||
*/
|
||||
this.SearchSuggestionController.engineOffersSuggestions = function(engine) {
|
||||
return engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON);
|
||||
};
|
||||
|
249
toolkit/modules/WebChannel.jsm
Normal file
@ -0,0 +1,249 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* WebChannel is an abstraction that uses the Message Manager and Custom Events
|
||||
* to create a two-way communication channel between chrome and content code.
|
||||
*/
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["WebChannel", "WebChannelBroker"];
|
||||
|
||||
const ERRNO_UNKNOWN_ERROR = 999;
|
||||
const ERROR_UNKNOWN = "UNKNOWN_ERROR";
|
||||
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
|
||||
/**
|
||||
* WebChannelBroker is a global object that helps manage WebChannel objects.
|
||||
* This object handles channel registration, origin validation and message multiplexing.
|
||||
*/
|
||||
|
||||
let WebChannelBroker = Object.create({
|
||||
/**
|
||||
* Register a new channel that callbacks messages
|
||||
* based on proper origin and channel name
|
||||
*
|
||||
* @param channel {WebChannel}
|
||||
*/
|
||||
registerChannel: function (channel) {
|
||||
if (!this._channelMap.has(channel)) {
|
||||
this._channelMap.set(channel);
|
||||
} else {
|
||||
Cu.reportError("Failed to register the channel. Channel already exists.");
|
||||
}
|
||||
|
||||
// attach the global message listener if needed
|
||||
if (!this._messageListenerAttached) {
|
||||
this._messageListenerAttached = true;
|
||||
this._manager.addMessageListener("WebChannelMessageToChrome", this._listener.bind(this));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unregister a channel
|
||||
*
|
||||
* @param channelToRemove {WebChannel}
|
||||
* WebChannel to remove from the channel map
|
||||
*
|
||||
* Removes the specified channel from the channel map
|
||||
*/
|
||||
unregisterChannel: function (channelToRemove) {
|
||||
if (!this._channelMap.delete(channelToRemove)) {
|
||||
Cu.reportError("Failed to unregister the channel. Channel not found.");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param event {Event}
|
||||
* Message Manager event
|
||||
* @private
|
||||
*/
|
||||
_listener: function (event) {
|
||||
let data = event.data;
|
||||
let sender = event.target;
|
||||
|
||||
if (data && data.id) {
|
||||
if (!event.principal) {
|
||||
this._sendErrorEventToContent(data.id, sender, "Message principal missing");
|
||||
} else {
|
||||
let validChannelFound = false;
|
||||
data.message = data.message || {};
|
||||
|
||||
for (var channel of this._channelMap.keys()) {
|
||||
if (channel.id === data.id &&
|
||||
channel.origin.prePath === event.principal.origin) {
|
||||
validChannelFound = true;
|
||||
channel.deliver(data, sender);
|
||||
}
|
||||
}
|
||||
|
||||
// if no valid origins send an event that there is no such valid channel
|
||||
if (!validChannelFound) {
|
||||
this._sendErrorEventToContent(data.id, sender, "No Such Channel");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Cu.reportError("WebChannel channel id missing");
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The global message manager operates on every <browser>
|
||||
*/
|
||||
_manager: Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager),
|
||||
/**
|
||||
* Boolean used to detect if the global message manager event is already attached
|
||||
*/
|
||||
_messageListenerAttached: false,
|
||||
/**
|
||||
* Object to store pairs of message origins and callback functions
|
||||
*/
|
||||
_channelMap: new Map(),
|
||||
/**
|
||||
*
|
||||
* @param id {String}
|
||||
* The WebChannel id to include in the message
|
||||
* @param sender {EventTarget}
|
||||
* EventTarget with a "messageManager" that will send be used to send the message
|
||||
* @param [errorMsg] {String}
|
||||
* Error message
|
||||
* @private
|
||||
*/
|
||||
_sendErrorEventToContent: function (id, sender, errorMsg) {
|
||||
errorMsg = errorMsg || "Web Channel Broker error";
|
||||
|
||||
if (sender.messageManager) {
|
||||
sender.messageManager.sendAsyncMessage("WebChannelMessageToContent", {
|
||||
id: id,
|
||||
error: errorMsg,
|
||||
}, sender);
|
||||
}
|
||||
Cu.reportError(id.toString() + " error message. " + errorMsg);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new WebChannel that listens and sends messages over some channel id
|
||||
*
|
||||
* @param id {String}
|
||||
* WebChannel id
|
||||
* @param origin {nsIURI}
|
||||
* Valid origin that should be part of requests for this channel
|
||||
* @constructor
|
||||
*/
|
||||
this.WebChannel = function(id, origin) {
|
||||
if (!id || !origin) {
|
||||
throw new Error("WebChannel id and origin are required.");
|
||||
}
|
||||
|
||||
this.id = id;
|
||||
this.origin = origin;
|
||||
};
|
||||
|
||||
this.WebChannel.prototype = {
|
||||
|
||||
/**
|
||||
* WebChannel id
|
||||
*/
|
||||
id: null,
|
||||
|
||||
/**
|
||||
* WebChannel origin
|
||||
*/
|
||||
origin: null,
|
||||
|
||||
/**
|
||||
* WebChannelBroker that manages WebChannels
|
||||
*/
|
||||
_broker: WebChannelBroker,
|
||||
|
||||
/**
|
||||
* Callback that will be called with the contents of an incoming message
|
||||
*/
|
||||
_deliverCallback: null,
|
||||
|
||||
/**
|
||||
* Registers the callback for messages on this channel
|
||||
* Registers the channel itself with the WebChannelBroker
|
||||
*
|
||||
* @param callback {Function}
|
||||
* Callback that will be called when there is a message
|
||||
* @param {String} id
|
||||
* The WebChannel id that was used for this message
|
||||
* @param {Object} message
|
||||
* The message itself
|
||||
* @param {EventTarget} sender
|
||||
* The source of the message
|
||||
*/
|
||||
listen: function (callback) {
|
||||
if (this._deliverCallback) {
|
||||
throw new Error("Failed to listen. Listener already attached.");
|
||||
} else if (!callback) {
|
||||
throw new Error("Failed to listen. Callback argument missing.");
|
||||
} else {
|
||||
this._deliverCallback = callback;
|
||||
this._broker.registerChannel(this);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets the callback for messages on this channel
|
||||
* Removes the channel from the WebChannelBroker
|
||||
*/
|
||||
stopListening: function () {
|
||||
this._broker.unregisterChannel(this);
|
||||
this._deliverCallback = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends messages over the WebChannel id using the "WebChannelMessageToContent" event
|
||||
*
|
||||
* @param message {Object}
|
||||
* The message object that will be sent
|
||||
* @param target {browser}
|
||||
* The <browser> object that has a "messageManager" that sends messages
|
||||
*
|
||||
*/
|
||||
send: function (message, target) {
|
||||
if (message && target && target.messageManager) {
|
||||
target.messageManager.sendAsyncMessage("WebChannelMessageToContent", {
|
||||
id: this.id,
|
||||
message: message
|
||||
});
|
||||
} else if (!message) {
|
||||
Cu.reportError("Failed to send a WebChannel message. Message not set.");
|
||||
} else {
|
||||
Cu.reportError("Failed to send a WebChannel message. Target invalid.");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deliver WebChannel messages to the set "_channelCallback"
|
||||
*
|
||||
* @param data {Object}
|
||||
* Message data
|
||||
* @param sender {browser}
|
||||
* Message sender
|
||||
*/
|
||||
deliver: function(data, sender) {
|
||||
if (this._deliverCallback) {
|
||||
try {
|
||||
this._deliverCallback(data.id, data.message, sender);
|
||||
} catch (ex) {
|
||||
this.send({
|
||||
errno: ERRNO_UNKNOWN_ERROR,
|
||||
error: ex.message ? ex.message : ERROR_UNKNOWN
|
||||
}, sender);
|
||||
Cu.reportError("Failed to execute callback:" + ex);
|
||||
}
|
||||
} else {
|
||||
Cu.reportError("No callback set for this channel.");
|
||||
}
|
||||
}
|
||||
};
|
@ -55,6 +55,7 @@ EXTRA_JS_MODULES += [
|
||||
'Task.jsm',
|
||||
'TelemetryTimestamps.jsm',
|
||||
'Timer.jsm',
|
||||
'WebChannel.jsm',
|
||||
'ZipUtils.jsm',
|
||||
]
|
||||
|
||||
|
104
toolkit/modules/tests/xpcshell/test_web_channel.js
Normal file
@ -0,0 +1,104 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
const Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/WebChannel.jsm");
|
||||
|
||||
const ERROR_ID_ORIGIN_REQUIRED = "WebChannel id and origin are required.";
|
||||
const VALID_WEB_CHANNEL_ID = "id";
|
||||
const URL_STRING = "http://example.com";
|
||||
const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null);
|
||||
|
||||
let MockWebChannelBroker = {
|
||||
_channelMap: new Map(),
|
||||
registerChannel: function(channel) {
|
||||
if (!this._channelMap.has(channel)) {
|
||||
this._channelMap.set(channel);
|
||||
}
|
||||
},
|
||||
unregisterChannel: function (channelToRemove) {
|
||||
this._channelMap.delete(channelToRemove)
|
||||
}
|
||||
};
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
/**
|
||||
* Web channel tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Test channel listening
|
||||
*/
|
||||
add_test(function test_web_channel_listen() {
|
||||
let channel = new WebChannel(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN, {
|
||||
broker: MockWebChannelBroker
|
||||
});
|
||||
let cb = Async.makeSpinningCallback();
|
||||
let delivered = 0;
|
||||
do_check_eq(channel.id, VALID_WEB_CHANNEL_ID);
|
||||
do_check_eq(channel.origin.spec, VALID_WEB_CHANNEL_ORIGIN.spec);
|
||||
do_check_eq(channel._deliverCallback, null);
|
||||
|
||||
channel.listen(function(id, message, target) {
|
||||
do_check_eq(id, VALID_WEB_CHANNEL_ID);
|
||||
do_check_true(message);
|
||||
do_check_true(message.command);
|
||||
do_check_true(target.sender);
|
||||
delivered++;
|
||||
// 2 messages should be delivered
|
||||
if (delivered === 2) {
|
||||
channel.stopListening();
|
||||
do_check_eq(channel._deliverCallback, null);
|
||||
cb();
|
||||
run_next_test();
|
||||
}
|
||||
});
|
||||
|
||||
// send two messages
|
||||
channel.deliver({
|
||||
id: VALID_WEB_CHANNEL_ID,
|
||||
message: {
|
||||
command: "one"
|
||||
}
|
||||
}, { sender: true });
|
||||
|
||||
channel.deliver({
|
||||
id: VALID_WEB_CHANNEL_ID,
|
||||
message: {
|
||||
command: "two"
|
||||
}
|
||||
}, { sender: true });
|
||||
|
||||
cb.wait();
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Test constructor
|
||||
*/
|
||||
add_test(function test_web_channel_constructor() {
|
||||
do_check_eq(constructorTester(), ERROR_ID_ORIGIN_REQUIRED);
|
||||
do_check_eq(constructorTester(undefined), ERROR_ID_ORIGIN_REQUIRED);
|
||||
do_check_eq(constructorTester(undefined, VALID_WEB_CHANNEL_ORIGIN), ERROR_ID_ORIGIN_REQUIRED);
|
||||
do_check_eq(constructorTester(VALID_WEB_CHANNEL_ID, undefined), ERROR_ID_ORIGIN_REQUIRED);
|
||||
do_check_false(constructorTester(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN));
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
function constructorTester(id, origin) {
|
||||
try {
|
||||
new WebChannel(id, origin);
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
return false;
|
||||
}
|
85
toolkit/modules/tests/xpcshell/test_web_channel_broker.js
Normal file
@ -0,0 +1,85 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
const Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/WebChannel.jsm");
|
||||
|
||||
const VALID_WEB_CHANNEL_ID = "id";
|
||||
const URL_STRING = "http://example.com";
|
||||
const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null);
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test WebChannelBroker channel map
|
||||
*/
|
||||
add_test(function test_web_channel_broker_channel_map() {
|
||||
let channel = new Object();
|
||||
let channel2 = new Object();
|
||||
|
||||
do_check_eq(WebChannelBroker._channelMap.size, 0);
|
||||
do_check_false(WebChannelBroker._messageListenerAttached);
|
||||
|
||||
// make sure _channelMap works correctly
|
||||
WebChannelBroker.registerChannel(channel);
|
||||
do_check_eq(WebChannelBroker._channelMap.size, 1);
|
||||
do_check_true(WebChannelBroker._messageListenerAttached);
|
||||
|
||||
WebChannelBroker.registerChannel(channel2);
|
||||
do_check_eq(WebChannelBroker._channelMap.size, 2);
|
||||
|
||||
WebChannelBroker.unregisterChannel(channel);
|
||||
do_check_eq(WebChannelBroker._channelMap.size, 1);
|
||||
|
||||
// make sure the correct channel is unregistered
|
||||
do_check_false(WebChannelBroker._channelMap.has(channel));
|
||||
do_check_true(WebChannelBroker._channelMap.has(channel2));
|
||||
|
||||
WebChannelBroker.unregisterChannel(channel2);
|
||||
do_check_eq(WebChannelBroker._channelMap.size, 0);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Test WebChannelBroker _listener test
|
||||
*/
|
||||
add_test(function test_web_channel_broker_listener() {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
var channel = new Object({
|
||||
id: VALID_WEB_CHANNEL_ID,
|
||||
origin: VALID_WEB_CHANNEL_ORIGIN,
|
||||
deliver: function(data, sender) {
|
||||
do_check_eq(data.id, VALID_WEB_CHANNEL_ID);
|
||||
do_check_eq(data.message.command, "hello");
|
||||
WebChannelBroker.unregisterChannel(channel);
|
||||
cb();
|
||||
run_next_test();
|
||||
}
|
||||
});
|
||||
|
||||
WebChannelBroker.registerChannel(channel);
|
||||
|
||||
var mockEvent = {
|
||||
data: {
|
||||
id: VALID_WEB_CHANNEL_ID,
|
||||
message: {
|
||||
command: "hello"
|
||||
}
|
||||
},
|
||||
principal: {
|
||||
origin: URL_STRING
|
||||
}
|
||||
};
|
||||
|
||||
WebChannelBroker._listener(mockEvent);
|
||||
cb.wait();
|
||||
});
|
@ -28,4 +28,6 @@ support-files =
|
||||
[test_task.js]
|
||||
[test_TelemetryTimestamps.js]
|
||||
[test_timer.js]
|
||||
[test_web_channel.js]
|
||||
[test_web_channel_broker.js]
|
||||
[test_ZipUtils.js]
|
||||
|