--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -977,17 +977,17 @@ let MozLoopServiceInternal = {
if (!code || !state) {
throw new Error("promiseFxAOAuthToken: code and state are required.");
}
let payload = {
code: code,
state: state,
};
- return this.hawkRequest(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/token", "POST", payload).then(response => {
+ return this.hawkRequestInternal(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/token", "POST", payload).then(response => {
return JSON.parse(response.body);
},
error => {this._hawkRequestError(error);});
},
/**
* Called once gFxAOAuthClient fires onComplete.
*
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -89,25 +89,20 @@ loop.roomViews = (function(mozL10n) {
if (event.which === 13) {
this.handleFormSubmit(event);
}
},
handleFormSubmit: function(event) {
event.preventDefault();
- var newRoomName = this.state.newRoomName;
-
- if (newRoomName && this.state.roomName != newRoomName) {
- this.props.dispatcher.dispatch(
- new sharedActions.RenameRoom({
- roomToken: this.state.roomToken,
- newRoomName: newRoomName
- }));
- }
+ this.props.dispatcher.dispatch(new sharedActions.RenameRoom({
+ roomToken: this.state.roomToken,
+ newRoomName: this.state.newRoomName
+ }));
},
handleEmailButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(
new sharedActions.EmailRoomUrl({roomUrl: this.state.roomUrl}));
},
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -89,25 +89,20 @@ loop.roomViews = (function(mozL10n) {
if (event.which === 13) {
this.handleFormSubmit(event);
}
},
handleFormSubmit: function(event) {
event.preventDefault();
- var newRoomName = this.state.newRoomName;
-
- if (newRoomName && this.state.roomName != newRoomName) {
- this.props.dispatcher.dispatch(
- new sharedActions.RenameRoom({
- roomToken: this.state.roomToken,
- newRoomName: newRoomName
- }));
- }
+ this.props.dispatcher.dispatch(new sharedActions.RenameRoom({
+ roomToken: this.state.roomToken,
+ newRoomName: this.state.newRoomName
+ }));
},
handleEmailButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(
new sharedActions.EmailRoomUrl({roomUrl: this.state.roomUrl}));
},
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -76,28 +76,31 @@ p {
/* A reset for all button-appearing elements, with the lowest-common
* denominator of the needed rules. Intended to be used as a base class
* together with .btn-*
*/
.btn {
display: inline-block;
- overflow: hidden;
margin: 0;
padding: 0;
border: none;
background: #a5a;
color: #fff;
text-align: center;
text-decoration: none;
+ font-size: .9em;
+ cursor: pointer;
+}
+
+.btn.btn-constrained {
+ overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- font-size: .9em;
- cursor: pointer;
}
.btn-info {
background-color: #0096dd;
border: 1px solid #0095dd;
color: #fff;
}
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -257,17 +257,17 @@
.call-action-group {
display: flex;
padding: 2.5em 4px 0 4px;
width: 100%;
}
.call-action-group > .btn {
- height: 26px;
+ min-height: 26px;
border-radius: 2px;
margin: 0 4px;
min-width: 64px;
}
.call-action-group .btn-group-chevron,
.call-action-group .btn-group {
width: 100%;
--- a/browser/components/loop/content/shared/js/roomStore.js
+++ b/browser/components/loop/content/shared/js/roomStore.js
@@ -149,20 +149,23 @@ loop.store = loop.store || {};
/**
* Updates current room list when a new room is available.
*
* @param {String} eventName The event name (unused).
* @param {Object} addedRoomData The added room data.
*/
_onRoomAdded: function(eventName, addedRoomData) {
- addedRoomData.participants = [];
- addedRoomData.ctime = new Date().getTime();
+ addedRoomData.participants = addedRoomData.participants || [];
+ addedRoomData.ctime = addedRoomData.ctime || new Date().getTime();
this.dispatchAction(new sharedActions.UpdateRoomList({
- roomList: this._storeState.rooms.concat(new Room(addedRoomData))
+ // Ensure the room isn't part of the list already, then add it.
+ roomList: this._storeState.rooms.filter(function(room) {
+ return addedRoomData.roomToken !== room.roomToken;
+ }).concat(new Room(addedRoomData))
}));
},
/**
* Executed when a room is updated.
*
* @param {String} eventName The event name (unused).
* @param {Object} updatedRoomData The updated room data.
@@ -415,18 +418,25 @@ loop.store = loop.store || {};
},
/**
* Renames a room.
*
* @param {sharedActions.RenameRoom} actionData
*/
renameRoom: function(actionData) {
+ var newRoomName = actionData.newRoomName.trim();
+
+ // Skip update if name is unchanged or empty.
+ if (!newRoomName || this.getStoreState("roomName") === newRoomName) {
+ return;
+ }
+
this.setStoreState({error: null});
- this._mozLoop.rooms.rename(actionData.roomToken, actionData.newRoomName,
+ this._mozLoop.rooms.rename(actionData.roomToken, newRoomName,
function(err) {
if (err) {
this.dispatchAction(new sharedActions.RenameRoomError({error: err}));
}
}.bind(this));
},
renameRoomError: function(actionData) {
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -386,17 +386,17 @@ loop.webapp = (function($, _, OT, mozL10
var chevronClasses = React.addons.classSet({
"btn-chevron": true,
"disabled": this.props.disabled
});
return (
React.DOM.div({className: "standalone-btn-chevron-menu-group"},
React.DOM.div({className: "btn-group-chevron"},
React.DOM.div({className: "btn-group"},
- React.DOM.button({className: "btn btn-large btn-accept",
+ React.DOM.button({className: "btn btn-constrained btn-large btn-accept",
onClick: this.props.startCall("audio-video"),
disabled: this.props.disabled,
title: mozL10n.get("initiate_audio_video_call_tooltip2")},
React.DOM.span({className: "standalone-call-btn-text"},
this.props.caption
),
React.DOM.span({className: "standalone-call-btn-video-icon"})
),
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -386,17 +386,17 @@ loop.webapp = (function($, _, OT, mozL10
var chevronClasses = React.addons.classSet({
"btn-chevron": true,
"disabled": this.props.disabled
});
return (
<div className="standalone-btn-chevron-menu-group">
<div className="btn-group-chevron">
<div className="btn-group">
- <button className="btn btn-large btn-accept"
+ <button className="btn btn-constrained btn-large btn-accept"
onClick={this.props.startCall("audio-video")}
disabled={this.props.disabled}
title={mozL10n.get("initiate_audio_video_call_tooltip2")}>
<span className="standalone-call-btn-text">
{this.props.caption}
</span>
<span className="standalone-call-btn-video-icon" />
</button>
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -123,36 +123,40 @@ describe("loop.roomViews", function () {
beforeEach(function() {
view = mountTestComponent();
view.setState({
roomToken: "fakeToken",
roomName: "fakeName"
});
roomNameBox = view.getDOMNode().querySelector('.input-room-name');
-
- React.addons.TestUtils.Simulate.change(roomNameBox, { target: {
- value: "reallyFake"
- }});
});
it("should dispatch a RenameRoom action when the focus is lost",
function() {
+ React.addons.TestUtils.Simulate.change(roomNameBox, { target: {
+ value: "reallyFake"
+ }});
+
React.addons.TestUtils.Simulate.blur(roomNameBox);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.RenameRoom({
roomToken: "fakeToken",
newRoomName: "reallyFake"
}));
});
it("should dispatch a RenameRoom action when Enter key is pressed",
function() {
+ React.addons.TestUtils.Simulate.change(roomNameBox, { target: {
+ value: "reallyFake"
+ }});
+
TestUtils.Simulate.keyDown(roomNameBox, {key: "Enter", which: 13});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.RenameRoom({
roomToken: "fakeToken",
newRoomName: "reallyFake"
}));
--- a/browser/components/loop/test/shared/roomStore_test.js
+++ b/browser/components/loop/test/shared/roomStore_test.js
@@ -16,44 +16,44 @@ describe("loop.store.Room", function ()
});
describe("loop.store.RoomStore", function () {
"use strict";
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var sandbox, dispatcher;
-
- var fakeRoomList = [{
- roomToken: "_nxD4V4FflQ",
- roomUrl: "http://sample/_nxD4V4FflQ",
- roomName: "First Room Name",
- maxSize: 2,
- participants: [],
- ctime: 1405517546
- }, {
- roomToken: "QzBbvGmIZWU",
- roomUrl: "http://sample/QzBbvGmIZWU",
- roomName: "Second Room Name",
- maxSize: 2,
- participants: [],
- ctime: 1405517418
- }, {
- roomToken: "3jKS_Els9IU",
- roomUrl: "http://sample/3jKS_Els9IU",
- roomName: "Third Room Name",
- maxSize: 3,
- clientMaxSize: 2,
- participants: [],
- ctime: 1405518241
- }];
+ var fakeRoomList;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
+ fakeRoomList = [{
+ roomToken: "_nxD4V4FflQ",
+ roomUrl: "http://sample/_nxD4V4FflQ",
+ roomName: "First Room Name",
+ maxSize: 2,
+ participants: [],
+ ctime: 1405517546
+ }, {
+ roomToken: "QzBbvGmIZWU",
+ roomUrl: "http://sample/QzBbvGmIZWU",
+ roomName: "Second Room Name",
+ maxSize: 2,
+ participants: [],
+ ctime: 1405517418
+ }, {
+ roomToken: "3jKS_Els9IU",
+ roomUrl: "http://sample/3jKS_Els9IU",
+ roomName: "Third Room Name",
+ maxSize: 3,
+ clientMaxSize: 2,
+ participants: [],
+ ctime: 1405518241
+ }];
});
afterEach(function() {
sandbox.restore();
});
describe("#constructor", function() {
it("should throw an error if mozLoop is missing", function() {
@@ -116,16 +116,27 @@ describe("loop.store.RoomStore", functio
roomName: "New room",
maxSize: 2,
participants: [],
ctime: 1405517546
});
expect(store.getStoreState().rooms).to.have.length.of(4);
});
+
+ it("should avoid adding a duplicate room", function() {
+ var sampleRoom = fakeRoomList[0];
+
+ fakeMozLoop.rooms.trigger("add", "add", sampleRoom);
+
+ expect(store.getStoreState().rooms).to.have.length.of(3);
+ expect(store.getStoreState().rooms.reduce(function(count, room) {
+ return count += room.roomToken === sampleRoom.roomToken ? 1 : 0;
+ }, 0)).eql(1);
+ });
});
describe("update", function() {
it("should update a room entry", function() {
fakeMozLoop.rooms.trigger("update", "update", {
roomToken: "_nxD4V4FflQ",
roomUrl: "http://sample/_nxD4V4FflQ",
roomName: "Changed First Room Name",
@@ -534,10 +545,21 @@ describe("loop.store.RoomStore", functio
dispatcher.dispatch(new sharedActions.RenameRoom({
roomToken: "42abc",
newRoomName: "silly name"
}));
expect(store.getStoreState().error).eql(err);
});
+
+ it("should ensure only submitting a non-empty room name", function() {
+ fakeMozLoop.rooms.rename = sinon.spy();
+
+ dispatcher.dispatch(new sharedActions.RenameRoom({
+ roomToken: "42abc",
+ newRoomName: " \t \t "
+ }));
+
+ sinon.assert.notCalled(fakeMozLoop.rooms.rename);
+ });
});
});
--- a/browser/components/preferences/in-content/search.js
+++ b/browser/components/preferences/in-content/search.js
@@ -85,16 +85,19 @@ var gSearchPane = {
onTreeSelect: function() {
document.getElementById("removeEngineButton").disabled =
gEngineView.selectedIndex == -1 || gEngineView.lastIndex == 0;
},
onTreeKeyPress: function(aEvent) {
let index = gEngineView.selectedIndex;
let tree = document.getElementById("engineList");
+ if (tree.hasAttribute("editing"))
+ return;
+
if (aEvent.charCode == KeyEvent.DOM_VK_SPACE) {
// Space toggles the checkbox.
let newValue = !gEngineView._engineStore.engines[index].shown;
gEngineView.setCellValue(index, tree.columns.getFirstColumn(),
newValue.toString());
}
else {
let isMac = Services.appinfo.OS == "Darwin";
--- a/browser/components/preferences/search.js
+++ b/browser/components/preferences/search.js
@@ -78,16 +78,19 @@ var gSearchPane = {
onTreeSelect: function() {
document.getElementById("removeEngineButton").disabled =
gEngineView.selectedIndex == -1 || gEngineView.lastIndex == 0;
},
onTreeKeyPress: function(aEvent) {
let index = gEngineView.selectedIndex;
let tree = document.getElementById("engineList");
+ if (tree.hasAttribute("editing"))
+ return;
+
if (aEvent.charCode == KeyEvent.DOM_VK_SPACE) {
// Space toggles the checkbox.
let newValue = !gEngineView._engineStore.engines[index].shown;
gEngineView.setCellValue(index, tree.columns.getFirstColumn(),
newValue.toString());
}
else {
let isMac = Services.appinfo.OS == "Darwin";
--- a/browser/devtools/fontinspector/test/browser_fontinspector.html
+++ b/browser/devtools/fontinspector/test/browser_fontinspector.html
@@ -1,30 +1,51 @@
<!DOCTYPE html>
<style>
@font-face {
font-family: bar;
src: url(bad/font/name.ttf), url(ostrich-regular.ttf) format("truetype");
}
@font-face {
+ font-family: barnormal;
+ font-weight: normal;
+ src: url(ostrich-regular.ttf);
+ }
+ @font-face {
+ font-family: bar;
+ font-weight: bold;
+ src: url(ostrich-black.ttf);
+ }
+ @font-face {
font-family: bar;
font-weight: 800;
src: url(ostrich-black.ttf);
}
-
body{
font-family:Arial;
+ font-size: 36px;
}
div {
font-family:Arial;
font-family:bar;
}
+ .normal-text {
+ font-family: barnormal;
+ font-weight: normal;
+ }
.bold-text {
font-family: bar;
+ font-weight: bold;
+ }
+ .black-text {
+ font-family: bar;
font-weight: 800;
}
</style>
<body>
BODY
<div>DIV</div>
+ <div class="normal-text">NORMAL DIV</div>
+ <div class="bold-text">BOLD DIV</div>
+ <div class="black-text">800 DIV</div>
</body>
--- a/browser/devtools/fontinspector/test/browser_fontinspector.js
+++ b/browser/devtools/fontinspector/test/browser_fontinspector.js
@@ -5,16 +5,29 @@ let tempScope = {};
let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
let TargetFactory = devtools.TargetFactory;
let TEST_URI = "http://mochi.test:8888/browser/browser/devtools/fontinspector/test/browser_fontinspector.html";
let view, viewDoc;
+const BASE_URI = "http://mochi.test:8888/browser/browser/devtools/fontinspector/test/"
+
+const FONTS = [
+ {name: "Ostrich Sans Medium", remote: true, url: BASE_URI + "ostrich-regular.ttf",
+ format: "truetype", cssName: "bar"},
+ {name: "Ostrich Sans Black", remote: true, url: BASE_URI + "ostrich-black.ttf",
+ format: "", cssName: "bar"},
+ {name: "Ostrich Sans Black", remote: true, url: BASE_URI + "ostrich-black.ttf",
+ format: "", cssName: "bar"},
+ {name: "Ostrich Sans Medium", remote: true, url: BASE_URI + "ostrich-regular.ttf",
+ format: "", cssName: "barnormal"},
+];
+
add_task(function*() {
yield loadTab(TEST_URI);
let {toolbox, inspector} = yield openInspector();
info("Selecting the test node");
yield selectNode("body", inspector);
let updated = inspector.once("fontinspector-updated");
@@ -34,42 +47,51 @@ add_task(function*() {
yield testShowAllFonts(inspector);
view = viewDoc = null;
});
function* testBodyFonts(inspector) {
let s = viewDoc.querySelectorAll("#all-fonts > section");
- is(s.length, 2, "Found 2 fonts");
+ is(s.length, 5, "Found 5 fonts");
- // test first web font
- is(s[0].querySelector(".font-name").textContent,
- "Ostrich Sans Medium", "font 0: Right font name");
- ok(s[0].classList.contains("is-remote"),
- "font 0: is remote");
- is(s[0].querySelector(".font-url").value,
- "http://mochi.test:8888/browser/browser/devtools/fontinspector/test/ostrich-regular.ttf",
- "font 0: right url");
- is(s[0].querySelector(".font-format").textContent,
- "truetype", "font 0: right font format");
- is(s[0].querySelector(".font-css-name").textContent,
- "bar", "font 0: right css name");
+ for (let i = 0; i < FONTS.length; i++) {
+ let section = s[i];
+ let font = FONTS[i];
+ is(section.querySelector(".font-name").textContent, font.name,
+ "font " + i + " right font name");
+ is(section.classList.contains("is-remote"), font.remote,
+ "font " + i + " remote value correct");
+ is(section.querySelector(".font-url").value, font.url,
+ "font " + i + " url correct");
+ is(section.querySelector(".font-format").hidden, !font.format,
+ "font " + i + " format hidden value correct");
+ is(section.querySelector(".font-format").textContent,
+ font.format, "font " + i + " format correct");
+ is(section.querySelector(".font-css-name").textContent,
+ font.cssName, "font " + i + " css name correct");
+ }
+
+ // test that the bold and regular fonts have different previews
+ let regSrc = s[0].querySelector(".font-preview").src;
+ let boldSrc = s[1].querySelector(".font-preview").src;
+ isnot(regSrc, boldSrc, "preview for bold font is different from regular");
// test system font
- let font2Name = s[1].querySelector(".font-name").textContent;
- let font2CssName = s[1].querySelector(".font-css-name").textContent;
+ let localFontName = s[4].querySelector(".font-name").textContent;
+ let localFontCSSName = s[4].querySelector(".font-css-name").textContent;
// On Linux test machines, the Arial font doesn't exist.
// The fallback is "Liberation Sans"
- ok((font2Name == "Arial") || (font2Name == "Liberation Sans"),
- "font 1: Right font name");
- ok(s[1].classList.contains("is-local"), "font 2: is local");
- ok((font2CssName == "Arial") || (font2CssName == "Liberation Sans"),
- "Arial", "font 2: right css name");
+ ok((localFontName == "Arial") || (localFontName == "Liberation Sans"),
+ "local font right font name");
+ ok(s[4].classList.contains("is-local"), "local font is local");
+ ok((localFontCSSName == "Arial") || (localFontCSSName == "Liberation Sans"),
+ "Arial", "local font has right css name");
}
function* testDivFonts(inspector) {
let updated = inspector.once("fontinspector-updated");
yield selectNode("div", inspector);
yield updated;
let sections1 = viewDoc.querySelectorAll("#all-fonts > section");
@@ -82,10 +104,10 @@ function* testShowAllFonts(inspector) {
info("testing showing all fonts");
let updated = inspector.once("fontinspector-updated");
viewDoc.querySelector("#showall").click();
yield updated;
is(inspector.selection.nodeFront.nodeName, "BODY", "Show all fonts selected the body node");
let sections = viewDoc.querySelectorAll("#all-fonts > section");
- is(sections.length, 2, "And font-inspector still shows 2 fonts for body");
+ is(sections.length, 5, "And font-inspector still shows 5 fonts for body");
}
--- a/browser/devtools/webide/content/prefs.xhtml
+++ b/browser/devtools/webide/content/prefs.xhtml
@@ -25,33 +25,39 @@
</div>
<h1>&prefs_title;</h1>
<h2>&prefs_general_title;</h2>
<ul>
<li>
- <label title="&prefs_options_templatesurl_tooltip;">
- <span>&prefs_options_templatesurl;</span>
- <input data-pref="devtools.webide.templatesURL"/>
- </label>
- </li>
- <li>
<label title="&prefs_options_rememberlastproject_tooltip;">
<input type="checkbox" data-pref="devtools.webide.restoreLastProject"/>
<span>&prefs_options_rememberlastproject;</span>
</label>
</li>
<li>
+ <label title="&prefs_options_autoconnectruntime_tooltip;">
+ <input type="checkbox" data-pref="devtools.webide.autoConnectRuntime"/>
+ <span>&prefs_options_autoconnectruntime;</span>
+ </label>
+ </li>
+ <li>
<label title="&prefs_options_showeditor_tooltip;">
<input type="checkbox" data-pref="devtools.webide.showProjectEditor"/>
<span>&prefs_options_showeditor;</span>
</label>
</li>
+ <li>
+ <label title="&prefs_options_templatesurl_tooltip;">
+ <span>&prefs_options_templatesurl;</span>
+ <input data-pref="devtools.webide.templatesURL"/>
+ </label>
+ </li>
</ul>
<h2>&prefs_editor_title;</h2>
<ul>
<li>
<label><span>&prefs_options_keybindings;</span>
<select data-pref="devtools.editor.keymap">
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -89,18 +89,16 @@ let UI = {
if (autoinstallFxdtAdapters) {
GetAvailableAddons().then(addons => {
addons.adapters.install();
}, console.error);
}
Services.prefs.setBoolPref("devtools.webide.autoinstallADBHelper", false);
Services.prefs.setBoolPref("devtools.webide.autoinstallFxdtAdapters", false);
- this.lastConnectedRuntime = Services.prefs.getCharPref("devtools.webide.lastConnectedRuntime");
-
if (Services.prefs.getBoolPref("devtools.webide.widget.autoinstall") &&
!Services.prefs.getBoolPref("devtools.webide.widget.enabled")) {
Services.prefs.setBoolPref("devtools.webide.widget.enabled", true);
gDevToolsBrowser.moveWebIDEWidgetInNavbar();
}
this.setupDeck();
@@ -388,20 +386,30 @@ let UI = {
this.hidePanels();
this.dismissErrorNotification();
this.connectToRuntime(r);
}, true);
}
}
},
+ get lastConnectedRuntime() {
+ return Services.prefs.getCharPref("devtools.webide.lastConnectedRuntime");
+ },
+
+ set lastConnectedRuntime(runtime) {
+ Services.prefs.setCharPref("devtools.webide.lastConnectedRuntime", runtime);
+ },
+
autoConnectRuntime: function () {
// Automatically reconnect to the previously selected runtime,
- // if available and has an ID
- if (AppManager.selectedRuntime || !this.lastConnectedRuntime) {
+ // if available and has an ID and feature is enabled
+ if (AppManager.selectedRuntime ||
+ !Services.prefs.getBoolPref("devtools.webide.autoConnectRuntime") ||
+ !this.lastConnectedRuntime) {
return;
}
let [_, type, id] = this.lastConnectedRuntime.match(/^(\w+):(.+)$/);
type = type.toLowerCase();
// Local connection is mapped to AppManager.runtimeList.other array
if (type == "local") {
@@ -410,16 +418,18 @@ let UI = {
// We support most runtimes except simulator, that needs to be manually
// launched
if (type == "usb" || type == "wifi" || type == "other") {
for (let runtime of AppManager.runtimeList[type]) {
// Some runtimes do not expose an id and don't support autoconnect (like
// remote connection)
if (runtime.id == id) {
+ // Only want one auto-connect attempt, so clear last runtime value
+ this.lastConnectedRuntime = "";
this.connectToRuntime(runtime);
}
}
}
},
connectToRuntime: function(runtime) {
let name = runtime.name;
@@ -441,18 +451,16 @@ let UI = {
saveLastConnectedRuntime: function () {
if (AppManager.selectedRuntime &&
AppManager.selectedRuntime.id !== undefined) {
this.lastConnectedRuntime = AppManager.selectedRuntime.type + ":" +
AppManager.selectedRuntime.id;
} else {
this.lastConnectedRuntime = "";
}
- Services.prefs.setCharPref("devtools.webide.lastConnectedRuntime",
- this.lastConnectedRuntime);
},
_actionsToLog: new Set(),
/**
* For each new connection, track whether play and debug were ever used. Only
* one value is collected for each button, even if they are used multiple
* times during a connection.
--- a/browser/devtools/webide/webide-prefs.js
+++ b/browser/devtools/webide/webide-prefs.js
@@ -6,16 +6,17 @@
pref("devtools.webide.showProjectEditor", true);
pref("devtools.webide.templatesURL", "https://code.cdn.mozilla.net/templates/list.json");
pref("devtools.webide.autoinstallADBHelper", true);
#ifdef MOZ_DEV_EDITION
pref("devtools.webide.autoinstallFxdtAdapters", true);
#else
pref("devtools.webide.autoinstallFxdtAdapters", false);
#endif
+pref("devtools.webide.autoConnectRuntime", true);
pref("devtools.webide.restoreLastProject", true);
pref("devtools.webide.enableLocalRuntime", false);
pref("devtools.webide.addonsURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/index.json");
pref("devtools.webide.simulatorAddonsURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/#VERSION#/#OS#/fxos_#SLASHED_VERSION#_simulator-#OS#-latest.xpi");
pref("devtools.webide.simulatorAddonID", "fxos_#SLASHED_VERSION#_simulator@mozilla.org");
pref("devtools.webide.adbAddonURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/adb-helper/#OS#/adbhelper-#OS#-latest.xpi");
pref("devtools.webide.adbAddonID", "adbhelper@mozilla.org");
pref("devtools.webide.adaptersAddonURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxdt-adapters/#OS#/fxdt-adapters-#OS#-latest.xpi");
--- a/browser/locales/en-US/chrome/browser/devtools/webide.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/webide.dtd
@@ -109,16 +109,18 @@
<!ENTITY addons_aboutaddons "Open Add-ons Manager">
<!-- Prefs -->
<!ENTITY prefs_title "Preferences">
<!ENTITY prefs_editor_title "Editor">
<!ENTITY prefs_general_title "General">
<!ENTITY prefs_restore "Restore Defaults">
<!ENTITY prefs_manage_components "Manage Extra Components">
+<!ENTITY prefs_options_autoconnectruntime "Reconnect to previous runtime">
+<!ENTITY prefs_options_autoconnectruntime_tooltip "Reconnect to previous runtime when WebIDE starts">
<!ENTITY prefs_options_rememberlastproject "Remember last project">
<!ENTITY prefs_options_rememberlastproject_tooltip "Restore previous project when WebIDE starts">
<!ENTITY prefs_options_templatesurl "Templates URL">
<!ENTITY prefs_options_templatesurl_tooltip "Index of available templates">
<!ENTITY prefs_options_showeditor "Show editor">
<!ENTITY prefs_options_showeditor_tooltip "Show internal editor">
<!ENTITY prefs_options_tabsize "Tab size">
<!ENTITY prefs_options_expandtab "Soft tabs">
--- a/browser/themes/shared/incontentprefs/preferences.inc.css
+++ b/browser/themes/shared/incontentprefs/preferences.inc.css
@@ -230,16 +230,19 @@ description > html|a {
background-color: rgba(0,0,0,0.5);
visibility: hidden;
}
#dialogBox {
background-color: #fbfbfb;
color: #424e5a;
font-size: 14px;
+ /* `transparent` will use the dialogText color in high-contrast themes and
+ when page colors are disabled */
+ border: 1px solid transparent;
border-radius: 2.5px;
box-shadow: 0 2px 4px 0 rgba(0,0,0,0.5);
display: -moz-box;
margin: 0;
padding: 0;
}
#dialogBox[resizable="true"] {
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -1645,16 +1645,17 @@ public abstract class GeckoApp
throw new SessionRestoreException("Could not read from session file");
}
// If we are doing an OOM restore, parse the session data and
// stub the restored tabs immediately. This allows the UI to be
// updated before Gecko has restored.
if (mShouldRestore) {
final JSONArray tabs = new JSONArray();
+ final JSONObject windowObject = new JSONObject();
SessionParser parser = new SessionParser() {
@Override
public void onTabRead(SessionTab sessionTab) {
JSONObject tabObject = sessionTab.getTabObject();
int flags = Tabs.LOADURL_NEW_TAB;
flags |= ((isExternalURL || !sessionTab.isSelected()) ? Tabs.LOADURL_DELAY_LOAD : 0);
flags |= (tabObject.optBoolean("desktopMode") ? Tabs.LOADURL_DESKTOP : 0);
@@ -1665,35 +1666,40 @@ public abstract class GeckoApp
try {
tabObject.put("tabId", tab.getId());
} catch (JSONException e) {
Log.e(LOGTAG, "JSON error", e);
}
tabs.put(tabObject);
}
+
+ @Override
+ public void onClosedTabsRead(final JSONArray closedTabData) throws JSONException {
+ windowObject.put("closedTabs", closedTabData);
+ }
};
if (mPrivateBrowsingSession == null) {
parser.parse(sessionString);
} else {
parser.parse(sessionString, mPrivateBrowsingSession);
}
if (tabs.length() > 0) {
- sessionString = new JSONObject().put("windows", new JSONArray().put(new JSONObject().put("tabs", tabs))).toString();
+ windowObject.put("tabs", tabs);
+ sessionString = new JSONObject().put("windows", new JSONArray().put(windowObject)).toString();
} else {
throw new SessionRestoreException("No tabs could be read from session file");
}
}
JSONObject restoreData = new JSONObject();
restoreData.put("sessionString", sessionString);
return restoreData.toString();
-
} catch (JSONException e) {
throw new SessionRestoreException(e);
}
}
@Override
public synchronized GeckoProfile getProfile() {
// fall back to default profile if we didn't load a specific one
--- a/mobile/android/base/SessionParser.java
+++ b/mobile/android/base/SessionParser.java
@@ -47,25 +47,38 @@ public abstract class SessionParser {
public JSONObject getTabObject() {
return mTabObject;
}
};
abstract public void onTabRead(SessionTab tab);
+ /**
+ * Placeholder method that must be overloaded to handle closedTabs while parsing session data.
+ *
+ * @param closedTabs, JSONArray of recently closed tab entries.
+ * @throws JSONException
+ */
+ public void onClosedTabsRead(final JSONArray closedTabs) throws JSONException{
+ }
+
public void parse(String... sessionStrings) {
final LinkedList<SessionTab> sessionTabs = new LinkedList<SessionTab>();
int totalCount = 0;
int selectedIndex = -1;
try {
for (String sessionString : sessionStrings) {
final JSONObject window = new JSONObject(sessionString).getJSONArray("windows").getJSONObject(0);
final JSONArray tabs = window.getJSONArray("tabs");
final int optSelected = window.optInt("selected", -1);
+ final JSONArray closedTabs = window.optJSONArray("closedTabs");
+ if (closedTabs != null) {
+ onClosedTabsRead(closedTabs);
+ }
for (int i = 0; i < tabs.length(); i++) {
final JSONObject tab = tabs.getJSONObject(i);
final int index = tab.getInt("index");
final JSONArray entries = tab.getJSONArray("entries");
if (index < 1 || entries.length() < index) {
Log.w(LOGTAG, "Session entries and index don't agree.");
continue;
--- a/mobile/android/base/toolbar/BrowserToolbar.java
+++ b/mobile/android/base/toolbar/BrowserToolbar.java
@@ -135,17 +135,16 @@ public abstract class BrowserToolbar ext
protected boolean hasSoftMenuButton;
protected UIMode uiMode;
protected TabHistoryController tabHistoryController;
private final Paint shadowPaint;
private final int shadowSize;
- private final LightweightTheme theme;
private final ToolbarPrefs prefs;
public abstract boolean isAnimating();
protected abstract boolean isTabsButtonOffscreen();
protected abstract void updateNavigationButtons(Tab tab);
@@ -174,17 +173,16 @@ public abstract class BrowserToolbar ext
return toolbar;
}
protected BrowserToolbar(final Context context, final AttributeSet attrs) {
super(context, attrs);
setWillNotDraw(false);
isNewTablet = NewTabletUI.isEnabled(context);
- theme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme();
// BrowserToolbar is attached to BrowserApp only.
activity = (BrowserApp) context;
// Inflate the content.
// TODO: Remove the branch when new tablet becomes old tablet.
if (!isNewTablet) {
LayoutInflater.from(context).inflate(R.layout.browser_toolbar, this);
--- a/mobile/android/base/toolbar/NavButton.java
+++ b/mobile/android/base/toolbar/NavButton.java
@@ -58,17 +58,17 @@ abstract class NavButton extends ShapedB
canvas.drawPath(mBorderPath, mBorderPaint);
}
// The drawable is constructed as per @drawable/url_bar_nav_button.
@Override
public void onLightweightThemeChanged() {
final Drawable drawable;
if (!NewTabletUI.isEnabled(getContext())) {
- drawable = mTheme.getDrawable(this);
+ drawable = getTheme().getDrawable(this);
} else {
drawable = BrowserToolbar.getLightweightThemeDrawable(this, getResources(), getTheme(),
R.color.background_normal);
}
if (drawable == null) {
return;
}
--- a/mobile/android/base/toolbar/ShapedButton.java
+++ b/mobile/android/base/toolbar/ShapedButton.java
@@ -18,24 +18,22 @@ import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff.Mode;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.StateListDrawable;
import android.util.AttributeSet;
public class ShapedButton extends ThemedImageButton
implements CanvasDelegate.DrawManager {
- protected final LightweightTheme mTheme;
protected final Path mPath;
protected final CanvasDelegate mCanvasDelegate;
public ShapedButton(Context context, AttributeSet attrs) {
super(context, attrs);
- mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme();
// Path is clipped.
mPath = new Path();
final Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setColor(getResources().getColor(R.color.canvas_delegate_paint));
paint.setStrokeWidth(0.0f);
@@ -56,17 +54,17 @@ public class ShapedButton extends Themed
public void defaultDraw(Canvas canvas) {
super.draw(canvas);
}
// The drawable is constructed as per @drawable/shaped_button.
@Override
public void onLightweightThemeChanged() {
final int background = getResources().getColor(R.color.background_tabs);
- final LightweightThemeDrawable lightWeight = mTheme.getColorDrawable(this, background);
+ final LightweightThemeDrawable lightWeight = getTheme().getColorDrawable(this, background);
if (lightWeight == null)
return;
lightWeight.setAlpha(34, 34);
final StateListDrawable stateList = new StateListDrawable();
stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.highlight_shaped));
--- a/mobile/android/components/SessionStore.js
+++ b/mobile/android/components/SessionStore.js
@@ -429,16 +429,20 @@ SessionStore.prototype = {
for (let winIndex = 0; winIndex < data.windows.length; ++winIndex) {
let win = data.windows[winIndex];
let normalWin = {};
for (let prop in win) {
normalWin[prop] = data[prop];
}
normalWin.tabs = [];
+
+ // Save normal closed tabs. Forget about private closed tabs.
+ normalWin.closedTabs = win.closedTabs.filter(tab => !tab.isPrivate);
+
normalData.windows.push(normalWin);
privateData.windows.push({ tabs: [] });
// Split the session data into private and non-private data objects.
// Non-private session data will be saved to disk, and private session
// data will be sent to Java for Android to hold it in memory.
for (let i = 0; i < win.tabs.length; ++i) {
let tab = win.tabs[i];
@@ -860,16 +864,21 @@ SessionStore.prototype = {
// Make sure the browser has its session data for the delay reload
tab.browser.__SS_data = tabData;
tab.browser.__SS_restore = true;
tab.browser.setAttribute("pending", "true");
}
tab.browser.__SS_extdata = tabData.extData;
}
+
+ // Restore the closed tabs array on the current window.
+ if (state.windows[0].closedTabs) {
+ this._windows[window.__SSID].closedTabs = state.windows[0].closedTabs;
+ }
},
getClosedTabCount: function ss_getClosedTabCount(aWindow) {
if (!aWindow || !aWindow.__SSID || !this._windows[aWindow.__SSID])
return 0; // not a browser window, or not otherwise tracked by SS.
return this._windows[aWindow.__SSID].closedTabs.length;
},
@@ -989,36 +998,19 @@ SessionStore.prototype = {
else
throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG);
},
restoreLastSession: Task.async(function* (aSessionString) {
let notifyMessage = "";
try {
- // Normally, we'll receive the session string from Java, but there are
- // cases where we may want to restore that Java cannot detect (e.g., if
- // browser.sessionstore.resume_session_once is true). In these cases, the
- // session will be read from sessionstore.bak (which is also used for
- // "tabs from last time").
- let data = aSessionString;
-
- if (data == null) {
- let bytes = yield OS.File.read(this._sessionFileBackup.path);
- data = JSON.parse(new TextDecoder().decode(bytes) || "");
- }
-
- this._restoreWindow(data);
+ this._restoreWindow(aSessionString);
} catch (e) {
- if (e instanceof OS.File.Error) {
- Cu.reportError("SessionStore: " + e.message);
- } else {
- Cu.reportError("SessionStore: " + e);
- }
-
+ Cu.reportError("SessionStore: " + e);
notifyMessage = "fail";
}
Services.obs.notifyObservers(null, "sessionstore-windows-restored", notifyMessage);
}),
removeWindow: function ss_removeWindow(aWindow) {
if (!aWindow || !aWindow.__SSID || !this._windows[aWindow.__SSID])
--- a/toolkit/devtools/server/actors/styles.js
+++ b/toolkit/devtools/server/actors/styles.js
@@ -35,16 +35,18 @@ exports.PSEUDO_ELEMENTS = PSEUDO_ELEMENT
const PSEUDO_ELEMENTS_TO_READ = PSEUDO_ELEMENTS.filter(pseudo => {
return pseudo !== ":before" && pseudo !== ":after";
});
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const FONT_PREVIEW_TEXT = "Abc";
const FONT_PREVIEW_FONT_SIZE = 40;
const FONT_PREVIEW_FILLSTYLE = "black";
+const NORMAL_FONT_WEIGHT = 400;
+const BOLD_FONT_WEIGHT = 700;
// Predeclare the domnode actor type for use in requests.
types.addActorType("domnode");
// Predeclare the domstylerule actor type
types.addActorType("domstylerule");
/**
@@ -271,34 +273,53 @@ var PageStyleActor = protocol.ActorClass
}
// If this font comes from a @font-face rule
if (font.rule) {
fontFace.rule = StyleRuleActor(this, font.rule);
fontFace.ruleText = font.rule.cssText;
}
+ // Get the weight and style of this font for the preview and sort order
+ let weight = NORMAL_FONT_WEIGHT, style = "";
+ if (font.rule) {
+ weight = font.rule.style.getPropertyValue("font-weight")
+ || NORMAL_FONT_WEIGHT;
+ if (weight == "bold") {
+ weight = BOLD_FONT_WEIGHT;
+ } else if (weight == "normal") {
+ weight = NORMAL_FONT_WEIGHT;
+ }
+ style = font.rule.style.getPropertyValue("font-style") || "";
+ }
+ fontFace.weight = weight;
+ fontFace.style = style;
+
if (options.includePreviews) {
let opts = {
previewText: options.previewText,
previewFontSize: options.previewFontSize,
+ fontStyle: weight + " " + style,
fillStyle: options.previewFillStyle
}
let { dataURL, size } = getFontPreviewData(font.CSSFamilyName,
contentDocument, opts);
fontFace.preview = {
data: LongStringActor(this.conn, dataURL),
size: size
};
}
fontsArray.push(fontFace);
}
// @font-face fonts at the top, then alphabetically, then by weight
fontsArray.sort(function(a, b) {
+ return a.weight > b.weight ? 1 : -1;
+ });
+ fontsArray.sort(function(a, b) {
if (a.CSSFamilyName == b.CSSFamilyName) {
return 0;
}
return a.CSSFamilyName > b.CSSFamilyName ? 1 : -1;
});
fontsArray.sort(function(a, b) {
if ((a.rule && b.rule) || (!a.rule && !b.rule)) {
return 0;
--- a/toolkit/mozapps/extensions/internal/AddonRepository.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonRepository.jsm
@@ -839,16 +839,21 @@ this.AddonRepository = {
if (result == null)
continue;
// Ignore add-on if it wasn't actually requested
let idIndex = ids.indexOf(result.addon.id);
if (idIndex == -1)
continue;
+ // Ignore add-on if the add-on manager doesn't know about its type:
+ if (!(result.addon.type in AddonManager.addonTypes)) {
+ continue;
+ }
+
results.push(result);
// Ignore this add-on from now on
ids.splice(idIndex, 1);
}
// Include any compatibility overrides for addons not hosted by the
// remote repository.
for each (let addonCompat in aCompatData) {
@@ -1084,20 +1089,18 @@ this.AddonRepository = {
break;
case 3:
addon.type = "dictionary";
break;
case 4:
addon.type = "search";
break;
case 5:
- addon.type = "langpack";
- break;
case 6:
- addon.type = "langpack-addon";
+ addon.type = "locale";
break;
case 7:
addon.type = "plugin";
break;
case 8:
addon.type = "api";
break;
case 9:
@@ -1298,16 +1301,20 @@ this.AddonRepository = {
if (result == null)
continue;
// Ignore add-on missing a required attribute
let requiredAttributes = ["id", "name", "version", "type", "creator"];
if (requiredAttributes.some(function parseAddons_attributeFilter(aAttribute) !result.addon[aAttribute]))
continue;
+ // Ignore add-on with a type AddonManager doesn't understand:
+ if (!(result.addon.type in AddonManager.addonTypes))
+ continue;
+
// Add only if the add-on is compatible with the platform
if (!result.addon.isPlatformCompatible)
continue;
// Add only if there was an xpi compatible with this OS or there was a
// way to purchase the add-on
if (!result.xpiURL && !result.addon.purchaseURL)
continue;
--- a/toolkit/mozapps/extensions/test/browser/browser_searching.xml
+++ b/toolkit/mozapps/extensions/test/browser/browser_searching.xml
@@ -28,16 +28,47 @@
<max_version>*</max_version>
</application>
</compatible_applications>
<compatible_os>ALL</compatible_os>
<install size="1">http://example.com/addon1.xpi</install>
</addon>
<addon>
<name>FAIL</name>
+ <type id='9'>lightweight theme</type>
+ <guid>addon12345@tests.mozilla.org</guid>
+ <version>1.0</version>
+ <authors>
+ <author>
+ <name>Test Creator</name>
+ <link>http://example.com/creator.html</link>
+ </author>
+ </authors>
+ <status id='4'>Public</status>
+ <summary>Addon with uninstallable type shouldn't be visible in search</summary>
+ <description>Test description</description>
+ <compatible_applications>
+ <application>
+ <name>Firefox</name>
+ <appID>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</appID>
+ <min_version>0</min_version>
+ <max_version>*</max_version>
+ </application>
+ <application>
+ <name>SeaMonkey</name>
+ <appID>{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}</appID>
+ <min_version>0</min_version>
+ <max_version>*</max_version>
+ </application>
+ </compatible_applications>
+ <compatible_os>ALL</compatible_os>
+ <install size="1">http://example.com/addon1.xpi</install>
+ </addon>
+ <addon>
+ <name>FAIL</name>
<type id='1'>Extension</type>
<guid>install1@tests.mozilla.org</guid>
<version>1.0</version>
<authors>
<author>
<name>Test Creator</name>
<link>http://example.com/creator.html</link>
</author>
--- a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.xml
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.xml
@@ -93,17 +93,17 @@
<weekly_downloads>3332</weekly_downloads>
<daily_users>4442</daily_users>
<last_updated epoch="9">1970-01-01T00:00:09Z</last_updated>
<install size="9">http://localhost:4444/repo/2/install.xpi</install>
</addon>
<addon>
<name>Repo Add-on 3</name>
- <type id="9999">Unknown</type>
+ <type id="2">Theme</type>
<guid>test_AddonRepository_3@tests.mozilla.org</guid>
<version>2.3</version>
<icon size="32">http://localhost/repo/3/icon.png</icon>
<previews>
<preview primary="1">
<full type="image/png">http://localhost:4444/repo/3/firstFull.png</full>
<thumbnail type="image/png">http://localhost:4444/repo/3/firstThumbnail.png</thumbnail>
<caption>Repo Add-on 3 - First Caption</caption>
--- a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.xml
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.xml
@@ -164,17 +164,17 @@
</addon>
<!-- Passes even though guid matches already installed add-on,
type is unknown, no defined author elements, status is not Public,
no compatible applications matched, no installs compatible with OS
-->
<addon>
<name>PASS</name>
- <type id="9999">Unknown</type>
+ <type id="2">Theme</type>
<guid>test_AddonRepository_1@tests.mozilla.org</guid>
<version>1.4</version>
<status id="9999">Unknown</status>
<compatible_applications>
<application>
<appID>unknown@tests.mozilla.org</appID>
<min_version>1</min_version>
<max_version>1</max_version>
--- a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js
@@ -100,16 +100,17 @@ var GET_RESULTS = [{
minVersion: 0.2,
maxVersion: 0.3,
appID: "xpcshell@tests.mozilla.org",
appMinVersion: 5.0,
appMaxVersion: 6.0
}]
}, {
id: "test_AddonRepository_1@tests.mozilla.org",
+ type: "theme",
version: "1.4",
repositoryStatus: 9999,
icons: {}
}];
// Results of retrieveRecommendedAddons and searchAddons
var SEARCH_RESULTS = [{
id: "test1@tests.mozilla.org",
--- a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js
@@ -147,16 +147,17 @@ const REPOSITORY_ADDONS = [{
reviewURL: BASE_URL + "/repo/2/review.html",
totalDownloads: 2222,
weeklyDownloads: 3332,
dailyUsers: 4442,
sourceURI: BASE_URL + "/repo/2/install.xpi",
repositoryStatus: 9
}, {
id: ADDON_IDS[2],
+ type: "theme",
name: "Repo Add-on 3",
version: "2.3",
iconURL: BASE_URL + "/repo/3/icon.png",
icons: { "32": BASE_URL + "/repo/3/icon.png" },
screenshots: [{
url: BASE_URL + "/repo/3/firstFull.png",
thumbnailURL: BASE_URL + "/repo/3/firstThumbnail.png",
caption: "Repo Add-on 3 - First Caption"