import changes from `dev' branch of rmottola/Arctic-Fox:

- Bug 1193050 - Update the copy of GentiumPlus used in font-inspector test. r=jdaggett (253fce5764)
- align tests (5b817116a0)
- Bug 1246106 - pass aStyleBorder as reference to ComputeBorderOverflow. r=mats (53ccaa1282)
- align tests (81b3943058)
- Bug 1269080 - Convert NS_ERROR to NS_WARNING when setting an invalid scheme in nsSimpleURI. r=mcmanus (cad86c963d)
- Bug 1248558 - "[Static Analysis][Unintentional integer overflow][CacheFile.cpp::PreloadChunk, CacheFile.cpp::GetChunkLocked]". r=michal (6a9e34dfbe)
- Bug 1121672 - Make CacheFile::IsDoomed() protect mHandle with lock, r=michal (9e925b903a)
- Bug 1253040 - Do not write metadata if CacheFile is killed, r=honzab (c0378088b2)
- grammar as 1268922 (c801618b1d)
- Bug 1247548 - Changed nsCookieService::EnsureReadComplete and nsCookieService::PurgeCookeis to allocate nsTArray instead of AutoTArray. r=jdm (0e2bb0465e)
- var-let (1702e664dc)
- Bug 1233813 - Fix mDNS bug in resolving services with no TXT records. r=schien (4ce7e5fca0)
- Bug 1266632 - Add a null check in nsHostResolver::SizeOfIncludingThis. r=sworkman. (035d4a7130)
- Bug 1267000 - null deref with spdy proxy r=hurley (099db40d0f)
- Bug 1240932 - figure out 'network id' on Linux. r=mcmanus (47ceb12a70)
- Bug 1240932: add Telemetry to record network id successes, r=mcmanus (6985d1a4ce)
- Bug 1205158 - Use channel->Open2() netwerk/test/ (r=sicking) (64de01cda3)
- Bug 1240932 - figure out 'network id' on OS X, r=mcmanus (7ee4875835)
- Bug 1240932 - figure out 'network id' on Windows, r=mcmanus (3227a81c14)
- Bug 1188644 - Use channel->ascynOpen2 in netwerk/test (r=mcmanus) (7097897cdc)
- Bug 1264887: |nsWifiScannerDBus|: Hold DBusMessage references in |RefPtr|, r=josh (7e68e8663f)
- Bug 1260718 - use plain promise in CustomizableUI.jsm and ScrollbarSampler.jsm; r=Gijs (275c4f9efa)
- Bug 1254268 - Distribution should use default prefs, regression from 1252466; r=mconnor (79503a338d)
- Bug 1239949 - Add a pref for showing bookmarks toolbar and menubar; r=mixedpuppy (a2538235e6)
- Bug 1261657 - Don't record SSTabRestored events in StartupPerformance that are the result of a remoteness flip. r=Yoric (1016be766a)
- Bug 1170166, add a capture flag to BrowserTestUtils.waitForEvent, r=paolo (639c68fd65)
- Bug 1245723 - Make browser_bug680727 e10s compatible. r=felipe (6f29088ba2)
- Bug 1233497 - Allow CPOW usage in BrowserTestUtils testing module. r=felipe (29f9ca39d2)
- Bug 1243928 - Make these tests work in e10s. r=felipe dd (2fe22b5802)
- Bug 1147700 - Part 1: Add aDragEvent parameter to ChromeUtils.synthesizeDrop to modify DragEvent, and set default position to center of destElement. r=enndeakin (e8fb568796)
- Bug 1196638 - Infer the proper button for synthesized mouse clicks on all callsites. r=jmaher (838230baaa)
- Bug 1241672 - Add synthesizeWheelAtPoint to avoid styling flush. r=smaug (35a1bff9f9)
- Bug 1245153 - Make EventUtils.js use aWindow argument for sub-calls consistently; r=jmaher (ee20272638)
- Bug 1093153: introduce 'BrowserTestUtils#synthesizeKey', 'BrowserTestUtils#synthesizeComposition' and 'BrowserTestUtils#synthesizeCompositionChange' to allow mochitests to remotely invoke EventUtils' text composition utilities. Changes to EventUtils include 1) removed dependency on the 'navigator' object when 'nsIXULRuntime' is available and 2) make '_getKeyboardEvent' more robust when used in frame scripts. r=Enn (010ff131a7)
- Bug 1267289 - add more URL bar tests and fix issue with error pages, r=mikedeboer,mconley (2fa5b4cd18)
- more hack since CustomizableUI isn't loaded (482d4c7054)
- add PanelFrame (dbc45cdfde)
- add ContentObservers (f697c17a3f)
- add some CustomizableUI stuff (7b352953a8)
- Bug 1260148 - Make sendWheelAndPaint wait for the system wheel event. r=masayuki (89de140823)
- Bug 1258532 - Port test_bug574596.html to mochitest-plain in order to enable it on e10s; r=mrbkap (ae5693ab54)
- no more shumway (4ec4c5929d)
- Bug 1208145 - Clear XUL persistence data for passwordManager.xul passwordCol.hidden. r=dolske (286326c6d4)
- Bug 1252855 - allow setting a specific list of prefs from the content process, r=mrbkap,margaret,haik (b13645826a)
- Bug 1260718 - use plain promise in panelUI.js; r=Gijs (5b2d20e6d8)
- Bug 1256085 - fix hamburger menu losing pressed state when a context menu inside the panel closes, r=jaws (8074b072f3)
- de-palemoonize slwo startup URL for now (22cd2896f1)
- minor inits, but PanelUI still doesn't init (0a982d67f4)
- Bug 690307 - make trimURL not generate URLs that parse back into search queries, r=mak (9d488702f4)
- Bug 1094179 - use uriFixup for trimURL, r=dao (7a5e6ea7c8)
- Bug 1254503 - ignore exceptions from trying to fix up obviously broken URIs, r=mak (8a7b5e08dc)
- Bug 1240169 - Revert to returning a dynamic newtab URL for BROWSER_NEW_TAB_URL r=mconley (f9426cd705)
- small bits (f667b5b40a)
- Bug 1207490 - Part 14: Remove use of expression closure from browser/modules/WindowsJumpLists.jsm. r=Gijs (f5ffeb762d)
- Bug 957585 - Make jumplists listen for "active" idle topic instead of the defunct "back". r=jimm (21faffc952)
- bug 1223573 and other move loop (b6f11f9b65)
- remove fuel (d231f1c07f)
- package some stuff that is there and wasn't (3df3a2461d)
- Bug 1249845 - bootstrap.js code to manage the e10s staged rollout. r=Mossop (93fdf6ddd6)
- Bug 1255013 - Tag disqualified cohorts in e10s staged rollout. r=Mossop (2a1bf3eb57)
- Bug 1257251 - Annotate e10s cohort in crash report. r=Mossop (eaa77c89bd)
- Bug 1257972 - Put users currently running an experiment into the disqualified cohort of the e10s rollout system add-on. r=Mossop (63cfa4e9bd)
- Bug 1257972 - Follow up, use getActiveExperimentID instead. r=me (062b8db4b0)
- Bug 1264345 - Remove restriction to disqualify from e10s the users who are running an experiment, by backing out bug 1257972. r=backout (ca4edc1985)
- Bug 1264437 - Manage browser.tabs.remote.autostart.2 pref even for disqualified users. r=rvitillo (20f34e41b6)
- Bug 1268921 - Allow non-integer values to be specified for e10srollout cohort samples. r=mconley (5d084d8a4c)
- Bug 1249845 - Set up structure for e10srollout system add-on. r=Standard8,glandium (653ffeb6f9)
- Bug 1268197 - stub for webcompat fix system add-on r=kmag (a6f25edf7e)
- missing pocket locales (453efccf16)
- Bug 1215965 - Remove use of non-standard features from toolkit/components/social/SocialService.jsm. r=mixedpuppy (a1d79f295c)
- Bug 898706 remove frameworker and all associated code and tests, r=markh (7dd5241c68)
- ship cmpiler for XP (ac60884173)
- convenience file (fedc58984f)
- Bug 1261045: remove unused MOZ_SYMBOLS_EXTRA_BUILDID; r=ted.mielczarek (96c4acfc47)
- Bug 1262814 - Warn on Wlanapi.dll load or init fail, don't fail assertion, r=mcmanus (aa1335320a)
- Bug 1137151: Marked destructors of ref-counted time-zone classes as protected, r=dhylands (5668226442)
- Bug 908038 - Move worker_buf.js installation into moz.build; r=mshal (5977402229)
- Bug 1225549 - Modify the data type of mThreshold of NetworkParams to long long from long. r=ettseng (7164a517ac)
- Bug 1139805 - B2G NetworkManager: move NetworkManager.js out of EXTRA_PP_COMPONENTS in moz.build. r=echen (e857f53cfc)
- Bug 1256246 - Improve prompt for loading an add-on from directory. r=ochameau (e4eaf98420)
- Bug 1231128 - Display errors when temporary add-on install fails. r=ochameau (7866ee6157)
- Bug 1249088 - Add eslint rules for React. r=pbrosset (43ccb89844)
- Bug 1264063 - 1 - Disable prop-types eslint rule and fix sort-comp errors; r=ochameau (a765af076a)
- Bug 1264063 - 2 - Make the CPOW rule log errors and ignore ContentTask.spawn; r=Mossop (d5a6de53ba)
- Bug 1217769 - aboutdebugging: default to addons tab without navigating if hash is empty;r=janx (d821504376)
- Bug 1229859 - Massively reduce the number of eslint errors in devtools by ignoring lib files, adding missing .eslintrc files and making some rules be warnings; r=Mossop (e6fdb3043a)
- Bug 1210778 - improving accessibility for about:debugging. r=janx (e9d1c80835)
- Bug 1087608 - ensuring multitap gestures do not resolve to explore. r=eeejay (6c4b603738)
- bug 1259023 - make nsIAccessible.{Next,Prev}Sibling work with proxied accessibles r=yzen (84b47a82e0)
- bug 1259023 - make nsIAccessible.indexInParent work on proxied accessibles r=yzen (3531b45e35)
- bug 1250882 - implement xpcAccessible::GetState() for proxied accessibles r=davidb (5e58214441)
- bug 1250882 - implement xpcAccessible::Name() for proxied accessibles r=davidb (4b11af6ecc)
- bug 1250882 - implement xpcAccessible::GetDescription() for proxied accessibles r=davidb (d0d0e133fb)
- bug 1250882 - implement xpcAccessible::GetLanguage() for proxied accessibles r=davidb (88e98a5b24)
- bug 1250882 - implement xpcAccessible::GetValue() for proxied accessibles r=davidb (9ad4347d45)
- bug 1250882 - implement xpcAccessible::GetBounds() for proxied accessibles r=davidb (e67f7c551b)
- Bug 1249930 - menupoup shouldn't look for children in XBL anonymous content, r=davidb (14b3e39954)
- Bug 1261473 - Remove INSTALL_TARGETS from addon-sdk/Makefile.in; r=chmanchester (8a65454da5)
- Bug 1148200 - Move isJSONable to sdk/lang/type. r=Mossop (df9e4a3e64)
- Bug 1026614 - SDK ui/toolbar not working in permanent private browsing. r=rpl (589c0eb203)
- Bug 1263077 - Stop using parseInt in the Add-on SDK when specifying octal numbers now that JS has the new octal notation. r=dietrich (9a82b90c79)
- Bug 1225800 - only import items that have valid URLs, r=MattN (b1609de6b5)
- Bug 1194692 - fix some cookie parsing issues in the IE/Edge cookie importer code, r=MattN (ce08d5a913)
- Bug 1255526 - fix import of typed URLs on versions of windows that do not store timestamps, r=MattN (abff778842)
- Bug 1236551 - Update the profile migrator to the Unified Telemetry changes. r=MattN (3fc75f60c8)
- Bug 1239085 - Remove Deprecation warning from NewTabUrl.jsm (r=olivier) (ef09dd61fd)
- Bug 1201977 - Replace usage of nsINavHistoryQuery with a string query to nsPIPlacesDatabase.asyncStatement r=oyiptong (105178d138)
- Bug 1258728 - remote newtab path starts with /newtab/ r=ursula (575bccc434)
- Bug 1262781 - Use the host compiler for the ASTMatcher check. r=froydnj (d5b04fc0f3)
- Followup for bug 1262781: change ASTMatcher check name to force override config.cache on a CLOSED TREE. r=me (7cda382eac)
- Bug 1268617 - Pass -g to rustc on debug builds. r=ted (f280e86566)
- Bug 1243233 - Test ALLOW_COMPILER_WARNINGS instead of WARNINGS_AS_ERRORS, and move it to after it is set, r=glandium (216c407d20)
- Bug 1245992 - Update the Safe Browsing phishing interstitial page. r=flod,past (80afbfc6c9)
- fix some elements (dac19cdc1a)
- Bug 1245260 - Ignore redundant calls to RestyleManager::IncrementAnimationGeneration; r=dbaron (e35571b613)
- bump overrides (90b8e83afd)
- Bug 1182778, r=margaret,f=bz (b379c97f8e)
- Bug 1244887: Fixing userContext label on awesomebar, r=baku (063a8e8263)
- Bug 1255499 - Remove SEC_NORMAL from browser/. r=sicking (12a374232b)
- Bug 1258212 - Hook the parent up to the cild for registerContentHandler. r=gwright (039c75d4d5)
- Bug 1233895 - Make Feeds.jsm properly handle principal origin attributes when loading subresources. r=sicking (6b4544effe)
- Bug 1234398 - declare |item|. r=dolske (fa9a5ff39c)
- Bug 1244684 - Make FX_TAB_SWITCH_TOTAL_MS work for non-e10s in an OMTC world. r=mstange (05cb04c474)
- Bug 1261738: Try to avoid overlapping FX_TAB_SWITCH_TOTAL_MS stopwatches. r=mconley (5d4bae300c)
- Bug 747338 - Set last-accessed timestamp when deselecting tabs rather than when selecting them. r=ttaubert (a1514ad247)
- Bug 1248302 - We should not show any decoration for tab with usercontextid=0, r=gijs (29c5addab7)
- specific google font overrides (03db96e884)
- add bits of CustomizableUI (8a67508a56)
- add some more customizeui but keep the button hidden, or it messes up the toolbar (b9787fc5bd)
This commit is contained in:
2024-10-01 10:16:10 +08:00
parent 157a125630
commit 4a6821ad9b
785 changed files with 170067 additions and 24701 deletions
+14
View File
@@ -0,0 +1,14 @@
# This Makefile is used as a shim to aid people with muscle memory
# so that they can type "make".
#
# This file and all of its targets should not be used by anything important.
all: build
build:
./mach build
clean:
./mach clobber
.PHONY: all build clean
+1 -5
View File
@@ -288,12 +288,8 @@ SYM_STORE_SOURCE_DIRS := $(topsrcdir)
include $(topsrcdir)/toolkit/mozapps/installer/package-name.mk
ifdef MOZ_SYMBOLS_EXTRA_BUILDID
EXTRA_BUILDID := -$(MOZ_SYMBOLS_EXTRA_BUILDID)
endif
SYMBOL_INDEX_NAME = \
$(MOZ_APP_NAME)-$(MOZ_APP_VERSION)-$(OS_TARGET)-$(BUILDID)-$(CPU_ARCH)$(EXTRA_BUILDID)-symbols.txt
$(MOZ_APP_NAME)-$(MOZ_APP_VERSION)-$(OS_TARGET)-$(BUILDID)-$(CPU_ARCH)-symbols.txt
buildsymbols:
ifdef MOZ_CRASHREPORTER
+9 -10
View File
@@ -13,10 +13,8 @@
DoubleTap -> TripleTap (x)
-> TapHold (x)
-> Explore (x)
TripleTap -> DoubleTapHold (x)
-> Explore (x)
Dwell -> DwellEnd (v)
@@ -39,7 +37,6 @@
'use strict';
const Ci = Components.interfaces;
const Cu = Components.utils;
this.EXPORTED_SYMBOLS = ['GestureSettings', 'GestureTracker']; // jshint ignore:line
@@ -73,8 +70,6 @@ const TAP_MAX_RADIUS = 0.2;
// Directness coefficient. It is based on the maximum 15 degree angle between
// consequent pointer move lines.
const DIRECTNESS_COEFF = 1.44;
// The virtual touch ID generated by a mouse event.
const MOUSE_ID = 'mouse';
// Amount in inches from the edges of the screen for it to be an edge swipe
const EDGE = 0.1;
// Multiply timeouts by this constant, x2 works great too for slower users.
@@ -211,7 +206,6 @@ this.GestureTracker = { // jshint ignore:line
if (aDetail.type !== 'pointerdown') {
return;
}
let points = aDetail.points;
let GestureConstructor = aGesture || (IS_ANDROID ? DoubleTap : Tap);
this._create(GestureConstructor);
this._update(aDetail, aTimeStamp);
@@ -270,6 +264,7 @@ this.GestureTracker = { // jshint ignore:line
this._create(gestureType, current.startTime, current.points,
current.lastEvent);
} else {
this.current.clearTimer();
delete this.current;
}
}
@@ -608,6 +603,9 @@ TravelGesture.prototype = Object.create(Gesture.prototype);
* this._travelTo gesture iff at least one point crosses this._threshold.
*/
TravelGesture.prototype.test = function TravelGesture_test() {
if (!this._travelTo) {
return;
}
for (let identifier in this.points) {
let point = this.points[identifier];
if (point.totalDistanceTraveled / Utils.dpi > this._threshold) {
@@ -680,10 +678,10 @@ DoubleTapHoldEnd.prototype.type = 'doubletapholdend';
* @param {Function} aRejectToOnWait A constructor for the next gesture to
* reject to in case no pointermove or pointerup happens within the
* GestureSettings.dwellThreshold.
* @param {Function} aRejectToOnPointerDown A constructor for the gesture to
* reject to if a finger comes down immediately after the tap.
* @param {Function} aTravelTo An optional constuctor for the next gesture to
* reject to in case the the TravelGesture test fails.
* @param {Function} aRejectToOnPointerDown A constructor for the gesture to
* reject to if a finger comes down immediately after the tap.
*/
function TapGesture(aTimeStamp, aPoints, aLastEvent, aRejectToOnWait, aTravelTo, aRejectToOnPointerDown) {
this._rejectToOnWait = aRejectToOnWait;
@@ -722,11 +720,12 @@ TapGesture.prototype.pointerup = function TapGesture_pointerup(aPoints) {
};
TapGesture.prototype.pointerdown = function TapGesture_pointerdown(aPoints, aTimeStamp) {
TravelGesture.prototype.pointerdown.call(this, aPoints, aTimeStamp);
if (this._pointerUpTimer) {
clearTimeout(this._pointerUpTimer);
delete this._pointerUpTimer;
this._deferred.reject(this._rejectToOnPointerDown);
} else {
TravelGesture.prototype.pointerdown.call(this, aPoints, aTimeStamp);
}
};
@@ -771,7 +770,7 @@ DoubleTap.prototype.type = 'doubletap';
*/
function TripleTap(aTimeStamp, aPoints, aLastEvent) {
this._inProgress = true;
TapGesture.call(this, aTimeStamp, aPoints, aLastEvent, DoubleTapHold);
TapGesture.call(this, aTimeStamp, aPoints, aLastEvent, DoubleTapHold, null, null);
}
TripleTap.prototype = Object.create(TapGesture.prototype);
+68 -25
View File
@@ -41,12 +41,17 @@ xpcAccessible::GetNextSibling(nsIAccessible** aNextSibling)
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
if (!Intl())
return NS_ERROR_FAILURE;
if (IntlGeneric().IsAccessible()) {
nsresult rv = NS_OK;
NS_IF_ADDREF(*aNextSibling = ToXPC(Intl()->GetSiblingAtOffset(1, &rv)));
return rv;
}
nsresult rv = NS_OK;
NS_IF_ADDREF(*aNextSibling = ToXPC(Intl()->GetSiblingAtOffset(1, &rv)));
return rv;
ProxyAccessible* proxy = IntlGeneric().AsProxy();
NS_ENSURE_STATE(proxy);
NS_IF_ADDREF(*aNextSibling = ToXPC(proxy->NextSibling()));
return *aNextSibling ? NS_OK : NS_ERROR_FAILURE;
}
NS_IMETHODIMP
@@ -57,12 +62,17 @@ xpcAccessible::GetPreviousSibling(nsIAccessible** aPreviousSibling)
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
if (!Intl())
return NS_ERROR_FAILURE;
if (IntlGeneric().IsAccessible()) {
nsresult rv = NS_OK;
NS_IF_ADDREF(*aPreviousSibling = ToXPC(Intl()->GetSiblingAtOffset(-1, &rv)));
return rv;
}
nsresult rv = NS_OK;
NS_IF_ADDREF(*aPreviousSibling = ToXPC(Intl()->GetSiblingAtOffset(-1, &rv)));
return rv;
ProxyAccessible* proxy = IntlGeneric().AsProxy();
NS_ENSURE_STATE(proxy);
NS_IF_ADDREF(*aPreviousSibling = ToXPC(proxy->PrevSibling()));
return *aPreviousSibling ? NS_OK : NS_ERROR_FAILURE;
}
NS_IMETHODIMP
@@ -157,10 +167,12 @@ xpcAccessible::GetIndexInParent(int32_t* aIndexInParent)
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
if (!Intl())
return NS_ERROR_FAILURE;
if (IntlGeneric().IsAccessible()) {
*aIndexInParent = Intl()->IndexInParent();
} else if (IntlGeneric().IsProxy()) {
*aIndexInParent = IntlGeneric().AsProxy()->IndexInParent();
}
*aIndexInParent = Intl()->IndexInParent();
return *aIndexInParent != -1 ? NS_OK : NS_ERROR_FAILURE;
}
@@ -239,10 +251,13 @@ xpcAccessible::GetState(uint32_t* aState, uint32_t* aExtraState)
{
NS_ENSURE_ARG_POINTER(aState);
if (!Intl())
if (IntlGeneric().IsNull())
nsAccUtils::To32States(states::DEFUNCT, aState, aExtraState);
else
else if (Intl())
nsAccUtils::To32States(Intl()->State(), aState, aExtraState);
else
nsAccUtils::To32States(IntlGeneric().AsProxy()->State(), aState,
aExtraState);
return NS_OK;
}
@@ -252,11 +267,16 @@ xpcAccessible::GetName(nsAString& aName)
{
aName.Truncate();
if (!Intl())
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
nsAutoString name;
Intl()->Name(name);
if (ProxyAccessible* proxy = IntlGeneric().AsProxy()) {
proxy->Name(name);
} else {
Intl()->Name(name);
}
aName.Assign(name);
return NS_OK;
@@ -265,11 +285,16 @@ xpcAccessible::GetName(nsAString& aName)
NS_IMETHODIMP
xpcAccessible::GetDescription(nsAString& aDescription)
{
if (!Intl())
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
nsAutoString desc;
Intl()->Description(desc);
if (ProxyAccessible* proxy = IntlGeneric().AsProxy()) {
proxy->Description(desc);
} else {
Intl()->Description(desc);
}
aDescription.Assign(desc);
return NS_OK;
@@ -278,21 +303,33 @@ xpcAccessible::GetDescription(nsAString& aDescription)
NS_IMETHODIMP
xpcAccessible::GetLanguage(nsAString& aLanguage)
{
if (!Intl())
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
Intl()->Language(aLanguage);
nsAutoString lang;
if (ProxyAccessible* proxy = IntlGeneric().AsProxy()) {
proxy->Language(lang);
} else {
Intl()->Language(lang);
}
aLanguage.Assign(lang);
return NS_OK;
}
NS_IMETHODIMP
xpcAccessible::GetValue(nsAString& aValue)
{
if (!Intl())
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
nsAutoString value;
Intl()->Value(value);
if (ProxyAccessible* proxy = IntlGeneric().AsProxy()) {
proxy->Value(value);
} else {
Intl()->Value(value);
}
aValue.Assign(value);
return NS_OK;
@@ -379,10 +416,16 @@ xpcAccessible::GetBounds(int32_t* aX, int32_t* aY,
NS_ENSURE_ARG_POINTER(aHeight);
*aHeight = 0;
if (!Intl())
if (IntlGeneric().IsNull())
return NS_ERROR_FAILURE;
nsIntRect rect = Intl()->Bounds();
nsIntRect rect;
if (Accessible* acc = IntlGeneric().AsAccessible()) {
rect = acc->Bounds();
} else {
rect = IntlGeneric().AsProxy()->Bounds();
}
*aX = rect.x;
*aY = rect.y;
*aWidth = rect.width;
+2
View File
@@ -407,6 +407,8 @@ XULMenupopupAccessible::
mSelectControl = do_QueryInterface(mContent->GetFlattenedTreeParent());
if (!mSelectControl)
mGenericTypes &= ~eSelect;
mStateFlags |= eNoXBLKids;
}
uint64_t
-11
View File
@@ -4,15 +4,6 @@
TESTADDONS = source/test/addons
ADDONSRC = $(srcdir)/$(TESTADDONS)
TESTROOT = $(CURDIR)/$(DEPTH)/_tests/testing/mochitest/jetpack-addon/$(relativesrcdir)/$(TESTADDONS)
# Build a list of the test add-ons
ADDONS = $(patsubst $(ADDONSRC)/%/package.json,$(TESTADDONS)/%.xpi,$(wildcard $(ADDONSRC)/*/package.json))
INSTALL_TARGETS += test_addons
test_addons_FILES = $(ADDONS)
test_addons_DEST = $(TESTROOT)
test_addons_TARGET := misc
sinclude $(topsrcdir)/config/rules.mk
@@ -20,8 +11,6 @@ sinclude $(topsrcdir)/config/rules.mk
$(TESTADDONS)/%.xpi: FORCE $(call mkdir_deps,$(CURDIR)/$(TESTADDONS)) $(ADDONSRC)/%
$(PYTHON) $(srcdir)/source/bin/cfx xpi --no-strip-xpi --pkgdir=$(lastword $^) --output-file=$@
#libs:: $(ADDONS)
TEST_FILES = \
$(srcdir)/source/app-extension \
$(srcdir)/source/bin \
+49
View File
@@ -16,6 +16,55 @@ BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
JETPACK_PACKAGE_MANIFESTS += ['source/test/jetpack-package.ini']
JETPACK_ADDON_MANIFESTS += ['source/test/addons/jetpack-addon.ini']
addons = [
'addon-manager',
'author-email',
'child_process',
'chrome',
'content-permissions',
'contributors',
'curly-id',
'developers',
'e10s-content',
'e10s-l10n',
'e10s-remote',
'e10s-tabs',
'e10s',
'l10n-properties',
'l10n',
'layout-change',
'main',
'name-in-numbers-plus',
'name-in-numbers',
'packaging',
'packed',
'page-mod-debugger-post',
'page-mod-debugger-pre',
'page-worker',
'places',
'predefined-id-with-at',
'preferences-branch',
'private-browsing-supported',
'remote',
'require',
'self',
'simple-prefs-l10n',
'simple-prefs-regression',
'simple-prefs',
'standard-id',
'tab-close-on-startup',
'toolkit-require-reload',
'translators',
'unsafe-content-script',
]
addons = ['source/test/addons/%s.xpi' % f for f in addons]
GENERATED_FILES += addons
TEST_HARNESS_FILES.testing.mochitest['jetpack-addon']['addon-sdk'].source.test.addons += [
'!%s' % f for f in addons
]
EXTRA_JS_MODULES.sdk += [
'source/app-extension/bootstrap.js',
]
+1 -11
View File
@@ -9,20 +9,10 @@ module.metadata = {
const { isValidURI, isLocalURL, URL } = require('../url');
const { contract } = require('../util/contract');
const { isString, isNil, instanceOf } = require('../lang/type');
const { isString, isNil, instanceOf, isJSONable } = require('../lang/type');
const { validateOptions,
string, array, object, either, required } = require('../deprecated/api-utils');
const isJSONable = (value) => {
try {
JSON.parse(JSON.stringify(value));
}
catch (e) {
return false;
}
return true;
};
const isValidScriptFile = (value) =>
(isString(value) || instanceOf(value, URL)) && isLocalURL(value);
+1 -1
View File
@@ -17,7 +17,7 @@ const { Sequence, seq, filter, object, pairs } = require("../util/sequence");
// When iterated over belowe sequences items will represent
// state of windows at the time of iteration.
const opened = seq(function*() {
const items = windows("navigator:browser", {includePrivates: true});
const items = windows("navigator:browser", {includePrivate: true});
for (let item of items) {
yield [getOuterId(item), item];
}
+2 -2
View File
@@ -142,7 +142,7 @@ exports.open = function open(filename, mode) {
var openFlags = OPEN_FLAGS.WRONLY |
OPEN_FLAGS.CREATE_FILE |
OPEN_FLAGS.TRUNCATE;
var permFlags = parseInt("0644", 8); // u+rw go+r
var permFlags = 0o644; // u+rw go+r
try {
stream.init(file, openFlags, permFlags, 0);
}
@@ -178,7 +178,7 @@ exports.remove = function remove(path) {
exports.mkpath = function mkpath(path) {
var file = MozFile(path);
if (!file.exists())
file.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8)); // u+rwx go+rx
file.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); // u+rwx go+rx
else if (!file.isDirectory())
throw new Error("The path already exists and is not a directory: " + path);
};
+1 -1
View File
@@ -41,7 +41,7 @@ const { REOPEN_ON_REWIND, DEFER_OPEN } = Ci.nsIFileInputStream;
const { DIRECTORY_TYPE, NORMAL_FILE_TYPE } = Ci.nsIFile;
const { NS_SEEK_SET, NS_SEEK_CUR, NS_SEEK_END } = Ci.nsISeekableStream;
const FILE_PERMISSION = parseInt("0666", 8);
const FILE_PERMISSION = 0o666;
const PR_UINT32_MAX = 0xfffffff;
// Values taken from:
// http://mxr.mozilla.org/mozilla-central/source/nsprpub/pr/include/prio.h#615
+14
View File
@@ -215,6 +215,20 @@ exports.isJSON = function (value) {
return isJSON(value);
};
/**
* Returns `true` if `value` is JSONable
*/
const isJSONable = (value) => {
try {
JSON.parse(JSON.stringify(value));
}
catch (e) {
return false;
}
return true;
};
exports.isJSONable = isJSONable;
/**
* Returns if `value` is an instance of a given `Type`. This is exactly same as
* `value instanceof Type` with a difference that `Type` can be from a scope
+5 -1
View File
@@ -23,6 +23,7 @@ const { curry, flip } = require("../../lang/functional");
const { patch, diff } = require("diffpatcher/index");
const prefs = require("../../preferences/service");
const { getByOuterId } = require("../../window/utils");
const { ignoreWindow } = require('../../private-browsing/utils');
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const PREF_ROOT = "extensions.sdk-toolbar-collapsed.";
@@ -85,7 +86,10 @@ const attributesChanged = mutations => {
// Utility function creates `toolbar` with a "close" button and returns
// it back. In addition it set's up a listener and observer to communicate
// state changes.
const addView = curry((options, {document}) => {
const addView = curry((options, {document, window}) => {
if (ignoreWindow(window))
return;
let view = document.createElementNS(XUL_NS, "toolbar");
view.setAttribute("id", options.id);
view.setAttribute("collapsed", options.collapsed);
@@ -2046,7 +2046,7 @@ function maybeAddHeaders(file, metadata, response)
return;
const PR_RDONLY = 0x01;
var fis = new FileInputStream(headerFile, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2572,7 +2572,7 @@ ServerHandler.prototype =
var type = this._getTypeFromFile(file);
if (type === SJS_TYPE)
{
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2666,7 +2666,7 @@ ServerHandler.prototype =
maybeAddHeaders(file, metadata, response);
response.setHeader("Content-Length", "" + count, false);
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
offset = offset || 0;
@@ -2047,7 +2047,7 @@ function maybeAddHeaders(file, metadata, response)
return;
const PR_RDONLY = 0x01;
var fis = new FileInputStream(headerFile, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2573,7 +2573,7 @@ ServerHandler.prototype =
var type = this._getTypeFromFile(file);
if (type === SJS_TYPE)
{
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2667,7 +2667,7 @@ ServerHandler.prototype =
maybeAddHeaders(file, metadata, response);
response.setHeader("Content-Length", "" + count, false);
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
offset = offset || 0;
@@ -2046,7 +2046,7 @@ function maybeAddHeaders(file, metadata, response)
return;
const PR_RDONLY = 0x01;
var fis = new FileInputStream(headerFile, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2572,7 +2572,7 @@ ServerHandler.prototype =
var type = this._getTypeFromFile(file);
if (type === SJS_TYPE)
{
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2666,7 +2666,7 @@ ServerHandler.prototype =
maybeAddHeaders(file, metadata, response);
response.setHeader("Content-Length", "" + count, false);
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
offset = offset || 0;
+1 -1
View File
@@ -66,7 +66,7 @@ function makeExecutable (name) {
let { CC } = require('chrome');
let nsILocalFile = CC('@mozilla.org/file/local;1', 'nsILocalFile', 'initWithPath');
let file = nsILocalFile(name);
file.permissions = parseInt('0777', 8);
file.permissions = 0o777;
}
function deleteFile (name) {
+3 -3
View File
@@ -2047,7 +2047,7 @@ function maybeAddHeaders(file, metadata, response)
return;
const PR_RDONLY = 0x01;
var fis = new FileInputStream(headerFile, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2573,7 +2573,7 @@ ServerHandler.prototype =
var type = this._getTypeFromFile(file);
if (type === SJS_TYPE)
{
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
try
@@ -2667,7 +2667,7 @@ ServerHandler.prototype =
maybeAddHeaders(file, metadata, response);
response.setHeader("Content-Length", "" + count, false);
var fis = new FileInputStream(file, PR_RDONLY, parseInt("444", 8),
var fis = new FileInputStream(file, PR_RDONLY, 0o444,
Ci.nsIFileInputStream.CLOSE_ON_EOF);
offset = offset || 0;
+10 -10
View File
@@ -510,12 +510,12 @@ exports["test fs.chmod"] = function (assert, done) {
testPerm("0755")()
.then(testPerm("0777"))
.then(testPerm("0666"))
.then(testPerm(parseInt("0511", 8)))
.then(testPerm(parseInt("0200", 8)))
.then(testPerm(0o511))
.then(testPerm(0o200))
.then(testPerm("0040"))
.then(testPerm("0000"))
.then(testPermSync(parseInt("0777", 8)))
.then(testPermSync(parseInt("0666", 8)))
.then(testPermSync(0o777))
.then(testPermSync(0o666))
.then(testPermSync("0511"))
.then(testPermSync("0200"))
.then(testPermSync("0040"))
@@ -524,20 +524,20 @@ exports["test fs.chmod"] = function (assert, done) {
assert.pass("Successful chmod passes");
}, assert.fail)
// Test invalid paths
.then(() => chmod("not-a-valid-file", parseInt("0755", 8)))
.then(() => chmod("not-a-valid-file", 0o755))
.then(assert.fail, (err) => {
checkPermError(err, "not-a-valid-file");
})
.then(() => chmod("not-a-valid-file", parseInt("0755", 8), "sync"))
.then(() => chmod("not-a-valid-file", 0o755, "sync"))
.then(assert.fail, (err) => {
checkPermError(err, "not-a-valid-file");
})
// Test invalid files
.then(() => chmod("resource://not-a-real-file", parseInt("0755", 8)))
.then(() => chmod("resource://not-a-real-file", 0o755))
.then(assert.fail, (err) => {
checkPermError(err, "resource://not-a-real-file");
})
.then(() => chmod("resource://not-a-real-file", parseInt("0755", 8), 'sync'))
.then(() => chmod("resource://not-a-real-file", 0o755, 'sync'))
.then(assert.fail, (err) => {
checkPermError(err, "resource://not-a-real-file");
})
@@ -603,8 +603,8 @@ exports["test fs.chmod"] = function (assert, done) {
if (!isWindows)
return mode;
var ANY_READ = parseInt("0444", 8);
var ANY_WRITE = parseInt("0222", 8);
var ANY_READ = 0o444;
var ANY_WRITE = 0o222;
var winMode = 0;
// On Windows, if WRITE is true, then READ is also true
+21
View File
@@ -101,6 +101,27 @@ exports["test json atoms"] = function (assert) {
assert.ok(utils.isJSON("foo bar"), "strings are JSON");
};
exports["test jsonable values"] = function (assert) {
assert.ok(utils.isJSONable(null), "`null` is JSONable");
assert.ok(!utils.isJSONable(undefined), "`undefined` is not JSONable");
assert.ok(utils.isJSONable(NaN), "`NaN` is JSONable");
assert.ok(utils.isJSONable(Infinity), "`Infinity` is JSONable");
assert.ok(utils.isJSONable(true) && utils.isJSONable(false), "booleans are JSONable");
assert.ok(utils.isJSONable(0), "numbers are JSONable");
assert.ok(utils.isJSONable("foo bar"), "strings are JSONable");
assert.ok(!utils.isJSONable(function(){}), "functions are not JSONable");
const functionWithToJSON = function(){};
functionWithToJSON.toJSON = function() { return "foo bar"; };
assert.ok(utils.isJSONable(functionWithToJSON), "functions with toJSON() are JSONable");
assert.ok(utils.isJSONable({}), "`{}` is JSONable");
const foo = {};
foo.bar = foo;
assert.ok(!utils.isJSONable(foo), "recursive objects are not JSONable");
};
exports["test instanceOf"] = function (assert) {
assert.ok(utils.instanceOf(assert, Object),
"assert is object from other sandbox");
+16
View File
@@ -492,4 +492,20 @@ exports["test button are attached to toolbar"] = function*(assert) {
yield cleanUI();
};
exports["test toolbar are not in private windows"] = function*(assert) {
const w = open(null, {features: {toolbar: true, private: true}});
yield ready(w);
const t = new Toolbar({title: "foo"});
yield wait(t, "attach");
assert.ok(!isAttached(t), "toolbar wasn't actually attached");
t.destroy();
yield cleanUI();
}
require("sdk/test").run(module.exports);
-1
View File
@@ -251,7 +251,6 @@
@RESPATH@/components/filepicker.xpt
#endif
@RESPATH@/components/find.xpt
@RESPATH@/components/fuel.xpt
@RESPATH@/components/gfx.xpt
@RESPATH@/components/hal.xpt
@RESPATH@/components/html5.xpt
@@ -31,7 +31,7 @@ externalProtocolChkMsg=Remember my choice for all links of this type.
externalProtocolLaunchBtn=Launch application
malwareBlocked=The site at %S has been reported as an attack site and has been blocked based on your security preferences.
unwantedBlocked=The site at %S has been reported as serving unwanted software and has been blocked based on your security preferences.
phishingBlocked=The website at %S has been reported as a web forgery designed to trick users into sharing personal or financial information.
deceptiveBlocked=This web page at %S has been reported as a deceptive site and has been blocked based on your security preferences.
forbiddenBlocked=The site at %S has been blocked by your browser configuration.
cspBlocked=This page has a content security policy that prevents it from being loaded in this way.
corruptedContentError=The page you are trying to view cannot be shown because an error in the data transmission was detected.
+4 -4
View File
@@ -76,7 +76,7 @@
case "malwareBlocked" :
error = "malware";
break;
case "phishingBlocked" :
case "deceptiveBlocked" :
error = "phishing";
break;
case "unwantedBlocked" :
@@ -176,7 +176,7 @@
<!-- Error Title -->
<div id="errorTitle">
<h1 id="errorTitleText_phishing">&safeb.blocked.phishingPage.title;</h1>
<h1 id="errorTitleText_phishing">&safeb.blocked.phishingPage.title2;</h1>
<h1 id="errorTitleText_malware">&safeb.blocked.malwarePage.title;</h1>
<h1 id="errorTitleText_unwanted">&safeb.blocked.unwantedPage.title;</h1>
<h1 id="errorTitleText_forbidden">&safeb.blocked.forbiddenPage.title2;</h1>
@@ -186,7 +186,7 @@
<!-- Short Description -->
<div id="errorShortDesc">
<p id="errorShortDescText_phishing">&safeb.blocked.phishingPage.shortDesc;</p>
<p id="errorShortDescText_phishing">&safeb.blocked.phishingPage.shortDesc2;</p>
<p id="errorShortDescText_malware">&safeb.blocked.malwarePage.shortDesc;</p>
<p id="errorShortDescText_unwanted">&safeb.blocked.unwantedPage.shortDesc;</p>
<p id="errorShortDescText_forbidden">&safeb.blocked.forbiddenPage.shortDesc2;</p>
@@ -194,7 +194,7 @@
<!-- Long Description -->
<div id="errorLongDesc">
<p id="errorLongDescText_phishing">&safeb.blocked.phishingPage.longDesc;</p>
<p id="errorLongDescText_phishing">&safeb.blocked.phishingPage.longDesc2;</p>
<p id="errorLongDescText_malware">&safeb.blocked.malwarePage.longDesc;</p>
<p id="errorLongDescText_unwanted">&safeb.blocked.unwantedPage.longDesc;</p>
</div>
+2 -3
View File
@@ -1080,7 +1080,7 @@ var PlacesToolbarHelper = {
// CustomizableUI.addListener is idempotent, so we can safely
// call this multiple times.
//FIXME CustomizableUI.addListener(this);
CustomizableUI.addListener(this);
// If the bookmarks toolbar item is:
// - not in a toolbar, or;
@@ -1297,8 +1297,7 @@ var BookmarkingUI = {
*/
_currentAreaType: null,
_shouldUpdateStarState: function() {
// return this._currentAreaType == CustomizableUI.TYPE_TOOLBAR;
return;
return this._currentAreaType == CustomizableUI.TYPE_TOOLBAR;
},
/**
+1 -1
View File
@@ -15,7 +15,7 @@ var gSafeBrowsing = {
// will point to the internal error page we loaded instead.
var docURI = gBrowser.selectedBrowser.documentURI;
var isPhishingPage =
docURI && docURI.spec.startsWith("about:blocked?e=phishingBlocked");
docURI && docURI.spec.startsWith("about:blocked?e=deceptiveBlocked");
// Show/hide the appropriate menu item.
document.getElementById("menu_HelpPopup_reportPhishingtoolmenu")
+9 -3
View File
@@ -1285,6 +1285,10 @@ var gBrowserInit = {
// We do this before the session restore service gets initialized so we can
// apply full zoom settings to tabs restored by the session restore service.
FullZoom.init();
PanelUI.init();
LightweightThemeListener.init();
Services.telemetry.getHistogramById("E10S_WINDOW").add(gMultiProcessBrowser);
SidebarUI.startDelayedLoad();
@@ -1612,6 +1616,8 @@ var gBrowserInit = {
BrowserOffline.uninit();
IndexedDBPromptHelper.uninit();
LightweightThemeListener.uninit();
PanelUI.uninit();
AddonManager.removeAddonListener(AddonsMgrListener);
}
@@ -2974,10 +2980,10 @@ var BrowserOnClick = {
}
};
} else if (reason === 'phishing') {
title = gNavigatorBundle.getString("safebrowsing.reportedWebForgery");
title = gNavigatorBundle.getString("safebrowsing.deceptiveSite");
buttons[1] = {
label: gNavigatorBundle.getString("safebrowsing.notAForgeryButton.label"),
accessKey: gNavigatorBundle.getString("safebrowsing.notAForgeryButton.accessKey"),
label: gNavigatorBundle.getString("safebrowsing.notADeceptiveSiteButton.label"),
accessKey: gNavigatorBundle.getString("safebrowsing.notADeceptiveSiteButton.accessKey"),
callback: function() {
openUILinkIn(gSafeBrowsing.getReportURL('PhishMistake'), 'tab');
}
+11
View File
@@ -781,6 +781,17 @@
</menupopup>
</toolbarbutton>
<toolbaritem id="PanelUI-button"
hidden="true"
class="chromeclass-toolbar-additional"
removable="false">
<toolbarbutton id="PanelUI-menu-button"
class="toolbarbutton-1 badged-button"
consumeanchor="PanelUI-button"
label="&brandShortName;"
tooltiptext="&appmenu.tooltip;"/>
</toolbaritem>
<hbox id="window-controls" hidden="true" pack="end">
<toolbarbutton id="minimize-button"
tooltiptext="&fullScreenMinimize.tooltip;"
+1
View File
@@ -7,6 +7,7 @@
<script type="application/javascript" src="chrome://global/content/viewZoomOverlay.js"/>
<script type="application/javascript" src="chrome://browser/content/places/browserPlacesViews.js"/>
<script type="application/javascript" src="chrome://browser/content/browser.js"/>
<script type="application/javascript" src="chrome://browser/content/customizableui/panelUI.js"/>
<script type="application/javascript" src="chrome://browser/content/downloads/downloads.js"/>
<script type="application/javascript" src="chrome://browser/content/downloads/indicator.js"/>
<script type="application/javascript" src="chrome://global/content/inlineSpellCheckUI.js"/>
@@ -18,15 +18,15 @@
</broadcasterset>
<menupopup id="menu_HelpPopup">
<menuitem id="menu_HelpPopup_reportPhishingtoolmenu"
label="&reportPhishSiteMenu.title2;"
accesskey="&reportPhishSiteMenu.accesskey;"
label="&reportDeceptiveSiteMenu.title;"
accesskey="&reportDeceptiveSiteMenu.accesskey;"
insertbefore="aboutSeparator"
observes="reportPhishingBroadcaster"
oncommand="openUILink(gSafeBrowsing.getReportURL('Phish'), event);"
onclick="checkForMiddleClick(this, event);"/>
<menuitem id="menu_HelpPopup_reportPhishingErrortoolmenu"
label="&safeb.palm.notforgery.label2;"
accesskey="&reportPhishSiteMenu.accesskey;"
label="&safeb.palm.notdeceptive.label;"
accesskey="&reportDeceptiveSiteMenu.accesskey;"
insertbefore="aboutSeparator"
observes="reportPhishingErrorBroadcaster"
oncommand="openUILinkIn(gSafeBrowsing.getReportURL('Error'), 'tab');"
+39 -10
View File
@@ -1053,6 +1053,11 @@
</body>
</method>
<!-- Holds a unique ID for the tab change that's currently being timed.
Used to make sure that multiple, rapid tab switches do not try to
create overlapping timers. -->
<field name="_tabSwitchID">null</field>
<method name="updateCurrentBrowser">
<parameter name="aForceUpdate"/>
<body>
@@ -1063,11 +1068,28 @@
if (!aForceUpdate) {
//TelemetryStopwatch.start("FX_TAB_SWITCH_UPDATE_MS");
if (!Services.appinfo.browserTabsRemoteAutostart) {
// old way of measuring tab paint which is not
// valid with e10s.
window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils)
.beginTabSwitch();
if (!gMultiProcessBrowser) {
// old way of measuring tab paint which is not valid with e10s.
// Waiting until the next MozAfterPaint ensures that we capture
// the time it takes to paint, upload the textures to the compositor,
// and then composite.
if (this._tabSwitchID) {
//TelemetryStopwatch.cancel("FX_TAB_SWITCH_TOTAL_MS");
}
let tabSwitchID = Symbol();
//TelemetryStopwatch.start("FX_TAB_SWITCH_TOTAL_MS");
this._tabSwitchID = tabSwitchID;
let onMozAfterPaint = () => {
if (this._tabSwitchID === tabSwitchID) {
TelemetryStopwatch.finish("FX_TAB_SWITCH_TOTAL_MS");
this._tabSwitchID = null;
}
window.removeEventListener("MozAfterPaint", onMozAfterPaint);
}
window.addEventListener("MozAfterPaint", onMozAfterPaint);
}
}
@@ -1150,7 +1172,7 @@
this.mCurrentTab.lastAccessed = Infinity;
this.mCurrentTab.removeAttribute("unread");
this.selectedTab.lastAccessed = Date.now();
oldTab.lastAccessed = Date.now();
let oldFindBar = oldTab._findBar;
if (oldFindBar &&
@@ -1590,7 +1612,7 @@
// Unhook our progress listener.
let tab = this.getTabForBrowser(aBrowser);
let filter = this._tabFilters.get(tab);
let listener = this._tabListeners.get(tab);
let listener = this._tabListeners.get(tab);
aBrowser.webProgress.removeProgressListener(filter);
filter.removeProgressListener(listener);
@@ -1830,6 +1852,7 @@
var notificationbox = document.createElementNS(NS_XUL,
"notificationbox");
notificationbox.setAttribute("flex", "1");
notificationbox.setAttribute("notificationside", "top");
notificationbox.appendChild(browserSidebarContainer);
// Prevent the superfluous initial load of a blank document
@@ -6263,7 +6286,6 @@
<field name="_overPlayingIcon">false</field>
<field name="mCorrespondingMenuitem">null</field>
<field name="closing">false</field>
<field name="lastAccessed">0</field>
<method name="_mouseenter">
<body><![CDATA[
@@ -6322,9 +6344,11 @@
if (browser.audioMuted) {
browser.unmute();
this.removeAttribute("muted");
BrowserUITelemetry.countTabMutingEvent("unmute", aMuteReason);
} else {
browser.mute();
this.setAttribute("muted", "true");
BrowserUITelemetry.countTabMutingEvent("mute", aMuteReason);
}
this.muteReason = aMuteReason || null;
tabContainer.tabbrowser._tabAttrModified(this, ["muted"]);
@@ -6336,8 +6360,13 @@
<parameter name="aUserContextId"/>
<body>
<![CDATA[
this.linkedBrowser.setAttribute("usercontextid", aUserContextId);
this.setAttribute("usercontextid", aUserContextId);
if (aUserContextId) {
this.linkedBrowser.setAttribute("usercontextid", aUserContextId);
this.setAttribute("usercontextid", aUserContextId);
} else {
this.linkedBrowser.removeAttribute("usercontextid");
this.removeAttribute("usercontextid");
}
]]>
</body>
</method>
@@ -0,0 +1,47 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
// gBrowser.selectedTab.lastAccessed and Date.now() called from this test can't
// run concurrently, and therefore don't always match exactly.
const CURRENT_TIME_TOLERANCE_MS = 15;
function isCurrent(tab, msg) {
const DIFF = Math.abs(Date.now() - tab.lastAccessed);
ok(DIFF <= CURRENT_TIME_TOLERANCE_MS, msg + " (difference: " + DIFF + ")");
}
function nextStep(fn) {
setTimeout(fn, CURRENT_TIME_TOLERANCE_MS + 10);
}
var originalTab;
var newTab;
function test() {
waitForExplicitFinish();
originalTab = gBrowser.selectedTab;
nextStep(step2);
}
function step2() {
isCurrent(originalTab, "selected tab has the current timestamp");
newTab = gBrowser.addTab("about:blank", {skipAnimation: true});
nextStep(step3);
}
function step3() {
ok(newTab.lastAccessed < Date.now(), "new tab hasn't been selected so far");
gBrowser.selectedTab = newTab;
isCurrent(newTab, "new tab has the current timestamp after being selected");
nextStep(step4);
}
function step4() {
ok(originalTab.lastAccessed < Date.now(),
"original tab has old timestamp after being deselected");
isCurrent(newTab, "new tab has the current timestamp since it's still selected");
gBrowser.removeTab(newTab);
finish();
}
@@ -0,0 +1,61 @@
registerCleanupFunction(function* cleanup() {
while (gBrowser.tabs.length > 1) {
yield BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
}
Services.search.currentEngine = originalEngine;
let engine = Services.search.getEngineByName("MozSearch");
Services.search.removeEngine(engine);
});
let originalEngine;
add_task(function* test_setup() {
// Stop search-engine loads from hitting the network
Services.search.addEngineWithDetails("MozSearch", "", "", "", "GET",
"http://example.com/?q={searchTerms}");
let engine = Services.search.getEngineByName("MozSearch");
originalEngine = Services.search.currentEngine;
Services.search.currentEngine = engine;
});
add_task(function*() { yield drop("mochi.test/first", true); });
add_task(function*() { yield drop("javascript:'bad'"); });
add_task(function*() { yield drop("jAvascript:'bad'"); });
add_task(function*() { yield drop("search this", true); });
add_task(function*() { yield drop("mochi.test/second", true); });
add_task(function*() { yield drop("data:text/html,bad"); });
add_task(function*() { yield drop("mochi.test/third", true); });
function* drop(text, valid = false) {
info(`Starting test for text:${text}; valid:${valid}`);
let scriptLoader = Cc["@mozilla.org/moz/jssubscript-loader;1"].
getService(Ci.mozIJSSubScriptLoader);
let ChromeUtils = {};
scriptLoader.loadSubScript("chrome://mochikit/content/tests/SimpleTest/ChromeUtils.js", ChromeUtils);
let awaitDrop = BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "drop");
let awaitTabOpen = valid && BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "TabOpen");
// A drop type of "link" onto an existing tab would normally trigger a
// load in that same tab, but tabbrowser code in _getDragTargetTab treats
// drops on the outer edges of a tab differently (loading a new tab
// instead). Make events created by synthesizeDrop have all of their
// coordinates set to 0 (screenX/screenY), so they're treated as drops
// on the outer edge of the tab, thus they open new tabs.
var event = {
clientX: 0,
clientY: 0,
screenX: 0,
screenY: 0,
};
ChromeUtils.synthesizeDrop(gBrowser.selectedTab, gBrowser.selectedTab, [[{type: "text/plain", data: text}]], "link", window, undefined, event);
let tabOpened = false;
if (awaitTabOpen) {
let tabOpenEvent = yield awaitTabOpen;
info("Got TabOpen event");
tabOpened = true;
yield BrowserTestUtils.removeTab(tabOpenEvent.target);
}
is(tabOpened, valid, `Tab for ${text} should only open if valid`);
yield awaitDrop;
ok(true, "Got drop event");
}
@@ -71,6 +71,18 @@ function test() {
testVal("http://localhost/ foo bar baz");
testVal("http://localhost.localdomain/ foo bar baz", "localhost.localdomain/ foo bar baz");
// Behaviour for hosts with no dots depends on the whitelist:
let fixupWhitelistPref = "browser.fixup.domainwhitelist.localhost";
Services.prefs.setBoolPref(fixupWhitelistPref, false);
testVal("http://localhost");
Services.prefs.setBoolPref(fixupWhitelistPref, true);
testVal("http://localhost", "localhost");
Services.prefs.clearUserPref(fixupWhitelistPref);
testVal("http:// invalid url");
testVal("http://someotherhostwithnodots");
Services.prefs.setBoolPref(prefname, false);
testVal("http://mozilla.org/");
@@ -18,7 +18,6 @@ var manifestUpgrade = { // used for testing install
name: "provider 3",
origin: "https://test2.example.com",
sidebarURL: "https://test2.example.com/browser/browser/base/content/test/social/social_sidebar.html",
workerURL: "https://test2.example.com/browser/browser/base/content/test/social/social_worker.js",
iconURL: "https://test2.example.com/browser/browser/base/content/test/general/moz.png",
version: "1.0"
};
@@ -211,72 +210,5 @@ var tests = {
Social.uninstallProvider(addonManifest.origin);
});
});
},
testUpgradeProviderFromWorker: function(next) {
// add the provider, change the pref, add it again. The provider at that
// point should be upgraded
let activationURL = manifestUpgrade.origin + "/browser/browser/base/content/test/social/social_activate.html"
ensureEventFired(PopupNotifications.panel, "popupshown").then(() => {
let panel = document.getElementById("servicesInstall-notification");
info("servicesInstall-notification panel opened");
panel.button.click();
});
addTab(activationURL, function(tab) {
let doc = tab.linkedBrowser.contentDocument;
let installFrom = doc.nodePrincipal.origin;
Services.prefs.setCharPref("social.whitelist", installFrom);
let data = {
origin: installFrom,
url: doc.location.href,
manifest: manifestUpgrade,
window: window
}
Social.installProvider(data, function(addonManifest) {
SocialService.enableProvider(addonManifest.origin, function(provider) {
is(provider.manifest.version, 1, "manifest version is 1");
// watch for the provider-update and test the new version
SocialService.registerProviderListener(function providerListener(topic, origin, providers) {
if (topic != "provider-update")
return;
// The worker will have reloaded and the current provider instance
// disabled, removed from the provider list. We have a reference
// here, check it is is disabled.
is(provider.enabled, false, "old provider instance is disabled")
is(origin, addonManifest.origin, "provider manifest updated")
SocialService.unregisterProviderListener(providerListener);
// Get the new provider instance, fetch the manifest via workerapi
// and validate that data as well.
let p = Social._getProviderFromOrigin(origin);
is(p.manifest.version, 2, "manifest version is 2");
let port = p.getWorkerPort();
ok(port, "got a new port");
port.onmessage = function (e) {
let topic = e.data.topic;
switch (topic) {
case "social.manifest":
let manifest = e.data.data;
is(manifest.version, 2, "manifest version is 2");
port.close();
Social.uninstallProvider(origin, function() {
Services.prefs.clearUserPref("social.whitelist");
ensureBrowserTabClosed(tab).then(next);
});
break;
}
}
port.postMessage({topic: "test-init"});
port.postMessage({topic: "manifest-get"});
});
let port = provider.getWorkerPort();
port.postMessage({topic: "worker.update", data: true});
});
});
});
}
}
@@ -1,4 +1,7 @@
[browser_moz_action_link.js]
[browser_urlbarHashChangeProxyState.js]
[browser_urlbarKeepStateAcrossTabSwitches.js]
[browser_urlbarUpdateForDomainCompletion.js]
[browser_urlbar_blanking.js]
support-files =
file_blank_but_not_blank.html
@@ -0,0 +1,111 @@
"use strict";
/**
* Check that navigating through both the URL bar and using in-page hash- or ref-
* based links and back or forward navigation updates the URL bar and identity block correctly.
*/
add_task(function* () {
let baseURL = "https://example.org/browser/browser/base/content/test/urlbar/dummy_page.html";
let url = baseURL + "#foo";
yield BrowserTestUtils.withNewTab({ gBrowser, url }, function(browser) {
let identityBox = document.getElementById("identity-box");
let expectedURL = url;
let verifyURLBarState = testType => {
is(gURLBar.textValue, expectedURL, "URL bar visible value should be correct " + testType);
is(gURLBar.value, expectedURL, "URL bar value should be correct " + testType);
ok(identityBox.classList.contains("verifiedDomain"), "Identity box should know we're doing SSL " + testType);
is(gURLBar.getAttribute("pageproxystate"), "valid", "URL bar is in valid page proxy state");
};
verifyURLBarState("at the beginning");
let locationChangePromise;
let resolveLocationChangePromise;
let expectURL = url => {
expectedURL = url;
locationChangePromise = new Promise(r => resolveLocationChangePromise = r);
};
let wpl = {
onLocationChange(wpl, request, location, flags) {
is(location.spec, expectedURL, "Got the expected URL");
resolveLocationChangePromise();
},
};
gBrowser.addProgressListener(wpl);
expectURL(baseURL + "#foo");
gURLBar.select();
EventUtils.sendKey("return");
yield locationChangePromise;
verifyURLBarState("after hitting enter on the same URL a second time");
expectURL(baseURL + "#bar");
gURLBar.value = expectedURL;
gURLBar.select();
EventUtils.sendKey("return");
yield locationChangePromise;
verifyURLBarState("after a URL bar hash navigation");
expectURL(baseURL + "#foo");
yield ContentTask.spawn(browser, null, function() {
let a = content.document.createElement("a");
a.href = "#foo";
a.textContent = "Foo Link";
content.document.body.appendChild(a);
a.click();
});
yield locationChangePromise;
verifyURLBarState("after a page link hash navigation");
expectURL(baseURL + "#bar");
gBrowser.goBack();
yield locationChangePromise;
verifyURLBarState("after going back");
expectURL(baseURL + "#foo");
gBrowser.goForward();
yield locationChangePromise;
verifyURLBarState("after going forward");
expectURL(baseURL + "#foo");
gURLBar.select();
EventUtils.sendKey("return");
yield locationChangePromise;
verifyURLBarState("after hitting enter on the same URL");
gBrowser.removeProgressListener(wpl);
});
});
/**
* Check that initial secure loads that swap remoteness
* get the correct page icon when finished.
*/
add_task(function* () {
let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:newtab", false);
// NB: CPOW usage because new tab pages can be preloaded, in which case no
// load events fire.
yield BrowserTestUtils.waitForCondition(() => !tab.linkedBrowser.contentDocument.hidden)
let url = "https://example.org/browser/browser/base/content/test/urlbar/dummy_page.html#foo";
gURLBar.value = url;
gURLBar.select();
EventUtils.sendKey("return");
yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
is(gURLBar.textValue, url, "URL bar visible value should be correct when the page loads from about:newtab");
is(gURLBar.value, url, "URL bar value should be correct when the page loads from about:newtab");
let identityBox = document.getElementById("identity-box");
ok(identityBox.classList.contains("verifiedDomain"),
"Identity box should know we're doing SSL when the page loads from about:newtab");
is(gURLBar.getAttribute("pageproxystate"), "valid",
"URL bar is in valid page proxy state when SSL page with hash loads from about:newtab");
yield BrowserTestUtils.removeTab(tab);
});
@@ -0,0 +1,49 @@
"use strict";
/**
* Verify user typed text remains in the URL bar when tab switching, even when
* loads fail.
*/
add_task(function* () {
let input = "i-definitely-dont-exist.example.com";
let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:newtab", false);
// NB: CPOW usage because new tab pages can be preloaded, in which case no
// load events fire.
yield BrowserTestUtils.waitForCondition(() => !tab.linkedBrowser.contentDocument.hidden)
let errorPageLoaded = BrowserTestUtils.waitForErrorPage(tab.linkedBrowser);
gURLBar.value = input;
gURLBar.select();
EventUtils.sendKey("return");
yield errorPageLoaded;
is(gURLBar.textValue, input, "Text is still in URL bar");
yield BrowserTestUtils.switchTab(gBrowser, tab.previousSibling);
yield BrowserTestUtils.switchTab(gBrowser, tab);
is(gURLBar.textValue, input, "Text is still in URL bar after tab switch");
yield BrowserTestUtils.removeTab(tab);
});
/**
* Invalid URIs fail differently (that is, immediately, in the loadURI call)
* if keyword searches are turned off. Test that this works, too.
*/
add_task(function* () {
let input = "To be or not to be-that is the question";
yield new Promise(resolve => SpecialPowers.pushPrefEnv({set: [["keyword.enabled", false]]}, resolve));
let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:newtab", false);
// NB: CPOW usage because new tab pages can be preloaded, in which case no
// load events fire.
yield BrowserTestUtils.waitForCondition(() => !tab.linkedBrowser.contentDocument.hidden)
let errorPageLoaded = BrowserTestUtils.waitForErrorPage(tab.linkedBrowser);
gURLBar.value = input;
gURLBar.select();
EventUtils.sendKey("return");
yield errorPageLoaded;
is(gURLBar.textValue, input, "Text is still in URL bar");
is(tab.linkedBrowser.userTypedValue, input, "Text still stored on browser");
yield BrowserTestUtils.switchTab(gBrowser, tab.previousSibling);
yield BrowserTestUtils.switchTab(gBrowser, tab);
is(gURLBar.textValue, input, "Text is still in URL bar after tab switch");
is(tab.linkedBrowser.userTypedValue, input, "Text still stored on browser");
yield BrowserTestUtils.removeTab(tab);
});
@@ -0,0 +1,17 @@
"use strict";
/**
* Disable keyword.enabled (so no keyword search), and check that when you type in
* "example" and hit enter, the browser loads and the URL bar is updated accordingly.
*/
add_task(function* () {
yield new Promise(resolve => SpecialPowers.pushPrefEnv({set: [["keyword.enabled", false]]}, resolve));
yield BrowserTestUtils.withNewTab({ gBrowser, url: "about:blank" }, function* (browser) {
gURLBar.value = "example";
gURLBar.select();
let loadPromise = BrowserTestUtils.browserLoaded(browser, false, url => url == "http://www.example.com/");
EventUtils.sendKey("return");
yield loadPromise;
is(gURLBar.textValue, "www.example.com");
});
});
+24 -5
View File
@@ -23,7 +23,7 @@ this.__defineGetter__("BROWSER_NEW_TAB_URL", () => {
!aboutNewTabService.overridden) {
return "about:privatebrowsing";
}
return "about:newtab";
return aboutNewTabService.newTabURL;
});
var TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
@@ -686,8 +686,27 @@ function openPrefsHelp() {
function trimURL(aURL) {
// This function must not modify the given URL such that calling
// nsIURIFixup::createFixupURI with the result will produce a different URI.
return aURL /* remove single trailing slash for http/https/ftp URLs */
.replace(/^((?:http|https|ftp):\/\/[^/]+)\/$/, "$1")
/* remove http:// unless the host starts with "ftp\d*\." or contains "@" */
.replace(/^http:\/\/((?!ftp\d*\.)[^\/@]+(?:\/|$))/, "$1");
// remove single trailing slash for http/https/ftp URLs
let url = aURL.replace(/^((?:http|https|ftp):\/\/[^/]+)\/$/, "$1");
// remove http://
if (!url.startsWith("http://")) {
return url;
}
let urlWithoutProtocol = url.substring(7);
let flags = Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP |
Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS;
let fixedUpURL, expectedURLSpec;
try {
fixedUpURL = Services.uriFixup.createFixupURI(urlWithoutProtocol, flags);
expectedURLSpec = makeURI(aURL).spec;
} catch (ex) {
return url;
}
if (fixedUpURL.spec == expectedURLSpec) {
return urlWithoutProtocol;
}
return url;
}
@@ -32,6 +32,11 @@ pref("@GUAO_PREF@.calendar.yahoo.com","Mozilla/5.0 (@OS_SLICE@ rv:@GK_VERSION@)
pref("@GUAO_PREF@.google.com","Mozilla/5.0 (@OS_SLICE@ rv:52.0) Gecko/20010101 @GRE_VERSION_SLICE@ Firefox/52.0 @PM_SLICE@");
pref("@GUAO_PREF@.googlevideos.com","Mozilla/5.0 (@OS_SLICE@ rv:52.0) Gecko/20010101 @GRE_VERSION_SLICE@ Firefox/52.0 @PM_SLICE@");
pref("@GUAO_PREF@.gstatic.com","Mozilla/5.0 (@OS_SLICE@ rv:52.0) Gecko/20010101 @GRE_VERSION_SLICE@ Firefox/52.0 @PM_SLICE@");
// Google fonts serves physically different fonts to later Firefox versions that render incorrectly unless on Gecko
pref("@GUAO_PREF@.fonts.googleapis.com", "Mozilla/5.0 (%OS_SLICE% rv:52.0) Gecko/20100101 Firefox/52.0 @PM_SLICE@"");
pref("@GUAO_PREF@.fonts-api.wp.com", "Mozilla/5.0 (%OS_SLICE% rv:52.0) Gecko/20100101 Firefox/52.0 @PM_SLICE@"");
// now YouTube needs to be fooled too
pref("@GUAO_PREF@.youtube.com","Mozilla/5.0 (Linux; U; Android 4.4.2) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30");
// also GMail
@@ -19,8 +19,6 @@ XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask",
"resource://gre/modules/DeferredTask.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyGetter(this, "gWidgetsBundle", function() {
const kUrl = "chrome://browser/locale/customizableui/customizableWidgets.properties";
return Services.strings.createBundle(kUrl);
@@ -4104,28 +4102,26 @@ OverflowableToolbar.prototype = {
},
show: function() {
let deferred = Promise.defer();
if (this._panel.state == "open") {
deferred.resolve();
return deferred.promise;
return Promise.resolve();
}
let doc = this._panel.ownerDocument;
this._panel.hidden = false;
let contextMenu = doc.getElementById(this._panel.getAttribute("context"));
gELS.addSystemEventListener(contextMenu, 'command', this, true);
let anchor = doc.getAnonymousElementByAttribute(this._chevron, "class", "toolbarbutton-icon");
this._panel.openPopup(anchor || this._chevron);
this._chevron.open = true;
return new Promise(resolve => {
let doc = this._panel.ownerDocument;
this._panel.hidden = false;
let contextMenu = doc.getElementById(this._panel.getAttribute("context"));
gELS.addSystemEventListener(contextMenu, 'command', this, true);
let anchor = doc.getAnonymousElementByAttribute(this._chevron, "class", "toolbarbutton-icon");
this._panel.openPopup(anchor || this._chevron);
this._chevron.open = true;
let overflowableToolbarInstance = this;
this._panel.addEventListener("popupshown", function onPopupShown(aEvent) {
this.removeEventListener("popupshown", onPopupShown);
this.addEventListener("dragover", overflowableToolbarInstance);
this.addEventListener("dragend", overflowableToolbarInstance);
deferred.resolve();
let overflowableToolbarInstance = this;
this._panel.addEventListener("popupshown", function onPopupShown(aEvent) {
this.removeEventListener("popupshown", onPopupShown);
this.addEventListener("dragover", overflowableToolbarInstance);
this.addEventListener("dragend", overflowableToolbarInstance);
resolve();
});
});
return deferred.promise;
},
_onClickChevron: function(aEvent) {
@@ -10,25 +10,21 @@ const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
var gSystemScrollbarWidth = null;
this.ScrollbarSampler = {
getSystemScrollbarWidth: function() {
let deferred = Promise.defer();
if (gSystemScrollbarWidth !== null) {
deferred.resolve(gSystemScrollbarWidth);
return deferred.promise;
return Promise.resolve(gSystemScrollbarWidth);
}
this._sampleSystemScrollbarWidth().then(function(systemScrollbarWidth) {
gSystemScrollbarWidth = systemScrollbarWidth;
deferred.resolve(gSystemScrollbarWidth);
return new Promise(resolve => {
this._sampleSystemScrollbarWidth().then(function(systemScrollbarWidth) {
gSystemScrollbarWidth = systemScrollbarWidth;
resolve(gSystemScrollbarWidth);
});
});
return deferred.promise;
},
resetSystemScrollbarWidth: function() {
@@ -36,7 +32,6 @@ this.ScrollbarSampler = {
},
_sampleSystemScrollbarWidth: function() {
let deferred = Promise.defer();
let hwin = Services.appShell.hiddenDOMWindow;
let hdoc = hwin.document.documentElement;
let iframe = hwin.document.createElementNS("http://www.w3.org/1999/xhtml",
@@ -48,23 +43,23 @@ this.ScrollbarSampler = {
let utils = cwindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
cwindow.addEventListener("load", function onLoad(aEvent) {
cwindow.removeEventListener("load", onLoad);
let sbWidth = {};
try {
utils.getScrollbarSize(true, sbWidth, {});
} catch(e) {
Cu.reportError("Could not sample scrollbar size: " + e + " -- " +
e.stack);
sbWidth.value = 0;
}
// Minimum width of 10 so that we have enough padding:
sbWidth.value = Math.max(sbWidth.value, 10);
deferred.resolve(sbWidth.value);
iframe.remove();
return new Promise(resolve => {
cwindow.addEventListener("load", function onLoad(aEvent) {
cwindow.removeEventListener("load", onLoad);
let sbWidth = {};
try {
utils.getScrollbarSize(true, sbWidth, {});
} catch(e) {
Cu.reportError("Could not sample scrollbar size: " + e + " -- " +
e.stack);
sbWidth.value = 0;
}
// Minimum width of 10 so that we have enough padding:
sbWidth.value = Math.max(sbWidth.value, 10);
resolve(sbWidth.value);
iframe.remove();
});
});
return deferred.promise;
}
};
Object.freeze(this.ScrollbarSampler);
@@ -6,8 +6,6 @@ XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
"resource:///modules/CustomizableUI.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ScrollbarSampler",
"resource:///modules/ScrollbarSampler.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ShortcutUtils",
"resource://gre/modules/ShortcutUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
@@ -127,48 +125,46 @@ const PanelUI = {
* @param aEvent the event (if any) that triggers showing the menu.
*/
show: function(aEvent) {
let deferred = Promise.defer();
return new Promise(resolve => {
this.ensureReady().then(() => {
if (this.panel.state == "open" ||
document.documentElement.hasAttribute("customizing")) {
resolve();
return;
}
this.ensureReady().then(() => {
if (this.panel.state == "open" ||
document.documentElement.hasAttribute("customizing")) {
deferred.resolve();
return;
}
let editControlPlacement = CustomizableUI.getPlacementOfWidget("edit-controls");
if (editControlPlacement && editControlPlacement.area == CustomizableUI.AREA_PANEL) {
updateEditUIVisibility();
}
let editControlPlacement = CustomizableUI.getPlacementOfWidget("edit-controls");
if (editControlPlacement && editControlPlacement.area == CustomizableUI.AREA_PANEL) {
updateEditUIVisibility();
}
let personalBookmarksPlacement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
if (personalBookmarksPlacement &&
personalBookmarksPlacement.area == CustomizableUI.AREA_PANEL) {
PlacesToolbarHelper.customizeChange();
}
let personalBookmarksPlacement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
if (personalBookmarksPlacement &&
personalBookmarksPlacement.area == CustomizableUI.AREA_PANEL) {
PlacesToolbarHelper.customizeChange();
}
let anchor;
if (!aEvent ||
aEvent.type == "command") {
anchor = this.menuButton;
} else {
anchor = aEvent.target;
}
let anchor;
if (!aEvent ||
aEvent.type == "command") {
anchor = this.menuButton;
} else {
anchor = aEvent.target;
}
this.panel.addEventListener("popupshown", function onPopupShown() {
this.removeEventListener("popupshown", onPopupShown);
resolve();
});
this.panel.addEventListener("popupshown", function onPopupShown() {
this.removeEventListener("popupshown", onPopupShown);
deferred.resolve();
let iconAnchor =
document.getAnonymousElementByAttribute(anchor, "class",
"toolbarbutton-icon");
this.panel.openPopup(iconAnchor || anchor);
}, (reason) => {
console.error("Error showing the PanelUI menu", reason);
});
let iconAnchor =
document.getAnonymousElementByAttribute(anchor, "class",
"toolbarbutton-icon");
this.panel.openPopup(iconAnchor || anchor);
}, (reason) => {
console.error("Error showing the PanelUI menu", reason);
});
return deferred.promise;
},
/**
@@ -183,6 +179,11 @@ const PanelUI = {
},
handleEvent: function(aEvent) {
// Ignore context menus and menu button menus showing and hiding:
if (aEvent.type.startsWith("popup") &&
aEvent.target != this.panel) {
return;
}
switch (aEvent.type) {
case "popupshowing":
this._adjustLabelsForAutoHyphens();
@@ -226,15 +227,15 @@ const PanelUI = {
}
this._readyPromise = Task.spawn(function*() {
if (!this._initialized) {
let delayedStartupDeferred = Promise.defer();
let delayedStartupObserver = (aSubject, aTopic, aData) => {
if (aSubject == window) {
Services.obs.removeObserver(delayedStartupObserver, "browser-delayed-startup-finished");
delayedStartupDeferred.resolve();
}
};
Services.obs.addObserver(delayedStartupObserver, "browser-delayed-startup-finished", false);
yield delayedStartupDeferred.promise;
yield new Promise(resolve => {
let delayedStartupObserver = (aSubject, aTopic, aData) => {
if (aSubject == window) {
Services.obs.removeObserver(delayedStartupObserver, "browser-delayed-startup-finished");
resolve();
}
};
Services.obs.addObserver(delayedStartupObserver, "browser-delayed-startup-finished", false);
});
}
this.contents.setAttributeNS("http://www.w3.org/XML/1998/namespace", "lang",
@@ -149,4 +149,5 @@ skip-if = os == "mac"
[browser_1096763_seen_widgets_post_reset.js]
[browser_1161838_inserted_new_default_buttons.js]
[browser_bootstrapped_custom_toolbar.js]
[browser_customizemode_contextmenu_menubuttonstate.js]
[browser_panel_toggle.js]
@@ -0,0 +1,24 @@
"use strict";
add_task(function*() {
ok(!PanelUI.menuButton.hasAttribute("open"), "Menu button should not be 'pressed' outside customize mode");
yield startCustomizing();
is(PanelUI.menuButton.getAttribute("open"), "true", "Menu button should be 'pressed' when in customize mode");
let contextMenu = document.getElementById("customizationPanelItemContextMenu");
let shownPromise = popupShown(contextMenu);
let newWindowButton = document.getElementById("wrapper-new-window-button");
EventUtils.synthesizeMouse(newWindowButton, 2, 2, {type: "contextmenu", button: 2});
yield shownPromise;
is(PanelUI.menuButton.getAttribute("open"), "true", "Menu button should be 'pressed' when in customize mode after opening a context menu");
let hiddenContextPromise = popupHidden(contextMenu);
contextMenu.hidePopup();
yield hiddenContextPromise;
is(PanelUI.menuButton.getAttribute("open"), "true", "Menu button should be 'pressed' when in customize mode after hiding a context menu");
yield endCustomizing();
ok(!PanelUI.menuButton.hasAttribute("open"), "Menu button should not be 'pressed' after ending customize mode");
});
+27 -3
View File
@@ -241,9 +241,14 @@ DistributionCustomizer.prototype = {
}
}),
_newProfile: false,
_customizationsApplied: false,
applyCustomizations: function DIST_applyCustomizations() {
this._customizationsApplied = true;
if (!Services.prefs.prefHasUserValue("browser.migration.version"))
this._newProfile = true;
if (!this._ini)
return this._checkCustomizationComplete();
@@ -348,7 +353,7 @@ DistributionCustomizer.prototype = {
try {
let value = this._ini.getString("Preferences-" + this._locale, key);
if (value) {
Preferences.set(key, parseValue(value));
defaults.set(key, parseValue(value));
}
usedPreferences.push(key);
} catch (e) { /* ignore bad prefs and move on */ }
@@ -363,7 +368,7 @@ DistributionCustomizer.prototype = {
try {
let value = this._ini.getString("Preferences-" + this._language, key);
if (value) {
Preferences.set(key, parseValue(value));
defaults.set(key, parseValue(value));
}
usedPreferences.push(key);
} catch (e) { /* ignore bad prefs and move on */ }
@@ -380,7 +385,7 @@ DistributionCustomizer.prototype = {
if (value) {
value = value.replace(/%LOCALE%/g, this._locale);
value = value.replace(/%LANGUAGE%/g, this._language);
Preferences.set(key, parseValue(value));
defaults.set(key, parseValue(value));
}
} catch (e) { /* ignore bad prefs and move on */ }
}
@@ -444,6 +449,25 @@ DistributionCustomizer.prototype = {
},
_checkCustomizationComplete: function DIST__checkCustomizationComplete() {
const BROWSER_DOCURL = "chrome://browser/content/browser.xul";
if (this._newProfile) {
let xulStore = Cc["@mozilla.org/xul/xulstore;1"].getService(Ci.nsIXULStore);
try {
var showPersonalToolbar = Services.prefs.getBoolPref("browser.showPersonalToolbar");
if (showPersonalToolbar) {
xulStore.setValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed", "false");
}
} catch(e) {}
try {
var showMenubar = Services.prefs.getBoolPref("browser.showMenubar");
if (showMenubar) {
xulStore.setValue(BROWSER_DOCURL, "toolbar-menubar", "collapsed", "false");
}
} catch(e) {}
}
let prefDefaultsApplied = this._prefDefaultsApplied || !this._ini;
if (this._customizationsApplied && this._bookmarksApplied &&
prefDefaultsApplied) {
-823
View File
@@ -1,823 +0,0 @@
/* 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/. */
const Ci = Components.interfaces;
const Cc = Components.classes;
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
"resource://gre/modules/Deprecated.jsm");
const APPLICATION_CID = Components.ID("fe74cf80-aa2d-11db-abbd-0800200c9a66");
const APPLICATION_CONTRACTID = "@mozilla.org/fuel/application;1";
//=================================================
// Singleton that holds services and utilities
var Utilities = {
get bookmarks() {
let bookmarks = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
getService(Ci.nsINavBookmarksService);
this.__defineGetter__("bookmarks", function() bookmarks);
return this.bookmarks;
},
get bookmarksObserver() {
let bookmarksObserver = new BookmarksObserver();
this.__defineGetter__("bookmarksObserver", function() bookmarksObserver);
return this.bookmarksObserver;
},
get annotations() {
let annotations = Cc["@mozilla.org/browser/annotation-service;1"].
getService(Ci.nsIAnnotationService);
this.__defineGetter__("annotations", function() annotations);
return this.annotations;
},
get history() {
let history = Cc["@mozilla.org/browser/nav-history-service;1"].
getService(Ci.nsINavHistoryService);
this.__defineGetter__("history", function() history);
return this.history;
},
get windowMediator() {
let windowMediator = Cc["@mozilla.org/appshell/window-mediator;1"].
getService(Ci.nsIWindowMediator);
this.__defineGetter__("windowMediator", function() windowMediator);
return this.windowMediator;
},
makeURI: function fuelutil_makeURI(aSpec) {
if (!aSpec)
return null;
var ios = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);
return ios.newURI(aSpec, null, null);
},
free: function fuelutil_free() {
delete this.bookmarks;
delete this.bookmarksObserver;
delete this.annotations;
delete this.history;
delete this.windowMediator;
}
};
//=================================================
// Window implementation
var fuelWindowMap = new WeakMap();
function getWindow(aWindow) {
let fuelWindow = fuelWindowMap.get(aWindow);
if (!fuelWindow) {
fuelWindow = new Window(aWindow);
fuelWindowMap.set(aWindow, fuelWindow);
}
return fuelWindow;
}
// Don't call new Window() directly; use getWindow instead.
function Window(aWindow) {
this._window = aWindow;
this._events = new Events();
this._watch("TabOpen");
this._watch("TabMove");
this._watch("TabClose");
this._watch("TabSelect");
}
Window.prototype = {
get events() {
return this._events;
},
get _tabbrowser() {
return this._window.getBrowser();
},
/*
* Helper used to setup event handlers on the XBL element. Note that the events
* are actually dispatched to tabs, so we capture them.
*/
_watch: function win_watch(aType) {
this._tabbrowser.tabContainer.addEventListener(aType, this,
/* useCapture = */ true);
},
handleEvent: function win_handleEvent(aEvent) {
this._events.dispatch(aEvent.type, getBrowserTab(this, aEvent.originalTarget.linkedBrowser));
},
get tabs() {
var tabs = [];
var browsers = this._tabbrowser.browsers;
for (var i=0; i<browsers.length; i++)
tabs.push(getBrowserTab(this, browsers[i]));
return tabs;
},
get activeTab() {
return getBrowserTab(this, this._tabbrowser.selectedBrowser);
},
open: function win_open(aURI) {
return getBrowserTab(this, this._tabbrowser.addTab(aURI.spec).linkedBrowser);
},
QueryInterface: XPCOMUtils.generateQI([Ci.fuelIWindow])
};
//=================================================
// BrowserTab implementation
var fuelBrowserTabMap = new WeakMap();
function getBrowserTab(aFUELWindow, aBrowser) {
let fuelBrowserTab = fuelBrowserTabMap.get(aBrowser);
if (!fuelBrowserTab) {
fuelBrowserTab = new BrowserTab(aFUELWindow, aBrowser);
fuelBrowserTabMap.set(aBrowser, fuelBrowserTab);
}
else {
// This tab may have moved to another window, so make sure its cached
// window is up-to-date.
fuelBrowserTab._window = aFUELWindow;
}
return fuelBrowserTab;
}
// Don't call new BrowserTab() directly; call getBrowserTab instead.
function BrowserTab(aFUELWindow, aBrowser) {
this._window = aFUELWindow;
this._browser = aBrowser;
this._events = new Events();
this._watch("load");
}
BrowserTab.prototype = {
get _tabbrowser() {
return this._window._tabbrowser;
},
get uri() {
return this._browser.currentURI;
},
get index() {
var tabs = this._tabbrowser.tabs;
for (var i=0; i<tabs.length; i++) {
if (tabs[i].linkedBrowser == this._browser)
return i;
}
return -1;
},
get events() {
return this._events;
},
get window() {
return this._window;
},
get document() {
return this._browser.contentDocument;
},
/*
* Helper used to setup event handlers on the XBL element
*/
_watch: function bt_watch(aType) {
this._browser.addEventListener(aType, this,
/* useCapture = */ true);
},
handleEvent: function bt_handleEvent(aEvent) {
if (aEvent.type == "load") {
if (!(aEvent.originalTarget instanceof Ci.nsIDOMDocument))
return;
if (aEvent.originalTarget.defaultView instanceof Ci.nsIDOMWindow &&
aEvent.originalTarget.defaultView.frameElement)
return;
}
this._events.dispatch(aEvent.type, this);
},
/*
* Helper used to determine the index offset of the browsertab
*/
_getTab: function bt_gettab() {
var tabs = this._tabbrowser.tabs;
return tabs[this.index] || null;
},
load: function bt_load(aURI) {
this._browser.loadURI(aURI.spec, null, null);
},
focus: function bt_focus() {
this._tabbrowser.selectedTab = this._getTab();
this._tabbrowser.focus();
},
close: function bt_close() {
this._tabbrowser.removeTab(this._getTab());
},
moveBefore: function bt_movebefore(aBefore) {
this._tabbrowser.moveTabTo(this._getTab(), aBefore.index);
},
moveToEnd: function bt_moveend() {
this._tabbrowser.moveTabTo(this._getTab(), this._tabbrowser.browsers.length);
},
QueryInterface: XPCOMUtils.generateQI([Ci.fuelIBrowserTab])
};
//=================================================
// Annotations implementation
function Annotations(aId) {
this._id = aId;
}
Annotations.prototype = {
get names() {
return Utilities.annotations.getItemAnnotationNames(this._id);
},
has: function ann_has(aName) {
return Utilities.annotations.itemHasAnnotation(this._id, aName);
},
get: function ann_get(aName) {
if (this.has(aName))
return Utilities.annotations.getItemAnnotation(this._id, aName);
return null;
},
set: function ann_set(aName, aValue, aExpiration) {
Utilities.annotations.setItemAnnotation(this._id, aName, aValue, 0, aExpiration);
},
remove: function ann_remove(aName) {
if (aName)
Utilities.annotations.removeItemAnnotation(this._id, aName);
},
QueryInterface: XPCOMUtils.generateQI([Ci.fuelIAnnotations])
};
//=================================================
// BookmarksObserver implementation (internal class)
//
// BookmarksObserver is a global singleton which watches the browser's
// bookmarks and sends you events when things change.
//
// You can register three different kinds of event listeners on
// BookmarksObserver, using addListener, addFolderListener, and
// addRootlistener.
//
// - addListener(aId, aEvent, aListener) lets you listen to a specific
// bookmark. You can listen to the "change", "move", and "remove" events.
//
// - addFolderListener(aId, aEvent, aListener) lets you listen to a specific
// bookmark folder. You can listen to "addchild" and "removechild".
//
// - addRootListener(aEvent, aListener) lets you listen to the root bookmark
// node. This lets you hear "add", "remove", and "change" events on all
// bookmarks.
//
function BookmarksObserver() {
this._eventsDict = {};
this._folderEventsDict = {};
this._rootEvents = new Events();
Utilities.bookmarks.addObserver(this, /* ownsWeak = */ true);
}
BookmarksObserver.prototype = {
onBeginUpdateBatch: function () {},
onEndUpdateBatch: function () {},
onItemVisited: function () {},
onItemAdded: function bo_onItemAdded(aId, aFolder, aIndex, aItemType, aURI) {
this._rootEvents.dispatch("add", aId);
this._dispatchToEvents("addchild", aId, this._folderEventsDict[aFolder]);
},
onItemRemoved: function bo_onItemRemoved(aId, aFolder, aIndex) {
this._rootEvents.dispatch("remove", aId);
this._dispatchToEvents("remove", aId, this._eventsDict[aId]);
this._dispatchToEvents("removechild", aId, this._folderEventsDict[aFolder]);
},
onItemChanged: function bo_onItemChanged(aId, aProperty, aIsAnnotationProperty, aValue) {
this._rootEvents.dispatch("change", aProperty);
this._dispatchToEvents("change", aProperty, this._eventsDict[aId]);
},
onItemMoved: function bo_onItemMoved(aId, aOldParent, aOldIndex, aNewParent, aNewIndex) {
this._dispatchToEvents("move", aId, this._eventsDict[aId]);
},
_dispatchToEvents: function bo_dispatchToEvents(aEvent, aData, aEvents) {
if (aEvents) {
aEvents.dispatch(aEvent, aData);
}
},
_addListenerToDict: function bo_addListenerToDict(aId, aEvent, aListener, aDict) {
var events = aDict[aId];
if (!events) {
events = new Events();
aDict[aId] = events;
}
events.addListener(aEvent, aListener);
},
_removeListenerFromDict: function bo_removeListenerFromDict(aId, aEvent, aListener, aDict) {
var events = aDict[aId];
if (!events) {
return;
}
events.removeListener(aEvent, aListener);
if (events._listeners.length == 0) {
delete aDict[aId];
}
},
addListener: function bo_addListener(aId, aEvent, aListener) {
this._addListenerToDict(aId, aEvent, aListener, this._eventsDict);
},
removeListener: function bo_removeListener(aId, aEvent, aListener) {
this._removeListenerFromDict(aId, aEvent, aListener, this._eventsDict);
},
addFolderListener: function addFolderListener(aId, aEvent, aListener) {
this._addListenerToDict(aId, aEvent, aListener, this._folderEventsDict);
},
removeFolderListener: function removeFolderListener(aId, aEvent, aListener) {
this._removeListenerFromDict(aId, aEvent, aListener, this._folderEventsDict);
},
addRootListener: function addRootListener(aEvent, aListener) {
this._rootEvents.addListener(aEvent, aListener);
},
removeRootListener: function removeRootListener(aEvent, aListener) {
this._rootEvents.removeListener(aEvent, aListener);
},
QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarksObserver,
Ci.nsISupportsWeakReference])
};
//=================================================
// Bookmark implementation
//
// Bookmark event listeners are stored in BookmarksObserver, not in the
// Bookmark objects themselves. Thus, you don't have to hold on to a Bookmark
// object in order for your event listener to stay valid, and Bookmark objects
// not kept alive by the extension can be GC'ed.
//
// A consequence of this is that if you have two different Bookmark objects x
// and y for the same bookmark (i.e., x != y but x.id == y.id), and you do
//
// x.addListener("foo", fun);
// y.removeListener("foo", fun);
//
// the second line will in fact remove the listener added in the first line.
//
function Bookmark(aId, aParent, aType) {
this._id = aId;
this._parent = aParent;
this._type = aType || "bookmark";
this._annotations = new Annotations(this._id);
// Our _events object forwards to bookmarksObserver.
var self = this;
this._events = {
addListener: function bookmarkevents_al(aEvent, aListener) {
Utilities.bookmarksObserver.addListener(self._id, aEvent, aListener);
},
removeListener: function bookmarkevents_rl(aEvent, aListener) {
Utilities.bookmarksObserver.removeListener(self._id, aEvent, aListener);
},
QueryInterface: XPCOMUtils.generateQI([Ci.extIEvents])
};
// For our onItemMoved listener, which updates this._parent.
Utilities.bookmarks.addObserver(this, /* ownsWeak = */ true);
}
Bookmark.prototype = {
get id() {
return this._id;
},
get title() {
return Utilities.bookmarks.getItemTitle(this._id);
},
set title(aTitle) {
Utilities.bookmarks.setItemTitle(this._id, aTitle);
},
get uri() {
return Utilities.bookmarks.getBookmarkURI(this._id);
},
set uri(aURI) {
return Utilities.bookmarks.changeBookmarkURI(this._id, aURI);
},
get description() {
return this._annotations.get("bookmarkProperties/description");
},
set description(aDesc) {
this._annotations.set("bookmarkProperties/description", aDesc, Ci.nsIAnnotationService.EXPIRE_NEVER);
},
get keyword() {
return Utilities.bookmarks.getKeywordForBookmark(this._id);
},
set keyword(aKeyword) {
Utilities.bookmarks.setKeywordForBookmark(this._id, aKeyword);
},
get type() {
return this._type;
},
get parent() {
return this._parent;
},
set parent(aFolder) {
Utilities.bookmarks.moveItem(this._id, aFolder.id, Utilities.bookmarks.DEFAULT_INDEX);
// this._parent is updated in onItemMoved
},
get annotations() {
return this._annotations;
},
get events() {
return this._events;
},
remove : function bm_remove() {
Utilities.bookmarks.removeItem(this._id);
},
onBeginUpdateBatch: function () {},
onEndUpdateBatch: function () {},
onItemAdded: function () {},
onItemVisited: function () {},
onItemRemoved: function () {},
onItemChanged: function () {},
onItemMoved: function(aId, aOldParent, aOldIndex, aNewParent, aNewIndex) {
if (aId == this._id) {
this._parent = new BookmarkFolder(aNewParent, Utilities.bookmarks.getFolderIdForItem(aNewParent));
}
},
QueryInterface: XPCOMUtils.generateQI([Ci.fuelIBookmark,
Ci.nsINavBookmarksObserver,
Ci.nsISupportsWeakReference])
};
//=================================================
// BookmarkFolder implementation
//
// As with Bookmark, events on BookmarkFolder are handled by the
// BookmarksObserver singleton.
//
function BookmarkFolder(aId, aParent) {
this._id = aId;
this._parent = aParent;
this._annotations = new Annotations(this._id);
// Our event listeners are handled by the BookmarksObserver singleton. This
// is a bit complicated because there are three different kinds of events we
// might want to listen to here:
//
// - If this._parent is null, we're the root bookmark folder, and all our
// listeners should be root listeners.
//
// - Otherwise, events ending with "child" (addchild, removechild) are
// handled by a folder listener.
//
// - Other events are handled by a vanilla bookmark listener.
var self = this;
this._events = {
addListener: function bmfevents_al(aEvent, aListener) {
if (self._parent) {
if (/child$/.test(aEvent)) {
Utilities.bookmarksObserver.addFolderListener(self._id, aEvent, aListener);
}
else {
Utilities.bookmarksObserver.addListener(self._id, aEvent, aListener);
}
}
else {
Utilities.bookmarksObserver.addRootListener(aEvent, aListener);
}
},
removeListener: function bmfevents_rl(aEvent, aListener) {
if (self._parent) {
if (/child$/.test(aEvent)) {
Utilities.bookmarksObserver.removeFolderListener(self._id, aEvent, aListener);
}
else {
Utilities.bookmarksObserver.removeListener(self._id, aEvent, aListener);
}
}
else {
Utilities.bookmarksObserver.removeRootListener(aEvent, aListener);
}
},
QueryInterface: XPCOMUtils.generateQI([Ci.extIEvents])
};
// For our onItemMoved listener, which updates this._parent.
Utilities.bookmarks.addObserver(this, /* ownsWeak = */ true);
}
BookmarkFolder.prototype = {
get id() {
return this._id;
},
get title() {
return Utilities.bookmarks.getItemTitle(this._id);
},
set title(aTitle) {
Utilities.bookmarks.setItemTitle(this._id, aTitle);
},
get description() {
return this._annotations.get("bookmarkProperties/description");
},
set description(aDesc) {
this._annotations.set("bookmarkProperties/description", aDesc, Ci.nsIAnnotationService.EXPIRE_NEVER);
},
get type() {
return "folder";
},
get parent() {
return this._parent;
},
set parent(aFolder) {
Utilities.bookmarks.moveItem(this._id, aFolder.id, Utilities.bookmarks.DEFAULT_INDEX);
// this._parent is updated in onItemMoved
},
get annotations() {
return this._annotations;
},
get events() {
return this._events;
},
get children() {
var items = [];
var options = Utilities.history.getNewQueryOptions();
var query = Utilities.history.getNewQuery();
query.setFolders([this._id], 1);
var result = Utilities.history.executeQuery(query, options);
var rootNode = result.root;
rootNode.containerOpen = true;
var cc = rootNode.childCount;
for (var i=0; i<cc; ++i) {
var node = rootNode.getChild(i);
if (node.type == node.RESULT_TYPE_FOLDER) {
var folder = new BookmarkFolder(node.itemId, this._id);
items.push(folder);
}
else if (node.type == node.RESULT_TYPE_SEPARATOR) {
var separator = new Bookmark(node.itemId, this._id, "separator");
items.push(separator);
}
else {
var bookmark = new Bookmark(node.itemId, this._id, "bookmark");
items.push(bookmark);
}
}
rootNode.containerOpen = false;
return items;
},
addBookmark: function bmf_addbm(aTitle, aUri) {
var newBookmarkID = Utilities.bookmarks.insertBookmark(this._id, aUri, Utilities.bookmarks.DEFAULT_INDEX, aTitle);
var newBookmark = new Bookmark(newBookmarkID, this, "bookmark");
return newBookmark;
},
addSeparator: function bmf_addsep() {
var newBookmarkID = Utilities.bookmarks.insertSeparator(this._id, Utilities.bookmarks.DEFAULT_INDEX);
var newBookmark = new Bookmark(newBookmarkID, this, "separator");
return newBookmark;
},
addFolder: function bmf_addfolder(aTitle) {
var newFolderID = Utilities.bookmarks.createFolder(this._id, aTitle, Utilities.bookmarks.DEFAULT_INDEX);
var newFolder = new BookmarkFolder(newFolderID, this);
return newFolder;
},
remove: function bmf_remove() {
Utilities.bookmarks.removeItem(this._id);
},
// observer
onBeginUpdateBatch: function () {},
onEndUpdateBatch : function () {},
onItemAdded : function () {},
onItemRemoved : function () {},
onItemChanged : function () {},
onItemMoved: function bf_onItemMoved(aId, aOldParent, aOldIndex, aNewParent, aNewIndex) {
if (this._id == aId) {
this._parent = new BookmarkFolder(aNewParent, Utilities.bookmarks.getFolderIdForItem(aNewParent));
}
},
QueryInterface: XPCOMUtils.generateQI([Ci.fuelIBookmarkFolder,
Ci.nsINavBookmarksObserver,
Ci.nsISupportsWeakReference])
};
//=================================================
// BookmarkRoots implementation
function BookmarkRoots() {
}
BookmarkRoots.prototype = {
get menu() {
if (!this._menu)
this._menu = new BookmarkFolder(Utilities.bookmarks.bookmarksMenuFolder, null);
return this._menu;
},
get toolbar() {
if (!this._toolbar)
this._toolbar = new BookmarkFolder(Utilities.bookmarks.toolbarFolder, null);
return this._toolbar;
},
get tags() {
if (!this._tags)
this._tags = new BookmarkFolder(Utilities.bookmarks.tagsFolder, null);
return this._tags;
},
get unfiled() {
if (!this._unfiled)
this._unfiled = new BookmarkFolder(Utilities.bookmarks.unfiledBookmarksFolder, null);
return this._unfiled;
},
QueryInterface: XPCOMUtils.generateQI([Ci.fuelIBookmarkRoots])
};
//=================================================
// Factory - Treat Application as a singleton
// XXX This is required, because we're registered for the 'JavaScript global
// privileged property' category, whose handler always calls createInstance.
// See bug 386535.
var gSingleton = null;
var ApplicationFactory = {
createInstance: function af_ci(aOuter, aIID) {
if (aOuter != null)
throw Components.results.NS_ERROR_NO_AGGREGATION;
if (gSingleton == null) {
gSingleton = new Application();
}
return gSingleton.QueryInterface(aIID);
}
};
#include ../../../toolkit/components/exthelper/extApplication.js
//=================================================
// Application constructor
function Application() {
Deprecated.warning("FUEL is deprecated, you should use the add-on SDK instead.",
"https://developer.mozilla.org/Add-ons/SDK/");
this.initToolkitHelpers();
}
//=================================================
// Application implementation
function ApplicationPrototype() {
// for nsIClassInfo + XPCOMUtils
this.classID = APPLICATION_CID;
// redefine the default factory for XPCOMUtils
this._xpcom_factory = ApplicationFactory;
// for nsISupports
this.QueryInterface = XPCOMUtils.generateQI([
Ci.fuelIApplication,
Ci.extIApplication,
Ci.nsIObserver,
Ci.nsISupportsWeakReference
]);
// for nsIClassInfo
this.classInfo = XPCOMUtils.generateCI({
classID: APPLICATION_CID,
contractID: APPLICATION_CONTRACTID,
interfaces: [
Ci.fuelIApplication,
Ci.extIApplication,
Ci.nsIObserver
],
flags: Ci.nsIClassInfo.SINGLETON
});
// for nsIObserver
this.observe = function (aSubject, aTopic, aData) {
// Call the extApplication version of this function first
var superPrototype = Object.getPrototypeOf(Object.getPrototypeOf(this));
superPrototype.observe.call(this, aSubject, aTopic, aData);
if (aTopic == "xpcom-shutdown") {
this._obs.removeObserver(this, "xpcom-shutdown");
Utilities.free();
}
};
Object.defineProperty(this, "bookmarks", {
get: function bookmarks () {
let bookmarks = new BookmarkRoots();
Object.defineProperty(this, "bookmarks", { value: bookmarks });
return this.bookmarks;
},
enumerable: true,
configurable: true
});
Object.defineProperty(this, "windows", {
get: function windows() {
var win = [];
var browserEnum = Utilities.windowMediator.getEnumerator("navigator:browser");
while (browserEnum.hasMoreElements())
win.push(getWindow(browserEnum.getNext()));
return win;
},
enumerable: true,
configurable: true
});
Object.defineProperty(this, "activeWindow", {
get: () => getWindow(Utilities.windowMediator.getMostRecentWindow("navigator:browser")),
enumerable: true,
configurable: true
});
};
// set the proto, defined in extApplication.js
ApplicationPrototype.prototype = extApplication.prototype;
Application.prototype = new ApplicationPrototype();
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Application]);
@@ -1,3 +0,0 @@
component {fe74cf80-aa2d-11db-abbd-0800200c9a66} fuelApplication.js
contract @mozilla.org/fuel/application;1 {fe74cf80-aa2d-11db-abbd-0800200c9a66}
category JavaScript-global-privileged-property Application @mozilla.org/fuel/application;1
@@ -1,347 +0,0 @@
/* 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/. */
#include "nsISupports.idl"
#include "extIApplication.idl"
interface nsIVariant;
interface nsIURI;
interface nsIDOMHTMLDocument;
interface fuelIBookmarkFolder;
interface fuelIBrowserTab;
/**
* Interface representing a collection of annotations associated
* with a bookmark or bookmark folder.
*/
[scriptable, uuid(335c9292-91a1-4ca0-ad0b-07d5f63ed6cd)]
interface fuelIAnnotations : nsISupports
{
/**
* Array of the annotation names associated with the owning item
*/
readonly attribute nsIVariant names;
/**
* Determines if an annotation exists with the given name.
* @param aName
* The name of the annotation
* @returns true if an annotation exists with the given name,
* false otherwise.
*/
boolean has(in AString aName);
/**
* Gets the value of an annotation with the given name.
* @param aName
* The name of the annotation
* @returns A variant containing the value of the annotation. Supports
* string, boolean and number.
*/
nsIVariant get(in AString aName);
/**
* Sets the value of an annotation with the given name.
* @param aName
* The name of the annotation
* @param aValue
* The new value of the annotation. Supports string, boolean
* and number
* @param aExpiration
* The expiration policy for the annotation.
* See nsIAnnotationService.
*/
void set(in AString aName, in nsIVariant aValue, in int32_t aExpiration);
/**
* Removes the named annotation from the owner item.
* @param aName
* The name of annotation.
*/
void remove(in AString aName);
};
/**
* Interface representing a bookmark item.
*/
[scriptable, uuid(808585b6-7568-4b26-8c62-545221bf2b8c)]
interface fuelIBookmark : nsISupports
{
/**
* The id of the bookmark.
*/
readonly attribute long long id;
/**
* The title of the bookmark.
*/
attribute AString title;
/**
* The uri of the bookmark.
*/
attribute nsIURI uri;
/**
* The description of the bookmark.
*/
attribute AString description;
/**
* The keyword associated with the bookmark.
*/
attribute AString keyword;
/**
* The type of the bookmark.
* values: "bookmark", "separator"
*/
readonly attribute AString type;
/**
* The parent folder of the bookmark.
*/
attribute fuelIBookmarkFolder parent;
/**
* The annotations object for the bookmark.
*/
readonly attribute fuelIAnnotations annotations;
/**
* The events object for the bookmark.
* supports: "remove", "change", "visit", "move"
*/
readonly attribute extIEvents events;
/**
* Removes the item from the parent folder. Used to
* delete a bookmark or separator
*/
void remove();
};
/**
* Interface representing a bookmark folder. Folders
* can hold bookmarks, separators and other folders.
*/
[scriptable, uuid(9f42fe20-52de-4a55-8632-a459c7716aa0)]
interface fuelIBookmarkFolder : nsISupports
{
/**
* The id of the folder.
*/
readonly attribute long long id;
/**
* The title of the folder.
*/
attribute AString title;
/**
* The description of the folder.
*/
attribute AString description;
/**
* The type of the folder.
* values: "folder"
*/
readonly attribute AString type;
/**
* The parent folder of the folder.
*/
attribute fuelIBookmarkFolder parent;
/**
* The annotations object for the folder.
*/
readonly attribute fuelIAnnotations annotations;
/**
* The events object for the folder.
* supports: "add", "addchild", "remove", "removechild", "change", "move"
*/
readonly attribute extIEvents events;
/**
* Array of all bookmarks, separators and folders contained
* in this folder.
*/
readonly attribute nsIVariant children;
/**
* Adds a new child bookmark to this folder.
* @param aTitle
* The title of bookmark.
* @param aURI
* The uri of bookmark.
*/
fuelIBookmark addBookmark(in AString aTitle, in nsIURI aURI);
/**
* Adds a new child separator to this folder.
*/
fuelIBookmark addSeparator();
/**
* Adds a new child folder to this folder.
* @param aTitle
* The title of folder.
*/
fuelIBookmarkFolder addFolder(in AString aTitle);
/**
* Removes the folder from the parent folder.
*/
void remove();
};
/**
* Interface representing a container for bookmark roots. Roots
* are the top level parents for the various types of bookmarks in the system.
*/
[scriptable, uuid(c9a80870-eb3c-11dc-95ff-0800200c9a66)]
interface fuelIBookmarkRoots : nsISupports
{
/**
* The folder for the 'bookmarks menu' root.
*/
readonly attribute fuelIBookmarkFolder menu;
/**
* The folder for the 'personal toolbar' root.
*/
readonly attribute fuelIBookmarkFolder toolbar;
/**
* The folder for the 'tags' root.
*/
readonly attribute fuelIBookmarkFolder tags;
/**
* The folder for the 'unfiled bookmarks' root.
*/
readonly attribute fuelIBookmarkFolder unfiled;
};
/**
* Interface representing a browser window.
*/
[scriptable, uuid(207edb28-eb5e-424e-a862-b0e97C8de866)]
interface fuelIWindow : nsISupports
{
/**
* A collection of browser tabs within the browser window.
*/
readonly attribute nsIVariant tabs;
/**
* The currently-active tab within the browser window.
*/
readonly attribute fuelIBrowserTab activeTab;
/**
* Open a new browser tab, pointing to the specified URI.
* @param aURI
* The uri to open the browser tab to
*/
fuelIBrowserTab open(in nsIURI aURI);
/**
* The events object for the browser window.
* supports: "TabOpen", "TabClose", "TabMove", "TabSelect"
*/
readonly attribute extIEvents events;
};
/**
* Interface representing a browser tab.
*/
[scriptable, uuid(3073ceff-777c-41ce-9ace-ab37268147c1)]
interface fuelIBrowserTab : nsISupports
{
/**
* The current uri of this tab.
*/
readonly attribute nsIURI uri;
/**
* The current index of this tab in the browser window.
*/
readonly attribute int32_t index;
/**
* The browser window that is holding the tab.
*/
readonly attribute fuelIWindow window;
/**
* The content document of the browser tab.
*/
readonly attribute nsIDOMHTMLDocument document;
/**
* The events object for the browser tab.
* supports: "load"
*/
readonly attribute extIEvents events;
/**
* Load a new URI into this browser tab.
* @param aURI
* The uri to load into the browser tab
*/
void load(in nsIURI aURI);
/**
* Give focus to this browser tab, and bring it to the front.
*/
void focus();
/**
* Close the browser tab. This may not actually close the tab
* as script may abort the close operation.
*/
void close();
/**
* Moves this browser tab before another browser tab within the window.
* @param aBefore
* The tab before which the target tab will be moved
*/
void moveBefore(in fuelIBrowserTab aBefore);
/**
* Move this browser tab to the last tab within the window.
*/
void moveToEnd();
};
/**
* Interface for managing and accessing the applications systems
*/
[scriptable, uuid(fe74cf80-aa2d-11db-abbd-0800200c9a66)]
interface fuelIApplication : extIApplication
{
/**
* The root bookmarks object for the application.
* Contains all the bookmark roots in the system.
*/
readonly attribute fuelIBookmarkRoots bookmarks;
/**
* An array of browser windows within the application.
*/
readonly attribute nsIVariant windows;
/**
* The currently active browser window.
*/
readonly attribute fuelIWindow activeWindow;
};
-17
View File
@@ -1,17 +0,0 @@
# This file currently uses a non-standard (and not on a standards track)
# if statement within catch.
modules/MozLoopWorker.js
# This file currently uses es7 features eslint issue:
# https://github.com/eslint/espree/issues/125
modules/MozLoopAPI.jsm
# Libs we don't need to check
content/libs
content/shared/libs
standalone/content/libs
standalone/node_modules
# We should look at turning these on when we fix the warnings
test/xpcshell
test/mochitest
# Libs we don't need to check
test/shared/vendor
-95
View File
@@ -1,95 +0,0 @@
// Note: there are extra allowances for files used solely in Firefox desktop,
// see content/js/.eslintrc and modules/.eslintrc
{
"plugins": [
"react"
],
"ecmaFeatures": {
"forOf": true,
"jsx": true,
},
"env": {
"browser": true,
"mocha": true
},
"globals": {
"_": false,
"$": false,
"Backbone": false,
"chai": false,
"console": false,
"jQuery": false,
"loop": false,
"MozActivity": false,
"OT": false,
"Promise": false,
"React": false,
"sinon": false
},
"rules": {
// turn off all kinds of stuff that we actually do want, because
// right now, we're bootstrapping the linting infrastructure. We'll
// want to audit these rules, and start turning them on and fixing the
// problems they find, one at a time.
// Eslint built-in rules are documented at <http://eslint.org/docs/rules/>
"camelcase": 0, // TODO: Remove (use default)
"comma-dangle": 0, // TODO: Remove (use default)
"comma-spacing": 0, // TODO: Remove (use default)
"consistent-return": 0, // TODO: Remove (use default)
"curly": 0, // TODO: Remove (use default)
"dot-notation": 0, // TODO: Remove (use default)
"eol-last": 0, // TODO: Remove (use default)
"eqeqeq": 0, // TBD. Might need to be separate for content & chrome
"global-strict": 0, // Leave as zero (this will be unsupported in eslint 1.0.0)
"key-spacing": 0, // TODO: Remove (use default)
"new-cap": 0, // TODO: Remove (use default)
"no-catch-shadow": 0, // TODO: Remove (use default)
"no-console": 0, // Leave as 0. We use console logging in content code.
"no-empty": 0, // TODO: Remove (use default)
"no-extra-bind": 0, // Leave as 0
"no-extra-boolean-cast": 0, // TODO: Remove (use default)
"no-multi-spaces": 0, // TBD.
"no-new": 0, // TODO: Remove (use default)
"no-redeclare": 0, // TODO: Remove (use default)
"no-return-assign": 0, // TODO: Remove (use default)
"no-shadow": 0, // TODO: Remove (use default)
"no-spaced-func": 0, // TODO: Remove (use default)
"no-trailing-spaces": 0, // TODO: Remove (use default)
"no-undef": 0, // TODO: Remove (use default)
"no-underscore-dangle": 0, // Leave as 0. Commonly used for private variables.
"no-unused-expressions": 0, // TODO: Remove (use default)
"no-unused-vars": 0, // TODO: Remove (use default)
"no-use-before-define": 0, // TODO: Remove (use default)
"no-wrap-func": 0, // TODO: Remove (use default)
"quotes": 0, // [2, "double", "avoid-escape"],
"space-infix-ops": 0, // TODO: Remove (use default)
"space-return-throw-case": 0, // TODO: Remove (use default)
"strict": 0, // [2, "function"],
"yoda": 0, // [2, "never"],
// eslint-plugin-react rules. These are documented at
// <https://github.com/yannickcr/eslint-plugin-react#list-of-supported-rules>
"react/jsx-quotes": [2, "double", "avoid-escape"],
"react/jsx-no-undef": 2,
// Need to fix instances where this is failing.
"react/jsx-sort-props": 0,
"react/jsx-sort-prop-types": 0,
"react/jsx-uses-vars": 2,
// Need to fix the couple of instances which don't
// currently pass this rule.
"react/no-did-mount-set-state": 0,
"react/no-did-update-set-state": 2,
"react/no-unknown-property": 2,
// Need to fix instances where this is currently failing
"react/prop-types": 0,
"react/self-closing-comp": 2,
"react/wrap-multilines": 2,
// Not worth it: React is defined globally
"react/jsx-uses-react": 0,
"react/react-in-jsx-scope": 0,
// These ones we don't want to ever enable
"react/display-name": 0,
"react/jsx-boolean-value": 0,
"react/no-multi-comp": 0
}
}
-839
View File
@@ -1,839 +0,0 @@
/* 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";
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://services-common/utils.js");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/loop/LoopCalls.jsm");
Cu.import("resource:///modules/loop/MozLoopService.jsm");
Cu.import("resource:///modules/loop/LoopRooms.jsm");
Cu.import("resource:///modules/loop/LoopContacts.jsm");
Cu.importGlobalProperties(["Blob"]);
XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
"resource:///modules/loop/LoopContacts.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
"resource:///modules/loop/LoopStorage.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
"resource://gre/modules/MozSocialAPI.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
"resource://gre/modules/PluralForm.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UITour",
"resource:///modules/UITour.jsm");
XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
return Cc["@mozilla.org/xre/app-info;1"]
.getService(Ci.nsIXULAppInfo)
.QueryInterface(Ci.nsIXULRuntime);
});
XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
"@mozilla.org/widget/clipboardhelper;1",
"nsIClipboardHelper");
XPCOMUtils.defineLazyServiceGetter(this, "extProtocolSvc",
"@mozilla.org/uriloader/external-protocol-service;1",
"nsIExternalProtocolService");
this.EXPORTED_SYMBOLS = ["injectLoopAPI"];
/**
* Trying to clone an Error object into a different container will yield an error.
* We can work around this by copying the properties we care about onto a regular
* object.
*
* @param {Error|nsIException} error Error object to copy
* @param {nsIDOMWindow} targetWindow The content window to clone into
*/
const cloneErrorObject = function(error, targetWindow) {
let obj = new targetWindow.Error();
let props = Object.getOwnPropertyNames(error);
// nsIException properties are not enumerable, so we'll try to copy the most
// common and useful ones.
if (!props.length) {
props.push("message", "filename", "lineNumber", "columnNumber", "stack");
}
for (let prop of props) {
let value = error[prop];
// for nsIException objects, the property may not be defined.
if (typeof value == "undefined") {
continue;
}
if (typeof value != "string" && typeof value != "number") {
value = String(value);
}
Object.defineProperty(Cu.waiveXrays(obj), prop, {
configurable: false,
enumerable: true,
value: value,
writable: false
});
}
return obj;
};
/**
* Makes an object or value available to an unprivileged target window.
*
* Primitives are returned as they are, while objects are cloned into the
* specified target. Error objects are also handled correctly.
*
* @param {any} value Value or object to copy
* @param {nsIDOMWindow} targetWindow The content window to copy to
*/
const cloneValueInto = function(value, targetWindow) {
if (!value || typeof value != "object") {
return value;
}
// HAWK request errors contain an nsIException object inside `value`.
if (("error" in value) && (value.error instanceof Ci.nsIException)) {
value = value.error;
}
// Strip Function properties, since they can not be cloned across boundaries
// like this.
for (let prop of Object.getOwnPropertyNames(value)) {
if (typeof value[prop] == "function") {
delete value[prop];
}
}
// Inspect for an error this way, because the Error object is special.
if (value.constructor.name == "Error" || value instanceof Ci.nsIException) {
return cloneErrorObject(value, targetWindow);
}
let clone;
try {
clone = Cu.cloneInto(value, targetWindow);
} catch (ex) {
MozLoopService.log.debug("Failed to clone value:", value);
throw ex;
}
return clone;
};
/**
* Inject any API containing _only_ function properties into the given window.
*
* @param {Object} api Object containing functions that need to
* be exposed to content
* @param {nsIDOMWindow} targetWindow The content window to attach the API
*/
const injectObjectAPI = function(api, targetWindow) {
let injectedAPI = {};
// Wrap all the methods in `api` to help results passed to callbacks get
// through the priv => unpriv barrier with `Cu.cloneInto()`.
Object.keys(api).forEach(func => {
injectedAPI[func] = function(...params) {
let lastParam = params.pop();
let callbackIsFunction = (typeof lastParam == "function");
// Clone params coming from content to the current scope.
params = [cloneValueInto(p, api) for (p of params)];
// If the last parameter is a function, assume its a callback
// and wrap it differently.
if (callbackIsFunction) {
api[func](...params, function(...results) {
// When the function was garbage collected due to async events, like
// closing a window, we want to circumvent a JS error.
if (callbackIsFunction && typeof lastParam != "function") {
MozLoopService.log.debug(func + ": callback function was lost.");
// Assume the presence of a first result argument to be an error.
if (results[0]) {
MozLoopService.log.error(func + " error:", results[0]);
}
return;
}
lastParam(...[cloneValueInto(r, targetWindow) for (r of results)]);
});
} else {
try {
lastParam = cloneValueInto(lastParam, api);
return cloneValueInto(api[func](...params, lastParam), targetWindow);
} catch (ex) {
return cloneValueInto(ex, targetWindow);
}
}
};
});
let contentObj = Cu.cloneInto(injectedAPI, targetWindow, {cloneFunctions: true});
// Since we deny preventExtensions on XrayWrappers, because Xray semantics make
// it difficult to act like an object has actually been frozen, we try to seal
// the `contentObj` without Xrays.
try {
Object.seal(Cu.waiveXrays(contentObj));
} catch (ex) {}
return contentObj;
};
/**
* Inject the loop API into the given window. The caller must be sure the
* window is a loop content window (eg, a panel, chatwindow, or similar).
*
* See the documentation on the individual functions for details of the API.
*
* @param {nsIDOMWindow} targetWindow The content window to attach the API.
*/
function injectLoopAPI(targetWindow) {
let ringer;
let ringerStopper;
let appVersionInfo;
let contactsAPI;
let roomsAPI;
let callsAPI;
let api = {
/**
* Gets an object with data that represents the currently
* authenticated user's identity.
*
* @return null if user not logged in; profile object otherwise
*/
userProfile: {
enumerable: true,
get: function() {
if (!MozLoopService.userProfile)
return null;
let userProfile = Cu.cloneInto({
email: MozLoopService.userProfile.email,
uid: MozLoopService.userProfile.uid
}, targetWindow);
return userProfile;
}
},
/**
* Sets and gets the "do not disturb" mode activation flag.
*/
doNotDisturb: {
enumerable: true,
get: function() {
return MozLoopService.doNotDisturb;
},
set: function(aFlag) {
MozLoopService.doNotDisturb = aFlag;
}
},
errors: {
enumerable: true,
get: function() {
let errors = {};
for (let [type, error] of MozLoopService.errors) {
// if error.error is an nsIException, just delete it since it's hard
// to clone across the boundary.
if (error.error instanceof Ci.nsIException) {
MozLoopService.log.debug("Warning: Some errors were omitted from MozLoopAPI.errors " +
"due to issues copying nsIException across boundaries.",
error.error);
delete error.error;
}
// We have to clone the error property since it may be an Error object.
if (error.hasOwnProperty("toString")) {
delete error.toString;
}
errors[type] = Cu.waiveXrays(Cu.cloneInto(error, targetWindow, { cloneFunctions: true }));
}
return Cu.cloneInto(errors, targetWindow, { cloneFunctions: true });
},
},
/**
* Returns the current locale of the browser.
*
* @returns {String} The locale string
*/
locale: {
enumerable: true,
get: function() {
return MozLoopService.locale;
}
},
getActiveTabWindowId: {
enumerable: true,
writable: true,
value: function(callback) {
let win = Services.wm.getMostRecentWindow("navigator:browser");
let browser = win && win.gBrowser.selectedTab.linkedBrowser;
if (!win || !browser) {
// This may happen when an undocked conversation window is the only
// window left.
let err = new Error("No tabs available to share.");
MozLoopService.log.error(err);
callback(cloneValueInto(err, targetWindow));
return;
}
let mm = browser.messageManager;
mm.addMessageListener("webrtc:response:StartBrowserSharing", function listener(message) {
mm.removeMessageListener("webrtc:response:StartBrowserSharing", listener);
callback(null, message.data.windowID);
});
mm.sendAsyncMessage("webrtc:StartBrowserSharing");
}
},
/**
* Returns the window data for a specific conversation window id.
*
* This data will be relevant to the type of window, e.g. rooms or calls.
* See LoopRooms or LoopCalls for more information.
*
* @param {String} conversationWindowId
* @returns {Object} The window data or null if error.
*/
getConversationWindowData: {
enumerable: true,
writable: true,
value: function(conversationWindowId) {
return cloneValueInto(MozLoopService.getConversationWindowData(conversationWindowId),
targetWindow);
}
},
/**
* Returns the contacts API.
*
* @returns {Object} The contacts API object
*/
contacts: {
enumerable: true,
get: function() {
if (contactsAPI) {
return contactsAPI;
}
// Make a database switch when a userProfile is active already.
let profile = MozLoopService.userProfile;
if (profile) {
LoopStorage.switchDatabase(profile.uid);
}
return contactsAPI = injectObjectAPI(LoopContacts, targetWindow);
}
},
/**
* Returns the rooms API.
*
* @returns {Object} The rooms API object
*/
rooms: {
enumerable: true,
get: function() {
if (roomsAPI) {
return roomsAPI;
}
return roomsAPI = injectObjectAPI(LoopRooms, targetWindow);
}
},
/**
* Returns the calls API.
*
* @returns {Object} The rooms API object
*/
calls: {
enumerable: true,
get: function() {
if (callsAPI) {
return callsAPI;
}
return callsAPI = injectObjectAPI(LoopCalls, targetWindow);
}
},
/**
* Import a list of (new) contacts from an external data source.
*
* @param {Object} options Property bag of options for the importer
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation, if successfull.
*/
startImport: {
enumerable: true,
writable: true,
value: function(options, callback) {
LoopContacts.startImport(options, getChromeWindow(targetWindow), function(...results) {
callback(...[cloneValueInto(r, targetWindow) for (r of results)]);
});
}
},
/**
* Returns translated strings associated with an element. Designed
* for use with l10n.js
*
* @param {String} key The element id
* @returns {Object} A JSON string containing the localized
* attribute/value pairs for the element.
*/
getStrings: {
enumerable: true,
writable: true,
value: function(key) {
return MozLoopService.getStrings(key);
}
},
/**
* Returns the correct form of a semi-colon separated string
* based on the value of the `num` argument and the current locale.
*
* @param {Integer} num The number used to find the plural form.
* @param {String} str The semi-colon separated string of word forms.
* @returns {String} The correct word form based on the value of the number
* and the current locale.
*/
getPluralForm: {
enumerable: true,
writable: true,
value: function(num, str) {
return PluralForm.get(num, str);
}
},
/**
* Displays a confirmation dialog using the specified strings.
*
* @param {Object} options Confirm dialog options
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument
* will be the result of the operation, TRUE if
* the user chose the OK button.
*/
confirm: {
enumerable: true,
writable: true,
value: function(options, callback) {
let buttonFlags;
if (options.okButton && options.cancelButton) {
buttonFlags =
(Ci.nsIPrompt.BUTTON_POS_0 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING) +
(Ci.nsIPrompt.BUTTON_POS_1 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING);
} else if (!options.okButton && !options.cancelButton) {
buttonFlags = Services.prompt.STD_YES_NO_BUTTONS;
} else {
callback(cloneValueInto(new Error("confirm: missing button options"), targetWindow));
}
try {
let chosenButton = Services.prompt.confirmEx(null, "",
options.message, buttonFlags, options.okButton, options.cancelButton,
null, null, {});
callback(null, chosenButton == 0);
} catch (ex) {
callback(cloneValueInto(ex, targetWindow));
}
}
},
/**
* Set any preference under "loop."
*
* @param {String} prefName The name of the pref without the preceding "loop."
* @param {*} value The value to set.
* @param {Enum} prefType Type of preference, defined at Ci.nsIPrefBranch. Optional.
*
* Any errors thrown by the Mozilla pref API are logged to the console
* and cause false to be returned.
*/
setLoopPref: {
enumerable: true,
writable: true,
value: function(prefName, value, prefType) {
MozLoopService.setLoopPref(prefName, value, prefType);
}
},
/**
* Return any preference under "loop.".
*
* @param {String} prefName The name of the pref without the preceding
* "loop."
* @param {Enum} prefType Type of preference, defined at Ci.nsIPrefBranch. Optional.
*
* Any errors thrown by the Mozilla pref API are logged to the console
* and cause null to be returned. This includes the case of the preference
* not being found.
*
* @return {*} on success, null on error
*/
getLoopPref: {
enumerable: true,
writable: true,
value: function(prefName, prefType) {
return MozLoopService.getLoopPref(prefName);
}
},
/**
* Starts alerting the user about an incoming call
*/
startAlerting: {
enumerable: true,
writable: true,
value: function() {
let chromeWindow = getChromeWindow(targetWindow);
chromeWindow.getAttention();
ringer = new chromeWindow.Audio();
ringer.src = Services.prefs.getCharPref("loop.ringtone");
ringer.loop = true;
ringer.load();
ringer.play();
targetWindow.document.addEventListener("visibilitychange",
ringerStopper = function(event) {
if (event.currentTarget.hidden) {
api.stopAlerting.value();
}
});
}
},
/**
* Stops alerting the user about an incoming call
*/
stopAlerting: {
enumerable: true,
writable: true,
value: function() {
if (ringerStopper) {
targetWindow.document.removeEventListener("visibilitychange",
ringerStopper);
ringerStopper = null;
}
if (ringer) {
ringer.pause();
ringer = null;
}
}
},
/**
* Performs a hawk based request to the loop server.
*
* Callback parameters:
* - {Object|null} null if success. Otherwise an object:
* {
* code: 401,
* errno: 401,
* error: "Request failed",
* message: "invalid token"
* }
* - {String} The body of the response.
*
* @param {LOOP_SESSION_TYPE} sessionType The type of session to use for
* the request. This is one of the
* LOOP_SESSION_TYPE members
* @param {String} path The path to make the request to.
* @param {String} method The request method, e.g. 'POST', 'GET'.
* @param {Object} payloadObj An object which is converted to JSON and
* transmitted with the request.
* @param {Function} callback Called when the request completes.
*/
hawkRequest: {
enumerable: true,
writable: true,
value: function(sessionType, path, method, payloadObj, callback) {
// XXX Should really return a DOM promise here.
let callbackIsFunction = (typeof callback == "function");
MozLoopService.hawkRequest(sessionType, path, method, payloadObj).then((response) => {
callback(null, response.body);
}, hawkError => {
// When the function was garbage collected due to async events, like
// closing a window, we want to circumvent a JS error.
if (callbackIsFunction && typeof callback != "function") {
MozLoopService.log.error("hawkRequest: callback function was lost.", hawkError);
return;
}
// The hawkError.error property, while usually a string representing
// an HTTP response status message, may also incorrectly be a native
// error object that will cause the cloning function to fail.
callback(Cu.cloneInto({
error: (hawkError.error && typeof hawkError.error == "string")
? hawkError.error : "Unexpected exception",
message: hawkError.message,
code: hawkError.code,
errno: hawkError.errno,
}, targetWindow));
}).catch(Cu.reportError);
}
},
LOOP_SESSION_TYPE: {
enumerable: true,
get: function() {
return Cu.cloneInto(LOOP_SESSION_TYPE, targetWindow);
}
},
fxAEnabled: {
enumerable: true,
get: function() {
return MozLoopService.fxAEnabled;
},
},
logInToFxA: {
enumerable: true,
writable: true,
value: function() {
return MozLoopService.logInToFxA();
}
},
logOutFromFxA: {
enumerable: true,
writable: true,
value: function() {
return MozLoopService.logOutFromFxA();
}
},
openFxASettings: {
enumerable: true,
writable: true,
value: function() {
return MozLoopService.openFxASettings();
},
},
/**
* Opens the Getting Started tour in the browser.
*
* @param {String} aSrc
* - The UI element that the user used to begin the tour, optional.
*/
openGettingStartedTour: {
enumerable: true,
writable: true,
value: function(aSrc) {
return MozLoopService.openGettingStartedTour(aSrc);
},
},
/**
* Copies passed string onto the system clipboard.
*
* @param {String} str The string to copy
*/
copyString: {
enumerable: true,
writable: true,
value: function(str) {
clipboardHelper.copyString(str);
}
},
/**
* Returns the app version information for use during feedback.
*
* @return {Object} An object containing:
* - channel: The update channel the application is on
* - version: The application version
* - OS: The operating system the application is running on
*/
appVersionInfo: {
enumerable: true,
get: function() {
if (!appVersionInfo) {
let defaults = Services.prefs.getDefaultBranch(null);
// If the lazy getter explodes, we're probably loaded in xpcshell,
// which doesn't have what we need, so log an error.
try {
appVersionInfo = Cu.cloneInto({
channel: defaults.getCharPref("app.update.channel"),
version: appInfo.version,
OS: appInfo.OS
}, targetWindow);
} catch (ex) {
// only log outside of xpcshell to avoid extra message noise
if (typeof targetWindow !== 'undefined' && "console" in targetWindow) {
MozLoopService.log.error("Failed to construct appVersionInfo; if this isn't " +
"an xpcshell unit test, something is wrong", ex);
}
}
}
return appVersionInfo;
}
},
/**
* Composes an email via the external protocol service.
*
* @param {String} subject Subject of the email to send
* @param {String} body Body message of the email to send
* @param {String} recipient Recipient email address (optional)
*/
composeEmail: {
enumerable: true,
writable: true,
value: function(subject, body, recipient) {
recipient = recipient || "";
let mailtoURL = "mailto:" + encodeURIComponent(recipient) +
"?subject=" + encodeURIComponent(subject) +
"&body=" + encodeURIComponent(body);
extProtocolSvc.loadURI(CommonUtils.makeURI(mailtoURL));
}
},
/**
* Adds a value to a telemetry histogram.
*
* @param {string} histogramId Name of the telemetry histogram to update.
* @param {integer} value Value to add to the histogram.
*/
telemetryAdd: {
enumerable: true,
writable: true,
value: function(histogramId, value) {
Services.telemetry.getHistogramById(histogramId).add(value);
}
},
/**
* Returns a new GUID (UUID) in curly braces format.
*/
generateUUID: {
enumerable: true,
writable: true,
value: function() {
return MozLoopService.generateUUID();
}
},
getAudioBlob: {
enumerable: true,
writable: true,
value: function(name, callback) {
let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
.createInstance(Ci.nsIXMLHttpRequest);
let url = `chrome://browser/content/loop/shared/sounds/${name}.ogg`;
request.open("GET", url, true);
request.responseType = "arraybuffer";
request.onload = () => {
if (request.status < 200 || request.status >= 300) {
let error = new Error(request.status + " " + request.statusText);
callback(cloneValueInto(error, targetWindow));
return;
}
let blob = new Blob([request.response], {type: "audio/ogg"});
callback(null, cloneValueInto(blob, targetWindow));
};
request.send();
}
},
/**
* Associates a session-id and a call-id with a window for debugging.
*
* @param {string} windowId The window id.
* @param {string} sessionId OT session id.
* @param {string} callId The callId on the server.
*/
addConversationContext: {
enumerable: true,
writable: true,
value: function(windowId, sessionId, callid) {
MozLoopService.addConversationContext(windowId, {
sessionId: sessionId,
callId: callid
});
}
},
/**
* Notifies the UITour module that an event occurred that it might be
* interested in.
*
* @param {String} subject Subject of the notification
* @param {mixed} [params] Optional parameters, providing more details to
* the notification subject
*/
notifyUITour: {
enumerable: true,
writable: true,
value: function(subject, params) {
UITour.notify(subject, params);
}
},
/**
* Used to record the screen sharing state for a window so that it can
* be reflected on the toolbar button.
*
* @param {String} windowId The id of the conversation window the state
* is being changed for.
* @param {Boolean} active Whether or not screen sharing is now active.
*/
setScreenShareState: {
enumerable: true,
writable: true,
value: function(windowId, active) {
MozLoopService.setScreenShareState(windowId, active);
}
}
};
function onStatusChanged(aSubject, aTopic, aData) {
let event = new targetWindow.CustomEvent("LoopStatusChanged");
targetWindow.dispatchEvent(event);
};
function onDOMWindowDestroyed(aSubject, aTopic, aData) {
if (targetWindow && aSubject != targetWindow)
return;
Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed");
Services.obs.removeObserver(onStatusChanged, "loop-status-changed");
};
let contentObj = Cu.createObjectIn(targetWindow);
Object.defineProperties(contentObj, api);
Object.seal(contentObj);
Cu.makeObjectPropsNormal(contentObj);
Services.obs.addObserver(onStatusChanged, "loop-status-changed", false);
Services.obs.addObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
if ("navigator" in targetWindow) {
targetWindow.navigator.wrappedJSObject.__defineGetter__("mozLoop", function () {
// We do this in a getter, so that we create these objects
// only on demand (this is a potential concern, since
// otherwise we might add one per iframe, and keep them
// alive for as long as the window is alive).
delete targetWindow.navigator.wrappedJSObject.mozLoop;
return targetWindow.navigator.wrappedJSObject.mozLoop = contentObj;
});
// Handle window.close correctly on the panel and chatbox.
hookWindowCloseForPanelClose(targetWindow);
} else {
// This isn't a window; but it should be a JS scope; used for testing
return targetWindow.mozLoop = contentObj;
}
}
function getChromeWindow(contentWin) {
return contentWin.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShellTreeItem)
.rootTreeItem
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindow);
}
@@ -1,15 +0,0 @@
{
"ecmaFeatures": {
// These are on for this directory for .jsm and content/js files.
"blockBindings": true,
"arrowFunctions": true,
"destructuring": true,
"generators": true,
"spread": true,
"restParams": true,
"objectLiteralShorthandMethods": true
},
"rules": {
"generator-star-spacing": [2, "after"]
}
}
-966
View File
@@ -1,966 +0,0 @@
/** @jsx React.DOM */
/* 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/. */
/*jshint newcap:false*/
/*global loop:true, React */
var loop = loop || {};
loop.panel = (function(_, mozL10n) {
"use strict";
var sharedViews = loop.shared.views;
var sharedModels = loop.shared.models;
var sharedMixins = loop.shared.mixins;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var Button = sharedViews.Button;
var ButtonGroup = sharedViews.ButtonGroup;
var ContactsList = loop.contacts.ContactsList;
var ContactDetailsForm = loop.contacts.ContactDetailsForm;
var TabView = React.createClass({displayName: "TabView",
propTypes: {
buttonsHidden: React.PropTypes.array,
// The selectedTab prop is used by the UI showcase.
selectedTab: React.PropTypes.string,
mozLoop: React.PropTypes.object
},
getDefaultProps: function() {
return {
buttonsHidden: []
};
},
shouldComponentUpdate: function(nextProps, nextState) {
var tabChange = this.state.selectedTab !== nextState.selectedTab;
if (tabChange) {
this.props.mozLoop.notifyUITour("Loop:PanelTabChanged", nextState.selectedTab);
}
if (!tabChange && nextProps.buttonsHidden) {
if (nextProps.buttonsHidden.length !== this.props.buttonsHidden.length) {
tabChange = true;
} else {
for (var i = 0, l = nextProps.buttonsHidden.length; i < l && !tabChange; ++i) {
if (this.props.buttonsHidden.indexOf(nextProps.buttonsHidden[i]) === -1) {
tabChange = true;
}
}
}
}
return tabChange;
},
getInitialState: function() {
// XXX Work around props.selectedTab being undefined initially.
// When we don't need to rely on the pref, this can move back to
// getDefaultProps (bug 1100258).
return {
selectedTab: this.props.selectedTab || "rooms"
};
},
handleSelectTab: function(event) {
var tabName = event.target.dataset.tabName;
this.setState({selectedTab: tabName});
},
render: function() {
var cx = React.addons.classSet;
var tabButtons = [];
var tabs = [];
React.Children.forEach(this.props.children, function(tab, i) {
// Filter out null tabs (eg. rooms when the feature is disabled)
if (!tab) {
return;
}
var tabName = tab.props.name;
if (this.props.buttonsHidden.indexOf(tabName) > -1) {
return;
}
var isSelected = (this.state.selectedTab == tabName);
if (!tab.props.hidden) {
tabButtons.push(
React.createElement("li", {className: cx({selected: isSelected}),
key: i,
"data-tab-name": tabName,
title: mozL10n.get(tabName + "_tab_button_tooltip"),
onClick: this.handleSelectTab})
);
}
tabs.push(
React.createElement("div", {key: i, className: cx({tab: true, selected: isSelected})},
tab.props.children
)
);
}, this);
return (
React.createElement("div", {className: "tab-view-container"},
React.createElement("ul", {className: "tab-view"}, tabButtons),
tabs
)
);
}
});
var Tab = React.createClass({displayName: "Tab",
render: function() {
return null;
}
});
/**
* Availability drop down menu subview.
*/
var AvailabilityDropdown = React.createClass({displayName: "AvailabilityDropdown",
mixins: [sharedMixins.DropdownMenuMixin],
getInitialState: function() {
return {
doNotDisturb: navigator.mozLoop.doNotDisturb
};
},
// XXX target event can either be the li, the span or the i tag
// this makes it easier to figure out the target by making a
// closure with the desired status already passed in.
changeAvailability: function(newAvailabilty) {
return function(event) {
// Note: side effect!
switch (newAvailabilty) {
case 'available':
this.setState({doNotDisturb: false});
navigator.mozLoop.doNotDisturb = false;
break;
case 'do-not-disturb':
this.setState({doNotDisturb: true});
navigator.mozLoop.doNotDisturb = true;
break;
}
this.hideDropdownMenu();
}.bind(this);
},
render: function() {
// XXX https://github.com/facebook/react/issues/310 for === htmlFor
var cx = React.addons.classSet;
var availabilityStatus = cx({
'status': true,
'status-dnd': this.state.doNotDisturb,
'status-available': !this.state.doNotDisturb
});
var availabilityDropdown = cx({
'dropdown-menu': true,
'hide': !this.state.showMenu
});
var availabilityText = this.state.doNotDisturb ?
mozL10n.get("display_name_dnd_status") :
mozL10n.get("display_name_available_status");
return (
React.createElement("div", {className: "dropdown"},
React.createElement("p", {className: "dnd-status", onClick: this.toggleDropdownMenu, ref: "menu-button"},
React.createElement("span", null, availabilityText),
React.createElement("i", {className: availabilityStatus})
),
React.createElement("ul", {className: availabilityDropdown},
React.createElement("li", {onClick: this.changeAvailability("available"),
className: "dropdown-menu-item dnd-make-available"},
React.createElement("i", {className: "status status-available"}),
React.createElement("span", null, mozL10n.get("display_name_available_status"))
),
React.createElement("li", {onClick: this.changeAvailability("do-not-disturb"),
className: "dropdown-menu-item dnd-make-unavailable"},
React.createElement("i", {className: "status status-dnd"}),
React.createElement("span", null, mozL10n.get("display_name_dnd_status"))
)
)
)
);
}
});
var GettingStartedView = React.createClass({displayName: "GettingStartedView",
mixins: [sharedMixins.WindowCloseMixin],
handleButtonClick: function() {
navigator.mozLoop.openGettingStartedTour("getting-started");
navigator.mozLoop.setLoopPref("gettingStarted.seen", true);
var event = new CustomEvent("GettingStartedSeen");
window.dispatchEvent(event);
this.closeWindow();
},
render: function() {
if (navigator.mozLoop.getLoopPref("gettingStarted.seen")) {
return null;
}
return (
React.createElement("div", {id: "fte-getstarted"},
React.createElement("header", {id: "fte-title"},
mozL10n.get("first_time_experience_title", {
"clientShortname": mozL10n.get("clientShortname2")
})
),
React.createElement(Button, {htmlId: "fte-button",
onClick: this.handleButtonClick,
caption: mozL10n.get("first_time_experience_button_label")})
)
);
}
});
var ToSView = React.createClass({displayName: "ToSView",
mixins: [sharedMixins.WindowCloseMixin],
getInitialState: function() {
var getPref = navigator.mozLoop.getLoopPref.bind(navigator.mozLoop);
return {
seenToS: getPref("seenToS"),
gettingStartedSeen: getPref("gettingStarted.seen"),
showPartnerLogo: getPref("showPartnerLogo")
};
},
handleLinkClick: function(event) {
if (!event.target || !event.target.href) {
return;
}
event.preventDefault();
navigator.mozLoop.openURL(event.target.href);
this.closeWindow();
},
renderPartnerLogo: function() {
if (!this.state.showPartnerLogo) {
return null;
}
var locale = mozL10n.getLanguage();
navigator.mozLoop.setLoopPref('showPartnerLogo', false);
return (
React.createElement("p", {id: "powered-by", className: "powered-by"},
mozL10n.get("powered_by_beforeLogo"),
React.createElement("img", {id: "powered-by-logo", className: locale}),
mozL10n.get("powered_by_afterLogo")
)
);
},
render: function() {
if (!this.state.gettingStartedSeen || this.state.seenToS == "unseen") {
var terms_of_use_url = navigator.mozLoop.getLoopPref('legal.ToS_url');
var privacy_notice_url = navigator.mozLoop.getLoopPref('legal.privacy_url');
var tosHTML = mozL10n.get("legal_text_and_links3", {
"clientShortname": mozL10n.get("clientShortname2"),
"terms_of_use": React.renderToStaticMarkup(
React.createElement("a", {href: terms_of_use_url, target: "_blank"},
mozL10n.get("legal_text_tos")
)
),
"privacy_notice": React.renderToStaticMarkup(
React.createElement("a", {href: privacy_notice_url, target: "_blank"},
mozL10n.get("legal_text_privacy")
)
)
});
return (
React.createElement("div", {id: "powered-by-wrapper"},
this.renderPartnerLogo(),
React.createElement("p", {className: "terms-service",
dangerouslySetInnerHTML: {__html: tosHTML},
onClick: this.handleLinkClick})
)
);
} else {
return React.createElement("div", null);
}
}
});
/**
* Panel settings (gear) menu entry.
*/
var SettingsDropdownEntry = React.createClass({displayName: "SettingsDropdownEntry",
propTypes: {
onClick: React.PropTypes.func.isRequired,
label: React.PropTypes.string.isRequired,
icon: React.PropTypes.string,
displayed: React.PropTypes.bool
},
getDefaultProps: function() {
return {displayed: true};
},
render: function() {
if (!this.props.displayed) {
return null;
}
return (
React.createElement("li", {onClick: this.props.onClick, className: "dropdown-menu-item"},
this.props.icon ?
React.createElement("i", {className: "icon icon-" + this.props.icon}) :
null,
React.createElement("span", null, this.props.label)
)
);
}
});
/**
* Panel settings (gear) menu.
*/
var SettingsDropdown = React.createClass({displayName: "SettingsDropdown",
propTypes: {
mozLoop: React.PropTypes.object.isRequired
},
mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.WindowCloseMixin],
handleClickSettingsEntry: function() {
// XXX to be implemented at the same time as unhiding the entry
},
handleClickAccountEntry: function() {
this.props.mozLoop.openFxASettings();
this.closeWindow();
},
handleClickAuthEntry: function() {
if (this._isSignedIn()) {
this.props.mozLoop.logOutFromFxA();
} else {
this.props.mozLoop.logInToFxA();
}
},
handleHelpEntry: function(event) {
event.preventDefault();
var helloSupportUrl = this.props.mozLoop.getLoopPref("support_url");
this.props.mozLoop.openURL(helloSupportUrl);
this.closeWindow();
},
_isSignedIn: function() {
return !!this.props.mozLoop.userProfile;
},
openGettingStartedTour: function() {
this.props.mozLoop.openGettingStartedTour("settings-menu");
this.closeWindow();
},
render: function() {
var cx = React.addons.classSet;
return (
React.createElement("div", {className: "settings-menu dropdown"},
React.createElement("a", {className: "button-settings",
onClick: this.toggleDropdownMenu,
title: mozL10n.get("settings_menu_button_tooltip"),
ref: "menu-button"}),
React.createElement("ul", {className: cx({"dropdown-menu": true, hide: !this.state.showMenu})},
React.createElement(SettingsDropdownEntry, {label: mozL10n.get("settings_menu_item_settings"),
onClick: this.handleClickSettingsEntry,
displayed: false,
icon: "settings"}),
React.createElement(SettingsDropdownEntry, {label: mozL10n.get("settings_menu_item_account"),
onClick: this.handleClickAccountEntry,
icon: "account",
displayed: this._isSignedIn() && this.props.mozLoop.fxAEnabled}),
React.createElement(SettingsDropdownEntry, {icon: "tour",
label: mozL10n.get("tour_label"),
onClick: this.openGettingStartedTour}),
React.createElement(SettingsDropdownEntry, {label: this._isSignedIn() ?
mozL10n.get("settings_menu_item_signout") :
mozL10n.get("settings_menu_item_signin"),
onClick: this.handleClickAuthEntry,
displayed: this.props.mozLoop.fxAEnabled,
icon: this._isSignedIn() ? "signout" : "signin"}),
React.createElement(SettingsDropdownEntry, {label: mozL10n.get("help_label"),
onClick: this.handleHelpEntry,
icon: "help"})
)
)
);
}
});
/**
* FxA sign in/up link component.
*/
var AuthLink = React.createClass({displayName: "AuthLink",
mixins: [sharedMixins.WindowCloseMixin],
handleSignUpLinkClick: function() {
navigator.mozLoop.logInToFxA();
this.closeWindow();
},
render: function() {
if (!navigator.mozLoop.fxAEnabled || navigator.mozLoop.userProfile) {
return null;
}
return (
React.createElement("p", {className: "signin-link"},
React.createElement("a", {href: "#", onClick: this.handleSignUpLinkClick},
mozL10n.get("panel_footer_signin_or_signup_link")
)
)
);
}
});
/**
* FxA user identity (guest/authenticated) component.
*/
var UserIdentity = React.createClass({displayName: "UserIdentity",
render: function() {
return (
React.createElement("p", {className: "user-identity"},
this.props.displayName
)
);
}
});
var RoomEntryContextItem = React.createClass({displayName: "RoomEntryContextItem",
mixins: [loop.shared.mixins.WindowCloseMixin],
propTypes: {
mozLoop: React.PropTypes.object.isRequired,
roomUrls: React.PropTypes.array
},
handleClick: function(event) {
event.stopPropagation();
event.preventDefault();
this.props.mozLoop.openURL(event.currentTarget.href);
this.closeWindow();
},
render: function() {
var roomUrl = this.props.roomUrls && this.props.roomUrls[0];
if (!roomUrl) {
return null;
}
return (
React.createElement("div", {className: "room-entry-context-item"},
React.createElement("a", {href: roomUrl.location, onClick: this.handleClick},
React.createElement("img", {title: roomUrl.description,
src: roomUrl.thumbnail})
)
)
);
}
});
/**
* Room list entry.
*/
var RoomEntry = React.createClass({displayName: "RoomEntry",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired,
room: React.PropTypes.instanceOf(loop.store.Room).isRequired
},
mixins: [loop.shared.mixins.WindowCloseMixin],
getInitialState: function() {
return { urlCopied: false };
},
shouldComponentUpdate: function(nextProps, nextState) {
return (nextProps.room.ctime > this.props.room.ctime) ||
(nextState.urlCopied !== this.state.urlCopied);
},
handleClickEntry: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.OpenRoom({
roomToken: this.props.room.roomToken
}));
this.closeWindow();
},
handleCopyButtonClick: function(event) {
event.stopPropagation();
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.CopyRoomUrl({
roomUrl: this.props.room.roomUrl
}));
this.setState({urlCopied: true});
},
handleDeleteButtonClick: function(event) {
event.stopPropagation();
event.preventDefault();
this.props.mozLoop.confirm({
message: mozL10n.get("rooms_list_deleteConfirmation_label"),
okButton: null,
cancelButton: null
}, function(err, result) {
if (err) {
throw err;
}
if (!result) {
return;
}
this.props.dispatcher.dispatch(new sharedActions.DeleteRoom({
roomToken: this.props.room.roomToken
}));
}.bind(this));
},
handleMouseLeave: function(event) {
this.setState({urlCopied: false});
},
_isActive: function() {
return this.props.room.participants.length > 0;
},
render: function() {
var roomClasses = React.addons.classSet({
"room-entry": true,
"room-active": this._isActive()
});
var copyButtonClasses = React.addons.classSet({
"copy-link": true,
"checked": this.state.urlCopied
});
return (
React.createElement("div", {className: roomClasses, onMouseLeave: this.handleMouseLeave,
onClick: this.handleClickEntry},
React.createElement("h2", null,
React.createElement("span", {className: "room-notification"}),
this.props.room.decryptedContext.roomName,
React.createElement("button", {className: copyButtonClasses,
title: mozL10n.get("rooms_list_copy_url_tooltip"),
onClick: this.handleCopyButtonClick}),
React.createElement("button", {className: "delete-link",
title: mozL10n.get("rooms_list_delete_tooltip"),
onClick: this.handleDeleteButtonClick})
),
React.createElement(RoomEntryContextItem, {mozLoop: this.props.mozLoop,
roomUrls: this.props.room.decryptedContext.urls})
)
);
}
});
/**
* Room list.
*/
var RoomList = React.createClass({displayName: "RoomList",
mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
propTypes: {
mozLoop: React.PropTypes.object.isRequired,
store: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
userDisplayName: React.PropTypes.string.isRequired // for room creation
},
getInitialState: function() {
return this.props.store.getStoreState();
},
componentDidMount: function() {
this.listenTo(this.props.store, "change", this._onStoreStateChanged);
// XXX this should no longer be necessary once have a better mechanism
// for updating the list (possibly as part of the content side of bug
// 1074665.
this.props.dispatcher.dispatch(new sharedActions.GetAllRooms());
},
componentWillUnmount: function() {
this.stopListening(this.props.store);
},
componentWillUpdate: function(nextProps, nextState) {
// If we've just created a room, close the panel - the store will open
// the room.
if (this.state.pendingCreation &&
!nextState.pendingCreation && !nextState.error) {
this.closeWindow();
}
},
_onStoreStateChanged: function() {
this.setState(this.props.store.getStoreState());
},
_getListHeading: function() {
var numRooms = this.state.rooms.length;
if (numRooms === 0) {
return mozL10n.get("rooms_list_no_current_conversations");
}
return mozL10n.get("rooms_list_current_conversations", {num: numRooms});
},
render: function() {
if (this.state.error) {
// XXX Better end user reporting of errors.
console.error("RoomList error", this.state.error);
}
return (
React.createElement("div", {className: "rooms"},
React.createElement("h1", null, this._getListHeading()),
React.createElement("div", {className: "room-list"},
this.state.rooms.map(function(room, i) {
return (
React.createElement(RoomEntry, {
key: room.roomToken,
dispatcher: this.props.dispatcher,
mozLoop: this.props.mozLoop,
room: room}
)
);
}, this)
),
React.createElement(NewRoomView, {dispatcher: this.props.dispatcher,
mozLoop: this.props.mozLoop,
pendingOperation: this.state.pendingCreation ||
this.state.pendingInitialRetrieval,
userDisplayName: this.props.userDisplayName})
)
);
}
});
/**
* Used for creating a new room with or without context.
*/
var NewRoomView = React.createClass({displayName: "NewRoomView",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired,
pendingOperation: React.PropTypes.bool.isRequired,
userDisplayName: React.PropTypes.string.isRequired
},
mixins: [sharedMixins.DocumentVisibilityMixin],
getInitialState: function() {
return {
checked: false,
previewImage: "",
description: "",
url: ""
};
},
onDocumentVisible: function() {
this.props.mozLoop.getSelectedTabMetadata(function callback(metadata) {
var previewImage = metadata.previews.length ? metadata.previews[0] : "";
var description = metadata.description || metadata.title;
var url = metadata.url;
this.setState({previewImage: previewImage,
description: description,
url: url});
}.bind(this));
},
onDocumentHidden: function() {
this.setState({previewImage: "",
description: "",
url: ""});
},
onCheckboxChange: function(event) {
this.setState({checked: event.target.checked});
},
handleCreateButtonClick: function() {
var createRoomAction = new sharedActions.CreateRoom({
nameTemplate: mozL10n.get("rooms_default_room_name_template"),
roomOwner: this.props.userDisplayName
});
if (this.state.checked) {
createRoomAction.urls = [{
location: this.state.url,
description: this.state.description,
thumbnail: this.state.previewImage
}];
}
this.props.dispatcher.dispatch(createRoomAction);
},
render: function() {
var hostname;
try {
hostname = new URL(this.state.url).hostname;
} catch (ex) {
// Empty catch - if there's an error, then we won't show the context.
}
var contextClasses = React.addons.classSet({
context: true,
hide: !hostname ||
!this.props.mozLoop.getLoopPref("contextInConversations.enabled")
});
return (
React.createElement("div", {className: "new-room-view"},
React.createElement("div", {className: contextClasses},
React.createElement("label", {className: "context-enabled"},
React.createElement("input", {className: "context-checkbox",
type: "checkbox", onChange: this.onCheckboxChange}),
mozL10n.get("context_offer_label")
),
React.createElement("img", {className: "context-preview", src: this.state.previewImage}),
React.createElement("span", {className: "context-description"}, this.state.description),
React.createElement("span", {className: "context-url"}, hostname)
),
React.createElement("button", {className: "btn btn-info new-room-button",
onClick: this.handleCreateButtonClick,
disabled: this.props.pendingOperation},
mozL10n.get("rooms_new_room_button_label")
)
)
);
}
});
/**
* Panel view.
*/
var PanelView = React.createClass({displayName: "PanelView",
propTypes: {
notifications: React.PropTypes.object.isRequired,
// Mostly used for UI components showcase and unit tests
userProfile: React.PropTypes.object,
// Used only for unit tests.
showTabButtons: React.PropTypes.bool,
selectedTab: React.PropTypes.string,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired,
roomStore:
React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
},
getInitialState: function() {
return {
userProfile: this.props.userProfile || this.props.mozLoop.userProfile,
gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
};
},
_serviceErrorToShow: function() {
if (!this.props.mozLoop.errors ||
!Object.keys(this.props.mozLoop.errors).length) {
return null;
}
// Just get the first error for now since more than one should be rare.
var firstErrorKey = Object.keys(this.props.mozLoop.errors)[0];
return {
type: firstErrorKey,
error: this.props.mozLoop.errors[firstErrorKey]
};
},
updateServiceErrors: function() {
var serviceError = this._serviceErrorToShow();
if (serviceError) {
this.props.notifications.set({
id: "service-error",
level: "error",
message: serviceError.error.friendlyMessage,
details: serviceError.error.friendlyDetails,
detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback
});
} else {
this.props.notifications.remove(this.props.notifications.get("service-error"));
}
},
_onStatusChanged: function() {
var profile = this.props.mozLoop.userProfile;
var currUid = this.state.userProfile ? this.state.userProfile.uid : null;
var newUid = profile ? profile.uid : null;
if (currUid != newUid) {
// On profile change (login, logout), switch back to the default tab.
this.selectTab("rooms");
this.setState({userProfile: profile});
}
this.updateServiceErrors();
},
_gettingStartedSeen: function() {
this.setState({
gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
});
},
_UIActionHandler: function(e) {
switch (e.detail.action) {
case "selectTab":
this.selectTab(e.detail.tab);
break;
default:
console.error("Invalid action", e.detail.action);
break;
}
},
startForm: function(name, contact) {
this.refs[name].initForm(contact);
this.selectTab(name);
},
selectTab: function(name) {
this.refs.tabView.setState({ selectedTab: name });
},
componentWillMount: function() {
this.updateServiceErrors();
},
componentDidMount: function() {
window.addEventListener("LoopStatusChanged", this._onStatusChanged);
window.addEventListener("GettingStartedSeen", this._gettingStartedSeen);
window.addEventListener("UIAction", this._UIActionHandler);
},
componentWillUnmount: function() {
window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
window.removeEventListener("GettingStartedSeen", this._gettingStartedSeen);
window.removeEventListener("UIAction", this._UIActionHandler);
},
_getUserDisplayName: function() {
return this.state.userProfile && this.state.userProfile.email ||
mozL10n.get("display_name_guest");
},
render: function() {
var NotificationListView = sharedViews.NotificationListView;
if (!this.state.gettingStartedSeen) {
return (
React.createElement("div", null,
React.createElement(NotificationListView, {notifications: this.props.notifications,
clearOnDocumentHidden: true}),
React.createElement(GettingStartedView, null),
React.createElement(ToSView, null)
)
);
}
// Determine which buttons to NOT show.
var hideButtons = [];
if (!this.state.userProfile && !this.props.showTabButtons) {
hideButtons.push("contacts");
}
return (
React.createElement("div", null,
React.createElement(NotificationListView, {notifications: this.props.notifications,
clearOnDocumentHidden: true}),
React.createElement(TabView, {ref: "tabView", selectedTab: this.props.selectedTab,
buttonsHidden: hideButtons, mozLoop: this.props.mozLoop},
React.createElement(Tab, {name: "rooms"},
React.createElement(RoomList, {dispatcher: this.props.dispatcher,
store: this.props.roomStore,
userDisplayName: this._getUserDisplayName(),
mozLoop: this.props.mozLoop}),
React.createElement(ToSView, null)
),
React.createElement(Tab, {name: "contacts"},
React.createElement(ContactsList, {selectTab: this.selectTab,
startForm: this.startForm,
notifications: this.props.notifications})
),
React.createElement(Tab, {name: "contacts_add", hidden: true},
React.createElement(ContactDetailsForm, {ref: "contacts_add", mode: "add",
selectTab: this.selectTab})
),
React.createElement(Tab, {name: "contacts_edit", hidden: true},
React.createElement(ContactDetailsForm, {ref: "contacts_edit", mode: "edit",
selectTab: this.selectTab})
),
React.createElement(Tab, {name: "contacts_import", hidden: true},
React.createElement(ContactDetailsForm, {ref: "contacts_import", mode: "import",
selectTab: this.selectTab})
)
),
React.createElement("div", {className: "footer"},
React.createElement("div", {className: "user-details"},
React.createElement(UserIdentity, {displayName: this._getUserDisplayName()}),
React.createElement(AvailabilityDropdown, null)
),
React.createElement("div", {className: "signin-details"},
React.createElement(AuthLink, null),
React.createElement("div", {className: "footer-signin-separator"}),
React.createElement(SettingsDropdown, {mozLoop: this.props.mozLoop})
)
)
)
);
}
});
/**
* Panel initialisation.
*/
function init() {
// Do the initial L10n setup, we do this before anything
// else to ensure the L10n environment is setup correctly.
mozL10n.initialize(navigator.mozLoop);
var notifications = new sharedModels.NotificationCollection();
var dispatcher = new loop.Dispatcher();
var roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: navigator.mozLoop,
notifications: notifications
});
React.render(React.createElement(PanelView, {
notifications: notifications,
roomStore: roomStore,
mozLoop: navigator.mozLoop,
dispatcher: dispatcher}
), document.querySelector("#main"));
document.body.setAttribute("dir", mozL10n.getDirection());
// Notify the window that we've finished initalization and initial layout
var evtObject = document.createEvent('Event');
evtObject.initEvent('loopPanelInitialized', true, false);
window.dispatchEvent(evtObject);
}
return {
init: init,
AuthLink: AuthLink,
AvailabilityDropdown: AvailabilityDropdown,
GettingStartedView: GettingStartedView,
NewRoomView: NewRoomView,
PanelView: PanelView,
RoomEntry: RoomEntry,
RoomList: RoomList,
SettingsDropdown: SettingsDropdown,
ToSView: ToSView,
UserIdentity: UserIdentity
};
})(_, document.mozL10n);
document.addEventListener('DOMContentLoaded', loop.panel.init);
@@ -1,966 +0,0 @@
/** @jsx React.DOM */
/* 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/. */
/*jshint newcap:false*/
/*global loop:true, React */
var loop = loop || {};
loop.panel = (function(_, mozL10n) {
"use strict";
var sharedViews = loop.shared.views;
var sharedModels = loop.shared.models;
var sharedMixins = loop.shared.mixins;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var Button = sharedViews.Button;
var ButtonGroup = sharedViews.ButtonGroup;
var ContactsList = loop.contacts.ContactsList;
var ContactDetailsForm = loop.contacts.ContactDetailsForm;
var TabView = React.createClass({
propTypes: {
buttonsHidden: React.PropTypes.array,
// The selectedTab prop is used by the UI showcase.
selectedTab: React.PropTypes.string,
mozLoop: React.PropTypes.object
},
getDefaultProps: function() {
return {
buttonsHidden: []
};
},
shouldComponentUpdate: function(nextProps, nextState) {
var tabChange = this.state.selectedTab !== nextState.selectedTab;
if (tabChange) {
this.props.mozLoop.notifyUITour("Loop:PanelTabChanged", nextState.selectedTab);
}
if (!tabChange && nextProps.buttonsHidden) {
if (nextProps.buttonsHidden.length !== this.props.buttonsHidden.length) {
tabChange = true;
} else {
for (var i = 0, l = nextProps.buttonsHidden.length; i < l && !tabChange; ++i) {
if (this.props.buttonsHidden.indexOf(nextProps.buttonsHidden[i]) === -1) {
tabChange = true;
}
}
}
}
return tabChange;
},
getInitialState: function() {
// XXX Work around props.selectedTab being undefined initially.
// When we don't need to rely on the pref, this can move back to
// getDefaultProps (bug 1100258).
return {
selectedTab: this.props.selectedTab || "rooms"
};
},
handleSelectTab: function(event) {
var tabName = event.target.dataset.tabName;
this.setState({selectedTab: tabName});
},
render: function() {
var cx = React.addons.classSet;
var tabButtons = [];
var tabs = [];
React.Children.forEach(this.props.children, function(tab, i) {
// Filter out null tabs (eg. rooms when the feature is disabled)
if (!tab) {
return;
}
var tabName = tab.props.name;
if (this.props.buttonsHidden.indexOf(tabName) > -1) {
return;
}
var isSelected = (this.state.selectedTab == tabName);
if (!tab.props.hidden) {
tabButtons.push(
<li className={cx({selected: isSelected})}
key={i}
data-tab-name={tabName}
title={mozL10n.get(tabName + "_tab_button_tooltip")}
onClick={this.handleSelectTab} />
);
}
tabs.push(
<div key={i} className={cx({tab: true, selected: isSelected})}>
{tab.props.children}
</div>
);
}, this);
return (
<div className="tab-view-container">
<ul className="tab-view">{tabButtons}</ul>
{tabs}
</div>
);
}
});
var Tab = React.createClass({
render: function() {
return null;
}
});
/**
* Availability drop down menu subview.
*/
var AvailabilityDropdown = React.createClass({
mixins: [sharedMixins.DropdownMenuMixin],
getInitialState: function() {
return {
doNotDisturb: navigator.mozLoop.doNotDisturb
};
},
// XXX target event can either be the li, the span or the i tag
// this makes it easier to figure out the target by making a
// closure with the desired status already passed in.
changeAvailability: function(newAvailabilty) {
return function(event) {
// Note: side effect!
switch (newAvailabilty) {
case 'available':
this.setState({doNotDisturb: false});
navigator.mozLoop.doNotDisturb = false;
break;
case 'do-not-disturb':
this.setState({doNotDisturb: true});
navigator.mozLoop.doNotDisturb = true;
break;
}
this.hideDropdownMenu();
}.bind(this);
},
render: function() {
// XXX https://github.com/facebook/react/issues/310 for === htmlFor
var cx = React.addons.classSet;
var availabilityStatus = cx({
'status': true,
'status-dnd': this.state.doNotDisturb,
'status-available': !this.state.doNotDisturb
});
var availabilityDropdown = cx({
'dropdown-menu': true,
'hide': !this.state.showMenu
});
var availabilityText = this.state.doNotDisturb ?
mozL10n.get("display_name_dnd_status") :
mozL10n.get("display_name_available_status");
return (
<div className="dropdown">
<p className="dnd-status" onClick={this.toggleDropdownMenu} ref="menu-button">
<span>{availabilityText}</span>
<i className={availabilityStatus}></i>
</p>
<ul className={availabilityDropdown}>
<li onClick={this.changeAvailability("available")}
className="dropdown-menu-item dnd-make-available">
<i className="status status-available"></i>
<span>{mozL10n.get("display_name_available_status")}</span>
</li>
<li onClick={this.changeAvailability("do-not-disturb")}
className="dropdown-menu-item dnd-make-unavailable">
<i className="status status-dnd"></i>
<span>{mozL10n.get("display_name_dnd_status")}</span>
</li>
</ul>
</div>
);
}
});
var GettingStartedView = React.createClass({
mixins: [sharedMixins.WindowCloseMixin],
handleButtonClick: function() {
navigator.mozLoop.openGettingStartedTour("getting-started");
navigator.mozLoop.setLoopPref("gettingStarted.seen", true);
var event = new CustomEvent("GettingStartedSeen");
window.dispatchEvent(event);
this.closeWindow();
},
render: function() {
if (navigator.mozLoop.getLoopPref("gettingStarted.seen")) {
return null;
}
return (
<div id="fte-getstarted">
<header id="fte-title">
{mozL10n.get("first_time_experience_title", {
"clientShortname": mozL10n.get("clientShortname2")
})}
</header>
<Button htmlId="fte-button"
onClick={this.handleButtonClick}
caption={mozL10n.get("first_time_experience_button_label")} />
</div>
);
}
});
var ToSView = React.createClass({
mixins: [sharedMixins.WindowCloseMixin],
getInitialState: function() {
var getPref = navigator.mozLoop.getLoopPref.bind(navigator.mozLoop);
return {
seenToS: getPref("seenToS"),
gettingStartedSeen: getPref("gettingStarted.seen"),
showPartnerLogo: getPref("showPartnerLogo")
};
},
handleLinkClick: function(event) {
if (!event.target || !event.target.href) {
return;
}
event.preventDefault();
navigator.mozLoop.openURL(event.target.href);
this.closeWindow();
},
renderPartnerLogo: function() {
if (!this.state.showPartnerLogo) {
return null;
}
var locale = mozL10n.getLanguage();
navigator.mozLoop.setLoopPref('showPartnerLogo', false);
return (
<p id="powered-by" className="powered-by">
{mozL10n.get("powered_by_beforeLogo")}
<img id="powered-by-logo" className={locale} />
{mozL10n.get("powered_by_afterLogo")}
</p>
);
},
render: function() {
if (!this.state.gettingStartedSeen || this.state.seenToS == "unseen") {
var terms_of_use_url = navigator.mozLoop.getLoopPref('legal.ToS_url');
var privacy_notice_url = navigator.mozLoop.getLoopPref('legal.privacy_url');
var tosHTML = mozL10n.get("legal_text_and_links3", {
"clientShortname": mozL10n.get("clientShortname2"),
"terms_of_use": React.renderToStaticMarkup(
<a href={terms_of_use_url} target="_blank">
{mozL10n.get("legal_text_tos")}
</a>
),
"privacy_notice": React.renderToStaticMarkup(
<a href={privacy_notice_url} target="_blank">
{mozL10n.get("legal_text_privacy")}
</a>
)
});
return (
<div id="powered-by-wrapper">
{this.renderPartnerLogo()}
<p className="terms-service"
dangerouslySetInnerHTML={{__html: tosHTML}}
onClick={this.handleLinkClick}></p>
</div>
);
} else {
return <div />;
}
}
});
/**
* Panel settings (gear) menu entry.
*/
var SettingsDropdownEntry = React.createClass({
propTypes: {
onClick: React.PropTypes.func.isRequired,
label: React.PropTypes.string.isRequired,
icon: React.PropTypes.string,
displayed: React.PropTypes.bool
},
getDefaultProps: function() {
return {displayed: true};
},
render: function() {
if (!this.props.displayed) {
return null;
}
return (
<li onClick={this.props.onClick} className="dropdown-menu-item">
{this.props.icon ?
<i className={"icon icon-" + this.props.icon}></i> :
null}
<span>{this.props.label}</span>
</li>
);
}
});
/**
* Panel settings (gear) menu.
*/
var SettingsDropdown = React.createClass({
propTypes: {
mozLoop: React.PropTypes.object.isRequired
},
mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.WindowCloseMixin],
handleClickSettingsEntry: function() {
// XXX to be implemented at the same time as unhiding the entry
},
handleClickAccountEntry: function() {
this.props.mozLoop.openFxASettings();
this.closeWindow();
},
handleClickAuthEntry: function() {
if (this._isSignedIn()) {
this.props.mozLoop.logOutFromFxA();
} else {
this.props.mozLoop.logInToFxA();
}
},
handleHelpEntry: function(event) {
event.preventDefault();
var helloSupportUrl = this.props.mozLoop.getLoopPref("support_url");
this.props.mozLoop.openURL(helloSupportUrl);
this.closeWindow();
},
_isSignedIn: function() {
return !!this.props.mozLoop.userProfile;
},
openGettingStartedTour: function() {
this.props.mozLoop.openGettingStartedTour("settings-menu");
this.closeWindow();
},
render: function() {
var cx = React.addons.classSet;
return (
<div className="settings-menu dropdown">
<a className="button-settings"
onClick={this.toggleDropdownMenu}
title={mozL10n.get("settings_menu_button_tooltip")}
ref="menu-button" />
<ul className={cx({"dropdown-menu": true, hide: !this.state.showMenu})}>
<SettingsDropdownEntry label={mozL10n.get("settings_menu_item_settings")}
onClick={this.handleClickSettingsEntry}
displayed={false}
icon="settings" />
<SettingsDropdownEntry label={mozL10n.get("settings_menu_item_account")}
onClick={this.handleClickAccountEntry}
icon="account"
displayed={this._isSignedIn() && this.props.mozLoop.fxAEnabled} />
<SettingsDropdownEntry icon="tour"
label={mozL10n.get("tour_label")}
onClick={this.openGettingStartedTour} />
<SettingsDropdownEntry label={this._isSignedIn() ?
mozL10n.get("settings_menu_item_signout") :
mozL10n.get("settings_menu_item_signin")}
onClick={this.handleClickAuthEntry}
displayed={this.props.mozLoop.fxAEnabled}
icon={this._isSignedIn() ? "signout" : "signin"} />
<SettingsDropdownEntry label={mozL10n.get("help_label")}
onClick={this.handleHelpEntry}
icon="help" />
</ul>
</div>
);
}
});
/**
* FxA sign in/up link component.
*/
var AuthLink = React.createClass({
mixins: [sharedMixins.WindowCloseMixin],
handleSignUpLinkClick: function() {
navigator.mozLoop.logInToFxA();
this.closeWindow();
},
render: function() {
if (!navigator.mozLoop.fxAEnabled || navigator.mozLoop.userProfile) {
return null;
}
return (
<p className="signin-link">
<a href="#" onClick={this.handleSignUpLinkClick}>
{mozL10n.get("panel_footer_signin_or_signup_link")}
</a>
</p>
);
}
});
/**
* FxA user identity (guest/authenticated) component.
*/
var UserIdentity = React.createClass({
render: function() {
return (
<p className="user-identity">
{this.props.displayName}
</p>
);
}
});
var RoomEntryContextItem = React.createClass({
mixins: [loop.shared.mixins.WindowCloseMixin],
propTypes: {
mozLoop: React.PropTypes.object.isRequired,
roomUrls: React.PropTypes.array
},
handleClick: function(event) {
event.stopPropagation();
event.preventDefault();
this.props.mozLoop.openURL(event.currentTarget.href);
this.closeWindow();
},
render: function() {
var roomUrl = this.props.roomUrls && this.props.roomUrls[0];
if (!roomUrl) {
return null;
}
return (
<div className="room-entry-context-item">
<a href={roomUrl.location} onClick={this.handleClick}>
<img title={roomUrl.description}
src={roomUrl.thumbnail} />
</a>
</div>
);
}
});
/**
* Room list entry.
*/
var RoomEntry = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired,
room: React.PropTypes.instanceOf(loop.store.Room).isRequired
},
mixins: [loop.shared.mixins.WindowCloseMixin],
getInitialState: function() {
return { urlCopied: false };
},
shouldComponentUpdate: function(nextProps, nextState) {
return (nextProps.room.ctime > this.props.room.ctime) ||
(nextState.urlCopied !== this.state.urlCopied);
},
handleClickEntry: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.OpenRoom({
roomToken: this.props.room.roomToken
}));
this.closeWindow();
},
handleCopyButtonClick: function(event) {
event.stopPropagation();
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.CopyRoomUrl({
roomUrl: this.props.room.roomUrl
}));
this.setState({urlCopied: true});
},
handleDeleteButtonClick: function(event) {
event.stopPropagation();
event.preventDefault();
this.props.mozLoop.confirm({
message: mozL10n.get("rooms_list_deleteConfirmation_label"),
okButton: null,
cancelButton: null
}, function(err, result) {
if (err) {
throw err;
}
if (!result) {
return;
}
this.props.dispatcher.dispatch(new sharedActions.DeleteRoom({
roomToken: this.props.room.roomToken
}));
}.bind(this));
},
handleMouseLeave: function(event) {
this.setState({urlCopied: false});
},
_isActive: function() {
return this.props.room.participants.length > 0;
},
render: function() {
var roomClasses = React.addons.classSet({
"room-entry": true,
"room-active": this._isActive()
});
var copyButtonClasses = React.addons.classSet({
"copy-link": true,
"checked": this.state.urlCopied
});
return (
<div className={roomClasses} onMouseLeave={this.handleMouseLeave}
onClick={this.handleClickEntry}>
<h2>
<span className="room-notification" />
{this.props.room.decryptedContext.roomName}
<button className={copyButtonClasses}
title={mozL10n.get("rooms_list_copy_url_tooltip")}
onClick={this.handleCopyButtonClick} />
<button className="delete-link"
title={mozL10n.get("rooms_list_delete_tooltip")}
onClick={this.handleDeleteButtonClick} />
</h2>
<RoomEntryContextItem mozLoop={this.props.mozLoop}
roomUrls={this.props.room.decryptedContext.urls} />
</div>
);
}
});
/**
* Room list.
*/
var RoomList = React.createClass({
mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
propTypes: {
mozLoop: React.PropTypes.object.isRequired,
store: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
userDisplayName: React.PropTypes.string.isRequired // for room creation
},
getInitialState: function() {
return this.props.store.getStoreState();
},
componentDidMount: function() {
this.listenTo(this.props.store, "change", this._onStoreStateChanged);
// XXX this should no longer be necessary once have a better mechanism
// for updating the list (possibly as part of the content side of bug
// 1074665.
this.props.dispatcher.dispatch(new sharedActions.GetAllRooms());
},
componentWillUnmount: function() {
this.stopListening(this.props.store);
},
componentWillUpdate: function(nextProps, nextState) {
// If we've just created a room, close the panel - the store will open
// the room.
if (this.state.pendingCreation &&
!nextState.pendingCreation && !nextState.error) {
this.closeWindow();
}
},
_onStoreStateChanged: function() {
this.setState(this.props.store.getStoreState());
},
_getListHeading: function() {
var numRooms = this.state.rooms.length;
if (numRooms === 0) {
return mozL10n.get("rooms_list_no_current_conversations");
}
return mozL10n.get("rooms_list_current_conversations", {num: numRooms});
},
render: function() {
if (this.state.error) {
// XXX Better end user reporting of errors.
console.error("RoomList error", this.state.error);
}
return (
<div className="rooms">
<h1>{this._getListHeading()}</h1>
<div className="room-list">{
this.state.rooms.map(function(room, i) {
return (
<RoomEntry
key={room.roomToken}
dispatcher={this.props.dispatcher}
mozLoop={this.props.mozLoop}
room={room}
/>
);
}, this)
}</div>
<NewRoomView dispatcher={this.props.dispatcher}
mozLoop={this.props.mozLoop}
pendingOperation={this.state.pendingCreation ||
this.state.pendingInitialRetrieval}
userDisplayName={this.props.userDisplayName} />
</div>
);
}
});
/**
* Used for creating a new room with or without context.
*/
var NewRoomView = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired,
pendingOperation: React.PropTypes.bool.isRequired,
userDisplayName: React.PropTypes.string.isRequired
},
mixins: [sharedMixins.DocumentVisibilityMixin],
getInitialState: function() {
return {
checked: false,
previewImage: "",
description: "",
url: ""
};
},
onDocumentVisible: function() {
this.props.mozLoop.getSelectedTabMetadata(function callback(metadata) {
var previewImage = metadata.previews.length ? metadata.previews[0] : "";
var description = metadata.description || metadata.title;
var url = metadata.url;
this.setState({previewImage: previewImage,
description: description,
url: url});
}.bind(this));
},
onDocumentHidden: function() {
this.setState({previewImage: "",
description: "",
url: ""});
},
onCheckboxChange: function(event) {
this.setState({checked: event.target.checked});
},
handleCreateButtonClick: function() {
var createRoomAction = new sharedActions.CreateRoom({
nameTemplate: mozL10n.get("rooms_default_room_name_template"),
roomOwner: this.props.userDisplayName
});
if (this.state.checked) {
createRoomAction.urls = [{
location: this.state.url,
description: this.state.description,
thumbnail: this.state.previewImage
}];
}
this.props.dispatcher.dispatch(createRoomAction);
},
render: function() {
var hostname;
try {
hostname = new URL(this.state.url).hostname;
} catch (ex) {
// Empty catch - if there's an error, then we won't show the context.
}
var contextClasses = React.addons.classSet({
context: true,
hide: !hostname ||
!this.props.mozLoop.getLoopPref("contextInConversations.enabled")
});
return (
<div className="new-room-view">
<div className={contextClasses}>
<label className="context-enabled">
<input className="context-checkbox"
type="checkbox" onChange={this.onCheckboxChange}/>
{mozL10n.get("context_offer_label")}
</label>
<img className="context-preview" src={this.state.previewImage}/>
<span className="context-description">{this.state.description}</span>
<span className="context-url">{hostname}</span>
</div>
<button className="btn btn-info new-room-button"
onClick={this.handleCreateButtonClick}
disabled={this.props.pendingOperation}>
{mozL10n.get("rooms_new_room_button_label")}
</button>
</div>
);
}
});
/**
* Panel view.
*/
var PanelView = React.createClass({
propTypes: {
notifications: React.PropTypes.object.isRequired,
// Mostly used for UI components showcase and unit tests
userProfile: React.PropTypes.object,
// Used only for unit tests.
showTabButtons: React.PropTypes.bool,
selectedTab: React.PropTypes.string,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired,
roomStore:
React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
},
getInitialState: function() {
return {
userProfile: this.props.userProfile || this.props.mozLoop.userProfile,
gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
};
},
_serviceErrorToShow: function() {
if (!this.props.mozLoop.errors ||
!Object.keys(this.props.mozLoop.errors).length) {
return null;
}
// Just get the first error for now since more than one should be rare.
var firstErrorKey = Object.keys(this.props.mozLoop.errors)[0];
return {
type: firstErrorKey,
error: this.props.mozLoop.errors[firstErrorKey]
};
},
updateServiceErrors: function() {
var serviceError = this._serviceErrorToShow();
if (serviceError) {
this.props.notifications.set({
id: "service-error",
level: "error",
message: serviceError.error.friendlyMessage,
details: serviceError.error.friendlyDetails,
detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback
});
} else {
this.props.notifications.remove(this.props.notifications.get("service-error"));
}
},
_onStatusChanged: function() {
var profile = this.props.mozLoop.userProfile;
var currUid = this.state.userProfile ? this.state.userProfile.uid : null;
var newUid = profile ? profile.uid : null;
if (currUid != newUid) {
// On profile change (login, logout), switch back to the default tab.
this.selectTab("rooms");
this.setState({userProfile: profile});
}
this.updateServiceErrors();
},
_gettingStartedSeen: function() {
this.setState({
gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
});
},
_UIActionHandler: function(e) {
switch (e.detail.action) {
case "selectTab":
this.selectTab(e.detail.tab);
break;
default:
console.error("Invalid action", e.detail.action);
break;
}
},
startForm: function(name, contact) {
this.refs[name].initForm(contact);
this.selectTab(name);
},
selectTab: function(name) {
this.refs.tabView.setState({ selectedTab: name });
},
componentWillMount: function() {
this.updateServiceErrors();
},
componentDidMount: function() {
window.addEventListener("LoopStatusChanged", this._onStatusChanged);
window.addEventListener("GettingStartedSeen", this._gettingStartedSeen);
window.addEventListener("UIAction", this._UIActionHandler);
},
componentWillUnmount: function() {
window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
window.removeEventListener("GettingStartedSeen", this._gettingStartedSeen);
window.removeEventListener("UIAction", this._UIActionHandler);
},
_getUserDisplayName: function() {
return this.state.userProfile && this.state.userProfile.email ||
mozL10n.get("display_name_guest");
},
render: function() {
var NotificationListView = sharedViews.NotificationListView;
if (!this.state.gettingStartedSeen) {
return (
<div>
<NotificationListView notifications={this.props.notifications}
clearOnDocumentHidden={true} />
<GettingStartedView />
<ToSView />
</div>
);
}
// Determine which buttons to NOT show.
var hideButtons = [];
if (!this.state.userProfile && !this.props.showTabButtons) {
hideButtons.push("contacts");
}
return (
<div>
<NotificationListView notifications={this.props.notifications}
clearOnDocumentHidden={true} />
<TabView ref="tabView" selectedTab={this.props.selectedTab}
buttonsHidden={hideButtons} mozLoop={this.props.mozLoop}>
<Tab name="rooms">
<RoomList dispatcher={this.props.dispatcher}
store={this.props.roomStore}
userDisplayName={this._getUserDisplayName()}
mozLoop={this.props.mozLoop}/>
<ToSView />
</Tab>
<Tab name="contacts">
<ContactsList selectTab={this.selectTab}
startForm={this.startForm}
notifications={this.props.notifications} />
</Tab>
<Tab name="contacts_add" hidden={true}>
<ContactDetailsForm ref="contacts_add" mode="add"
selectTab={this.selectTab} />
</Tab>
<Tab name="contacts_edit" hidden={true}>
<ContactDetailsForm ref="contacts_edit" mode="edit"
selectTab={this.selectTab} />
</Tab>
<Tab name="contacts_import" hidden={true}>
<ContactDetailsForm ref="contacts_import" mode="import"
selectTab={this.selectTab}/>
</Tab>
</TabView>
<div className="footer">
<div className="user-details">
<UserIdentity displayName={this._getUserDisplayName()} />
<AvailabilityDropdown />
</div>
<div className="signin-details">
<AuthLink />
<div className="footer-signin-separator" />
<SettingsDropdown mozLoop={this.props.mozLoop}/>
</div>
</div>
</div>
);
}
});
/**
* Panel initialisation.
*/
function init() {
// Do the initial L10n setup, we do this before anything
// else to ensure the L10n environment is setup correctly.
mozL10n.initialize(navigator.mozLoop);
var notifications = new sharedModels.NotificationCollection();
var dispatcher = new loop.Dispatcher();
var roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: navigator.mozLoop,
notifications: notifications
});
React.render(<PanelView
notifications={notifications}
roomStore={roomStore}
mozLoop={navigator.mozLoop}
dispatcher={dispatcher}
/>, document.querySelector("#main"));
document.body.setAttribute("dir", mozL10n.getDirection());
// Notify the window that we've finished initalization and initial layout
var evtObject = document.createEvent('Event');
evtObject.initEvent('loopPanelInitialized', true, false);
window.dispatchEvent(evtObject);
}
return {
init: init,
AuthLink: AuthLink,
AvailabilityDropdown: AvailabilityDropdown,
GettingStartedView: GettingStartedView,
NewRoomView: NewRoomView,
PanelView: PanelView,
RoomEntry: RoomEntry,
RoomList: RoomList,
SettingsDropdown: SettingsDropdown,
ToSView: ToSView,
UserIdentity: UserIdentity
};
})(_, document.mozL10n);
document.addEventListener('DOMContentLoaded', loop.panel.init);
@@ -1,708 +0,0 @@
/** @jsx React.DOM */
/* 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/. */
/* jshint newcap:false */
/* global loop:true, React */
var loop = loop || {};
loop.roomViews = (function(mozL10n) {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var sharedUtils = loop.shared.utils;
var sharedViews = loop.shared.views;
/**
* ActiveRoomStore mixin.
* @type {Object}
*/
var ActiveRoomStoreMixin = {
mixins: [Backbone.Events],
propTypes: {
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
},
componentWillMount: function() {
this.listenTo(this.props.roomStore, "change:activeRoom",
this._onActiveRoomStateChanged);
this.listenTo(this.props.roomStore, "change:error",
this._onRoomError);
},
componentWillUnmount: function() {
this.stopListening(this.props.roomStore);
},
_onActiveRoomStateChanged: function() {
// Only update the state if we're mounted, to avoid the problem where
// stopListening doesn't nuke the active listeners during a event
// processing.
if (this.isMounted()) {
this.setState(this.props.roomStore.getStoreState("activeRoom"));
}
},
_onRoomError: function() {
// Only update the state if we're mounted, to avoid the problem where
// stopListening doesn't nuke the active listeners during a event
// processing.
if (this.isMounted()) {
this.setState({error: this.props.roomStore.getStoreState("error")});
}
},
getInitialState: function() {
var storeState = this.props.roomStore.getStoreState("activeRoom");
return _.extend({
// Used by the UI showcase.
roomState: this.props.roomState || storeState.roomState
}, storeState);
}
};
var SocialShareDropdown = React.createClass({displayName: "SocialShareDropdown",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomUrl: React.PropTypes.string,
show: React.PropTypes.bool.isRequired,
socialShareButtonAvailable: React.PropTypes.bool,
socialShareProviders: React.PropTypes.array
},
handleToolbarAddButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.AddSocialShareButton());
},
handleAddServiceClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.AddSocialShareProvider());
},
handleProviderClick: function(event) {
event.preventDefault();
var origin = event.currentTarget.dataset.provider;
var provider = this.props.socialShareProviders.filter(function(provider) {
return provider.origin == origin;
})[0];
this.props.dispatcher.dispatch(new sharedActions.ShareRoomUrl({
provider: provider,
roomUrl: this.props.roomUrl,
previews: []
}));
},
render: function() {
// Don't render a thing when no data has been fetched yet.
if (!this.props.socialShareProviders) {
return null;
}
var cx = React.addons.classSet;
var shareDropdown = cx({
"share-service-dropdown": true,
"dropdown-menu": true,
"share-button-unavailable": !this.props.socialShareButtonAvailable,
"hide": !this.props.show
});
// When the button is not yet available, we offer to put it in the navbar
// for the user.
if (!this.props.socialShareButtonAvailable) {
return (
React.createElement("div", {className: shareDropdown},
React.createElement("div", {className: "share-panel-header"},
mozL10n.get("share_panel_header")
),
React.createElement("div", {className: "share-panel-body"},
mozL10n.get("share_panel_body", {
brandShortname: mozL10n.get("brandShortname"),
clientSuperShortname: mozL10n.get("clientSuperShortname")
})
),
React.createElement("button", {className: "btn btn-info btn-toolbar-add",
onClick: this.handleToolbarAddButtonClick},
mozL10n.get("add_to_toolbar_button")
)
)
);
}
return (
React.createElement("ul", {className: shareDropdown},
React.createElement("li", {className: "dropdown-menu-item", onClick: this.handleAddServiceClick},
React.createElement("i", {className: "icon icon-add-share-service"}),
React.createElement("span", null, mozL10n.get("share_add_service_button"))
),
this.props.socialShareProviders.length ? React.createElement("li", {className: "dropdown-menu-separator"}) : null,
this.props.socialShareProviders.map(function(provider, idx) {
return (
React.createElement("li", {className: "dropdown-menu-item",
key: "provider-" + idx,
"data-provider": provider.origin,
onClick: this.handleProviderClick},
React.createElement("img", {className: "icon", src: provider.iconURL}),
React.createElement("span", null, provider.name)
)
);
}.bind(this))
)
);
}
});
/**
* Desktop room invitation view (overlay).
*/
var DesktopRoomInvitationView = React.createClass({displayName: "DesktopRoomInvitationView",
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
error: React.PropTypes.object,
mozLoop: React.PropTypes.object.isRequired,
// This data is supplied by the activeRoomStore.
roomData: React.PropTypes.object.isRequired,
show: React.PropTypes.bool.isRequired,
showContext: React.PropTypes.bool.isRequired
},
getInitialState: function() {
return {
copiedUrl: false,
editMode: false
};
},
handleEmailButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(
new sharedActions.EmailRoomUrl({roomUrl: this.props.roomData.roomUrl}));
},
handleCopyButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(
new sharedActions.CopyRoomUrl({roomUrl: this.props.roomData.roomUrl}));
this.setState({copiedUrl: true});
},
handleShareButtonClick: function(event) {
event.preventDefault();
this.toggleDropdownMenu();
},
handleAddContextClick: function(event) {
event.preventDefault();
this.handleEditModeChange(true);
},
handleEditModeChange: function(newEditMode) {
this.setState({ editMode: newEditMode });
},
render: function() {
if (!this.props.show) {
return null;
}
var canAddContext = this.props.mozLoop.getLoopPref("contextInConversations.enabled") &&
!this.props.showContext && !this.state.editMode;
var cx = React.addons.classSet;
return (
React.createElement("div", {className: "room-invitation-overlay"},
React.createElement("div", {className: "room-invitation-content"},
React.createElement("p", {className: cx({hide: this.state.editMode})},
mozL10n.get("invite_header_text")
),
React.createElement("a", {className: cx({hide: !canAddContext, "room-invitation-addcontext": true}),
onClick: this.handleAddContextClick},
mozL10n.get("context_add_some_label")
),
React.createElement("div", {className: "btn-group call-action-group"},
React.createElement("button", {className: "btn btn-info btn-email",
onClick: this.handleEmailButtonClick},
mozL10n.get("email_link_button")
),
React.createElement("button", {className: "btn btn-info btn-copy",
onClick: this.handleCopyButtonClick},
this.state.copiedUrl ? mozL10n.get("copied_url_button") :
mozL10n.get("copy_url_button2")
),
React.createElement("button", {className: "btn btn-info btn-share",
ref: "anchor",
onClick: this.handleShareButtonClick},
mozL10n.get("share_button3")
)
),
React.createElement(SocialShareDropdown, {
dispatcher: this.props.dispatcher,
roomUrl: this.props.roomData.roomUrl,
show: this.state.showMenu,
socialShareButtonAvailable: this.props.socialShareButtonAvailable,
socialShareProviders: this.props.socialShareProviders,
ref: "menu"})
),
React.createElement(DesktopRoomContextView, {
dispatcher: this.props.dispatcher,
editMode: this.state.editMode,
error: this.props.error,
mozLoop: this.props.mozLoop,
onEditModeChange: this.handleEditModeChange,
roomData: this.props.roomData,
show: this.props.showContext || this.state.editMode})
)
);
}
});
var DesktopRoomContextView = React.createClass({displayName: "DesktopRoomContextView",
mixins: [React.addons.LinkedStateMixin],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
editMode: React.PropTypes.bool,
error: React.PropTypes.object,
mozLoop: React.PropTypes.object.isRequired,
onEditModeChange: React.PropTypes.func,
// This data is supplied by the activeRoomStore.
roomData: React.PropTypes.object.isRequired,
show: React.PropTypes.bool.isRequired
},
componentWillReceiveProps: function(nextProps) {
var newState = {};
// When the 'show' prop is changed from outside this component, we do need
// to update the state.
if (("show" in nextProps) && nextProps.show !== this.props.show) {
newState.show = nextProps.show;
}
if (("editMode" in nextProps && nextProps.editMode !== this.props.editMode)) {
newState.editMode = nextProps.editMode;
// If we're switching to edit mode, fetch the metadata of the current tab.
// But _only_ if there's no context currently attached to the room; the
// checkbox will be disabled in that case.
if (nextProps.editMode) {
this.props.mozLoop.getSelectedTabMetadata(function(metadata) {
var previewImage = metadata.previews.length ? metadata.previews[0] : "";
var description = metadata.description || metadata.title;
var url = metadata.url;
this.setState({
availableContext: {
previewImage: previewImage,
description: description,
url: url
}
});
}.bind(this));
}
}
// When we receive an update for the `roomData` property, make sure that
// the current form fields reflect reality. This is necessary, because the
// form state is maintained in the components' state.
if (nextProps.roomData) {
// Right now it's only necessary to update the form input states when
// they contain no text yet.
if (!this.state.newRoomName && nextProps.roomData.roomName) {
newState.newRoomName = nextProps.roomData.roomName;
}
var url = this._getURL(nextProps.roomData);
if (url) {
if (!this.state.newRoomURL && url.location) {
newState.newRoomURL = url.location;
}
if (!this.state.newRoomDescription && url.description) {
newState.newRoomDescription = url.description;
}
if (!this.state.newRoomThumbnail && url.thumbnail) {
newState.newRoomThumbnail = url.thumbnail;
}
}
}
if (Object.getOwnPropertyNames(newState).length) {
this.setState(newState);
}
},
getDefaultProps: function() {
return { editMode: false };
},
getInitialState: function() {
var url = this._getURL();
return {
// `availableContext` prop only used in tests.
availableContext: this.props.availableContext,
editMode: this.props.editMode,
show: this.props.show,
newRoomName: this.props.roomData.roomName || "",
newRoomURL: url && url.location || "",
newRoomDescription: url && url.description || "",
newRoomThumbnail: url && url.thumbnail || ""
};
},
handleCloseClick: function(event) {
event.preventDefault();
if (this.state.editMode) {
this.setState({ editMode: false });
if (this.props.onEditModeChange) {
this.props.onEditModeChange(false);
}
return;
}
this.setState({ show: false });
},
handleEditClick: function(event) {
event.preventDefault();
this.setState({ editMode: true });
if (this.props.onEditModeChange) {
this.props.onEditModeChange(true);
}
},
handleCheckboxChange: function(state) {
if (state.checked) {
// The checkbox was checked, prefill the fields with the values available
// in `availableContext`.
var context = this.state.availableContext;
this.setState({
newRoomURL: context.url,
newRoomDescription: context.description,
newRoomThumbnail: context.previewImage
}, this.handleFormSubmit);
} else {
this.setState({
newRoomURL: "",
newRoomDescription: "",
newRoomThumbnail: ""
}, this.handleFormSubmit);
}
},
handleFormSubmit: function(event) {
event && event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.UpdateRoomContext({
roomToken: this.props.roomData.roomToken,
newRoomName: this.state.newRoomName,
newRoomURL: this.state.newRoomURL,
newRoomDescription: this.state.newRoomDescription,
newRoomThumbnail: this.state.newRoomThumbnail
}));
},
handleTextareaKeyDown: function(event) {
// Submit the form as soon as the user press Enter in that field
// Note: We're using a textarea instead of a simple text input to display
// placeholder and entered text on two lines, to circumvent l10n
// rendering/UX issues for some locales.
if (event.which === 13) {
this.handleFormSubmit(event);
}
},
/**
* Utility function to extract URL context data from the `roomData` property
* that can also be supplied as an argument.
*
* @param {Object} roomData Optional room data object to use, equivalent to
* the activeRoomStore state.
* @return {Object} The first context URL found on the `roomData` object.
*/
_getURL: function(roomData) {
roomData = roomData || this.props.roomData;
return this.props.roomData.roomContextUrls &&
this.props.roomData.roomContextUrls[0];
},
/**
* Truncate a string if it exceeds the length as defined in `maxLen`, which
* is defined as '72' characters by default. If the string needs trimming,
* it'll be suffixed with the unicode ellipsis char, \u2026.
*
* @param {String} str The string to truncate, if needed.
* @param {Number} maxLen Maximum number of characters that the string is
* allowed to contain. Optional, defaults to 72.
* @return {String} Truncated version of `str`.
*/
_truncate: function(str, maxLen) {
if (!maxLen) {
maxLen = 72;
}
return (str.length > maxLen) ? str.substr(0, maxLen) + "…" : str;
},
render: function() {
if (!this.state.show && !this.state.editMode)
return null;
var url = this._getURL();
var thumbnail = url && url.thumbnail || "";
var urlDescription = url && url.description || "";
var location = url && url.location || "";
var checkboxLabel = null;
var locationData = null;
if (location) {
locationData = checkboxLabel = sharedUtils.formatURL(location);
}
if (!checkboxLabel) {
checkboxLabel = sharedUtils.formatURL((this.state.availableContext ?
this.state.availableContext.url : ""));
}
var cx = React.addons.classSet;
if (this.state.editMode) {
return (
React.createElement("div", {className: "room-context"},
React.createElement("div", {className: "room-context-content"},
React.createElement("p", {className: cx({"error": !!this.props.error,
"error-display-area": true})},
mozL10n.get("rooms_change_failed_label")
),
React.createElement("div", {className: "room-context-label"}, mozL10n.get("context_inroom_label")),
React.createElement(sharedViews.Checkbox, {
checked: !!url,
disabled: !!url || !checkboxLabel,
label: mozL10n.get("context_edit_activate_label", {
title: checkboxLabel ? checkboxLabel.hostname : ""
}),
onChange: this.handleCheckboxChange,
value: location}),
React.createElement("form", {onSubmit: this.handleFormSubmit},
React.createElement("textarea", {rows: "2", type: "text", className: "room-context-name",
onBlur: this.handleFormSubmit,
onKeyDown: this.handleTextareaKeyDown,
placeholder: mozL10n.get("context_edit_name_placeholder"),
valueLink: this.linkState("newRoomName")}),
React.createElement("input", {type: "text", className: "room-context-url",
onBlur: this.handleFormSubmit,
onKeyDown: this.handleTextareaKeyDown,
placeholder: "https://",
valueLink: this.linkState("newRoomURL")}),
React.createElement("textarea", {rows: "4", type: "text", className: "room-context-comments",
onBlur: this.handleFormSubmit,
onKeyDown: this.handleTextareaKeyDown,
placeholder: mozL10n.get("context_edit_comments_placeholder"),
valueLink: this.linkState("newRoomDescription")})
),
React.createElement("button", {className: "room-context-btn-close",
onClick: this.handleCloseClick})
)
)
);
}
if (!locationData) {
return null;
}
return (
React.createElement("div", {className: "room-context"},
React.createElement("img", {className: "room-context-thumbnail", src: thumbnail}),
React.createElement("div", {className: "room-context-content"},
React.createElement("div", {className: "room-context-label"}, mozL10n.get("context_inroom_label")),
React.createElement("div", {className: "room-context-description",
title: urlDescription}, this._truncate(urlDescription)),
React.createElement("a", {className: "room-context-url",
href: location,
target: "_blank",
title: locationData.location}, locationData.hostname),
this.props.roomData.roomDescription ?
React.createElement("div", {className: "room-context-comment"}, this.props.roomData.roomDescription) :
null,
React.createElement("button", {className: "room-context-btn-close",
onClick: this.handleCloseClick}),
React.createElement("button", {className: "room-context-btn-edit",
onClick: this.handleEditClick})
)
)
);
}
});
/**
* Desktop room conversation view.
*/
var DesktopRoomConversationView = React.createClass({displayName: "DesktopRoomConversationView",
mixins: [
ActiveRoomStoreMixin,
sharedMixins.DocumentTitleMixin,
sharedMixins.MediaSetupMixin,
sharedMixins.RoomsAudioMixin,
sharedMixins.WindowCloseMixin
],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired
},
componentWillUpdate: function(nextProps, nextState) {
// The SDK needs to know about the configuration and the elements to use
// for display. So the best way seems to pass the information here - ideally
// the sdk wouldn't need to know this, but we can't change that.
if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this.getDefaultPublisherConfig({
publishVideo: !this.state.videoMuted
}),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getScreenShareElementFunc: this._getElement.bind(this, ".screen"),
getRemoteElementFunc: this._getElement.bind(this, ".remote")
}));
}
},
/**
* User clicked on the "Leave" button.
*/
leaveRoom: function() {
if (this.state.used) {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
} else {
this.closeWindow();
}
},
/**
* Used to control publishing a stream - i.e. to mute a stream
*
* @param {String} type The type of stream, e.g. "audio" or "video".
* @param {Boolean} enabled True to enable the stream, false otherwise.
*/
publishStream: function(type, enabled) {
this.props.dispatcher.dispatch(
new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
_shouldRenderInvitationOverlay: function() {
return (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS);
},
_shouldRenderContextView: function() {
return !!(
this.props.mozLoop.getLoopPref("contextInConversations.enabled") &&
(this.state.roomContextUrls || this.state.roomDescription)
);
},
render: function() {
if (this.state.roomName) {
this.setTitle(this.state.roomName);
}
var localStreamClasses = React.addons.classSet({
local: true,
"local-stream": true,
"local-stream-audio": this.state.videoMuted,
"room-preview": this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS
});
var screenShareData = {
state: this.state.screenSharingState,
visible: true
};
var shouldRenderInvitationOverlay = this._shouldRenderInvitationOverlay();
var shouldRenderContextView = this._shouldRenderContextView();
var roomData = this.props.roomStore.getStoreState("activeRoom");
switch(this.state.roomState) {
case ROOM_STATES.FAILED:
case ROOM_STATES.FULL: {
// Note: While rooms are set to hold a maximum of 2 participants, the
// FULL case should never happen on desktop.
return (
React.createElement(loop.conversationViews.GenericFailureView, {
cancelCall: this.closeWindow,
failureReason: this.state.failureReason})
);
}
case ROOM_STATES.ENDED: {
return (
React.createElement(sharedViews.FeedbackView, {
onAfterFeedbackReceived: this.closeWindow})
);
}
default: {
return (
React.createElement("div", {className: "room-conversation-wrapper"},
React.createElement(DesktopRoomInvitationView, {
dispatcher: this.props.dispatcher,
error: this.state.error,
mozLoop: this.props.mozLoop,
roomData: roomData,
show: shouldRenderInvitationOverlay,
showContext: shouldRenderContextView,
socialShareButtonAvailable: this.state.socialShareButtonAvailable,
socialShareProviders: this.state.socialShareProviders}),
React.createElement("div", {className: "video-layout-wrapper"},
React.createElement("div", {className: "conversation room-conversation"},
React.createElement("div", {className: "media nested"},
React.createElement("div", {className: "video_wrapper remote_wrapper"},
React.createElement("div", {className: "video_inner remote focus-stream"})
),
React.createElement("div", {className: localStreamClasses}),
React.createElement("div", {className: "screen hide"})
),
React.createElement(sharedViews.ConversationToolbar, {
dispatcher: this.props.dispatcher,
video: {enabled: !this.state.videoMuted, visible: true},
audio: {enabled: !this.state.audioMuted, visible: true},
publishStream: this.publishStream,
hangup: this.leaveRoom,
screenShare: screenShareData})
)
),
React.createElement(DesktopRoomContextView, {
dispatcher: this.props.dispatcher,
error: this.state.error,
mozLoop: this.props.mozLoop,
roomData: roomData,
show: !shouldRenderInvitationOverlay && shouldRenderContextView})
)
);
}
}
}
});
return {
ActiveRoomStoreMixin: ActiveRoomStoreMixin,
SocialShareDropdown: SocialShareDropdown,
DesktopRoomContextView: DesktopRoomContextView,
DesktopRoomConversationView: DesktopRoomConversationView,
DesktopRoomInvitationView: DesktopRoomInvitationView
};
})(document.mozL10n || navigator.mozL10n);
@@ -1,708 +0,0 @@
/** @jsx React.DOM */
/* 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/. */
/* jshint newcap:false */
/* global loop:true, React */
var loop = loop || {};
loop.roomViews = (function(mozL10n) {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var sharedUtils = loop.shared.utils;
var sharedViews = loop.shared.views;
/**
* ActiveRoomStore mixin.
* @type {Object}
*/
var ActiveRoomStoreMixin = {
mixins: [Backbone.Events],
propTypes: {
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
},
componentWillMount: function() {
this.listenTo(this.props.roomStore, "change:activeRoom",
this._onActiveRoomStateChanged);
this.listenTo(this.props.roomStore, "change:error",
this._onRoomError);
},
componentWillUnmount: function() {
this.stopListening(this.props.roomStore);
},
_onActiveRoomStateChanged: function() {
// Only update the state if we're mounted, to avoid the problem where
// stopListening doesn't nuke the active listeners during a event
// processing.
if (this.isMounted()) {
this.setState(this.props.roomStore.getStoreState("activeRoom"));
}
},
_onRoomError: function() {
// Only update the state if we're mounted, to avoid the problem where
// stopListening doesn't nuke the active listeners during a event
// processing.
if (this.isMounted()) {
this.setState({error: this.props.roomStore.getStoreState("error")});
}
},
getInitialState: function() {
var storeState = this.props.roomStore.getStoreState("activeRoom");
return _.extend({
// Used by the UI showcase.
roomState: this.props.roomState || storeState.roomState
}, storeState);
}
};
var SocialShareDropdown = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomUrl: React.PropTypes.string,
show: React.PropTypes.bool.isRequired,
socialShareButtonAvailable: React.PropTypes.bool,
socialShareProviders: React.PropTypes.array
},
handleToolbarAddButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.AddSocialShareButton());
},
handleAddServiceClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.AddSocialShareProvider());
},
handleProviderClick: function(event) {
event.preventDefault();
var origin = event.currentTarget.dataset.provider;
var provider = this.props.socialShareProviders.filter(function(provider) {
return provider.origin == origin;
})[0];
this.props.dispatcher.dispatch(new sharedActions.ShareRoomUrl({
provider: provider,
roomUrl: this.props.roomUrl,
previews: []
}));
},
render: function() {
// Don't render a thing when no data has been fetched yet.
if (!this.props.socialShareProviders) {
return null;
}
var cx = React.addons.classSet;
var shareDropdown = cx({
"share-service-dropdown": true,
"dropdown-menu": true,
"share-button-unavailable": !this.props.socialShareButtonAvailable,
"hide": !this.props.show
});
// When the button is not yet available, we offer to put it in the navbar
// for the user.
if (!this.props.socialShareButtonAvailable) {
return (
<div className={shareDropdown}>
<div className="share-panel-header">
{mozL10n.get("share_panel_header")}
</div>
<div className="share-panel-body">
{
mozL10n.get("share_panel_body", {
brandShortname: mozL10n.get("brandShortname"),
clientSuperShortname: mozL10n.get("clientSuperShortname")
})
}
</div>
<button className="btn btn-info btn-toolbar-add"
onClick={this.handleToolbarAddButtonClick}>
{mozL10n.get("add_to_toolbar_button")}
</button>
</div>
);
}
return (
<ul className={shareDropdown}>
<li className="dropdown-menu-item" onClick={this.handleAddServiceClick}>
<i className="icon icon-add-share-service"></i>
<span>{mozL10n.get("share_add_service_button")}</span>
</li>
{this.props.socialShareProviders.length ? <li className="dropdown-menu-separator"/> : null}
{
this.props.socialShareProviders.map(function(provider, idx) {
return (
<li className="dropdown-menu-item"
key={"provider-" + idx}
data-provider={provider.origin}
onClick={this.handleProviderClick}>
<img className="icon" src={provider.iconURL}/>
<span>{provider.name}</span>
</li>
);
}.bind(this))
}
</ul>
);
}
});
/**
* Desktop room invitation view (overlay).
*/
var DesktopRoomInvitationView = React.createClass({
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
error: React.PropTypes.object,
mozLoop: React.PropTypes.object.isRequired,
// This data is supplied by the activeRoomStore.
roomData: React.PropTypes.object.isRequired,
show: React.PropTypes.bool.isRequired,
showContext: React.PropTypes.bool.isRequired
},
getInitialState: function() {
return {
copiedUrl: false,
editMode: false
};
},
handleEmailButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(
new sharedActions.EmailRoomUrl({roomUrl: this.props.roomData.roomUrl}));
},
handleCopyButtonClick: function(event) {
event.preventDefault();
this.props.dispatcher.dispatch(
new sharedActions.CopyRoomUrl({roomUrl: this.props.roomData.roomUrl}));
this.setState({copiedUrl: true});
},
handleShareButtonClick: function(event) {
event.preventDefault();
this.toggleDropdownMenu();
},
handleAddContextClick: function(event) {
event.preventDefault();
this.handleEditModeChange(true);
},
handleEditModeChange: function(newEditMode) {
this.setState({ editMode: newEditMode });
},
render: function() {
if (!this.props.show) {
return null;
}
var canAddContext = this.props.mozLoop.getLoopPref("contextInConversations.enabled") &&
!this.props.showContext && !this.state.editMode;
var cx = React.addons.classSet;
return (
<div className="room-invitation-overlay">
<div className="room-invitation-content">
<p className={cx({hide: this.state.editMode})}>
{mozL10n.get("invite_header_text")}
</p>
<a className={cx({hide: !canAddContext, "room-invitation-addcontext": true})}
onClick={this.handleAddContextClick}>
{mozL10n.get("context_add_some_label")}
</a>
<div className="btn-group call-action-group">
<button className="btn btn-info btn-email"
onClick={this.handleEmailButtonClick}>
{mozL10n.get("email_link_button")}
</button>
<button className="btn btn-info btn-copy"
onClick={this.handleCopyButtonClick}>
{this.state.copiedUrl ? mozL10n.get("copied_url_button") :
mozL10n.get("copy_url_button2")}
</button>
<button className="btn btn-info btn-share"
ref="anchor"
onClick={this.handleShareButtonClick}>
{mozL10n.get("share_button3")}
</button>
</div>
<SocialShareDropdown
dispatcher={this.props.dispatcher}
roomUrl={this.props.roomData.roomUrl}
show={this.state.showMenu}
socialShareButtonAvailable={this.props.socialShareButtonAvailable}
socialShareProviders={this.props.socialShareProviders}
ref="menu" />
</div>
<DesktopRoomContextView
dispatcher={this.props.dispatcher}
editMode={this.state.editMode}
error={this.props.error}
mozLoop={this.props.mozLoop}
onEditModeChange={this.handleEditModeChange}
roomData={this.props.roomData}
show={this.props.showContext || this.state.editMode} />
</div>
);
}
});
var DesktopRoomContextView = React.createClass({
mixins: [React.addons.LinkedStateMixin],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
editMode: React.PropTypes.bool,
error: React.PropTypes.object,
mozLoop: React.PropTypes.object.isRequired,
onEditModeChange: React.PropTypes.func,
// This data is supplied by the activeRoomStore.
roomData: React.PropTypes.object.isRequired,
show: React.PropTypes.bool.isRequired
},
componentWillReceiveProps: function(nextProps) {
var newState = {};
// When the 'show' prop is changed from outside this component, we do need
// to update the state.
if (("show" in nextProps) && nextProps.show !== this.props.show) {
newState.show = nextProps.show;
}
if (("editMode" in nextProps && nextProps.editMode !== this.props.editMode)) {
newState.editMode = nextProps.editMode;
// If we're switching to edit mode, fetch the metadata of the current tab.
// But _only_ if there's no context currently attached to the room; the
// checkbox will be disabled in that case.
if (nextProps.editMode) {
this.props.mozLoop.getSelectedTabMetadata(function(metadata) {
var previewImage = metadata.previews.length ? metadata.previews[0] : "";
var description = metadata.description || metadata.title;
var url = metadata.url;
this.setState({
availableContext: {
previewImage: previewImage,
description: description,
url: url
}
});
}.bind(this));
}
}
// When we receive an update for the `roomData` property, make sure that
// the current form fields reflect reality. This is necessary, because the
// form state is maintained in the components' state.
if (nextProps.roomData) {
// Right now it's only necessary to update the form input states when
// they contain no text yet.
if (!this.state.newRoomName && nextProps.roomData.roomName) {
newState.newRoomName = nextProps.roomData.roomName;
}
var url = this._getURL(nextProps.roomData);
if (url) {
if (!this.state.newRoomURL && url.location) {
newState.newRoomURL = url.location;
}
if (!this.state.newRoomDescription && url.description) {
newState.newRoomDescription = url.description;
}
if (!this.state.newRoomThumbnail && url.thumbnail) {
newState.newRoomThumbnail = url.thumbnail;
}
}
}
if (Object.getOwnPropertyNames(newState).length) {
this.setState(newState);
}
},
getDefaultProps: function() {
return { editMode: false };
},
getInitialState: function() {
var url = this._getURL();
return {
// `availableContext` prop only used in tests.
availableContext: this.props.availableContext,
editMode: this.props.editMode,
show: this.props.show,
newRoomName: this.props.roomData.roomName || "",
newRoomURL: url && url.location || "",
newRoomDescription: url && url.description || "",
newRoomThumbnail: url && url.thumbnail || ""
};
},
handleCloseClick: function(event) {
event.preventDefault();
if (this.state.editMode) {
this.setState({ editMode: false });
if (this.props.onEditModeChange) {
this.props.onEditModeChange(false);
}
return;
}
this.setState({ show: false });
},
handleEditClick: function(event) {
event.preventDefault();
this.setState({ editMode: true });
if (this.props.onEditModeChange) {
this.props.onEditModeChange(true);
}
},
handleCheckboxChange: function(state) {
if (state.checked) {
// The checkbox was checked, prefill the fields with the values available
// in `availableContext`.
var context = this.state.availableContext;
this.setState({
newRoomURL: context.url,
newRoomDescription: context.description,
newRoomThumbnail: context.previewImage
}, this.handleFormSubmit);
} else {
this.setState({
newRoomURL: "",
newRoomDescription: "",
newRoomThumbnail: ""
}, this.handleFormSubmit);
}
},
handleFormSubmit: function(event) {
event && event.preventDefault();
this.props.dispatcher.dispatch(new sharedActions.UpdateRoomContext({
roomToken: this.props.roomData.roomToken,
newRoomName: this.state.newRoomName,
newRoomURL: this.state.newRoomURL,
newRoomDescription: this.state.newRoomDescription,
newRoomThumbnail: this.state.newRoomThumbnail
}));
},
handleTextareaKeyDown: function(event) {
// Submit the form as soon as the user press Enter in that field
// Note: We're using a textarea instead of a simple text input to display
// placeholder and entered text on two lines, to circumvent l10n
// rendering/UX issues for some locales.
if (event.which === 13) {
this.handleFormSubmit(event);
}
},
/**
* Utility function to extract URL context data from the `roomData` property
* that can also be supplied as an argument.
*
* @param {Object} roomData Optional room data object to use, equivalent to
* the activeRoomStore state.
* @return {Object} The first context URL found on the `roomData` object.
*/
_getURL: function(roomData) {
roomData = roomData || this.props.roomData;
return this.props.roomData.roomContextUrls &&
this.props.roomData.roomContextUrls[0];
},
/**
* Truncate a string if it exceeds the length as defined in `maxLen`, which
* is defined as '72' characters by default. If the string needs trimming,
* it'll be suffixed with the unicode ellipsis char, \u2026.
*
* @param {String} str The string to truncate, if needed.
* @param {Number} maxLen Maximum number of characters that the string is
* allowed to contain. Optional, defaults to 72.
* @return {String} Truncated version of `str`.
*/
_truncate: function(str, maxLen) {
if (!maxLen) {
maxLen = 72;
}
return (str.length > maxLen) ? str.substr(0, maxLen) + "…" : str;
},
render: function() {
if (!this.state.show && !this.state.editMode)
return null;
var url = this._getURL();
var thumbnail = url && url.thumbnail || "";
var urlDescription = url && url.description || "";
var location = url && url.location || "";
var checkboxLabel = null;
var locationData = null;
if (location) {
locationData = checkboxLabel = sharedUtils.formatURL(location);
}
if (!checkboxLabel) {
checkboxLabel = sharedUtils.formatURL((this.state.availableContext ?
this.state.availableContext.url : ""));
}
var cx = React.addons.classSet;
if (this.state.editMode) {
return (
<div className="room-context">
<div className="room-context-content">
<p className={cx({"error": !!this.props.error,
"error-display-area": true})}>
{mozL10n.get("rooms_change_failed_label")}
</p>
<div className="room-context-label">{mozL10n.get("context_inroom_label")}</div>
<sharedViews.Checkbox
checked={!!url}
disabled={!!url || !checkboxLabel}
label={mozL10n.get("context_edit_activate_label", {
title: checkboxLabel ? checkboxLabel.hostname : ""
})}
onChange={this.handleCheckboxChange}
value={location} />
<form onSubmit={this.handleFormSubmit}>
<textarea rows="2" type="text" className="room-context-name"
onBlur={this.handleFormSubmit}
onKeyDown={this.handleTextareaKeyDown}
placeholder={mozL10n.get("context_edit_name_placeholder")}
valueLink={this.linkState("newRoomName")} />
<input type="text" className="room-context-url"
onBlur={this.handleFormSubmit}
onKeyDown={this.handleTextareaKeyDown}
placeholder="https://"
valueLink={this.linkState("newRoomURL")} />
<textarea rows="4" type="text" className="room-context-comments"
onBlur={this.handleFormSubmit}
onKeyDown={this.handleTextareaKeyDown}
placeholder={mozL10n.get("context_edit_comments_placeholder")}
valueLink={this.linkState("newRoomDescription")} />
</form>
<button className="room-context-btn-close"
onClick={this.handleCloseClick}/>
</div>
</div>
);
}
if (!locationData) {
return null;
}
return (
<div className="room-context">
<img className="room-context-thumbnail" src={thumbnail}/>
<div className="room-context-content">
<div className="room-context-label">{mozL10n.get("context_inroom_label")}</div>
<div className="room-context-description"
title={urlDescription}>{this._truncate(urlDescription)}</div>
<a className="room-context-url"
href={location}
target="_blank"
title={locationData.location}>{locationData.hostname}</a>
{this.props.roomData.roomDescription ?
<div className="room-context-comment">{this.props.roomData.roomDescription}</div> :
null}
<button className="room-context-btn-close"
onClick={this.handleCloseClick}/>
<button className="room-context-btn-edit"
onClick={this.handleEditClick}/>
</div>
</div>
);
}
});
/**
* Desktop room conversation view.
*/
var DesktopRoomConversationView = React.createClass({
mixins: [
ActiveRoomStoreMixin,
sharedMixins.DocumentTitleMixin,
sharedMixins.MediaSetupMixin,
sharedMixins.RoomsAudioMixin,
sharedMixins.WindowCloseMixin
],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
mozLoop: React.PropTypes.object.isRequired
},
componentWillUpdate: function(nextProps, nextState) {
// The SDK needs to know about the configuration and the elements to use
// for display. So the best way seems to pass the information here - ideally
// the sdk wouldn't need to know this, but we can't change that.
if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this.getDefaultPublisherConfig({
publishVideo: !this.state.videoMuted
}),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getScreenShareElementFunc: this._getElement.bind(this, ".screen"),
getRemoteElementFunc: this._getElement.bind(this, ".remote")
}));
}
},
/**
* User clicked on the "Leave" button.
*/
leaveRoom: function() {
if (this.state.used) {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
} else {
this.closeWindow();
}
},
/**
* Used to control publishing a stream - i.e. to mute a stream
*
* @param {String} type The type of stream, e.g. "audio" or "video".
* @param {Boolean} enabled True to enable the stream, false otherwise.
*/
publishStream: function(type, enabled) {
this.props.dispatcher.dispatch(
new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
_shouldRenderInvitationOverlay: function() {
return (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS);
},
_shouldRenderContextView: function() {
return !!(
this.props.mozLoop.getLoopPref("contextInConversations.enabled") &&
(this.state.roomContextUrls || this.state.roomDescription)
);
},
render: function() {
if (this.state.roomName) {
this.setTitle(this.state.roomName);
}
var localStreamClasses = React.addons.classSet({
local: true,
"local-stream": true,
"local-stream-audio": this.state.videoMuted,
"room-preview": this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS
});
var screenShareData = {
state: this.state.screenSharingState,
visible: true
};
var shouldRenderInvitationOverlay = this._shouldRenderInvitationOverlay();
var shouldRenderContextView = this._shouldRenderContextView();
var roomData = this.props.roomStore.getStoreState("activeRoom");
switch(this.state.roomState) {
case ROOM_STATES.FAILED:
case ROOM_STATES.FULL: {
// Note: While rooms are set to hold a maximum of 2 participants, the
// FULL case should never happen on desktop.
return (
<loop.conversationViews.GenericFailureView
cancelCall={this.closeWindow}
failureReason={this.state.failureReason} />
);
}
case ROOM_STATES.ENDED: {
return (
<sharedViews.FeedbackView
onAfterFeedbackReceived={this.closeWindow} />
);
}
default: {
return (
<div className="room-conversation-wrapper">
<DesktopRoomInvitationView
dispatcher={this.props.dispatcher}
error={this.state.error}
mozLoop={this.props.mozLoop}
roomData={roomData}
show={shouldRenderInvitationOverlay}
showContext={shouldRenderContextView}
socialShareButtonAvailable={this.state.socialShareButtonAvailable}
socialShareProviders={this.state.socialShareProviders} />
<div className="video-layout-wrapper">
<div className="conversation room-conversation">
<div className="media nested">
<div className="video_wrapper remote_wrapper">
<div className="video_inner remote focus-stream"></div>
</div>
<div className={localStreamClasses}></div>
<div className="screen hide"></div>
</div>
<sharedViews.ConversationToolbar
dispatcher={this.props.dispatcher}
video={{enabled: !this.state.videoMuted, visible: true}}
audio={{enabled: !this.state.audioMuted, visible: true}}
publishStream={this.publishStream}
hangup={this.leaveRoom}
screenShare={screenShareData} />
</div>
</div>
<DesktopRoomContextView
dispatcher={this.props.dispatcher}
error={this.state.error}
mozLoop={this.props.mozLoop}
roomData={roomData}
show={!shouldRenderInvitationOverlay && shouldRenderContextView} />
</div>
);
}
}
}
});
return {
ActiveRoomStoreMixin: ActiveRoomStoreMixin,
SocialShareDropdown: SocialShareDropdown,
DesktopRoomContextView: DesktopRoomContextView,
DesktopRoomConversationView: DesktopRoomConversationView,
DesktopRoomInvitationView: DesktopRoomInvitationView
};
})(document.mozL10n || navigator.mozL10n);
File diff suppressed because it is too large Load Diff
@@ -1,65 +0,0 @@
<?xml version="1.0"?>
<!-- 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/. -->
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px"
viewBox="0 0 10 10"
enable-background="new 0 0 10 10"
xml:space="preserve">
<style>
use:not(:target) {
display: none;
}
use {
fill: #ccc;
}
use[id$="-hover"] {
fill: #444;
}
use[id$="-active"] {
fill: #0095dd;
}
use[id$="-white"] {
fill: rgba(255,255,255,0.8);
}
use[id$="-disabled"] {
fill: rgba(255,255,255,0.4);
}
</style>
<defs style="display:none">
<polygon id="close-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,1.717 8.336,0.049 5.024,3.369 1.663,0 0,1.668
3.36,5.037 0.098,8.307 1.762,9.975 5.025,6.705 8.311,10 9.975,8.332 6.688,5.037"/>
<path id="dropdown-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9,3L4.984,7L1,3H9z"/>
<polygon id="expand-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,0 4.838,0 6.506,1.669 0,8.175 1.825,10 8.331,3.494
10,5.162"/>
<path id="edit-shape" d="M5.493,1.762l2.745,2.745L2.745,10H0V7.255L5.493,1.762z M2.397,9.155l0.601-0.601L1.446,7.002L0.845,7.603
V8.31H1.69v0.845H2.397z M5.849,3.028c0-0.096-0.048-0.144-0.146-0.144c-0.044,0-0.081,0.015-0.112,0.046L2.014,6.508
C1.983,6.538,1.968,6.577,1.968,6.619c0,0.098,0.048,0.146,0.144,0.146c0.044,0,0.081-0.015,0.112-0.046l3.579-3.577
C5.834,3.111,5.849,3.073,5.849,3.028z M10,2.395c0,0.233-0.081,0.431-0.245,0.595L8.66,4.085L5.915,1.34L7.01,0.25
C7.168,0.083,7.366,0,7.605,0c0.233,0,0.433,0.083,0.601,0.25l1.55,1.544C9.919,1.966,10,2.166,10,2.395z"/>
<rect id="minimize-shape" y="3.6" fill-rule="evenodd" clip-rule="evenodd" width="10" height="2.8"/>
</defs>
<use id="close" xlink:href="#close-shape"/>
<use id="close-active" xlink:href="#close-shape"/>
<use id="close-disabled" xlink:href="#close-shape"/>
<use id="dropdown" xlink:href="#dropdown-shape"/>
<use id="dropdown-white" xlink:href="#dropdown-shape"/>
<use id="dropdown-active" xlink:href="#dropdown-shape"/>
<use id="dropdown-disabled" xlink:href="#dropdown-shape"/>
<use id="edit" xlink:href="#edit-shape"/>
<use id="edit-active" xlink:href="#edit-shape"/>
<use id="edit-disabled" xlink:href="#edit-shape"/>
<use id="expand" xlink:href="#expand-shape"/>
<use id="expand-active" xlink:href="#expand-shape"/>
<use id="expand-disabled" xlink:href="#expand-shape"/>
<use id="minimize" xlink:href="#minimize-shape"/>
<use id="minimize-active" xlink:href="#minimize-shape"/>
<use id="minimize-disabled" xlink:href="#minimize-shape"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,301 +0,0 @@
/* 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.CallConnectionWebSocket = (function() {
"use strict";
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
// Response timeout is 5 seconds as per API.
var kResponseTimeout = 5000;
/**
* Handles a websocket specifically for a call connection.
*
* There should be one of these created for each call connection.
*
* options items:
* - url The url of the websocket to connect to.
* - callId The call id for the call
* - websocketToken The authentication token for the websocket
*
* @param {Object} options The options for this websocket.
*/
function CallConnectionWebSocket(options) {
this.options = options || {};
if (!this.options.url) {
throw new Error("No url in options");
}
if (!this.options.callId) {
throw new Error("No callId in options");
}
if (!this.options.websocketToken) {
throw new Error("No websocketToken in options");
}
this._lastServerState = "init";
// Set loop.debug.sdk to true in the browser, or standalone:
// localStorage.setItem("debug.websocket", true);
this._debugWebSocket =
loop.shared.utils.getBoolPreference("debug.websocket");
_.extend(this, Backbone.Events);
}
CallConnectionWebSocket.prototype = {
/**
* Start the connection to the websocket.
*
* @return {Promise} A promise that resolves when the websocket
* server connection is open and "hello"s have been
* exchanged. It is rejected if there is a failure in
* connection or the initial exchange of "hello"s.
*/
promiseConnect: function() {
var promise = new Promise(
function(resolve, reject) {
this.socket = new WebSocket(this.options.url);
this.socket.onopen = this._onopen.bind(this);
this.socket.onmessage = this._onmessage.bind(this);
this.socket.onerror = this._onerror.bind(this);
this.socket.onclose = this._onclose.bind(this);
var timeout = setTimeout(function() {
if (this.connectDetails && this.connectDetails.reject) {
this.connectDetails.reject(WEBSOCKET_REASONS.TIMEOUT);
this._clearConnectionFlags();
}
}.bind(this), kResponseTimeout);
this.connectDetails = {
resolve: resolve,
reject: reject,
timeout: timeout
};
}.bind(this));
return promise;
},
/**
* Closes the websocket. This shouldn't be the normal action as the server
* will normally close the socket. Only in bad error cases, or where we need
* to close the socket just before closing the window (to avoid an error)
* should we call this.
*/
close: function() {
if (this.socket) {
this.socket.close();
}
},
_clearConnectionFlags: function() {
clearTimeout(this.connectDetails.timeout);
delete this.connectDetails;
},
/**
* Internal function called to resolve the connection promise.
*
* It will log an error if no promise is found.
*
* @param {String} progressState The current state of progress of the
* websocket.
*/
_completeConnection: function(progressState) {
if (this.connectDetails && this.connectDetails.resolve) {
this.connectDetails.resolve(progressState);
this._clearConnectionFlags();
return;
}
console.error("Failed to complete connection promise - no promise available");
},
/**
* Checks if the websocket is connecting, and rejects the connection
* promise if appropriate.
*
* @param {Object} event The event to reject the promise with if
* appropriate.
*/
_checkConnectionFailed: function(event) {
if (this.connectDetails && this.connectDetails.reject) {
this.connectDetails.reject(event);
this._clearConnectionFlags();
return true;
}
return false;
},
/**
* Notifies the server that the user has declined the call.
*/
decline: function() {
this._send({
messageType: "action",
event: "terminate",
reason: WEBSOCKET_REASONS.REJECT
});
},
/**
* Notifies the server that the user has accepted the call.
*/
accept: function() {
this._send({
messageType: "action",
event: "accept"
});
},
/**
* Notifies the server that the outgoing media is up, and the
* incoming media is being received.
*/
mediaUp: function() {
this._send({
messageType: "action",
event: "media-up"
});
},
/**
* Notifies the server that the outgoing call is cancelled by the
* user.
*/
cancel: function() {
this._send({
messageType: "action",
event: "terminate",
reason: WEBSOCKET_REASONS.CANCEL
});
},
/**
* Notifies the server that something failed during setup.
*/
mediaFail: function() {
this._send({
messageType: "action",
event: "terminate",
reason: WEBSOCKET_REASONS.MEDIA_FAIL
});
},
/**
* Sends data on the websocket.
*
* @param {Object} data The data to send.
*/
_send: function(data) {
this._log("WS Sending", data);
this.socket.send(JSON.stringify(data));
},
/**
* Used to determine if the server state is in a completed state, i.e.
* the server has determined the connection is terminated or connected.
*
* @return True if the last received state is terminated or connected.
*/
get _stateIsCompleted() {
return this._lastServerState === "terminated" ||
this._lastServerState === "connected";
},
/**
* Called when the socket is open. Automatically sends a "hello"
* message to the server.
*/
_onopen: function() {
// Auto-register with the server.
this._send({
messageType: "hello",
callId: this.options.callId,
auth: this.options.websocketToken
});
},
/**
* Called when a message is received from the server.
*
* @param {Object} event The websocket onmessage event.
*/
_onmessage: function(event) {
var msgData;
try {
msgData = JSON.parse(event.data);
} catch (x) {
console.error("Error parsing received message:", x);
return;
}
this._log("WS Receiving", event.data);
var previousState = this._lastServerState;
this._lastServerState = msgData.state;
switch(msgData.messageType) {
case "hello":
this._completeConnection(msgData.state);
break;
case "progress":
this.trigger("progress:" + msgData.state);
this.trigger("progress", msgData, previousState);
break;
}
},
/**
* Called when there is an error on the websocket.
*
* @param {Object} event A simple error event.
*/
_onerror: function(event) {
this._log("WS Error", event);
if (!this._stateIsCompleted &&
!this._checkConnectionFailed(event)) {
this.trigger("error", event);
}
},
/**
* Called when the websocket is closed.
*
* @param {CloseEvent} event The details of the websocket closing.
*/
_onclose: function(event) {
this._log("WS Close", event);
// If the websocket goes away when we're not in a completed state
// then its an error. So we either pass it back via the connection
// promise, or trigger the closed event.
if (!this._stateIsCompleted &&
!this._checkConnectionFailed(event)) {
this.trigger("closed", event);
}
},
/**
* Logs debug to the console.
*
* Parameters: same as console.log
*/
_log: function() {
if (this._debugWebSocket) {
console.log.apply(console, arguments);
}
}
};
return CallConnectionWebSocket;
})();
-17
View File
@@ -1,17 +0,0 @@
{
"ecmaFeatures": {
"arrowFunctions": true,
"blockBindings": true,
"destructuring": true,
"generators": true,
"restParams": true,
"spread": true,
"objectLiteralShorthandMethods": true,
},
"rules": {
"generator-star-spacing": [2, "after"],
// We should fix the errors and enable this (set to 2)
"no-var": 0,
"strict": [2, "global"]
}
}
@@ -1,463 +0,0 @@
/* 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";
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Log.jsm");
this.EXPORTED_SYMBOLS = ["CardDavImporter"];
let log = Log.repository.getLogger("Loop.Importer.CardDAV");
log.level = Log.Level.Debug;
log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
const DEPTH_RESOURCE_ONLY = "0";
const DEPTH_RESOURCE_AND_CHILDREN = "1";
const DEPTH_RESOURCE_AND_ALL_DESCENDENTS = "infinity";
this.CardDavImporter = function() {
};
/**
* CardDAV Address Book importer for Loop.
*
* The model for address book importers is to have a single public method,
* "startImport." When the import is done (or upon a fatal error), the
* caller's callback method is called.
*
* The current model for this importer is based on the subset of CardDAV
* implemented by Google. In theory, it should work with other CardDAV
* sources, but it has only been tested against Google at the moment.
*
* At the moment, this importer assumes that no local changes will be made
* to data retreived from a remote source: when performing a re-import,
* any records that have been previously imported will be completely
* removed and replaced with the data received from the CardDAV server.
* Witout this behavior, it would be impossible for users to take any
* actions to remove fields that are no longer valid.
*/
this.CardDavImporter.prototype = {
/**
* Begin import of an address book from a CardDAV server.
*
* @param {Object} options Information needed to perform the address
* book import. The following fields are currently
* defined:
* - "host": CardDAV server base address
* (e.g., "google.com")
* - "auth": Authentication mechanism to use.
* Currently, only "basic" is implemented.
* - "user": Username to use for basic auth
* - "password": Password to use for basic auth
* @param {Function} callback Callback function that will be invoked once the
* import operation is complete. The first argument
* passed to the callback will be an 'Error' object
* or 'null'. If the import operation was
* successful, then the second parameter will be a
* count of the number of contacts that were
* successfully imported.
* @param {Object} db Database to add imported contacts into.
* Nominally, this is the LoopContacts API. In
* practice, anything with the same interface
* should work here.
*/
startImport: function(options, callback, db) {
let auth;
if (!("auth" in options)) {
callback(new Error("No authentication specified"));
return;
}
if (options.auth === "basic") {
if (!("user" in options) || !("password" in options)) {
callback(new Error("Missing user or password for basic authentication"));
return;
}
auth = { method: "basic",
user: options.user,
password: options.password };
} else {
callback(new Error("Unknown authentication method"));
return;
}
if (!("host" in options)){
callback(new Error("Missing host for CardDav import"));
return;
}
let host = options.host;
Task.spawn(function* () {
log.info("Starting CardDAV import from " + host);
let baseURL = "https://" + host;
let startURL = baseURL + "/.well-known/carddav";
let abookURL;
// Get list of contact URLs
let body = "<d:propfind xmlns:d='DAV:'><d:prop><d:getetag />" +
"</d:prop></d:propfind>";
let abook = yield this._davPromise("PROPFIND", startURL, auth,
DEPTH_RESOURCE_AND_CHILDREN, body);
// Build multiget REPORT body from URLs in PROPFIND result
let contactElements = abook.responseXML.
getElementsByTagNameNS("DAV:", "href");
body = "<c:addressbook-multiget xmlns:d='DAV:' " +
"xmlns:c='urn:ietf:params:xml:ns:carddav'>" +
"<d:prop><d:getetag /> <c:address-data /></d:prop>\n";
for (let element of contactElements) {
let href = element.textContent;
if (href.substr(-1) == "/") {
abookURL = baseURL + href;
} else {
body += "<d:href>" + href + "</d:href>\n";
}
}
body += "</c:addressbook-multiget>";
// Retreive contact URL contents
let allEntries = yield this._davPromise("REPORT", abookURL, auth,
DEPTH_RESOURCE_AND_CHILDREN,
body);
// Parse multiget entites and add to DB
let addressData = allEntries.responseXML.getElementsByTagNameNS(
"urn:ietf:params:xml:ns:carddav", "address-data");
log.info("Retreived " + addressData.length + " contacts from " +
host + "; importing into database");
let importCount = 0;
for (let i = 0; i < addressData.length; i++) {
let vcard = addressData.item(i).textContent;
let contact = this._convertVcard(vcard);
contact.id += "@" + host;
contact.category = ["carddav@" + host];
let existing = yield this._dbPromise(db, "getByServiceId", contact.id);
if (existing) {
yield this._dbPromise(db, "remove", existing._guid);
}
// If the contact contains neither email nor phone number, then it
// is not useful in the Loop address book: do not add.
if (!("tel" in contact) && !("email" in contact)) {
continue;
}
yield this._dbPromise(db, "add", contact);
importCount++;
}
return importCount;
}.bind(this)).then(
(result) => {
log.info("Import complete: " + result + " contacts imported.");
callback(null, result);
},
(error) => {
log.error("Aborting import: " + error.fileName + ":" +
error.lineNumber + ": " + error.message);
callback(error);
}).then(null,
(error) => {
log.error("Error in callback: " + error.fileName +
":" + error.lineNumber + ": " + error.message);
callback(error);
}).then(null,
(error) => {
log.error("Error calling failure callback, giving up: " +
error.fileName + ":" + error.lineNumber + ": " +
error.message);
});
},
/**
* Wrap a LoopContacts-style operation in a promise. The operation is run
* immediately, and a corresponding Promise is returned. Error callbacks
* cause the promise to be rejected, and success cause it to be resolved.
*
* @param {Object} db Object the operation is to be performed on
* @param {String} method Name of operation being wrapped
* @param {Object} param Parameter to be passed to the operation
*
* @return {Object} Promise corresponding to the result of the operation.
*/
_dbPromise: function(db, method, param) {
return new Promise((resolve, reject) => {
db[method](param, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
},
/**
* Converts a contact in VCard format (see RFC 6350) to the format used
* by the LoopContacts class.
*
* @param {String} vcard The contact to convert, in vcard format
* @return {Object} a LoopContacts-style contact object containing
* the relevant fields from the vcard.
*/
_convertVcard: function(vcard) {
let contact = {};
let nickname;
vcard.split(/[\r\n]+(?! )/).forEach(
function (contentline) {
contentline = contentline.replace(/[\r\n]+ /g, "");
let match = /^(.*?[^\\]):(.*)$/.exec(contentline);
if (match) {
let nameparam = match[1];
let value = match[2];
// Poor-man's unescaping
value = value.replace(/\\:/g, ":");
value = value.replace(/\\,/g, ",");
value = value.replace(/\\n/gi, "\n");
value = value.replace(/\\\\/g, "\\");
let param = nameparam.split(/;/);
let name = param[0];
let pref = false;
let type = [];
for (let i = 1; i < param.length; i++) {
if (/^PREF/.exec(param[i]) || /^TYPE=PREF/.exec(param[i])) {
pref = true;
}
let typeMatch = /^TYPE=(.*)/.exec(param[i]);
if (typeMatch) {
type.push(typeMatch[1].toLowerCase());
}
}
if (!type.length) {
type.push("other");
}
if (name === "FN") {
value = value.replace(/\\;/g, ";");
contact.name = [value];
}
if (name === "N") {
// Because we don't have lookbehinds, matching unescaped
// semicolons is a pain. Luckily, we know that \r and \n
// cannot appear in the strings, so we use them to swap
// unescaped semicolons for \n.
value = value.replace(/\\;/g, "\r");
value = value.replace(/;/g, "\n");
value = value.replace(/\r/g, ";");
let family, given, additional, prefix, suffix;
let values = value.split(/\n/);
if (values.length >= 5) {
[family, given, additional, prefix, suffix] = values;
if (prefix.length) {
contact.honorificPrefix = [prefix];
}
if (given.length) {
contact.givenName = [given];
}
if (additional.length) {
contact.additionalName = [additional];
}
if (family.length) {
contact.familyName = [family];
}
if (suffix.length) {
contact.honorificSuffix = [suffix];
}
}
}
if (name === "EMAIL") {
value = value.replace(/\\;/g, ";");
if (!("email" in contact)) {
contact.email = [];
}
contact.email.push({
pref: pref,
type: type,
value: value
});
}
if (name === "NICKNAME") {
value = value.replace(/\\;/g, ";");
// We don't store nickname in contact because it's not
// a supported field. We're saving it off here in case we
// need to use it if the fullname is blank.
nickname = value;
}
if (name === "ADR") {
value = value.replace(/\\;/g, "\r");
value = value.replace(/;/g, "\n");
value = value.replace(/\r/g, ";");
let pobox, extra, street, locality, region, code, country;
let values = value.split(/\n/);
if (values.length >= 7) {
[pobox, extra, street, locality, region, code, country] = values;
if (!("adr" in contact)) {
contact.adr = [];
}
contact.adr.push({
pref: pref,
type: type,
streetAddress: (street || pobox) + (extra ? (" " + extra) : ""),
locality: locality,
region: region,
postalCode: code,
countryName: country
});
}
}
if (name === "TEL") {
value = value.replace(/\\;/g, ";");
if (!("tel" in contact)) {
contact.tel = [];
}
contact.tel.push({
pref: pref,
type: type,
value: value
});
}
if (name === "ORG") {
value = value.replace(/\\;/g, "\r");
value = value.replace(/;/g, "\n");
value = value.replace(/\r/g, ";");
if (!("org" in contact)) {
contact.org = [];
}
contact.org.push(value.replace(/\n.*/, ""));
}
if (name === "TITLE") {
value = value.replace(/\\;/g, ";");
if (!("jobTitle" in contact)) {
contact.jobTitle = [];
}
contact.jobTitle.push(value);
}
if (name === "BDAY") {
value = value.replace(/\\;/g, ";");
contact.bday = Date.parse(value);
}
if (name === "UID") {
contact.id = value;
}
if (name === "NOTE") {
value = value.replace(/\\;/g, ";");
if (!("note" in contact)) {
contact.note = [];
}
contact.note.push(value);
}
}
}
);
// Basic sanity checking: make sure the name field isn't empty
if (!("name" in contact) || contact.name[0].length == 0) {
if (("familyName" in contact) && ("givenName" in contact)) {
// First, try to synthesize a full name from the name fields.
// Ordering is culturally sensitive, but we don't have
// cultural origin information available here. The best we
// can really do is "family, given additional"
contact.name = [contact.familyName[0] + ", " + contact.givenName[0]];
if (("additionalName" in contact)) {
contact.name[0] += " " + contact.additionalName[0];
}
} else {
if (nickname) {
contact.name = [nickname];
} else if ("familyName" in contact) {
contact.name = [contact.familyName[0]];
} else if ("givenName" in contact) {
contact.name = [contact.givenName[0]];
} else if ("org" in contact) {
contact.name = [contact.org[0]];
} else if ("email" in contact) {
contact.name = [contact.email[0].value];
} else if ("tel" in contact) {
contact.name = [contact.tel[0].value];
}
}
}
return contact;
},
/**
* Issues a CardDAV request (see RFC 6352) and returns a Promise to represent
* the success or failure state of the request.
*
* @param {String} method WebDAV method to use (e.g., "PROPFIND")
* @param {String} url HTTP URL to use for the request
* @param {Object} auth Object with authentication-related configuration.
* See documentation for startImport for details.
* @param {Number} depth Value to use for the WebDAV (HTTP) "Depth" header
* @param {String} body Body to include in the WebDAV (HTTP) request
*
* @return {Object} Promise representing the request operation outcome.
* If resolved, the resolution value is the XMLHttpRequest
* that was used to perform the request.
*/
_davPromise: function(method, url, auth, depth, body) {
return new Promise((resolve, reject) => {
let req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
createInstance(Ci.nsIXMLHttpRequest);
let user = "";
let password = "";
if (auth.method == "basic") {
user = auth.user;
password = auth.password;
}
req.open(method, url, true, user, password);
req.setRequestHeader("Depth", depth);
req.setRequestHeader("Content-Type", "application/xml; charset=utf-8");
req.onload = function() {
if (req.status < 400) {
resolve(req);
} else {
reject(new Error(req.status + " " + req.statusText));
}
};
req.onerror = function(error) {
reject(error);
};
req.send(body);
});
}
};
@@ -1,598 +0,0 @@
/* 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";
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Log",
"resource://gre/modules/Log.jsm");
this.EXPORTED_SYMBOLS = ["GoogleImporter"];
let log = Log.repository.getLogger("Loop.Importer.Google");
log.level = Log.Level.Debug;
log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
/**
* Helper function that reads and maps the respective node value from specific
* XML DOMNodes to fields on a `target` object.
* Example: the value for field 'fullName' can be read from the XML DOMNode
* 'name', so that's the mapping we need to make; get the nodeValue of
* the node called 'name' and tack it to the target objects' 'fullName'
* property.
*
* @param {Map} fieldMap Map object containing the field name -> node
* name mapping
* @param {XMLDOMNode} node DOM node to fetch the values from for each field
* @param {String} ns XML namespace for the DOM nodes to retrieve. Optional.
* @param {Object} target Object to store the values found. Optional.
* Defaults to a new object.
* @param {Boolean} wrapInArray Indicates whether to map the field values in
* an Array. Optional. Defaults to `false`.
* @returns The `target` object with the node values mapped to the appropriate fields.
*/
const extractFieldsFromNode = function(fieldMap, node, ns = null, target = {}, wrapInArray = false) {
for (let [field, nodeName] of fieldMap) {
let nodeList = ns ? node.getElementsByTagNameNS(ns, nodeName) :
node.getElementsByTagName(nodeName);
if (nodeList.length) {
if (!nodeList[0].firstChild) {
continue;
}
let value = nodeList[0].textContent;
target[field] = wrapInArray ? [value] : value;
}
}
return target;
};
/**
* Helper function that reads the type of (email-)address or phone number from an
* XMLDOMNode.
*
* @param {XMLDOMNode} node
* @returns String that depicts the type of field value.
*/
const getFieldType = function(node) {
if (node.hasAttribute("rel")) {
let rel = node.getAttribute("rel");
// The 'rel' attribute is formatted like: http://schemas.google.com/g/2005#work.
return rel.substr(rel.lastIndexOf("#") + 1);
}
if (node.hasAttribute("label")) {
return node.getAttribute("label");
}
return "other";
};
/**
* Fetch the preferred entry of a contact. Returns the first entry when no
* preferred flag is set.
*
* @param {Object} contact The contact object to check for preferred entries
* @param {String} which Type of entry to check. Optional, defaults to 'email'
* @throws An Error when no (preferred) entries are listed for this contact.
*/
const getPreferred = function(contact, which = "email") {
if (!(which in contact) || !contact[which].length) {
throw new Error("No " + which + " entry available.");
}
let preferred = contact[which][0];
contact[which].some(function(entry) {
if (entry.pref) {
preferred = entry;
return true;
}
return false;
});
return preferred;
};
/**
* Fetch an auth token (clientID or client secret), which may be overridden by
* a pref if it's set.
*
* @param {String} paramValue Initial, default, value of the parameter
* @param {String} prefName Fully qualified name of the pref to check for
* @param {Boolean} encode Whether to URLEncode the param string
*/
const getUrlParam = function(paramValue, prefName, encode = true) {
if (Services.prefs.getPrefType(prefName))
paramValue = Services.prefs.getCharPref(prefName);
paramValue = Services.urlFormatter.formatURL(paramValue);
return encode ? encodeURIComponent(paramValue) : paramValue;
};
let gAuthWindow, gProfileId;
const kAuthWindowSize = {
width: 420,
height: 460
};
const kContactsMaxResults = 10000000;
const kContactsChunkSize = 100;
const kTitlebarPollTimeout = 200;
const kNS_GD = "http://schemas.google.com/g/2005";
/**
* GoogleImporter class.
*
* Main entrypoint is the `startImport` method which calls several tasks necessary
* to import contacts from Google.
* Authentication is performed using an OAuth strategy which is loaded in a popup
* window.
*/
this.GoogleImporter = function() {};
this.GoogleImporter.prototype = {
/**
* Start the import process of contacts from the Google service, using its Contacts
* API - https://developers.google.com/google-apps/contacts/v3/.
* The import consists of four tasks:
* 1. Get the authentication code which can be used to retrieve an OAuth token
* pair. This is the bulk of the authentication flow that will be handled in
* a popup window by Google. The user will need to login to the Google service
* with his or her account and grant permission to our app to manage their
* contacts.
* 2. Get the tokenset from the Google service, using the authentication code
* that was retrieved in task 1.
* 3. Fetch all the contacts from the Google service, using the OAuth tokenset
* that was retrieved in task 2.
* 4. Process the contacts, map them to the MozContact format and store each
* contact in the database, if it doesn't exist yet.
*
* @param {Object} options Options to control the behavior of the import.
* Not used by this importer class.
* @param {Function} callback Function to invoke when the import process
* is done or when an error occurs that halts
* the import process. The first argument passed
* in an Error object or `null` and the second
* argument is an object with import statistics.
* @param {LoopContacts} db Instance of the LoopContacts database object,
* which will store the newly found contacts
* @param {nsIDomWindow} windowRef Reference to the ChromeWindow the import is
* invoked from. It will be used to be able to
* open a window for the OAuth process with chrome
* privileges.
*/
startImport: function(options, callback, db, windowRef) {
Task.spawn(function* () {
let code = yield this._promiseAuthCode(windowRef);
let tokenSet = yield this._promiseTokenSet(code);
let contactEntries = yield this._getContactEntries(tokenSet);
let {total, success, ids} = yield this._processContacts(contactEntries, db, tokenSet);
yield this._purgeContacts(ids, db);
return {
total: total,
success: success
};
}.bind(this)).then(stats => callback(null, stats),
error => callback(error))
.then(null, ex => log.error(ex.fileName + ":" + ex.lineNumber + ": " + ex.message));
},
/**
* Task that yields an authentication code that is returned after the user signs
* in to the Google service. This code can be used by this class to retrieve an
* OAuth tokenset.
*
* @param {nsIDOMWindow} windowRef Reference to the ChromeWindow the import is
* invoked from. It will be used to be able to
* open a window for the OAuth process with chrome
* privileges.
* @throws An `Error` object when authentication fails, or the authentication
* code as a String.
*/
_promiseAuthCode: Task.async(function* (windowRef) {
// Close a window that got lost in a previous login attempt.
if (gAuthWindow && !gAuthWindow.closed) {
gAuthWindow.close();
gAuthWindow = null;
}
let url = getUrlParam("https://accounts.google.com/o/oauth2/",
"loop.oauth.google.URL", false) +
"auth?response_type=code&client_id=" +
getUrlParam("%GOOGLE_OAUTH_API_CLIENTID%", "loop.oauth.google.clientIdOverride");
for (let param of ["redirect_uri", "scope"]) {
url += "&" + param + "=" + encodeURIComponent(
Services.prefs.getCharPref("loop.oauth.google." + param));
}
const features = "centerscreen,resizable=yes,toolbar=no,menubar=no,status=no,directories=no," +
"width=" + kAuthWindowSize.width + ",height=" + kAuthWindowSize.height;
gAuthWindow = windowRef.openDialog(windowRef.getBrowserURL(), "_blank", features, url);
gAuthWindow.focus();
let code;
function promiseTimeOut() {
return new Promise(resolve => {
setTimeout(resolve, kTitlebarPollTimeout);
});
}
// The following loops runs as long as the OAuth windows' titlebar doesn't
// yield a response from the Google service. If an error occurs, the loop
// will terminate early.
while (!code) {
if (!gAuthWindow || gAuthWindow.closed) {
throw new Error("Popup window was closed before authentication succeeded");
}
let matches = gAuthWindow.document.title.match(/(error|code)=([^\s]+)/);
if (matches && matches.length) {
let [, type, message] = matches;
gAuthWindow.close();
gAuthWindow = null;
if (type == "error") {
throw new Error("Google authentication failed with error: " + message.trim());
} else if (type == "code") {
code = message.trim();
} else {
throw new Error("Unknown response from Google");
}
} else {
yield promiseTimeOut();
}
}
return code;
}),
/**
* Fetch an OAuth tokenset, that will be used to authenticate Google API calls,
* using the authentication token retrieved in `_promiseAuthCode`.
*
* @param {String} code The authentication code.
* @returns an `Error` object upon failure or an object containing OAuth tokens.
*/
_promiseTokenSet: function(code) {
return new Promise(function(resolve, reject) {
let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
.createInstance(Ci.nsIXMLHttpRequest);
request.open("POST", getUrlParam("https://accounts.google.com/o/oauth2/",
"loop.oauth.google.URL",
false) + "token");
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.onload = function() {
if (request.status < 400) {
let tokenSet = JSON.parse(request.responseText);
tokenSet.date = Date.now();
resolve(tokenSet);
} else {
reject(new Error(request.status + " " + request.statusText));
}
};
request.onerror = function(error) {
reject(error);
};
let body = "grant_type=authorization_code&code=" + encodeURIComponent(code) +
"&client_id=" + getUrlParam("%GOOGLE_OAUTH_API_CLIENTID%",
"loop.oauth.google.clientIdOverride") +
"&client_secret=" + getUrlParam("%GOOGLE_OAUTH_API_KEY%",
"loop.oauth.google.clientSecretOverride") +
"&redirect_uri=" + encodeURIComponent(Services.prefs.getCharPref(
"loop.oauth.google.redirect_uri"));
request.send(body);
});
},
_promiseRequestXML: function(URL, tokenSet) {
return new Promise((resolve, reject) => {
let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
.createInstance(Ci.nsIXMLHttpRequest);
request.open("GET", URL);
request.setRequestHeader("Content-Type", "application/xml; charset=utf-8");
request.setRequestHeader("GData-Version", "3.0");
request.setRequestHeader("Authorization", "Bearer " + tokenSet.access_token);
request.onload = function() {
if (request.status < 400) {
let doc = request.responseXML;
// First get the profile id, which is present in each XML request.
let currNode = doc.documentElement.firstChild;
while (currNode) {
if (currNode.nodeType == 1 && currNode.localName == "id") {
gProfileId = currNode.textContent;
break;
}
currNode = currNode.nextSibling;
}
resolve(doc);
} else {
reject(new Error(request.status + " " + request.statusText));
}
};
request.onerror = function(error) {
reject(error);
};
request.send();
});
},
/**
* Fetches all the contacts in a users' address book.
*
* @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contacts
*
* @param {Object} tokenSet OAuth tokenset used to authenticate the request
* @returns An `Error` object upon failure or an Array of contact XML nodes.
*/
_getContactEntries: Task.async(function* (tokenSet) {
let URL = getUrlParam("https://www.google.com/m8/feeds/contacts/default/full",
"loop.oauth.google.getContactsURL",
false) + "?max-results=" + kContactsMaxResults;
let xmlDoc = yield this._promiseRequestXML(URL, tokenSet);
// Then kick of the importing of contact entries.
return Array.prototype.slice.call(xmlDoc.querySelectorAll("entry"));
}),
/**
* Fetches the default group from a users' address book, called 'Contacts'.
*
* @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contact_groups
*
* @param {Object} tokenSet OAuth tokenset used to authenticate the request
* @returns An `Error` object upon failure or the String group ID.
*/
_getContactsGroupId: Task.async(function* (tokenSet) {
let URL = getUrlParam("https://www.google.com/m8/feeds/groups/default/full",
"loop.oauth.google.getGroupsURL",
false) + "?max-results=" + kContactsMaxResults;
let xmlDoc = yield this._promiseRequestXML(URL, tokenSet);
let contactsEntry = xmlDoc.querySelector("systemGroup[id=\"Contacts\"]");
if (!contactsEntry) {
throw new Error("Contacts group not present");
}
// Select the actual <entry> node, which is the parent of the <systemGroup>
// node we just selected.
contactsEntry = contactsEntry.parentNode;
return contactsEntry.getElementsByTagName("id")[0].textContent;
}),
/**
* Process the contact XML nodes that Google provides, convert them to the MozContact
* format, check if the contact already exists in the database and when it doesn't,
* store it permanently.
* During this process statistics are collected about the amount of successful
* imports. The consumer of this class may use these statistics to inform the
* user.
* Note: only contacts that are part of the 'Contacts' system group will be
* imported.
*
* @param {Array} contactEntries List of XML DOMNodes contact entries.
* @param {LoopContacts} db Instance of the LoopContacts database
* object, which will store the newly found
* contacts.
* @param {Object} tokenSet OAuth tokenset used to authenticate a
* request
* @returns An `Error` object upon failure or an Object with statistics in the
* following format: `{ total: 25, success: 13, ids: {} }`.
*/
_processContacts: Task.async(function* (contactEntries, db, tokenSet) {
let stats = {
total: contactEntries.length,
success: 0,
ids: {}
};
// Contacts that are _not_ part of the 'Contacts' group will be ignored.
let contactsGroupId = yield this._getContactsGroupId(tokenSet);
for (let entry of contactEntries) {
let contact = this._processContactFields(entry);
stats.ids[contact.id] = 1;
let existing = yield db.promise("getByServiceId", contact.id);
if (existing) {
yield db.promise("remove", existing._guid);
}
// After contact removal, check if the entry is part of the correct group.
if (!entry.querySelector("groupMembershipInfo[deleted=\"false\"][href=\"" +
contactsGroupId + "\"]")) {
continue;
}
// If the contact contains neither email nor phone number, then it is not
// useful in the Loop address book: do not add.
if (!("email" in contact) && !("tel" in contact)) {
continue;
}
yield db.promise("add", contact);
stats.success++;
}
return stats;
}),
/**
* Parse an XML node to map the appropriate data to MozContact field equivalents.
*
* @param {XMLDOMNode} entry The contact XML node in Google format to process.
* @returns `null` if the contact entry appears to be invalid or an Object containing
* all the contact data found in the XML.
*/
_processContactFields: function(entry) {
// Basic fields in the main 'atom' namespace.
let contact = extractFieldsFromNode(new Map([
["id", "id"],
// published: n/a
["updated", "updated"]
// bday: n/a
]), entry);
// Fields that need to wrapped in an Array.
extractFieldsFromNode(new Map([
["name", "fullName"],
["givenName", "givenName"],
["familyName", "familyName"],
["additionalName", "additionalName"]
]), entry, kNS_GD, contact, true);
// The 'note' field needs to wrapped in an array, but its source node is not
// namespaced.
extractFieldsFromNode(new Map([
["note", "content"]
]), entry, null, contact, true);
// Process physical, earthly addresses.
let addressNodes = entry.getElementsByTagNameNS(kNS_GD, "structuredPostalAddress");
if (addressNodes.length) {
contact.adr = [];
for (let [,addressNode] of Iterator(addressNodes)) {
let adr = extractFieldsFromNode(new Map([
["countryName", "country"],
["locality", "city"],
["postalCode", "postcode"],
["region", "region"],
["streetAddress", "street"]
]), addressNode, kNS_GD);
if (Object.keys(adr).length) {
adr.pref = (addressNode.getAttribute("primary") == "true");
adr.type = [getFieldType(addressNode)];
contact.adr.push(adr);
}
}
}
// Process email addresses.
let emailNodes = entry.getElementsByTagNameNS(kNS_GD, "email");
if (emailNodes.length) {
contact.email = [];
for (let [,emailNode] of Iterator(emailNodes)) {
contact.email.push({
pref: (emailNode.getAttribute("primary") == "true"),
type: [getFieldType(emailNode)],
value: emailNode.getAttribute("address")
});
}
}
// Process telephone numbers.
let phoneNodes = entry.getElementsByTagNameNS(kNS_GD, "phoneNumber");
if (phoneNodes.length) {
contact.tel = [];
for (let [,phoneNode] of Iterator(phoneNodes)) {
let phoneNumber = phoneNode.hasAttribute("uri") ?
phoneNode.getAttribute("uri").replace("tel:", "") :
phoneNode.textContent;
contact.tel.push({
pref: (phoneNode.getAttribute("primary") == "true"),
type: [getFieldType(phoneNode)],
value: phoneNumber
});
}
}
let orgNodes = entry.getElementsByTagNameNS(kNS_GD, "organization");
if (orgNodes.length) {
contact.org = [];
contact.jobTitle = [];
for (let [,orgNode] of Iterator(orgNodes)) {
let orgElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgName")[0];
let titleElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgTitle")[0];
contact.org.push(orgElement ? orgElement.textContent : "");
contact.jobTitle.push(titleElement ? titleElement.textContent : "");
}
}
contact.category = ["google"];
// Basic sanity checking: make sure the name field isn't empty
if (!("name" in contact) || contact.name[0].length == 0) {
if (("familyName" in contact) && ("givenName" in contact)) {
// First, try to synthesize a full name from the name fields.
// Ordering is culturally sensitive, but we don't have
// cultural origin information available here. The best we
// can really do is "family, given additional"
contact.name = [contact.familyName[0] + ", " + contact.givenName[0]];
if (("additionalName" in contact)) {
contact.name[0] += " " + contact.additionalName[0];
}
} else {
let profileTitle = extractFieldsFromNode(new Map([["title", "title"]]), entry);
if (("title" in profileTitle)) {
contact.name = [profileTitle.title];
} else if ("familyName" in contact) {
contact.name = [contact.familyName[0]];
} else if ("givenName" in contact) {
contact.name = [contact.givenName[0]];
} else if ("org" in contact) {
contact.name = [contact.org[0]];
} else {
let email;
try {
email = getPreferred(contact);
} catch (ex) {}
if (email) {
contact.name = [email.value];
} else {
let tel;
try {
tel = getPreferred(contact, "tel");
} catch (ex) {}
if (tel) {
contact.name = [tel.value];
}
}
}
}
}
return contact;
},
/**
* Remove all contacts from the database that are not present anymore in the
* remote data-source.
*
* @param {Object} ids Map of IDs collected earlier of all the contacts
* that are available on the remote data-source
* @param {LoopContacts} db Instance of the LoopContacts database object, which
* will store the newly found contacts
*/
_purgeContacts: Task.async(function* (ids, db) {
let contacts = yield db.promise("getAll");
let profileId = "https://www.google.com/m8/feeds/contacts/" + encodeURIComponent(gProfileId);
let processed = 0;
function promiseSkipABeat() {
return new Promise(resolve => Services.tm.currentThread.dispatch(resolve,
Ci.nsIThread.DISPATCH_NORMAL));
}
for (let [guid, contact] of Iterator(contacts)) {
if (++processed % kContactsChunkSize === 0) {
// Skip a beat every time we processed a chunk.
yield promiseSkipABeat;
}
if (contact.id.indexOf(profileId) >= 0 && !ids[contact.id]) {
yield db.promise("remove", guid);
}
}
})
};
@@ -1,467 +0,0 @@
/* 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";
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
this.EXPORTED_SYMBOLS = ["LoopCalls"];
const EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
"resource:///modules/loop/MozLoopService.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LOOP_SESSION_TYPE",
"resource:///modules/loop/MozLoopService.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
"resource:///modules/loop/LoopContacts.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
/**
* Attempts to open a websocket.
*
* A new websocket interface is used each time. If an onStop callback
* was received, calling asyncOpen() on the same interface will
* trigger a "alreay open socket" exception even though the channel
* is logically closed.
*/
function CallProgressSocket(progressUrl, callId, token) {
if (!progressUrl || !callId || !token) {
throw new Error("missing required arguments");
}
this._progressUrl = progressUrl;
this._callId = callId;
this._token = token;
}
CallProgressSocket.prototype = {
/**
* Open websocket and run hello exchange.
* Sends a hello message to the server.
*
* @param {function} Callback used after a successful handshake
* over the progressUrl.
* @param {function} Callback used if an error is encountered
*/
connect: function(onSuccess, onError) {
this._onSuccess = onSuccess;
this._onError = onError || (reason => {
MozLoopService.log.warn("LoopCalls::callProgessSocket - ", reason);
});
if (!onSuccess) {
this._onError("missing onSuccess argument");
return;
}
if (Services.io.offline) {
this._onError("IO offline");
return;
}
let uri = Services.io.newURI(this._progressUrl, null, null);
// Allow _websocket to be set for testing.
if (!this._websocket) {
this._websocket = Cc["@mozilla.org/network/protocol;1?name=" + uri.scheme]
.createInstance(Ci.nsIWebSocketChannel);
this._websocket.initLoadInfo(null, // aLoadingNode
Services.scriptSecurityManager.getSystemPrincipal(),
null, // aTriggeringPrincipal
Ci.nsILoadInfo.SEC_NORMAL,
Ci.nsIContentPolicy.TYPE_WEBSOCKET);
}
this._websocket.asyncOpen(uri, this._progressUrl, this, null);
},
/**
* Listener method, handles the start of the websocket stream.
* Sends a hello message to the server.
*
* @param {nsISupports} aContext Not used
*/
onStart: function() {
let helloMsg = {
messageType: "hello",
callId: this._callId,
auth: this._token,
};
try { // in case websocket has closed before this handler is run
this._websocket.sendMsg(JSON.stringify(helloMsg));
}
catch (error) {
this._onError(error);
}
},
/**
* Listener method, called when the websocket is closed.
*
* @param {nsISupports} aContext Not used
* @param {nsresult} aStatusCode Reason for stopping (NS_OK = successful)
*/
onStop: function(aContext, aStatusCode) {
if (!this._handshakeComplete) {
this._onError("[" + aStatusCode + "]");
}
},
/**
* Listener method, called when the websocket is closed by the server.
* If there are errors, onStop may be called without ever calling this
* method.
*
* @param {nsISupports} aContext Not used
* @param {integer} aCode the websocket closing handshake close code
* @param {String} aReason the websocket closing handshake close reason
*/
onServerClose: function(aContext, aCode, aReason) {
if (!this._handshakeComplete) {
this._onError("[" + aCode + "]" + aReason);
}
},
/**
* Listener method, called when the websocket receives a message.
*
* @param {nsISupports} aContext Not used
* @param {String} aMsg The message data
*/
onMessageAvailable: function(aContext, aMsg) {
let msg = {};
try {
msg = JSON.parse(aMsg);
} catch (error) {
MozLoopService.log.error("LoopCalls: error parsing progress message - ", error);
return;
}
if (msg.messageType && msg.messageType === 'hello') {
this._handshakeComplete = true;
this._onSuccess();
}
},
/**
* Create a JSON message payload and send on websocket.
*
* @param {Object} aMsg Message to send.
*/
_send: function(aMsg) {
if (!this._handshakeComplete) {
MozLoopService.log.warn("LoopCalls::_send error - handshake not complete");
return;
}
try {
this._websocket.sendMsg(JSON.stringify(aMsg));
}
catch (error) {
this._onError(error);
}
},
/**
* Notifies the server that the user has declined the call
* with a reason of busy.
*/
sendBusy: function() {
this._send({
messageType: "action",
event: "terminate",
reason: "busy"
});
},
};
/**
* Internal helper methods and state
*
* The registration is a two-part process. First we need to connect to
* and register with the push server. Then we need to take the result of that
* and register with the Loop server.
*/
let LoopCallsInternal = {
mocks: {
webSocket: undefined,
},
conversationInProgress: {},
/**
* Callback from MozLoopPushHandler - A push notification has been received from
* the server.
*
* @param {String} version The version information from the server.
*/
onNotification: function(version, channelID) {
if (MozLoopService.doNotDisturb) {
return;
}
// We set this here as it is assumed that once the user receives an incoming
// call, they'll have had enough time to see the terms of service. See
// bug 1046039 for background.
Services.prefs.setCharPref("loop.seenToS", "seen");
// Request the information on the new call(s) associated with this version.
// The registered FxA session is checked first, then the anonymous session.
// Make the call to get the GUEST session regardless of whether the FXA
// request fails.
if (channelID == MozLoopService.channelIDs.callsFxA && MozLoopService.userProfile) {
this._getCalls(LOOP_SESSION_TYPE.FXA, version);
}
},
/**
* Make a hawkRequest to GET/calls?=version for this session type.
*
* @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
* for the GET operation.
* @param {Object} version - LoopPushService notification version
*
* @returns {Promise}
*
*/
_getCalls: function(sessionType, version) {
return MozLoopService.hawkRequest(sessionType, "/calls?version=" + version, "GET").then(
response => { this._processCalls(response, sessionType); }
);
},
/**
* Process the calls array returned from a GET/calls?version request.
* Only one active call is permitted at this time.
*
* @param {Object} response - response payload from GET
*
* @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
* for the GET operation.
*
*/
_processCalls: function(response, sessionType) {
try {
let respData = JSON.parse(response.body);
if (respData.calls && Array.isArray(respData.calls)) {
respData.calls.forEach((callData) => {
if ("id" in this.conversationInProgress) {
this._returnBusy(callData);
} else {
callData.sessionType = sessionType;
callData.type = "incoming";
this._startCall(callData);
}
});
} else {
MozLoopService.log.warn("Error: missing calls[] in response");
}
} catch (err) {
MozLoopService.log.warn("Error parsing calls info", err);
}
},
/**
* Starts a call, saves the call data, and opens a chat window.
*
* @param {Object} callData The data associated with the call including an id.
* The data should include the type - "incoming" or
* "outgoing".
*/
_startCall: function(callData) {
const openChat = () => {
let windowId = MozLoopService.openChatWindow(callData);
if (windowId) {
this.conversationInProgress.id = windowId;
}
};
if (callData.type == "incoming" && ("callerId" in callData) &&
EMAIL_OR_PHONE_RE.test(callData.callerId)) {
LoopContacts.search({
q: callData.callerId,
field: callData.callerId.includes("@") ? "email" : "tel"
}, (err, contacts) => {
if (err) {
// Database error, helas!
openChat();
return;
}
for (let contact of contacts) {
if (contact.blocked) {
// Blocked! Send a busy signal back to the caller.
this._returnBusy(callData);
return;
}
}
openChat();
});
} else {
openChat();
}
},
/**
* Starts a direct call to the contact addresses.
*
* @param {Object} contact The contact to call
* @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
* @return true if the call is opened, false if it is not opened (i.e. busy)
*/
startDirectCall: function(contact, callType) {
if ("id" in this.conversationInProgress)
return false;
var callData = {
contact: contact,
callType: callType,
type: "outgoing"
};
this._startCall(callData);
return true;
},
/**
* Block a caller so it will show up in the contacts list as a blocked contact.
* If the contact is not yet part of the users' contacts list, it will be added
* as a blocked contact directly.
*
* @param {String} callerId Email address or phone number that may identify
* the caller as an existing contact
* @param {Function} callback Function that will be invoked once the operation
* has completed. When an error occurs, it will be
* passed as its first argument
*/
blockDirectCaller: function(callerId, callback) {
let field = callerId.contains("@") ? "email" : "tel";
Task.spawn(function* () {
// See if we can find the caller in our database.
let contacts = yield LoopContacts.promise("search", {
q: callerId,
field: field
});
let contact;
if (contacts.length) {
for (contact of contacts) {
yield LoopContacts.promise("block", contact._guid);
}
} else {
// If the contact doesn't exist yet, add it as a blocked contact.
contact = {
id: MozLoopService.generateUUID(),
name: [callerId],
category: ["local"],
blocked: true
};
// Add the phone OR email field to the contact.
contact[field] = [{
pref: true,
value: callerId
}];
yield LoopContacts.promise("add", contact);
}
}).then(callback, callback);
},
/**
* Open call progress websocket and terminate with a reason of busy
* the server.
*
* @param {callData} Must contain the progressURL, callId and websocketToken
* returned by the LoopService.
*/
_returnBusy: function(callData) {
let callProgress = new CallProgressSocket(
callData.progressURL,
callData.callId,
callData.websocketToken);
if (this.mocks.webSocket) {
callProgress._websocket = this.mocks.webSocket;
}
// This instance of CallProgressSocket should stay alive until the underlying
// websocket is closed since it is passed to the websocket as the nsIWebSocketListener.
callProgress.connect(() => { callProgress.sendBusy(); });
}
};
Object.freeze(LoopCallsInternal);
/**
* Public API
*/
this.LoopCalls = {
/**
* Callback from MozLoopPushHandler - A push notification has been received from
* the server.
*
* @param {String} version The version information from the server.
*/
onNotification: function(version, channelID) {
LoopCallsInternal.onNotification(version, channelID);
},
/**
* Used to signify that a call is in progress.
*
* @param {String} The window id for the call in progress.
*/
setCallInProgress: function(conversationWindowId) {
if ("id" in LoopCallsInternal.conversationInProgress &&
LoopCallsInternal.conversationInProgress.id != conversationWindowId) {
MozLoopService.log.error("Starting a new conversation when one is already in progress?");
return;
}
LoopCallsInternal.conversationInProgress.id = conversationWindowId;
},
/**
* Releases the callData for a specific conversation window id.
*
* The result of this call will be a free call session slot.
*
* @param {Number} conversationWindowId
*/
clearCallInProgress: function(conversationWindowId) {
if ("id" in LoopCallsInternal.conversationInProgress &&
LoopCallsInternal.conversationInProgress.id == conversationWindowId) {
delete LoopCallsInternal.conversationInProgress.id;
}
},
/**
* Starts a direct call to the contact addresses.
*
* @param {Object} contact The contact to call
* @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
* @return true if the call is opened, false if it is not opened (i.e. busy)
*/
startDirectCall: function(contact, callType) {
LoopCallsInternal.startDirectCall(contact, callType);
},
/**
* @see LoopCallsInternal#blockDirectCaller
*/
blockDirectCaller: function(callerId, callback) {
return LoopCallsInternal.blockDirectCaller(callerId, callback);
}
};
Object.freeze(LoopCalls);
@@ -1,961 +0,0 @@
/* 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";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "console",
"resource://gre/modules/devtools/Console.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
"resource:///modules/loop/LoopStorage.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CardDavImporter",
"resource:///modules/loop/CardDavImporter.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "GoogleImporter",
"resource:///modules/loop/GoogleImporter.jsm");
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
return new EventEmitter();
});
this.EXPORTED_SYMBOLS = ["LoopContacts"];
const kObjectStoreName = "contacts";
/*
* The table used to store contacts information contains two identifiers,
* both of which can be used to look up entries in the table. The table
* key path (primary index, which must be unique) is "_guid", and is
* automatically generated by IndexedDB when an entry is first inserted.
* The other identifier, "id", is the supposedly unique key assigned to this
* entry by whatever service generated it (e.g., Google Contacts). While
* this key should, in theory, be completely unique, we don't use it
* as the key path to avoid generating errors when an external database
* violates this constraint. This second ID is referred to as the "serviceId".
*/
const kKeyPath = "_guid";
const kServiceIdIndex = "id";
/**
* Contacts validation.
*
* To allow for future integration with the Contacts API and/ or potential
* integration with contact synchronization across devices (including Firefox OS
* devices), we are using objects with properties having the same names and
* structure as those used by mozContact.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/mozContact for more
* information.
*/
const kFieldTypeString = "string";
const kFieldTypeNumber = "number";
const kFieldTypeNumberOrString = "number|string";
const kFieldTypeArray = "array";
const kFieldTypeBool = "boolean";
const kContactFields = {
"id": {
// Because "id" is externally generated, it might be numeric
type: kFieldTypeNumberOrString
},
"published": {
// mozContact, from which we are derived, defines dates as
// "a Date object, which will eventually be converted to a
// long long" -- to be forwards compatible, we allow both
// formats for now.
type: kFieldTypeNumberOrString
},
"updated": {
// mozContact, from which we are derived, defines dates as
// "a Date object, which will eventually be converted to a
// long long" -- to be forwards compatible, we allow both
// formats for now.
type: kFieldTypeNumberOrString
},
"bday": {
// mozContact, from which we are derived, defines dates as
// "a Date object, which will eventually be converted to a
// long long" -- to be forwards compatible, we allow both
// formats for now.
type: kFieldTypeNumberOrString
},
"blocked": {
type: kFieldTypeBool
},
"adr": {
type: kFieldTypeArray,
contains: {
"countryName": {
type: kFieldTypeString
},
"locality": {
type: kFieldTypeString
},
"postalCode": {
// In some (but not all) locations, postal codes can be strictly numeric
type: kFieldTypeNumberOrString
},
"pref": {
type: kFieldTypeBool
},
"region": {
type: kFieldTypeString
},
"streetAddress": {
type: kFieldTypeString
},
"type": {
type: kFieldTypeArray,
contains: kFieldTypeString
}
}
},
"email": {
type: kFieldTypeArray,
contains: {
"pref": {
type: kFieldTypeBool
},
"type": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"value": {
type: kFieldTypeString
}
}
},
"tel": {
type: kFieldTypeArray,
contains: {
"pref": {
type: kFieldTypeBool
},
"type": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"value": {
type: kFieldTypeString
}
}
},
"name": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"honorificPrefix": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"givenName": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"additionalName": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"familyName": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"honorificSuffix": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"category": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"org": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"jobTitle": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"note": {
type: kFieldTypeArray,
contains: kFieldTypeString
}
};
/**
* Compares the properties contained in an object to the definition as defined in
* `kContactFields`.
* If a property is encountered that is not found in the spec, an Error is thrown.
* If a property is encountered with an invalid value, an Error is thrown.
*
* Please read the spec at https://wiki.mozilla.org/Loop/Architecture/Address_Book
* for more information.
*
* @param {Object} obj The contact object, or part of it when called recursively
* @param {Object} def The definition of properties to validate against. Defaults
* to `kContactFields`
*/
const validateContact = function(obj, def = kContactFields) {
for (let propName of Object.getOwnPropertyNames(obj)) {
// Ignore internal properties.
if (propName.startsWith("_")) {
continue;
}
let propDef = def[propName];
if (!propDef) {
throw new Error("Field '" + propName + "' is not supported for contacts");
}
let val = obj[propName];
switch (propDef.type) {
case kFieldTypeString:
if (typeof val != kFieldTypeString) {
throw new Error("Field '" + propName + "' must be of type String");
}
break;
case kFieldTypeNumberOrString:
let type = typeof val;
if (type != kFieldTypeNumber && type != kFieldTypeString) {
throw new Error("Field '" + propName + "' must be of type Number or String");
}
break;
case kFieldTypeBool:
if (typeof val != kFieldTypeBool) {
throw new Error("Field '" + propName + "' must be of type Boolean");
}
break;
case kFieldTypeArray:
if (!Array.isArray(val)) {
throw new Error("Field '" + propName + "' must be an Array");
}
let contains = propDef.contains;
// If the type of `contains` is a scalar value, it means that the array
// consists of items of only that type.
let isScalarCheck = (typeof contains == kFieldTypeString);
for (let arrayValue of val) {
if (isScalarCheck) {
if (typeof arrayValue != contains) {
throw new Error("Field '" + propName + "' must be of type " + contains);
}
} else {
validateContact(arrayValue, contains);
}
}
break;
}
}
};
/**
* Provides a method to perform multiple operations in a single transaction on the
* contacts store.
*
* @param {String} operation Name of an operation supported by `IDBObjectStore`
* @param {Array} data List of objects that will be passed to the object
* store operation
* @param {Function} callback Function that will be invoked once the operations
* have finished. The first argument passed will be
* an `Error` object or `null`. The second argument
* will be the `data` Array, if all operations finished
* successfully.
*/
const batch = function(operation, data, callback) {
let processed = [];
if (!LoopContactsInternal.hasOwnProperty(operation) ||
typeof LoopContactsInternal[operation] != 'function') {
callback(new Error ("LoopContactsInternal does not contain a '" +
operation + "' method"));
return;
}
LoopStorage.asyncForEach(data, (item, next) => {
LoopContactsInternal[operation](item, (err, result) => {
if (err) {
next(err);
return;
}
processed.push(result);
next();
});
}, err => {
if (err) {
callback(err, processed);
return;
}
callback(null, processed);
});
};
/**
* Extend a `target` object with the properties defined in `source`.
*
* @param {Object} target The target object to receive properties defined in `source`
* @param {Object} source The source object to copy properties from
*/
const extend = function(target, source) {
for (let key of Object.getOwnPropertyNames(source)) {
target[key] = source[key];
}
return target;
};
LoopStorage.on("upgrade", function(e, db) {
if (db.objectStoreNames.contains(kObjectStoreName)) {
return;
}
// Create the 'contacts' store as it doesn't exist yet.
let store = db.createObjectStore(kObjectStoreName, {
keyPath: kKeyPath,
autoIncrement: true
});
store.createIndex(kServiceIdIndex, kServiceIdIndex, {unique: false});
});
/**
* The Contacts class.
*
* Each method that is a member of this class requires the last argument to be a
* callback Function. MozLoopAPI will cause things to break if this invariant is
* violated. You'll notice this as well in the documentation for each method.
*/
let LoopContactsInternal = Object.freeze({
/**
* Map of contact importer names to instances
*/
_importServices: {
"carddav": new CardDavImporter(),
"google": new GoogleImporter()
},
/**
* Add a contact to the data store.
*
* @param {Object} details An object that will be added to the data store
* as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
* for more information of this objects' structure
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if it was stored successfully.
*/
add: function(details, callback) {
if (!(kServiceIdIndex in details)) {
callback(new Error("No '" + kServiceIdIndex + "' field present"));
return;
}
try {
validateContact(details);
} catch (ex) {
callback(ex);
return;
}
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let contact = extend({}, details);
let now = Date.now();
// The data source should have included "published" and "updated" values
// for any imported records, and we need to keep track of those dated for
// sync purposes (i.e., when we add functionality to push local changes to
// a remote server from which we originally got a contact). We also need
// to track the time at which *we* added and most recently changed the
// contact, so as to determine whether the local or the remote store has
// fresher data.
//
// For clarity: the fields "published" and "updated" indicate when the
// *remote* data source published and updated the contact. The fields
// "_date_add" and "_date_lch" track when the *local* data source
// created and updated the contact.
contact.published = contact.published ? new Date(contact.published).getTime() : now;
contact.updated = contact.updated ? new Date(contact.updated).getTime() : now;
contact._date_add = contact._date_lch = now;
let request;
try {
request = store.add(contact);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
contact[kKeyPath] = event.target.result;
eventEmitter.emit("add", contact);
callback(null, contact);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
},
/**
* Add a batch of contacts to the data store.
*
* @param {Array} contacts A list of contact objects to be added
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the list of added contacts.
*/
addMany: function(contacts, callback) {
batch("add", contacts, callback);
},
/**
* Remove a contact from the data store.
*
* @param {String} guid String identifier of the contact to remove
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation.
*/
remove: function(guid, callback) {
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let request;
try {
request = store.delete(guid);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
if (contact) {
eventEmitter.emit("remove", contact);
}
callback(null, event.target.result);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
});
},
/**
* Remove a batch of contacts from the data store.
*
* @param {Array} guids A list of IDs of the contacts to remove
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the list of IDs, if successfull.
*/
removeMany: function(guids, callback) {
batch("remove", guids, callback);
},
/**
* Remove _all_ contacts from the data store.
* CAUTION: this method will clear the whole data store - you won't have any
* contacts left!
*
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation, if successfull.
*/
removeAll: function(callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let request;
try {
request = store.clear();
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
eventEmitter.emit("removeAll", event.target.result);
callback(null, event.target.result);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
},
/**
* Retrieve a specific contact from the data store.
*
* @param {String} guid String identifier of the contact to retrieve
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successful.
* If no object matching guid could be found,
* then the callback is called with both arguments
* set to `null`.
*/
get: function(guid, callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let request;
try {
request = store.get(guid);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
if (!event.target.result) {
callback(null, null);
return;
}
let contact = extend({}, event.target.result);
contact[kKeyPath] = guid;
callback(null, contact);
};
request.onerror = event => callback(event.target.error);
});
},
/**
* Retrieve a specific contact from the data store using the kServiceIdIndex
* property.
*
* @param {String} serviceId String identifier of the contact to retrieve
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
* If no object matching serviceId could be found,
* then the callback is called with both arguments
* set to `null`.
*/
getByServiceId: function(serviceId, callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let index = store.index(kServiceIdIndex);
let request;
try {
request = index.get(serviceId);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
if (!event.target.result) {
callback(null, null);
return;
}
let contact = extend({}, event.target.result);
callback(null, contact);
};
request.onerror = event => callback(event.target.error);
});
},
/**
* Retrieve _all_ contacts from the data store.
* CAUTION: If the amount of contacts is very large (say > 100000), this method
* may slow down your application!
*
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be an `Array` of contact objects, if successfull.
*/
getAll: function(callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let cursorRequest = store.openCursor();
let contactsList = [];
cursorRequest.onsuccess = event => {
let cursor = event.target.result;
// No more results, return the list.
if (!cursor) {
callback(null, contactsList);
return;
}
let contact = extend({}, cursor.value);
contact[kKeyPath] = cursor.key;
contactsList.push(contact);
cursor.continue();
};
cursorRequest.onerror = event => callback(event.target.error);
});
},
/**
* Retrieve an arbitrary amount of contacts from the data store.
* CAUTION: If the amount of contacts is very large (say > 1000), this method
* may slow down your application!
*
* @param {Array} guids List of contact IDs to retrieve contact objects of
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be an `Array` of contact objects, if successfull.
*/
getMany: function(guids, callback) {
let contacts = [];
LoopStorage.asyncParallel(guids, (guid, next) => {
this.get(guid, (err, contact) => {
if (err) {
next(err);
return;
}
contacts.push(contact);
next();
});
}, err => {
callback(err, !err ? contacts : null);
});
},
/**
* Update a specific contact in the data store.
* The contact object is modified by replacing the fields passed in the `details`
* param and any fields not passed in are left unchanged.
*
* @param {Object} details An object that will be updated in the data store
* as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
* for more information of this objects' structure
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
update: function(details, callback) {
if (!(kKeyPath in details)) {
callback(new Error("No '" + kKeyPath + "' field present"));
return;
}
try {
validateContact(details);
} catch (ex) {
callback(ex);
return;
}
let guid = details[kKeyPath];
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
if (!contact) {
callback(new Error("Contact with " + kKeyPath + " '" +
guid + "' could not be found"));
return;
}
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let previous = extend({}, contact);
// Update the contact with properties provided by `details`.
extend(contact, details);
details._date_lch = Date.now();
let request;
try {
request = store.put(contact);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
eventEmitter.emit("update", contact, previous);
callback(null, event.target.result);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
});
},
/**
* Block a specific contact in the data store.
*
* @param {String} guid String identifier of the contact to block
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
block: function(guid, callback) {
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
if (!contact) {
callback(new Error("Contact with " + kKeyPath + " '" +
guid + "' could not be found"));
return;
}
contact.blocked = true;
this.update(contact, callback);
});
},
/**
* Un-block a specific contact in the data store.
*
* @param {String} guid String identifier of the contact to unblock
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
unblock: function(guid, callback) {
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
if (!contact) {
callback(new Error("Contact with " + kKeyPath + " '" +
guid + "' could not be found"));
return;
}
contact.blocked = false;
this.update(contact, callback);
});
},
/**
* Import a list of (new) contacts from an external data source.
*
* @param {Object} options Property bag of options for the importer
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation, if successfull.
*/
startImport: function(options, windowRef, callback) {
if (!("service" in options)) {
callback(new Error("No import service specified in options"));
return;
}
if (!(options.service in this._importServices)) {
callback(new Error("Unknown import service specified: " + options.service));
return;
}
this._importServices[options.service].startImport(options, callback,
LoopContacts, windowRef);
},
/**
* Search through the data store for contacts that match a certain (sub-)string.
* NB: The current implementation is very simple, naive if you will; we fetch
* _all_ the contacts via `getAll()` and iterate over all of them to find
* the contacts matching the supplied query (brute-force search in
* exponential time).
*
* @param {Object} query Needle to search for in our haystack of contacts
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be an `Array` of contact objects, if successfull.
*
* Example:
* LoopContacts.search({
* q: "foo@bar.com",
* field: "email" // 'email' is the default.
* }, function(err, contacts) {
* if (err) {
* throw err;
* }
* console.dir(contacts);
* });
*/
search: function(query, callback) {
if (!("q" in query) || !query.q) {
callback(new Error("Nothing to search for. 'q' is required."));
return;
}
if (!("field" in query)) {
query.field = "email";
}
let queryValue = query.q;
if (query.field == "tel") {
queryValue = queryValue.replace(/[\D]+/g, "");
}
const checkForMatch = function(fieldValue) {
if (typeof fieldValue == "string") {
if (query.field == "tel") {
return fieldValue.replace(/[\D]+/g, "").endsWith(queryValue);
}
return fieldValue == queryValue;
}
if (typeof fieldValue == "number" || typeof fieldValue == "boolean") {
return fieldValue == queryValue;
}
if ("value" in fieldValue) {
return checkForMatch(fieldValue.value);
}
return false;
};
let foundContacts = [];
this.getAll((err, contacts) => {
if (err) {
callback(err);
return;
}
for (let contact of contacts) {
let matchWith = contact[query.field];
if (!matchWith) {
continue;
}
// Many fields are defined as Arrays.
if (Array.isArray(matchWith)) {
for (let fieldValue of matchWith) {
if (checkForMatch(fieldValue)) {
foundContacts.push(contact);
break;
}
}
} else if (checkForMatch(matchWith)) {
foundContacts.push(contact);
}
}
callback(null, foundContacts);
});
}
});
/**
* Public Loop Contacts API.
*
* LoopContacts implements the EventEmitter interface by exposing three methods -
* `on`, `once` and `off` - to subscribe to events.
* At this point the following events may be subscribed to:
* - 'add': A new contact object was successfully added to the data store.
* - 'remove': A contact was successfully removed from the data store.
* - 'removeAll': All contacts were successfully removed from the data store.
* - 'update': A contact object was successfully updated with changed
* properties in the data store.
*/
this.LoopContacts = Object.freeze({
add: function(details, callback) {
return LoopContactsInternal.add(details, callback);
},
addMany: function(contacts, callback) {
return LoopContactsInternal.addMany(contacts, callback);
},
remove: function(guid, callback) {
return LoopContactsInternal.remove(guid, callback);
},
removeMany: function(guids, callback) {
return LoopContactsInternal.removeMany(guids, callback);
},
removeAll: function(callback) {
return LoopContactsInternal.removeAll(callback);
},
get: function(guid, callback) {
return LoopContactsInternal.get(guid, callback);
},
getByServiceId: function(serviceId, callback) {
return LoopContactsInternal.getByServiceId(serviceId, callback);
},
getAll: function(callback) {
return LoopContactsInternal.getAll(callback);
},
getMany: function(guids, callback) {
return LoopContactsInternal.getMany(guids, callback);
},
update: function(details, callback) {
return LoopContactsInternal.update(details, callback);
},
block: function(guid, callback) {
return LoopContactsInternal.block(guid, callback);
},
unblock: function(guid, callback) {
return LoopContactsInternal.unblock(guid, callback);
},
startImport: function(options, windowRef, callback) {
return LoopContactsInternal.startImport(options, windowRef, callback);
},
search: function(query, callback) {
return LoopContactsInternal.search(query, callback);
},
promise: function(method, ...params) {
return new Promise((resolve, reject) => {
this[method](...params, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
},
on: (...params) => eventEmitter.on(...params),
once: (...params) => eventEmitter.once(...params),
off: (...params) => eventEmitter.off(...params)
});
@@ -1,377 +0,0 @@
/* 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";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
// Make it possible to load LoopStorage.jsm in xpcshell tests
try {
Cu.importGlobalProperties(["indexedDB"]);
} catch (ex) {
// don't write this is out in xpcshell, since it's expected there
if (typeof window !== 'undefined' && "console" in window) {
console.log("Failed to import indexedDB; if this isn't a unit test," +
" something is wrong", ex);
}
}
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
return new EventEmitter();
});
this.EXPORTED_SYMBOLS = ["LoopStorage"];
const kDatabasePrefix = "loop-";
const kDefaultDatabaseName = "default";
let gDatabaseName = kDatabasePrefix + kDefaultDatabaseName;
const kDatabaseVersion = 1;
let gWaitForOpenCallbacks = new Set();
let gDatabase = null;
let gClosed = false;
/**
* Properly shut the database instance down. This is done on application shutdown.
*/
const closeDatabase = function() {
Services.obs.removeObserver(closeDatabase, "quit-application");
if (!gDatabase) {
return;
}
gDatabase.close();
gDatabase = null;
gClosed = true;
};
/**
* Open a connection to the IndexedDB database.
* This function is different than IndexedDBHelper.jsm provides, as it ensures
* only one connection is open during the lifetime of this API. Callbacks are
* queued when a connection attempt is in progress and are invoked once the
* connection is established.
*
* @param {Function} onOpen Callback to be invoked once a database connection is
* established. It takes an Error object as first argument
* and the database connection object as second argument,
* if successful.
*/
const ensureDatabaseOpen = function(onOpen) {
if (gClosed) {
onOpen(new Error("Database already closed"));
return;
}
if (gDatabase) {
onOpen(null, gDatabase);
return;
}
if (!gWaitForOpenCallbacks.has(onOpen)) {
gWaitForOpenCallbacks.add(onOpen);
if (gWaitForOpenCallbacks.size !== 1) {
return;
}
}
let invokeCallbacks = err => {
for (let callback of gWaitForOpenCallbacks) {
callback(err, gDatabase);
}
gWaitForOpenCallbacks.clear();
};
let openRequest = indexedDB.open(gDatabaseName, kDatabaseVersion);
openRequest.onblocked = function(event) {
invokeCallbacks(new Error("Database cannot be upgraded cause in use: " + event.target.error));
};
openRequest.onerror = function(event) {
// Try to delete the old database so that we can start this process over
// next time.
indexedDB.deleteDatabase(gDatabaseName);
invokeCallbacks(new Error("Error while opening database: " + event.target.errorCode));
};
openRequest.onupgradeneeded = function(event) {
let db = event.target.result;
eventEmitter.emit("upgrade", db, event.oldVersion, kDatabaseVersion);
};
openRequest.onsuccess = function(event) {
gDatabase = event.target.result;
invokeCallbacks();
// Close the database instance properly on application shutdown.
Services.obs.addObserver(closeDatabase, "quit-application", false);
};
};
/**
* Switch to a database with a different name by closing the current connection
* and making sure that the next connection attempt will be made using the updated
* name.
*
* @param {String} name New name of the database to switch to.
*/
const switchDatabase = function(name) {
if (!name) {
name = kDefaultDatabaseName;
}
name = kDatabasePrefix + name;
if (name == gDatabaseName) {
// This is already the current database, so there's no need to switch.
return;
}
gDatabaseName = name;
if (gDatabase) {
try {
gDatabase.close();
} finally {
gDatabase = null;
}
}
};
/**
* Start a transaction on the loop database and return it.
*
* @param {String} store Name of the object store to start a transaction on
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* transaction object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
const getTransaction = function(store, callback, mode) {
ensureDatabaseOpen((err, db) => {
if (err) {
callback(err);
return;
}
let trans;
try {
trans = db.transaction(store, mode);
} catch(ex) {
callback(ex);
return;
}
callback(null, trans);
});
};
/**
* Start a transaction on the loop database and return the requested store.
*
* @param {String} store Name of the object store to retrieve
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* store object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
const getStore = function(store, callback, mode) {
getTransaction(store, (err, trans) => {
if (err) {
callback(err);
return;
}
callback(null, trans.objectStore(store));
}, mode);
};
/**
* Public Loop Storage API.
*
* Since IndexedDB transaction can not stand a spin of the event loop _before_
* using a IDBTransaction object, we can't use Promise.jsm promises. Therefore
* LoopStorage provides two async helper functions, `asyncForEach` and `asyncParallel`.
*
* LoopStorage implements the EventEmitter interface by exposing two methods, `on`
* and `off`, to subscribe to events.
* At this point only the `upgrade` event will be emitted. This happens when the
* database is loaded in memory and consumers will be able to change its structure.
*/
this.LoopStorage = Object.freeze({
/**
* @var {String} databaseName The name of the database that is currently active,
* WITHOUT the prefix
*/
get databaseName() {
return gDatabaseName.substr(kDatabasePrefix.length);
},
/**
* Open a connection to the IndexedDB database and return the database object.
*
* @param {Function} callback Callback to be invoked once a database connection
* is established. It takes an Error object as first
* argument and the database connection object as
* second argument, if successful.
*/
getSingleton: function(callback) {
ensureDatabaseOpen(callback);
},
/**
* Switch to a database with a different name.
*
* @param {String} name New name of the database to switch to. Defaults to
* `kDefaultDatabaseName`
*/
switchDatabase: function(name = kDefaultDatabaseName) {
switchDatabase(name);
},
/**
* Start a transaction on the loop database and return it.
* If only two arguments are passed, the default mode will be assumed and the
* second argument is assumed to be a callback.
*
* @param {String} store Name of the object store to start a transaction on
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* transaction object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
getTransaction: function(store, callback, mode = "readonly") {
getTransaction(store, callback, mode);
},
/**
* Start a transaction on the loop database and return the requested store.
* If only two arguments are passed, the default mode will be assumed and the
* second argument is assumed to be a callback.
*
* @param {String} store Name of the object store to retrieve
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* store object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
getStore: function(store, callback, mode = "readonly") {
getStore(store, callback, mode);
},
/**
* Perform an async function in serial on each of the list items and call a
* callback Function when all list items are done.
* IMPORTANT: only use this iteration method if you are sure that the operations
* performed in `onItem` are guaranteed to be async in the success case.
*
* @param {Array} list Non-empty list of items to iterate
* @param {Function} onItem Callback to invoke for each item in the list. It
* takes the item is first argument and a callback
* function as second, which is to be invoked once
* the consumer is done with its async operation. If
* an error is passed as the first argument to this
* callback function, the iteration will stop and
* `onDone` callback will be invoked with that error.
* @param {callback} onDone Callback to invoke when the list is completed or
* on error. It takes an Error object as first
* argument.
*/
asyncForEach: function(list, onItem, onDone) {
let i = 0;
let len = list.length;
if (!len) {
onDone(new Error("Argument error: empty list"));
return;
}
onItem(list[i], function handler(err) {
if (err) {
onDone(err);
return;
}
i++;
if (i < len) {
onItem(list[i], handler, i);
} else {
onDone();
}
}, i);
},
/**
* Perform an async function in parallel on each of the list items and call a
* callback Function when all list items are done.
* IMPORTANT: only use this iteration method if you are sure that the operations
* performed in `onItem` are guaranteed to be async in the success case.
*
* @param {Array} list Non-empty list of items to iterate
* @param {Function} onItem Callback to invoke for each item in the list. It
* takes the item is first argument and a callback
* function as second, which is to be invoked once
* the consumer is done with its async operation. If
* an error is passed as the first argument to this
* callback function, the iteration will stop and
* `onDone` callback will be invoked with that error.
* @param {callback} onDone Callback to invoke when the list is completed or
* on error. It takes an Error object as first
* argument.
*/
asyncParallel: function(list, onItem, onDone) {
let i = 0;
let done = 0;
let callbackCalled = false;
let len = list.length;
if (!len) {
onDone(new Error("Argument error: empty list"));
return;
}
function handleCallback(err) {
if (callbackCalled) {
return;
}
if (err) {
onDone(err);
callbackCalled = true;
return;
}
if (++done === len) {
onDone();
callbackCalled = true;
}
}
for (; i < len; ++i) {
onItem(list[i], handleCallback, i);
}
},
on: (...params) => eventEmitter.on(...params),
off: (...params) => eventEmitter.off(...params)
});
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# 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/.
JAR_MANIFESTS += ['jar.mn']
XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
BROWSER_CHROME_MANIFESTS += [
'test/mochitest/browser.ini',
]
EXTRA_JS_MODULES.loop += [
'content/shared/js/crypto.js',
'content/shared/js/utils.js',
'modules/CardDavImporter.jsm',
'modules/GoogleImporter.jsm',
'modules/LoopCalls.jsm',
'modules/LoopContacts.jsm',
'modules/LoopRooms.jsm',
'modules/LoopRoomsCache.jsm',
'modules/LoopStorage.jsm',
'modules/MozLoopAPI.jsm',
'modules/MozLoopPushHandler.jsm',
'modules/MozLoopService.jsm',
'modules/MozLoopWorker.js',
]
with Files('**'):
BUG_COMPONENT = ('Loop', 'Client')
@@ -1,150 +0,0 @@
/* 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/. */
var loop = loop || {};
/**
* Monkeypatch getUserMedia in a way that prevents additional camera and
* microphone prompts, at the cost of ignoring all constraints other than
* the first set passed in.
*
* The first call to navigator.getUserMedia (also now aliased to
* multiplexGum.getPermsAndCacheMedia to allow for explicit calling code)
* will cause the underlying gUM implementation to be called.
*
* While permission is pending, subsequent calls will result in the callbacks
* being queued. Once the call succeeds or fails, all queued success or
* failure callbacks will be invoked. Subsequent calls to either function will
* cause the success or failure callback to be invoked immediately.
*/
loop.standaloneMedia = (function() {
"use strict";
function patchSymbolIfExtant(objectName, propertyName, replacement) {
var object;
if (window[objectName]) {
object = window[objectName];
}
if (object && object[propertyName]) {
object[propertyName] = replacement;
}
}
// originalGum _must_ be on navigator; otherwise things blow up.
// For TBPlugin users, navigator.originalGum is set after the TB SDK is loaded.
navigator.originalGum = navigator.getUserMedia ||
navigator.mozGetUserMedia ||
navigator.webkitGetUserMedia;
function _MultiplexGum() {
this.reset();
}
_MultiplexGum.prototype = {
/**
* @see The docs at the top of this file for overall semantics,
* & http://developer.mozilla.org/en-US/docs/NavigatorUserMedia.getUserMedia
* for params, since this is intended to be purely a passthrough to gUM.
*/
getPermsAndCacheMedia: function(constraints, onSuccess, onError) {
function handleResult(callbacks, param) {
// Operate on a copy of the array in case any of the callbacks
// calls reset, which would cause an infinite-recursion.
this.userMedia.successCallbacks = [];
this.userMedia.errorCallbacks = [];
callbacks.forEach(function(cb) {
if (typeof cb == "function") {
cb(param);
}
});
}
function handleSuccess(localStream) {
this.userMedia.pending = false;
this.userMedia.localStream = localStream;
this.userMedia.error = null;
handleResult.call(this, this.userMedia.successCallbacks.slice(0), localStream);
}
function handleError(error) {
this.userMedia.pending = false;
this.userMedia.error = error;
handleResult.call(this, this.userMedia.errorCallbacks.slice(0), error);
this.error = null;
}
if (this.userMedia.localStream &&
this.userMedia.localStream.ended) {
this.userMedia.localStream = null;
}
this.userMedia.errorCallbacks.push(onError);
this.userMedia.successCallbacks.push(onSuccess);
if (this.userMedia.localStream) {
handleSuccess.call(this, this.userMedia.localStream);
return;
} else if (this.userMedia.error) {
handleError.call(this, this.userMedia.error);
return;
}
if (this.userMedia.pending) {
return;
}
this.userMedia.pending = true;
navigator.originalGum(constraints, handleSuccess.bind(this),
handleError.bind(this));
},
/**
* Reset the cached permissions, callbacks, and media to their default
* state and call any error callbacks to let any waiting callers know
* not to ever expect any more callbacks. We use "PERMISSION_DENIED",
* for lack of a better, more specific gUM code that callers are likely
* to be prepared to handle.
*/
reset: function() {
// When called from the ctor, userMedia is not created yet.
if (this.userMedia) {
this.userMedia.errorCallbacks.forEach(function(cb) {
if (typeof cb == "function") {
cb("PERMISSION_DENIED");
}
});
if (this.userMedia.localStream &&
typeof this.userMedia.localStream.stop == "function") {
this.userMedia.localStream.stop();
}
}
this.userMedia = {
error: null,
localStream: null,
pending: false,
errorCallbacks: [],
successCallbacks: [],
};
}
};
var singletonMultiplexGum = new _MultiplexGum();
function myGetUserMedia() {
// This function is needed to pull in the instance
// of the singleton for tests to overwrite the used instance.
singletonMultiplexGum.getPermsAndCacheMedia.apply(singletonMultiplexGum, arguments);
}
patchSymbolIfExtant("navigator", "mozGetUserMedia", myGetUserMedia);
patchSymbolIfExtant("navigator", "webkitGetUserMedia", myGetUserMedia);
patchSymbolIfExtant("navigator", "getUserMedia", myGetUserMedia);
patchSymbolIfExtant("TBPlugin", "getUserMedia", myGetUserMedia);
return {
multiplexGum: singletonMultiplexGum,
_MultiplexGum: _MultiplexGum,
setSingleton: function(singleton) {
singletonMultiplexGum = singleton;
},
};
})();
@@ -1,210 +0,0 @@
/* 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/. */
var loop = loop || {};
loop.store = loop.store || {};
/**
* The standalone metrics store is used to log activities to
* analytics.
*
* Where possible we log events via receiving actions. However, some
* combinations of actions and events require us to listen directly to
* changes in the activeRoomStore so that we gain the benefit of the logic
* in that store.
*/
loop.store.StandaloneMetricsStore = (function() {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
loop.store.metrics = loop.store.metrics || {};
var METRICS_GA_CATEGORY = loop.store.METRICS_GA_CATEGORY = {
general: "/conversation/ interactions",
download: "Firefox Downloads"
};
var METRICS_GA_ACTIONS = loop.store.METRICS_GA_ACTIONS = {
audioMute: "audio mute",
button: "button click",
download: "download button click",
faceMute: "face mute",
link: "link click",
pageLoad: "page load messages",
success: "success",
support: "support link click"
};
var StandaloneMetricsStore = loop.store.createStore({
actions: [
"gotMediaPermission",
"joinRoom",
"leaveRoom",
"mediaConnected",
"recordClick"
],
/**
* Initializes the store and starts listening to the activeRoomStore.
*
* @param {Object} options Options for the store, should include a
* reference to the activeRoomStore.
*/
initialize: function(options) {
options = options || {};
if (!options.activeRoomStore) {
throw new Error("Missing option activeRoomStore");
}
// Don't bother listening if we're not storing metrics.
// I'd love for ga to be an option, but that messes up the function
// working properly.
if (window.ga) {
this.activeRoomStore = options.activeRoomStore;
this.listenTo(options.activeRoomStore, "change",
this._onActiveRoomStoreChange.bind(this));
}
},
/**
* Returns initial state data for this store. These are mainly reflections
* of activeRoomStore so we can match initial states for change tracking
* purposes.
*
* @return {Object} The initial store state.
*/
getInitialStoreState: function() {
return {
audioMuted: false,
roomState: ROOM_STATES.INIT,
videoMuted: false
};
},
/**
* Saves an event to ga.
*
* @param {String} category The category for the event.
* @param {String} action The type of action.
* @param {String} label The label detailing the action.
*/
_storeEvent: function(category, action, label) {
// ga might not be defined if donottrack is enabled, see index.html.
if (!window.ga) {
return;
}
// For now all we need to do is forward onto ga.
window.ga("send", "event", category, action, label);
},
/**
* Handles media permssion being obtained.
*/
gotMediaPermission: function() {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
"Media granted");
},
/**
* Handles the user clicking the join room button.
*/
joinRoom: function() {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
"Join the conversation");
},
/**
* Handles the user clicking the leave room button.
*/
leaveRoom: function() {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
"Leave conversation");
},
/**
* Handles notification that two-way media has been achieved.
*/
mediaConnected: function() {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
"Media connected");
},
/**
* Handles recording link clicks.
*
* @param {sharedActions.RecordClick} actionData The data associated with
* the link.
*/
recordClick: function(actionData) {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.linkClick,
actionData.linkInfo);
},
/**
* Handles notifications that the activeRoomStore has changed, updating
* the metrics for room state and mute state as necessary.
*/
_onActiveRoomStoreChange: function() {
var roomStore = this.activeRoomStore.getStoreState();
this._checkRoomState(roomStore.roomState, roomStore.failureReason);
this._checkMuteState("audio", roomStore.audioMuted);
this._checkMuteState("video", roomStore.videoMuted);
},
/**
* Handles checking of the room state to look for events we need to send.
*
* @param {String} roomState The new room state.
* @param {String} failureReason Optional, if the room is in the failure
* state, this should contain the reason for
* the failure.
*/
_checkRoomState: function(roomState, failureReason) {
if (this._storeState.roomState === roomState) {
return;
}
this._storeState.roomState = roomState;
if (roomState === ROOM_STATES.FAILED &&
failureReason === FAILURE_DETAILS.EXPIRED_OR_INVALID) {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.pageLoad,
"Link expired or invalid");
}
if (roomState === ROOM_STATES.FULL) {
this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.pageLoad,
"Room full");
}
},
/**
* Handles check of the mute state to look for events we need to send.
*
* @param {String} type The type of mute being adjusted, i.e. "audio" or
* "video".
* @param {Boolean} muted The new state of mute
*/
_checkMuteState: function(type, muted) {
var muteItem = type + "Muted";
if (this._storeState[muteItem] === muted) {
return;
}
this._storeState[muteItem] = muted;
var muteType = type === "audio" ? METRICS_GA_ACTIONS.audioMute : METRICS_GA_ACTIONS.faceMute;
var muteState = muted ? "mute" : "unmute";
this._storeEvent(METRICS_GA_CATEGORY.general, muteType, muteState);
}
});
return StandaloneMetricsStore;
})();
@@ -1,637 +0,0 @@
/** @jsx React.DOM */
/* 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, React */
/* jshint newcap:false, maxlen:false */
var loop = loop || {};
loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var sharedUtils = loop.shared.utils;
var sharedViews = loop.shared.views;
var StandaloneRoomInfoArea = React.createClass({displayName: "StandaloneRoomInfoArea",
propTypes: {
isFirefox: React.PropTypes.bool.isRequired,
activeRoomStore: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
]).isRequired
},
onFeedbackSent: function() {
// We pass a tick to prevent React warnings regarding nested updates.
setTimeout(function() {
this.props.activeRoomStore.dispatchAction(new sharedActions.FeedbackComplete());
}.bind(this));
},
_renderCallToActionLink: function() {
if (this.props.isFirefox) {
return (
React.createElement("a", {href: loop.config.learnMoreUrl, className: "btn btn-info"},
mozL10n.get("rooms_room_full_call_to_action_label", {
clientShortname: mozL10n.get("clientShortname2")
})
)
);
}
return (
React.createElement("a", {href: loop.config.downloadFirefoxUrl, className: "btn btn-info"},
mozL10n.get("rooms_room_full_call_to_action_nonFx_label", {
brandShortname: mozL10n.get("brandShortname")
})
)
);
},
/**
* @return String An appropriate string according to the failureReason.
*/
_getFailureString: function() {
switch(this.props.failureReason) {
case FAILURE_DETAILS.MEDIA_DENIED:
return mozL10n.get("rooms_media_denied_message");
case FAILURE_DETAILS.EXPIRED_OR_INVALID:
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
}
},
render: function() {
switch(this.props.roomState) {
case ROOM_STATES.INIT:
case ROOM_STATES.READY: {
// XXX: In ENDED state, we should rather display the feedback form.
return (
React.createElement("div", {className: "room-inner-info-area"},
React.createElement("button", {className: "btn btn-join btn-info",
onClick: this.props.joinRoom},
mozL10n.get("rooms_room_join_label")
)
)
);
}
case ROOM_STATES.MEDIA_WAIT: {
var msg = mozL10n.get("call_progress_getting_media_description",
{clientShortname: mozL10n.get("clientShortname2")});
var utils = loop.shared.utils;
var isChrome = utils.isChrome(navigator.userAgent);
var isFirefox = utils.isFirefox(navigator.userAgent);
var isOpera = utils.isOpera(navigator.userAgent);
var promptMediaMessageClasses = React.addons.classSet({
"prompt-media-message": true,
"chrome": isChrome,
"firefox": isFirefox,
"opera": isOpera,
"other": !isChrome && !isFirefox && !isOpera
});
return (
React.createElement("div", {className: "room-inner-info-area"},
React.createElement("p", {className: promptMediaMessageClasses},
msg
)
)
);
}
case ROOM_STATES.JOINING:
case ROOM_STATES.JOINED:
case ROOM_STATES.SESSION_CONNECTED: {
return (
React.createElement("div", {className: "room-inner-info-area"},
React.createElement("p", {className: "empty-room-message"},
mozL10n.get("rooms_only_occupant_label")
)
)
);
}
case ROOM_STATES.FULL: {
return (
React.createElement("div", {className: "room-inner-info-area"},
React.createElement("p", {className: "full-room-message"},
mozL10n.get("rooms_room_full_label")
),
React.createElement("p", null, this._renderCallToActionLink())
)
);
}
case ROOM_STATES.ENDED: {
if (this.props.roomUsed)
return (
React.createElement("div", {className: "ended-conversation"},
React.createElement(sharedViews.FeedbackView, {
onAfterFeedbackReceived: this.onFeedbackSent}
)
)
);
// In case the room was not used (no one was here), we
// bypass the feedback form.
this.onFeedbackSent();
return null;
}
case ROOM_STATES.FAILED: {
return (
React.createElement("div", {className: "room-inner-info-area"},
React.createElement("p", {className: "failed-room-message"},
this._getFailureString()
),
React.createElement("button", {className: "btn btn-join btn-info",
onClick: this.props.joinRoom},
mozL10n.get("retry_call_button")
)
)
);
}
default: {
return null;
}
}
}
});
var StandaloneRoomHeader = React.createClass({displayName: "StandaloneRoomHeader",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
},
recordClick: function() {
this.props.dispatcher.dispatch(new sharedActions.RecordClick({
linkInfo: "Support link click"
}));
},
render: function() {
return (
React.createElement("header", null,
React.createElement("h1", null, mozL10n.get("clientShortname2")),
React.createElement("a", {href: loop.config.generalSupportUrl,
onClick: this.recordClick,
target: "_blank"},
React.createElement("i", {className: "icon icon-help"})
)
)
);
}
});
var StandaloneRoomFooter = React.createClass({displayName: "StandaloneRoomFooter",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
},
_getContent: function() {
// We use this technique of static markup as it means we get
// just one overall string for L10n to define the structure of
// the whole item.
return mozL10n.get("legal_text_and_links", {
"clientShortname": mozL10n.get("clientShortname2"),
"terms_of_use_url": React.renderToStaticMarkup(
React.createElement("a", {href: loop.config.legalWebsiteUrl, target: "_blank"},
mozL10n.get("terms_of_use_link_text")
)
),
"privacy_notice_url": React.renderToStaticMarkup(
React.createElement("a", {href: loop.config.privacyWebsiteUrl, target: "_blank"},
mozL10n.get("privacy_notice_link_text")
)
),
});
},
recordClick: function(event) {
// Check for valid href, as this is clicking on the paragraph -
// so the user may be clicking on the text rather than the link.
if (event.target && event.target.href) {
this.props.dispatcher.dispatch(new sharedActions.RecordClick({
linkInfo: event.target.href
}));
}
},
render: function() {
return (
React.createElement("footer", null,
React.createElement("p", {dangerouslySetInnerHTML: {__html: this._getContent()},
onClick: this.recordClick}),
React.createElement("div", {className: "footer-logo"})
)
);
}
});
var StandaloneRoomContextItem = React.createClass({displayName: "StandaloneRoomContextItem",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
receivingScreenShare: React.PropTypes.bool,
roomContextUrl: React.PropTypes.object
},
recordClick: function() {
this.props.dispatcher.dispatch(new sharedActions.RecordClick({
linkInfo: "Shared URL"
}));
},
render: function() {
if (!this.props.roomContextUrl ||
!this.props.roomContextUrl.location) {
return null;
}
var locationInfo = sharedUtils.formatURL(this.props.roomContextUrl.location);
if (!locationInfo) {
return null;
}
var cx = React.addons.classSet;
var classes = cx({
"standalone-context-url": true,
"screen-share-active": this.props.receivingScreenShare
});
return (
React.createElement("div", {className: classes},
React.createElement("img", {src: this.props.roomContextUrl.thumbnail}),
React.createElement("div", {className: "standalone-context-url-description-wrapper"},
this.props.roomContextUrl.description,
React.createElement("br", null), React.createElement("a", {href: locationInfo.location,
onClick: this.recordClick,
target: "_blank",
title: locationInfo.location}, locationInfo.hostname)
)
)
);
}
});
var StandaloneRoomContextView = React.createClass({displayName: "StandaloneRoomContextView",
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
receivingScreenShare: React.PropTypes.bool.isRequired,
roomContextUrls: React.PropTypes.array,
roomName: React.PropTypes.string,
roomInfoFailure: React.PropTypes.string
},
getInitialState: function() {
return {
failureLogged: false
};
},
_logFailure: function(message) {
if (!this.state.failureLogged) {
console.error(mozL10n.get(message));
this.state.failureLogged = true;
}
},
render: function() {
// For failures, we currently just log the messages - UX doesn't want them
// displayed on primary UI at the moment.
if (this.props.roomInfoFailure === ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED) {
this._logFailure("room_information_failure_unsupported_browser");
return null;
} else if (this.props.roomInfoFailure) {
this._logFailure("room_information_failure_not_available");
return null;
}
// We only support one item in the context Urls array for now.
var roomContextUrl = (this.props.roomContextUrls &&
this.props.roomContextUrls.length > 0) ?
this.props.roomContextUrls[0] : null;
return (
React.createElement("div", {className: "standalone-room-info"},
React.createElement("h2", {className: "room-name"}, this.props.roomName),
React.createElement(StandaloneRoomContextItem, {
dispatcher: this.props.dispatcher,
receivingScreenShare: this.props.receivingScreenShare,
roomContextUrl: roomContextUrl})
)
);
}
});
var StandaloneRoomView = React.createClass({displayName: "StandaloneRoomView",
mixins: [
Backbone.Events,
sharedMixins.MediaSetupMixin,
sharedMixins.RoomsAudioMixin
],
propTypes: {
activeRoomStore: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
]).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
isFirefox: React.PropTypes.bool.isRequired
},
getInitialState: function() {
var storeState = this.props.activeRoomStore.getStoreState();
return _.extend({}, storeState, {
// Used by the UI showcase.
roomState: this.props.roomState || storeState.roomState
});
},
componentWillMount: function() {
this.listenTo(this.props.activeRoomStore, "change",
this._onActiveRoomStateChanged);
},
/**
* Handles a "change" event on the roomStore, and updates this.state
* to match the store.
*
* @private
*/
_onActiveRoomStateChanged: function() {
var state = this.props.activeRoomStore.getStoreState();
this.updateVideoDimensions(state.localVideoDimensions, state.remoteVideoDimensions);
this.setState(state);
},
componentDidMount: function() {
// Adding a class to the document body element from here to ease styling it.
document.body.classList.add("is-standalone-room");
},
componentWillUnmount: function() {
this.stopListening(this.props.activeRoomStore);
},
/**
* Watches for when we transition to MEDIA_WAIT room state, so we can request
* user media access.
*
* @param {Object} nextProps (Unused)
* @param {Object} nextState Next state object.
*/
componentWillUpdate: function(nextProps, nextState) {
if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this.getDefaultPublisherConfig({publishVideo: true}),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getRemoteElementFunc: this._getElement.bind(this, ".remote"),
getScreenShareElementFunc: this._getElement.bind(this, ".screen")
}));
}
if (this.state.roomState !== ROOM_STATES.JOINED &&
nextState.roomState === ROOM_STATES.JOINED) {
// This forces the video size to update - creating the publisher
// first, and then connecting to the session doesn't seem to set the
// initial size correctly.
this.updateVideoContainer();
}
if (nextState.roomState === ROOM_STATES.INIT ||
nextState.roomState === ROOM_STATES.GATHER ||
nextState.roomState === ROOM_STATES.READY) {
this.resetDimensionsCache();
}
// When screen sharing stops.
if (this.state.receivingScreenShare && !nextState.receivingScreenShare) {
// Remove the custom screenshare styles on the remote camera.
var node = this._getElement(".remote");
node.removeAttribute("style");
// Force the video sizes to update.
this.updateVideoContainer();
}
},
joinRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
},
leaveRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
},
/**
* Toggles streaming status for a given stream type.
*
* @param {String} type Stream type ("audio" or "video").
* @param {Boolean} enabled Enabled stream flag.
*/
publishStream: function(type, enabled) {
this.props.dispatcher.dispatch(new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
/**
* Specifically updates the local camera stream size and position, depending
* on the size and position of the remote video stream.
* This method gets called from `updateVideoContainer`, which is defined in
* the `MediaSetupMixin`.
*
* @param {Object} ratio Aspect ratio of the local camera stream
*/
updateLocalCameraPosition: function(ratio) {
// The local stream is a quarter of the remote stream.
var LOCAL_STREAM_SIZE = 0.25;
// The local stream overlaps the remote stream by a quarter of the local stream.
var LOCAL_STREAM_OVERLAP = 0.25;
// The minimum size of video height/width allowed by the sdk css.
var SDK_MIN_SIZE = 48;
var node = this._getElement(".local");
var targetWidth;
node.style.right = "auto";
if (window.matchMedia && window.matchMedia("screen and (max-width:640px)").matches) {
// For reduced screen widths, we just go for a fixed size and no overlap.
targetWidth = 180;
node.style.width = (targetWidth * ratio.width) + "px";
node.style.height = (targetWidth * ratio.height) + "px";
node.style.left = "auto";
} else {
// The local camera view should be a quarter of the size of the remote stream
// and positioned to overlap with the remote stream at a quarter of its width.
// Now position the local camera view correctly with respect to the remote
// video stream or the screen share stream.
var remoteVideoDimensions = this.getRemoteVideoDimensions(
this.state.receivingScreenShare ? "screen" : "camera");
targetWidth = remoteVideoDimensions.streamWidth * LOCAL_STREAM_SIZE;
var realWidth = targetWidth * ratio.width;
var realHeight = targetWidth * ratio.height;
// If we've hit the min size limits, then limit at the minimum.
if (realWidth < SDK_MIN_SIZE) {
realWidth = SDK_MIN_SIZE;
realHeight = realWidth / ratio.width * ratio.height;
}
if (realHeight < SDK_MIN_SIZE) {
realHeight = SDK_MIN_SIZE;
realWidth = realHeight / ratio.height * ratio.width;
}
var offsetX = (remoteVideoDimensions.streamWidth + remoteVideoDimensions.offsetX);
// The horizontal offset of the stream, and the width of the resulting
// pillarbox, is determined by the height exponent of the aspect ratio.
// Therefore we multiply the width of the local camera view by the height
// ratio.
node.style.left = (offsetX - (realWidth * LOCAL_STREAM_OVERLAP)) + "px";
node.style.width = realWidth + "px";
node.style.height = realHeight + "px";
}
},
/**
* Specifically updates the remote camera stream size and position, if
* a screen share is being received. It is slaved from the position of the
* local stream.
* This method gets called from `updateVideoContainer`, which is defined in
* the `MediaSetupMixin`.
*
* @param {Object} ratio Aspect ratio of the remote camera stream
*/
updateRemoteCameraPosition: function(ratio) {
// Nothing to do for screenshare
if (!this.state.receivingScreenShare) {
return;
}
// XXX For the time being, if we're a narrow screen, aka mobile, we don't display
// the remote media (bug 1133534).
if (window.matchMedia && window.matchMedia("screen and (max-width:640px)").matches) {
return;
}
// 10px separation between the two streams.
var LOCAL_REMOTE_SEPARATION = 10;
var node = this._getElement(".remote");
var localNode = this._getElement(".local");
// Match the width to the local video.
node.style.width = localNode.offsetWidth + "px";
// The height is then determined from the aspect ratio
var height = ((localNode.offsetWidth / ratio.width) * ratio.height);
node.style.height = height + "px";
node.style.right = "auto";
node.style.bottom = "auto";
// Now position the local camera view correctly with respect to the remote
// video stream.
// The top is measured from the top of the element down the screen,
// so subtract the height of the video and the separation distance.
node.style.top = (localNode.offsetTop - height - LOCAL_REMOTE_SEPARATION) + "px";
// Match the left-hand sides.
node.style.left = localNode.offsetLeft + "px";
},
/**
* Checks if current room is active.
*
* @return {Boolean}
*/
_roomIsActive: function() {
return this.state.roomState === ROOM_STATES.JOINED ||
this.state.roomState === ROOM_STATES.SESSION_CONNECTED ||
this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
},
render: function() {
var localStreamClasses = React.addons.classSet({
hide: !this._roomIsActive(),
local: true,
"local-stream": true,
"local-stream-audio": this.state.videoMuted
});
var remoteStreamClasses = React.addons.classSet({
"video_inner": true,
"remote": true,
"focus-stream": !this.state.receivingScreenShare,
"remote-inset-stream": this.state.receivingScreenShare
});
var screenShareStreamClasses = React.addons.classSet({
"screen": true,
"focus-stream": this.state.receivingScreenShare,
hide: !this.state.receivingScreenShare,
});
return (
React.createElement("div", {className: "room-conversation-wrapper"},
React.createElement("div", {className: "beta-logo"}),
React.createElement(StandaloneRoomHeader, {dispatcher: this.props.dispatcher}),
React.createElement(StandaloneRoomInfoArea, {roomState: this.state.roomState,
failureReason: this.state.failureReason,
joinRoom: this.joinRoom,
isFirefox: this.props.isFirefox,
activeRoomStore: this.props.activeRoomStore,
roomUsed: this.state.used}),
React.createElement("div", {className: "video-layout-wrapper"},
React.createElement("div", {className: "conversation room-conversation"},
React.createElement(StandaloneRoomContextView, {
dispatcher: this.props.dispatcher,
receivingScreenShare: this.state.receivingScreenShare,
roomContextUrls: this.state.roomContextUrls,
roomName: this.state.roomName,
roomInfoFailure: this.state.roomInfoFailure}),
React.createElement("div", {className: "media nested"},
React.createElement("span", {className: "self-view-hidden-message"},
mozL10n.get("self_view_hidden_message")
),
React.createElement("div", {className: "video_wrapper remote_wrapper"},
React.createElement("div", {className: remoteStreamClasses}),
React.createElement("div", {className: screenShareStreamClasses})
),
React.createElement("div", {className: localStreamClasses})
),
React.createElement(sharedViews.ConversationToolbar, {
dispatcher: this.props.dispatcher,
video: {enabled: !this.state.videoMuted,
visible: this._roomIsActive()},
audio: {enabled: !this.state.audioMuted,
visible: this._roomIsActive()},
publishStream: this.publishStream,
hangup: this.leaveRoom,
hangupButtonLabel: mozL10n.get("rooms_leave_button_label"),
enableHangup: this._roomIsActive()})
)
),
React.createElement(loop.fxOSMarketplaceViews.FxOSHiddenMarketplaceView, {
marketplaceSrc: this.state.marketplaceSrc,
onMarketplaceMessage: this.state.onMarketplaceMessage}),
React.createElement(StandaloneRoomFooter, {dispatcher: this.props.dispatcher})
)
);
}
});
return {
StandaloneRoomContextView: StandaloneRoomContextView,
StandaloneRoomFooter: StandaloneRoomFooter,
StandaloneRoomHeader: StandaloneRoomHeader,
StandaloneRoomView: StandaloneRoomView
};
})(navigator.mozL10n);
@@ -1,637 +0,0 @@
/** @jsx React.DOM */
/* 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, React */
/* jshint newcap:false, maxlen:false */
var loop = loop || {};
loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var sharedUtils = loop.shared.utils;
var sharedViews = loop.shared.views;
var StandaloneRoomInfoArea = React.createClass({
propTypes: {
isFirefox: React.PropTypes.bool.isRequired,
activeRoomStore: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
]).isRequired
},
onFeedbackSent: function() {
// We pass a tick to prevent React warnings regarding nested updates.
setTimeout(function() {
this.props.activeRoomStore.dispatchAction(new sharedActions.FeedbackComplete());
}.bind(this));
},
_renderCallToActionLink: function() {
if (this.props.isFirefox) {
return (
<a href={loop.config.learnMoreUrl} className="btn btn-info">
{mozL10n.get("rooms_room_full_call_to_action_label", {
clientShortname: mozL10n.get("clientShortname2")
})}
</a>
);
}
return (
<a href={loop.config.downloadFirefoxUrl} className="btn btn-info">
{mozL10n.get("rooms_room_full_call_to_action_nonFx_label", {
brandShortname: mozL10n.get("brandShortname")
})}
</a>
);
},
/**
* @return String An appropriate string according to the failureReason.
*/
_getFailureString: function() {
switch(this.props.failureReason) {
case FAILURE_DETAILS.MEDIA_DENIED:
return mozL10n.get("rooms_media_denied_message");
case FAILURE_DETAILS.EXPIRED_OR_INVALID:
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
}
},
render: function() {
switch(this.props.roomState) {
case ROOM_STATES.INIT:
case ROOM_STATES.READY: {
// XXX: In ENDED state, we should rather display the feedback form.
return (
<div className="room-inner-info-area">
<button className="btn btn-join btn-info"
onClick={this.props.joinRoom}>
{mozL10n.get("rooms_room_join_label")}
</button>
</div>
);
}
case ROOM_STATES.MEDIA_WAIT: {
var msg = mozL10n.get("call_progress_getting_media_description",
{clientShortname: mozL10n.get("clientShortname2")});
var utils = loop.shared.utils;
var isChrome = utils.isChrome(navigator.userAgent);
var isFirefox = utils.isFirefox(navigator.userAgent);
var isOpera = utils.isOpera(navigator.userAgent);
var promptMediaMessageClasses = React.addons.classSet({
"prompt-media-message": true,
"chrome": isChrome,
"firefox": isFirefox,
"opera": isOpera,
"other": !isChrome && !isFirefox && !isOpera
});
return (
<div className="room-inner-info-area">
<p className={promptMediaMessageClasses}>
{msg}
</p>
</div>
);
}
case ROOM_STATES.JOINING:
case ROOM_STATES.JOINED:
case ROOM_STATES.SESSION_CONNECTED: {
return (
<div className="room-inner-info-area">
<p className="empty-room-message">
{mozL10n.get("rooms_only_occupant_label")}
</p>
</div>
);
}
case ROOM_STATES.FULL: {
return (
<div className="room-inner-info-area">
<p className="full-room-message">
{mozL10n.get("rooms_room_full_label")}
</p>
<p>{this._renderCallToActionLink()}</p>
</div>
);
}
case ROOM_STATES.ENDED: {
if (this.props.roomUsed)
return (
<div className="ended-conversation">
<sharedViews.FeedbackView
onAfterFeedbackReceived={this.onFeedbackSent}
/>
</div>
);
// In case the room was not used (no one was here), we
// bypass the feedback form.
this.onFeedbackSent();
return null;
}
case ROOM_STATES.FAILED: {
return (
<div className="room-inner-info-area">
<p className="failed-room-message">
{this._getFailureString()}
</p>
<button className="btn btn-join btn-info"
onClick={this.props.joinRoom}>
{mozL10n.get("retry_call_button")}
</button>
</div>
);
}
default: {
return null;
}
}
}
});
var StandaloneRoomHeader = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
},
recordClick: function() {
this.props.dispatcher.dispatch(new sharedActions.RecordClick({
linkInfo: "Support link click"
}));
},
render: function() {
return (
<header>
<h1>{mozL10n.get("clientShortname2")}</h1>
<a href={loop.config.generalSupportUrl}
onClick={this.recordClick}
target="_blank">
<i className="icon icon-help"></i>
</a>
</header>
);
}
});
var StandaloneRoomFooter = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
},
_getContent: function() {
// We use this technique of static markup as it means we get
// just one overall string for L10n to define the structure of
// the whole item.
return mozL10n.get("legal_text_and_links", {
"clientShortname": mozL10n.get("clientShortname2"),
"terms_of_use_url": React.renderToStaticMarkup(
<a href={loop.config.legalWebsiteUrl} target="_blank">
{mozL10n.get("terms_of_use_link_text")}
</a>
),
"privacy_notice_url": React.renderToStaticMarkup(
<a href={loop.config.privacyWebsiteUrl} target="_blank">
{mozL10n.get("privacy_notice_link_text")}
</a>
),
});
},
recordClick: function(event) {
// Check for valid href, as this is clicking on the paragraph -
// so the user may be clicking on the text rather than the link.
if (event.target && event.target.href) {
this.props.dispatcher.dispatch(new sharedActions.RecordClick({
linkInfo: event.target.href
}));
}
},
render: function() {
return (
<footer>
<p dangerouslySetInnerHTML={{__html: this._getContent()}}
onClick={this.recordClick}></p>
<div className="footer-logo" />
</footer>
);
}
});
var StandaloneRoomContextItem = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
receivingScreenShare: React.PropTypes.bool,
roomContextUrl: React.PropTypes.object
},
recordClick: function() {
this.props.dispatcher.dispatch(new sharedActions.RecordClick({
linkInfo: "Shared URL"
}));
},
render: function() {
if (!this.props.roomContextUrl ||
!this.props.roomContextUrl.location) {
return null;
}
var locationInfo = sharedUtils.formatURL(this.props.roomContextUrl.location);
if (!locationInfo) {
return null;
}
var cx = React.addons.classSet;
var classes = cx({
"standalone-context-url": true,
"screen-share-active": this.props.receivingScreenShare
});
return (
<div className={classes}>
<img src={this.props.roomContextUrl.thumbnail} />
<div className="standalone-context-url-description-wrapper">
{this.props.roomContextUrl.description}
<br /><a href={locationInfo.location}
onClick={this.recordClick}
target="_blank"
title={locationInfo.location}>{locationInfo.hostname}</a>
</div>
</div>
);
}
});
var StandaloneRoomContextView = React.createClass({
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
receivingScreenShare: React.PropTypes.bool.isRequired,
roomContextUrls: React.PropTypes.array,
roomName: React.PropTypes.string,
roomInfoFailure: React.PropTypes.string
},
getInitialState: function() {
return {
failureLogged: false
};
},
_logFailure: function(message) {
if (!this.state.failureLogged) {
console.error(mozL10n.get(message));
this.state.failureLogged = true;
}
},
render: function() {
// For failures, we currently just log the messages - UX doesn't want them
// displayed on primary UI at the moment.
if (this.props.roomInfoFailure === ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED) {
this._logFailure("room_information_failure_unsupported_browser");
return null;
} else if (this.props.roomInfoFailure) {
this._logFailure("room_information_failure_not_available");
return null;
}
// We only support one item in the context Urls array for now.
var roomContextUrl = (this.props.roomContextUrls &&
this.props.roomContextUrls.length > 0) ?
this.props.roomContextUrls[0] : null;
return (
<div className="standalone-room-info">
<h2 className="room-name">{this.props.roomName}</h2>
<StandaloneRoomContextItem
dispatcher={this.props.dispatcher}
receivingScreenShare={this.props.receivingScreenShare}
roomContextUrl={roomContextUrl} />
</div>
);
}
});
var StandaloneRoomView = React.createClass({
mixins: [
Backbone.Events,
sharedMixins.MediaSetupMixin,
sharedMixins.RoomsAudioMixin
],
propTypes: {
activeRoomStore: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
]).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
isFirefox: React.PropTypes.bool.isRequired
},
getInitialState: function() {
var storeState = this.props.activeRoomStore.getStoreState();
return _.extend({}, storeState, {
// Used by the UI showcase.
roomState: this.props.roomState || storeState.roomState
});
},
componentWillMount: function() {
this.listenTo(this.props.activeRoomStore, "change",
this._onActiveRoomStateChanged);
},
/**
* Handles a "change" event on the roomStore, and updates this.state
* to match the store.
*
* @private
*/
_onActiveRoomStateChanged: function() {
var state = this.props.activeRoomStore.getStoreState();
this.updateVideoDimensions(state.localVideoDimensions, state.remoteVideoDimensions);
this.setState(state);
},
componentDidMount: function() {
// Adding a class to the document body element from here to ease styling it.
document.body.classList.add("is-standalone-room");
},
componentWillUnmount: function() {
this.stopListening(this.props.activeRoomStore);
},
/**
* Watches for when we transition to MEDIA_WAIT room state, so we can request
* user media access.
*
* @param {Object} nextProps (Unused)
* @param {Object} nextState Next state object.
*/
componentWillUpdate: function(nextProps, nextState) {
if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this.getDefaultPublisherConfig({publishVideo: true}),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getRemoteElementFunc: this._getElement.bind(this, ".remote"),
getScreenShareElementFunc: this._getElement.bind(this, ".screen")
}));
}
if (this.state.roomState !== ROOM_STATES.JOINED &&
nextState.roomState === ROOM_STATES.JOINED) {
// This forces the video size to update - creating the publisher
// first, and then connecting to the session doesn't seem to set the
// initial size correctly.
this.updateVideoContainer();
}
if (nextState.roomState === ROOM_STATES.INIT ||
nextState.roomState === ROOM_STATES.GATHER ||
nextState.roomState === ROOM_STATES.READY) {
this.resetDimensionsCache();
}
// When screen sharing stops.
if (this.state.receivingScreenShare && !nextState.receivingScreenShare) {
// Remove the custom screenshare styles on the remote camera.
var node = this._getElement(".remote");
node.removeAttribute("style");
// Force the video sizes to update.
this.updateVideoContainer();
}
},
joinRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
},
leaveRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
},
/**
* Toggles streaming status for a given stream type.
*
* @param {String} type Stream type ("audio" or "video").
* @param {Boolean} enabled Enabled stream flag.
*/
publishStream: function(type, enabled) {
this.props.dispatcher.dispatch(new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
/**
* Specifically updates the local camera stream size and position, depending
* on the size and position of the remote video stream.
* This method gets called from `updateVideoContainer`, which is defined in
* the `MediaSetupMixin`.
*
* @param {Object} ratio Aspect ratio of the local camera stream
*/
updateLocalCameraPosition: function(ratio) {
// The local stream is a quarter of the remote stream.
var LOCAL_STREAM_SIZE = 0.25;
// The local stream overlaps the remote stream by a quarter of the local stream.
var LOCAL_STREAM_OVERLAP = 0.25;
// The minimum size of video height/width allowed by the sdk css.
var SDK_MIN_SIZE = 48;
var node = this._getElement(".local");
var targetWidth;
node.style.right = "auto";
if (window.matchMedia && window.matchMedia("screen and (max-width:640px)").matches) {
// For reduced screen widths, we just go for a fixed size and no overlap.
targetWidth = 180;
node.style.width = (targetWidth * ratio.width) + "px";
node.style.height = (targetWidth * ratio.height) + "px";
node.style.left = "auto";
} else {
// The local camera view should be a quarter of the size of the remote stream
// and positioned to overlap with the remote stream at a quarter of its width.
// Now position the local camera view correctly with respect to the remote
// video stream or the screen share stream.
var remoteVideoDimensions = this.getRemoteVideoDimensions(
this.state.receivingScreenShare ? "screen" : "camera");
targetWidth = remoteVideoDimensions.streamWidth * LOCAL_STREAM_SIZE;
var realWidth = targetWidth * ratio.width;
var realHeight = targetWidth * ratio.height;
// If we've hit the min size limits, then limit at the minimum.
if (realWidth < SDK_MIN_SIZE) {
realWidth = SDK_MIN_SIZE;
realHeight = realWidth / ratio.width * ratio.height;
}
if (realHeight < SDK_MIN_SIZE) {
realHeight = SDK_MIN_SIZE;
realWidth = realHeight / ratio.height * ratio.width;
}
var offsetX = (remoteVideoDimensions.streamWidth + remoteVideoDimensions.offsetX);
// The horizontal offset of the stream, and the width of the resulting
// pillarbox, is determined by the height exponent of the aspect ratio.
// Therefore we multiply the width of the local camera view by the height
// ratio.
node.style.left = (offsetX - (realWidth * LOCAL_STREAM_OVERLAP)) + "px";
node.style.width = realWidth + "px";
node.style.height = realHeight + "px";
}
},
/**
* Specifically updates the remote camera stream size and position, if
* a screen share is being received. It is slaved from the position of the
* local stream.
* This method gets called from `updateVideoContainer`, which is defined in
* the `MediaSetupMixin`.
*
* @param {Object} ratio Aspect ratio of the remote camera stream
*/
updateRemoteCameraPosition: function(ratio) {
// Nothing to do for screenshare
if (!this.state.receivingScreenShare) {
return;
}
// XXX For the time being, if we're a narrow screen, aka mobile, we don't display
// the remote media (bug 1133534).
if (window.matchMedia && window.matchMedia("screen and (max-width:640px)").matches) {
return;
}
// 10px separation between the two streams.
var LOCAL_REMOTE_SEPARATION = 10;
var node = this._getElement(".remote");
var localNode = this._getElement(".local");
// Match the width to the local video.
node.style.width = localNode.offsetWidth + "px";
// The height is then determined from the aspect ratio
var height = ((localNode.offsetWidth / ratio.width) * ratio.height);
node.style.height = height + "px";
node.style.right = "auto";
node.style.bottom = "auto";
// Now position the local camera view correctly with respect to the remote
// video stream.
// The top is measured from the top of the element down the screen,
// so subtract the height of the video and the separation distance.
node.style.top = (localNode.offsetTop - height - LOCAL_REMOTE_SEPARATION) + "px";
// Match the left-hand sides.
node.style.left = localNode.offsetLeft + "px";
},
/**
* Checks if current room is active.
*
* @return {Boolean}
*/
_roomIsActive: function() {
return this.state.roomState === ROOM_STATES.JOINED ||
this.state.roomState === ROOM_STATES.SESSION_CONNECTED ||
this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
},
render: function() {
var localStreamClasses = React.addons.classSet({
hide: !this._roomIsActive(),
local: true,
"local-stream": true,
"local-stream-audio": this.state.videoMuted
});
var remoteStreamClasses = React.addons.classSet({
"video_inner": true,
"remote": true,
"focus-stream": !this.state.receivingScreenShare,
"remote-inset-stream": this.state.receivingScreenShare
});
var screenShareStreamClasses = React.addons.classSet({
"screen": true,
"focus-stream": this.state.receivingScreenShare,
hide: !this.state.receivingScreenShare,
});
return (
<div className="room-conversation-wrapper">
<div className="beta-logo" />
<StandaloneRoomHeader dispatcher={this.props.dispatcher} />
<StandaloneRoomInfoArea roomState={this.state.roomState}
failureReason={this.state.failureReason}
joinRoom={this.joinRoom}
isFirefox={this.props.isFirefox}
activeRoomStore={this.props.activeRoomStore}
roomUsed={this.state.used} />
<div className="video-layout-wrapper">
<div className="conversation room-conversation">
<StandaloneRoomContextView
dispatcher={this.props.dispatcher}
receivingScreenShare={this.state.receivingScreenShare}
roomContextUrls={this.state.roomContextUrls}
roomName={this.state.roomName}
roomInfoFailure={this.state.roomInfoFailure} />
<div className="media nested">
<span className="self-view-hidden-message">
{mozL10n.get("self_view_hidden_message")}
</span>
<div className="video_wrapper remote_wrapper">
<div className={remoteStreamClasses}></div>
<div className={screenShareStreamClasses}></div>
</div>
<div className={localStreamClasses}></div>
</div>
<sharedViews.ConversationToolbar
dispatcher={this.props.dispatcher}
video={{enabled: !this.state.videoMuted,
visible: this._roomIsActive()}}
audio={{enabled: !this.state.audioMuted,
visible: this._roomIsActive()}}
publishStream={this.publishStream}
hangup={this.leaveRoom}
hangupButtonLabel={mozL10n.get("rooms_leave_button_label")}
enableHangup={this._roomIsActive()} />
</div>
</div>
<loop.fxOSMarketplaceViews.FxOSHiddenMarketplaceView
marketplaceSrc={this.state.marketplaceSrc}
onMarketplaceMessage={this.state.onMarketplaceMessage} />
<StandaloneRoomFooter dispatcher={this.props.dispatcher} />
</div>
);
}
});
return {
StandaloneRoomContextView: StandaloneRoomContextView,
StandaloneRoomFooter: StandaloneRoomFooter,
StandaloneRoomHeader: StandaloneRoomHeader,
StandaloneRoomView: StandaloneRoomView
};
})(navigator.mozL10n);
@@ -1,139 +0,0 @@
## LOCALIZATION NOTE: In this file, don't translate the part between {{..}}
restart_call=Rejoin
conversation_has_ended=Your conversation has ended.
call_timeout_notification_text=Your call did not go through.
missing_conversation_info=Missing conversation information.
network_disconnected=The network connection terminated abruptly.
peer_ended_conversation2=The person you were calling has ended the conversation.
call_failed_title=Call failed.
generic_failure_title=Something went wrong.
generic_failure_with_reason2=You can try again or email a link to be reached at later.
generic_failure_no_reason2=Would you like to try again?
retry_call_button=Retry
unable_retrieve_call_info=Unable to retrieve conversation information.
hangup_button_title=Hang up
hangup_button_caption2=Exit
mute_local_audio_button_title=Mute your audio
unmute_local_audio_button_title=Unmute your audio
mute_local_video_button_title=Mute your video
unmute_local_video_button_title=Unmute your video
active_screenshare_button_title=Stop sharing
inactive_screenshare_button_title=Share your screen
outgoing_call_title=Start conversation?
call_with_contact_title=Conversation with {{incomingCallIdentity}}
welcome=Welcome to the {{clientShortname}} web client.
incompatible_browser_heading=Oops!
incompatible_browser_message=Firefox Hello only works in browsers that support WebRTC
powered_by_webrtc=The audio and video components of {{clientShortname}} are powered by WebRTC.
use_latest_firefox=Please try this link in a WebRTC-enabled browser, such as {{firefoxBrandNameLink}}.
unsupported_platform_heading=Sorry!
unsupported_platform_message={{platform}} does not currently support {{clientShortname}}
unsupported_platform_ios=iOS
unsupported_platform_windows_phone=Windows Phone
unsupported_platform_blackberry=Blackberry
unsupported_platform_learn_more_link=Learn more about why your platform doesn't support {{clientShortname}}
connection_error_see_console_notification=Call failed; see console for details.
call_url_unavailable_notification_heading=Oops!
call_url_unavailable_notification_message2=Sorry, this URL is not available. It may be expired or entered incorrectly.
promote_firefox_hello_heading=Download {{brandShortname}} to make free audio and video calls!
get_firefox_button=Get {{brandShortname}}
initiate_call_button_label2=Ready to start your conversation?
initiate_audio_video_call_button2=Start
initiate_audio_video_call_tooltip2=Start a video conversation
initiate_audio_call_button2=Voice conversation
initiate_call_cancel_button=Cancel
legal_text_and_links=By using {{clientShortname}} 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
invite_header_text=Invite someone to join you.
self_view_hidden_message=Self-view hidden but still being sent; resize window \
to show
## LOCALIZATION NOTE(brandShortname): This should not be localized and
## should remain "Firefox" for all locales.
brandShortname=Firefox
## LOCALIZATION NOTE(clientShortname2): This should not be localized and
## should remain "Firefox Hello" for all locales.
clientShortname2=Firefox Hello
## LOCALIZATION NOTE(vendorShortname): This should not be localized and
## should remain "Mozilla" for all locales.
vendorShortname=Mozilla
## LOCALIZATION NOTE(client_alttext): {{clientShortname}} will be replaced with the
## value of the clientShortname2 string above.
client_alttext={{clientShortname}} logo
vendor_alttext={{vendorShortname}} logo
## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
call_url_creation_date_label=(from {{call_url_creation_date}})
call_progress_getting_media_description={{clientShortname}} requires access to your camera and microphone.
call_progress_getting_media_title=Waiting for media…
call_progress_connecting_description=Connecting…
call_progress_ringing_description=Ringing…
fxos_app_needed=Please install the {{fxosAppName}} app from the Firefox Marketplace.
feedback_call_experience_heading2=How was your conversation?
feedback_thank_you_heading=Thank you for your feedback!
feedback_category_list_heading=What made you sad?
feedback_category_audio_quality=Audio quality
feedback_category_video_quality=Video quality
feedback_category_was_disconnected=Was disconnected
feedback_category_confusing2=Confusing controls
feedback_category_other2=Other
feedback_custom_category_text_placeholder=What went wrong?
feedback_submit_button=Submit
feedback_back_button=Back
## LOCALIZATION NOTE (feedback_window_will_close_in2):
## Gaia l10n format; see https://github.com/mozilla-b2g/gaia/blob/f108c706fae43cd61628babdd9463e7695b2496e/apps/email/locales/email.en-US.properties#L387
## In this item, don't translate the part between {{..}}
feedback_window_will_close_in2={[ plural(countdown) ]}
feedback_window_will_close_in2[one] = This window will close in {{countdown}} second
feedback_window_will_close_in2[two] = This window will close in {{countdown}} seconds
feedback_window_will_close_in2[few] = This window will close in {{countdown}} seconds
feedback_window_will_close_in2[many] = This window will close in {{countdown}} seconds
feedback_window_will_close_in2[other] = This window will close in {{countdown}} seconds
## LOCALIZATION_NOTE (feedback_rejoin_button): Displayed on the feedback form after
## a signed-in to signed-in user call.
## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#feedback
feedback_rejoin_button=Rejoin
## LOCALIZATION NOTE (feedback_report_user_button): Used to report a user in the case of
## an abusive user.
feedback_report_user_button=Report User
## LOCALIZATION_NOTE(first_time_experience.title): clientShortname will be
## replaced by the brand name
first_time_experience_title={{clientShortname}} — Join the conversation
first_time_experience_button_label=Get Started
help_label=Help
tour_label=Tour
rooms_default_room_name_template=Conversation {{conversationLabel}}
rooms_leave_button_label=Leave
rooms_list_copy_url_tooltip=Copy Link
rooms_list_delete_tooltip=Delete conversation
rooms_list_deleteConfirmation_label=Are you sure?
rooms_new_room_button_label=Start a conversation
rooms_only_occupant_label=You're the first one here.
rooms_panel_title=Choose a conversation or start a new one
rooms_room_full_label=There are already two people in this conversation.
rooms_room_full_call_to_action_nonFx_label=Download {{brandShortname}} to start your own
rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} »
rooms_room_joined_label=Someone has joined the conversation!
rooms_room_join_label=Join the conversation
rooms_display_name_guest=Guest
rooms_unavailable_notification_message=Sorry, you cannot join this conversation. The link may be expired or invalid.
rooms_media_denied_message=We could not get access to your microphone or camera. Please reload the page to try again.
room_information_failure_not_available=No information about this conversation is available. Please request a new link from the person who sent it to you.
room_information_failure_unsupported_browser=Your browser cannot access any information about this conversation. Please make sure you're using the latest version.
## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
## replaced by the brand name and {{currentStatus}} will be replaced
## by the current call status (Connecting, Ringing, etc.)
standalone_title_with_status={{clientShortname}} — {{currentStatus}}
status_in_conversation=In conversation
status_conversation_ended=Conversation ended
status_error=Something went wrong
support_link=Get Help
@@ -1,685 +0,0 @@
var expect = chai.expect;
/* jshint newcap:false */
describe("loop.roomViews", function () {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
var sandbox, dispatcher, roomStore, activeRoomStore, fakeWindow,
fakeMozLoop, fakeContextURL;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
fakeMozLoop = {
getAudioBlob: sinon.stub(),
getLoopPref: sinon.stub(),
isSocialShareButtonAvailable: sinon.stub()
};
fakeWindow = {
close: sinon.stub(),
document: {},
navigator: {
mozLoop: fakeMozLoop
},
addEventListener: function() {},
removeEventListener: function() {},
setTimeout: function(callback) { callback(); }
};
loop.shared.mixins.setRootObject(fakeWindow);
// XXX These stubs should be hoisted in a common file
// Bug 1040968
sandbox.stub(document.mozL10n, "get", function(x) {
return x;
});
activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
mozLoop: {},
sdkDriver: {}
});
roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: {},
activeRoomStore: activeRoomStore
});
fakeContextURL = {
description: "An invalid page",
location: "http://invalid.com",
thumbnail: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
};
});
afterEach(function() {
sandbox.restore();
loop.shared.mixins.setRootObject(window);
});
describe("ActiveRoomStoreMixin", function() {
it("should merge initial state", function() {
var TestView = React.createClass({
mixins: [loop.roomViews.ActiveRoomStoreMixin],
getInitialState: function() {
return {foo: "bar"};
},
render: function() { return React.DOM.div(); }
});
var testView = TestUtils.renderIntoDocument(
React.createElement(TestView, {
roomStore: roomStore
}));
var expectedState = _.extend({foo: "bar"},
activeRoomStore.getInitialStoreState());
expect(testView.state).eql(expectedState);
});
it("should listen to store changes", function() {
var TestView = React.createClass({
mixins: [loop.roomViews.ActiveRoomStoreMixin],
render: function() { return React.DOM.div(); }
});
var testView = TestUtils.renderIntoDocument(
React.createElement(TestView, {
roomStore: roomStore
}));
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
expect(testView.state.roomState).eql(ROOM_STATES.READY);
});
});
describe("DesktopRoomInvitationView", function() {
var view;
beforeEach(function() {
sandbox.stub(dispatcher, "dispatch");
});
afterEach(function() {
view = null;
});
function mountTestComponent(props) {
props = _.extend({
dispatcher: dispatcher,
roomData: {},
show: true,
showContext: false
}, props);
return TestUtils.renderIntoDocument(
React.createElement(loop.roomViews.DesktopRoomInvitationView, props));
}
it("should dispatch an EmailRoomUrl action when the email button is pressed",
function() {
view = mountTestComponent({
roomData: { roomUrl: "http://invalid" }
});
var emailBtn = view.getDOMNode().querySelector(".btn-email");
React.addons.TestUtils.Simulate.click(emailBtn);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWith(dispatcher.dispatch,
new sharedActions.EmailRoomUrl({roomUrl: "http://invalid"}));
});
describe("Rename Room", function() {
var roomNameBox;
beforeEach(function() {
view = mountTestComponent({
roomData: {
roomToken: "fakeToken",
roomName: "fakeName"
}
});
roomNameBox = view.getDOMNode().querySelector(".input-room-name");
});
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"
}));
});
});
describe("Copy Button", function() {
beforeEach(function() {
view = mountTestComponent({
roomData: { roomUrl: "http://invalid" }
});
});
it("should dispatch a CopyRoomUrl action when the copy button is " +
"pressed", function() {
var copyBtn = view.getDOMNode().querySelector(".btn-copy");
React.addons.TestUtils.Simulate.click(copyBtn);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWith(dispatcher.dispatch,
new sharedActions.CopyRoomUrl({roomUrl: "http://invalid"}));
});
it("should change the text when the url has been copied", function() {
var copyBtn = view.getDOMNode().querySelector(".btn-copy");
React.addons.TestUtils.Simulate.click(copyBtn);
// copied_url_button is the l10n string.
expect(copyBtn.textContent).eql("copied_url_button");
});
});
describe("Share button", function() {
beforeEach(function() {
view = mountTestComponent();
});
it("should toggle the share dropdown when the share button is clicked", function() {
var shareBtn = view.getDOMNode().querySelector(".btn-share");
React.addons.TestUtils.Simulate.click(shareBtn);
expect(view.state.showMenu).to.eql(true);
expect(view.refs.menu.props.show).to.eql(true);
});
});
describe("Context", function() {
it("should not render the context data when told not to", function() {
view = mountTestComponent();
expect(view.getDOMNode().querySelector(".room-context")).to.eql(null);
});
it("should render context when data is available", function() {
view = mountTestComponent({
showContext: true,
roomData: {
roomContextUrls: [fakeContextURL]
}
});
expect(view.getDOMNode().querySelector(".room-context")).to.not.eql(null);
});
it("should format the context url for display", function() {
sandbox.stub(sharedUtils, "formatURL").returns({
location: "location",
hostname: "hostname"
});
view = mountTestComponent({
showContext: true,
roomData: {
roomContextUrls: [fakeContextURL]
}
});
expect(view.getDOMNode().querySelector(".room-context-url").textContent)
.eql("hostname");
});
});
});
describe("DesktopRoomConversationView", function() {
var view;
beforeEach(function() {
loop.store.StoreMixin.register({
feedbackStore: new loop.store.FeedbackStore(dispatcher, {
feedbackClient: {}
})
});
sandbox.stub(dispatcher, "dispatch");
});
function mountTestComponent() {
return TestUtils.renderIntoDocument(
React.createElement(loop.roomViews.DesktopRoomConversationView, {
dispatcher: dispatcher,
roomStore: roomStore,
mozLoop: fakeMozLoop
}));
}
it("should dispatch a setMute action when the audio mute button is pressed",
function() {
view = mountTestComponent();
view.setState({audioMuted: true});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
React.addons.TestUtils.Simulate.click(muteBtn);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "setMute"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("enabled", true));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("type", "audio"));
});
it("should dispatch a setMute action when the video mute button is pressed",
function() {
view = mountTestComponent();
view.setState({videoMuted: false});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
React.addons.TestUtils.Simulate.click(muteBtn);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "setMute"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("enabled", false));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("type", "video"));
});
it("should set the mute button as mute off", function() {
view = mountTestComponent();
view.setState({videoMuted: false});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
expect(muteBtn.classList.contains("muted")).eql(false);
});
it("should set the mute button as mute on", function() {
view = mountTestComponent();
view.setState({audioMuted: true});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
expect(muteBtn.classList.contains("muted")).eql(true);
});
it("should dispatch a `StartScreenShare` action when sharing is not active " +
"and the screen share button is pressed", function() {
view = mountTestComponent();
view.setState({screenSharingState: SCREEN_SHARE_STATES.INACTIVE});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
React.addons.TestUtils.Simulate.click(muteBtn);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "setMute"));
});
it("should dispatch a `LeaveRoom` action when the hangup button is pressed and the room has been used", function() {
view = mountTestComponent();
view.setState({used: true});
var hangupBtn = view.getDOMNode().querySelector(".btn-hangup");
React.addons.TestUtils.Simulate.click(hangupBtn);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.LeaveRoom());
});
it("should close the window when the hangup button is pressed and the room has not been used", function() {
view = mountTestComponent();
view.setState({used: false});
var hangupBtn = view.getDOMNode().querySelector(".btn-hangup");
React.addons.TestUtils.Simulate.click(hangupBtn);
sinon.assert.calledOnce(fakeWindow.close);
});
describe("#componentWillUpdate", function() {
function expectActionDispatched(view) {
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
sinon.match.instanceOf(sharedActions.SetupStreamElements));
sinon.assert.calledWithExactly(dispatcher.dispatch,
sinon.match(function(value) {
return value.getLocalElementFunc() ===
view.getDOMNode().querySelector(".local");
}));
sinon.assert.calledWithExactly(dispatcher.dispatch,
sinon.match(function(value) {
return value.getRemoteElementFunc() ===
view.getDOMNode().querySelector(".remote");
}));
}
it("should dispatch a `SetupStreamElements` action when the MEDIA_WAIT state " +
"is entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
var view = mountTestComponent();
activeRoomStore.setStoreState({roomState: ROOM_STATES.MEDIA_WAIT});
expectActionDispatched(view);
});
it("should dispatch a `SetupStreamElements` action on MEDIA_WAIT state is " +
"re-entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
var view = mountTestComponent();
activeRoomStore.setStoreState({roomState: ROOM_STATES.MEDIA_WAIT});
expectActionDispatched(view);
});
});
describe("#render", function() {
it("should set document.title to store.serverData.roomName", function() {
mountTestComponent();
activeRoomStore.setStoreState({roomName: "fakeName"});
expect(fakeWindow.document.title).to.equal("fakeName");
});
it("should render the GenericFailureView if the roomState is `FAILED`",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.conversationViews.GenericFailureView);
});
it("should render the GenericFailureView if the roomState is `FULL`",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.conversationViews.GenericFailureView);
});
it("should render the DesktopRoomInvitationView if roomState is `JOINED`",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.roomViews.DesktopRoomInvitationView);
});
it("should render the DesktopRoomConversationView if roomState is `HAS_PARTICIPANTS`",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.roomViews.DesktopRoomConversationView);
});
it("should render the FeedbackView if roomState is `ENDED`",
function() {
activeRoomStore.setStoreState({
roomState: ROOM_STATES.ENDED,
used: true
});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.shared.views.FeedbackView);
});
});
describe("Mute", function() {
it("should render local media as audio-only if video is muted",
function() {
activeRoomStore.setStoreState({
roomState: ROOM_STATES.SESSION_CONNECTED,
videoMuted: true
});
view = mountTestComponent();
expect(view.getDOMNode().querySelector(".local-stream-audio"))
.not.eql(null);
});
});
});
describe("SocialShareDropdown", function() {
var view, fakeProvider;
beforeEach(function() {
sandbox.stub(dispatcher, "dispatch");
fakeProvider = {
name: "foo",
origin: "https://foo",
iconURL: "http://example.com/foo.png"
};
});
afterEach(function() {
view = fakeProvider = null;
});
function mountTestComponent(props) {
props = _.extend({
dispatcher: dispatcher,
show: true
}, props);
return TestUtils.renderIntoDocument(
React.createElement(loop.roomViews.SocialShareDropdown, props));
}
describe("#render", function() {
it("should show no contents when the Social Providers have not been fetched yet", function() {
view = mountTestComponent();
expect(view.getDOMNode()).to.eql(null);
});
it("should show different contents when the Share XUL button is not available", function() {
view = mountTestComponent({
socialShareProviders: []
});
var node = view.getDOMNode();
expect(node.querySelector(".share-panel-header")).to.not.eql(null);
});
it("should show an empty list when no Social Providers are available", function() {
view = mountTestComponent({
socialShareButtonAvailable: true,
socialShareProviders: []
});
var node = view.getDOMNode();
expect(node.querySelector(".icon-add-share-service")).to.not.eql(null);
expect(node.querySelectorAll(".dropdown-menu-item").length).to.eql(1);
});
it("should show a list of available Social Providers", function() {
view = mountTestComponent({
socialShareButtonAvailable: true,
socialShareProviders: [fakeProvider]
});
var node = view.getDOMNode();
expect(node.querySelector(".icon-add-share-service")).to.not.eql(null);
expect(node.querySelector(".dropdown-menu-separator")).to.not.eql(null);
var dropdownNodes = node.querySelectorAll(".dropdown-menu-item");
expect(dropdownNodes.length).to.eql(2);
expect(dropdownNodes[1].querySelector("img").src).to.eql(fakeProvider.iconURL);
expect(dropdownNodes[1].querySelector("span").textContent)
.to.eql(fakeProvider.name);
});
});
describe("#handleToolbarAddButtonClick", function() {
it("should dispatch an action when the 'add to toolbar' button is clicked", function() {
view = mountTestComponent({
socialShareProviders: []
});
var addButton = view.getDOMNode().querySelector(".btn-toolbar-add");
React.addons.TestUtils.Simulate.click(addButton);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.AddSocialShareButton());
});
});
describe("#handleAddServiceClick", function() {
it("should dispatch an action when the 'add provider' item is clicked", function() {
view = mountTestComponent({
socialShareProviders: [],
socialShareButtonAvailable: true
});
var addItem = view.getDOMNode().querySelector(".dropdown-menu-item:first-child");
React.addons.TestUtils.Simulate.click(addItem);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.AddSocialShareProvider());
});
});
describe("#handleProviderClick", function() {
it("should dispatch an action when a provider item is clicked", function() {
view = mountTestComponent({
roomUrl: "http://example.com",
socialShareButtonAvailable: true,
socialShareProviders: [fakeProvider]
});
var providerItem = view.getDOMNode().querySelector(".dropdown-menu-item:last-child");
React.addons.TestUtils.Simulate.click(providerItem);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.ShareRoomUrl({
provider: fakeProvider,
roomUrl: "http://example.com",
previews: []
}));
});
});
});
describe("DesktopRoomContextView", function() {
var view;
afterEach(function() {
view = null;
});
function mountTestComponent(props) {
props = _.extend({
show: true
}, props);
return TestUtils.renderIntoDocument(
React.createElement(loop.roomViews.DesktopRoomContextView, props));
}
describe("#render", function() {
it("should show the context information properly when available", function() {
view = mountTestComponent({
roomData: {
roomDescription: "Hello, is it me you're looking for?",
roomContextUrls: [fakeContextURL]
}
});
var node = view.getDOMNode();
expect(node).to.not.eql(null);
expect(node.querySelector(".room-context-thumbnail").src).to.
eql(fakeContextURL.thumbnail);
expect(node.querySelector(".room-context-description").textContent).to.
eql(fakeContextURL.description);
expect(node.querySelector(".room-context-comment").textContent).to.
eql(view.props.roomData.roomDescription);
});
it("should not render optional data", function() {
view = mountTestComponent({
roomData: { roomContextUrls: [fakeContextURL] }
});
expect(view.getDOMNode().querySelector(".room-context-comment")).to.
eql(null);
});
it("should not render the component when 'show' is false", function() {
view = mountTestComponent({
show: false
});
expect(view.getDOMNode()).to.eql(null);
});
it("should close the view when the close button is clicked", function() {
view = mountTestComponent({
roomData: { roomContextUrls: [fakeContextURL] }
});
var closeBtn = view.getDOMNode().querySelector(".room-context-btn-close");
React.addons.TestUtils.Simulate.click(closeBtn);
expect(view.getDOMNode()).to.eql(null);
});
});
});
});
@@ -1,326 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const {CardDavImporter} = Cu.import("resource:///modules/loop/CardDavImporter.jsm", {});
const kAuth = {
"method": "basic",
"user": "username",
"password": "p455w0rd"
};
// "pid" for "provider ID"
let vcards = [
"VERSION:3.0\n" +
"N:Smith;John;;;\n" +
"FN:John Smith\n" +
"EMAIL;TYPE=work:john.smith@example.com\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid1\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"N:Smith;Jane;;;\n" +
"FN:Jane Smith\n" +
"EMAIL:jane.smith@example.com\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid2\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"N:García Fernández;Miguel Angel;José Antonio;Mr.;Jr.\n" +
"FN:Mr. Miguel Angel José Antonio\n García Fernández, Jr.\n" +
"EMAIL:mike@example.org\n" +
"EMAIL;PREF=1;TYPE=work:miguel.angel@example.net\n" +
"EMAIL;TYPE=home;UNKNOWNPARAMETER=frotz:majacf@example.com\n" +
"TEL:+3455555555\n" +
"TEL;PREF=1;TYPE=work:+3455556666\n" +
"TEL;TYPE=home;UNKNOWNPARAMETER=frotz:+3455557777\n" +
"ADR:;Suite 123;Calle Aduana\\, 29;MADRID;;28070;SPAIN\n" +
"ADR;TYPE=work:P.O. BOX 555;;;Washington;DC;20024-00555;USA\n" +
"ORG:Acme España SL\n" +
"TITLE:President\n" +
"BDAY:1965-05-05\n" +
"NOTE:Likes tulips\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid3\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"N:Jones;Bob;;;\n" +
"EMAIL:bob.jones@example.com\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid4\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"N:Jones;Davy;Randall;;\n" +
"EMAIL:davy.jones@example.com\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid5\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"EMAIL:trip@example.com\n" +
"NICKNAME:Trip\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid6\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"EMAIL:acme@example.com\n" +
"ORG:Acme, Inc.\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid7\n" +
"END:VCARD\n",
"VERSION:3.0\n" +
"EMAIL:anyone@example.com\n" +
"REV:2011-07-12T14:43:20Z\n" +
"UID:pid8\n" +
"END:VCARD\n",
];
const monkeyPatchImporter = function(importer) {
// Set up the response bodies
let listPropfind =
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<d:multistatus xmlns:card="urn:ietf:params:xml:ns:carddav"\n' +
' xmlns:d="DAV:">\n' +
' <d:response>\n' +
' <d:href>/carddav/abook/</d:href>\n' +
' <d:propstat>\n' +
' <d:status>HTTP/1.1 200 OK</d:status>\n' +
' </d:propstat>\n' +
' <d:propstat>\n' +
' <d:status>HTTP/1.1 404 Not Found</d:status>\n' +
' <d:prop>\n' +
' <d:getetag/>\n' +
' </d:prop>\n' +
' </d:propstat>\n' +
' </d:response>\n';
let listReportMultiget =
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<d:multistatus xmlns:card="urn:ietf:params:xml:ns:carddav"\n' +
' xmlns:d="DAV:">\n';
vcards.forEach(vcard => {
let uid = /\nUID:(.*?)\n/.exec(vcard);
listPropfind +=
' <d:response>\n' +
' <d:href>/carddav/abook/' + uid + '</d:href>\n' +
' <d:propstat>\n' +
' <d:status>HTTP/1.1 200 OK</d:status>\n' +
' <d:prop>\n' +
' <d:getetag>"2011-07-12T07:43:20.855-07:00"</d:getetag>\n' +
' </d:prop>\n' +
' </d:propstat>\n' +
' </d:response>\n';
listReportMultiget +=
' <d:response>\n' +
' <d:href>/carddav/abook/' + uid + '</d:href>\n' +
' <d:propstat>\n' +
' <d:status>HTTP/1.1 200 OK</d:status>\n' +
' <d:prop>\n' +
' <d:getetag>"2011-07-12T07:43:20.855-07:00"</d:getetag>\n' +
' <card:address-data>' + vcard + '</card:address-data>\n' +
' </d:prop>\n' +
' </d:propstat>\n' +
' </d:response>\n';
});
listPropfind += "</d:multistatus>\n";
listReportMultiget += "</d:multistatus>\n";
importer._davPromise = function(method, url, auth, depth, body) {
return new Promise((resolve, reject) => {
if (auth.method != "basic" ||
auth.user != kAuth.user ||
auth.password != kAuth.password) {
reject(new Error("401 Auth Failure"));
return;
}
let request = method + " " + url + " " + depth;
let xmlParser = new DOMParser();
let responseXML;
switch (request) {
case "PROPFIND https://example.com/.well-known/carddav 1":
responseXML = xmlParser.parseFromString(listPropfind, "text/xml");
break;
case "REPORT https://example.com/carddav/abook/ 1":
responseXML = xmlParser.parseFromString(listReportMultiget, "text/xml");
break;
default:
reject(new Error("404 Not Found"));
return;
}
resolve({"responseXML": responseXML});
});
}.bind(importer);
return importer;
};
add_task(function* test_CardDavImport() {
let importer = monkeyPatchImporter(new CardDavImporter());
yield new Promise ((resolve, reject) => {
info("Initiating import");
importer.startImport({
"host": "example.com",
"auth": kAuth.method,
"user": kAuth.user,
"password": kAuth.password
}, (err, result) => { err ? reject(err) : resolve(result); }, mockDb);
});
info("Import succeeded");
Assert.equal(vcards.length, Object.keys(mockDb._store).length,
"Should import all VCards into database");
// Basic checks
let c = mockDb._store[1];
Assert.equal(c.name[0], "John Smith", "Full name should match");
Assert.equal(c.givenName[0], "John", "Given name should match");
Assert.equal(c.familyName[0], "Smith", "Family name should match");
Assert.equal(c.email[0].type, "work", "Email type should match");
Assert.equal(c.email[0].value, "john.smith@example.com", "Email should match");
Assert.equal(c.email[0].pref, false, "Pref should match");
Assert.equal(c.id, "pid1@example.com", "UID should match and be scoped to provider");
c = mockDb._store[2];
Assert.equal(c.name[0], "Jane Smith", "Full name should match");
Assert.equal(c.givenName[0], "Jane", "Given name should match");
Assert.equal(c.familyName[0], "Smith", "Family name should match");
Assert.equal(c.email[0].type, "other", "Email type should match");
Assert.equal(c.email[0].value, "jane.smith@example.com", "Email should match");
Assert.equal(c.email[0].pref, false, "Pref should match");
Assert.equal(c.id, "pid2@example.com", "UID should match and be scoped to provider");
// Check every field
c = mockDb._store[3];
Assert.equal(c.name[0], "Mr. Miguel Angel José Antonio García Fernández, Jr.", "Full name should match");
Assert.equal(c.givenName[0], "Miguel Angel", "Given name should match");
Assert.equal(c.additionalName[0], "José Antonio", "Other name should match");
Assert.equal(c.familyName[0], "García Fernández", "Family name should match");
Assert.equal(c.email.length, 3, "Email count should match");
Assert.equal(c.email[0].type, "other", "Email type should match");
Assert.equal(c.email[0].value, "mike@example.org", "Email should match");
Assert.equal(c.email[0].pref, false, "Pref should match");
Assert.equal(c.email[1].type, "work", "Email type should match");
Assert.equal(c.email[1].value, "miguel.angel@example.net", "Email should match");
Assert.equal(c.email[1].pref, true, "Pref should match");
Assert.equal(c.email[2].type, "home", "Email type should match");
Assert.equal(c.email[2].value, "majacf@example.com", "Email should match");
Assert.equal(c.email[2].pref, false, "Pref should match");
Assert.equal(c.tel.length, 3, "Phone number count should match");
Assert.equal(c.tel[0].type, "other", "Phone type should match");
Assert.equal(c.tel[0].value, "+3455555555", "Phone number should match");
Assert.equal(c.tel[0].pref, false, "Pref should match");
Assert.equal(c.tel[1].type, "work", "Phone type should match");
Assert.equal(c.tel[1].value, "+3455556666", "Phone number should match");
Assert.equal(c.tel[1].pref, true, "Pref should match");
Assert.equal(c.tel[2].type, "home", "Phone type should match");
Assert.equal(c.tel[2].value, "+3455557777", "Phone number should match");
Assert.equal(c.tel[2].pref, false, "Pref should match");
Assert.equal(c.adr.length, 2, "Address count should match");
Assert.equal(c.adr[0].pref, false, "Pref should match");
Assert.equal(c.adr[0].type, "other", "Type should match");
Assert.equal(c.adr[0].streetAddress, "Calle Aduana, 29 Suite 123", "Street address should match");
Assert.equal(c.adr[0].locality, "MADRID", "Locality should match");
Assert.equal(c.adr[0].postalCode, "28070", "Post code should match");
Assert.equal(c.adr[0].countryName, "SPAIN", "Country should match");
Assert.equal(c.adr[1].pref, false, "Pref should match");
Assert.equal(c.adr[1].type, "work", "Type should match");
Assert.equal(c.adr[1].streetAddress, "P.O. BOX 555", "Street address should match");
Assert.equal(c.adr[1].locality, "Washington", "Locality should match");
Assert.equal(c.adr[1].region, "DC", "Region should match");
Assert.equal(c.adr[1].postalCode, "20024-00555", "Post code should match");
Assert.equal(c.adr[1].countryName, "USA", "Country should match");
Assert.equal(c.org[0], "Acme España SL", "Org should match");
Assert.equal(c.jobTitle[0], "President", "Title should match");
Assert.equal(c.note[0], "Likes tulips", "Note should match");
let bday = new Date(c.bday);
Assert.equal(bday.getUTCFullYear(), 1965, "Birthday year should match");
Assert.equal(bday.getUTCMonth(), 4, "Birthday month should match");
Assert.equal(bday.getUTCDate(), 5, "Birthday day should match");
Assert.equal(c.id, "pid3@example.com", "UID should match and be scoped to provider");
// Check name synthesis
c = mockDb._store[4];
Assert.equal(c.name[0], "Jones, Bob", "Full name should be synthesized correctly");
c = mockDb._store[5];
Assert.equal(c.name[0], "Jones, Davy Randall", "Full name should be synthesized correctly");
c = mockDb._store[6];
Assert.equal(c.name[0], "Trip", "Full name should be synthesized correctly");
c = mockDb._store[7];
Assert.equal(c.name[0], "Acme, Inc.", "Full name should be synthesized correctly");
c = mockDb._store[8];
Assert.equal(c.name[0], "anyone@example.com", "Full name should be synthesized correctly");
// Check that a re-import doesn't cause contact duplication.
yield new Promise ((resolve, reject) => {
info("Initiating import");
importer.startImport({
"host": "example.com",
"auth": kAuth.method,
"user": kAuth.user,
"password": kAuth.password
}, (err, result) => { err ? reject(err) : resolve(result); }, mockDb);
});
Assert.equal(vcards.length, Object.keys(mockDb._store).length,
"Second import shouldn't increase DB size");
// Check that errors are propagated back to caller
let error = yield new Promise ((resolve, reject) => {
info("Initiating import");
importer.startImport({
"host": "example.com",
"auth": kAuth.method,
"user": kAuth.user,
"password": "invalidpassword"
}, (err, result) => { err ? resolve(err) : reject(new Error("Should have failed")); }, mockDb);
});
Assert.equal(error.message, "401 Auth Failure", "Auth error should propagate");
error = yield new Promise ((resolve, reject) => {
info("Initiating import");
importer.startImport({
"host": "example.invalid",
"auth": kAuth.method,
"user": kAuth.user,
"password": kAuth.password
}, (err, result) => { err ? resolve(err) : reject(new Error("Should have failed")); }, mockDb);
});
Assert.equal(error.message, "404 Not Found", "Not found error should propagate");
let tmp = mockDb.getByServiceId;
mockDb.getByServiceId = function(serviceId, callback) {
callback(new Error("getByServiceId failed"));
};
error = yield new Promise ((resolve, reject) => {
info("Initiating import");
importer.startImport({
"host": "example.com",
"auth": kAuth.method,
"user": kAuth.user,
"password": kAuth.password
}, (err, result) => { err ? resolve(err) : reject(new Error("Should have failed")); }, mockDb);
});
Assert.equal(error.message, "getByServiceId failed", "Database error should propagate");
mockDb.getByServiceId = tmp;
error = yield new Promise ((resolve, reject) => {
info("Initiating import");
importer.startImport({
"host": "example.com",
}, (err, result) => { err ? resolve(err) : reject(new Error("Should have failed")); }, mockDb);
});
Assert.equal(error.message, "No authentication specified", "Missing parameters should generate error");
});
@@ -1,489 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const {LoopContacts} = Cu.import("resource:///modules/loop/LoopContacts.jsm", {});
const {LoopStorage} = Cu.import("resource:///modules/loop/LoopStorage.jsm", {});
XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
const kContacts = [{
id: 1,
name: ["Ally Avocado"],
email: [{
"pref": true,
"type": ["work"],
"value": "ally@mail.com"
}],
tel: [{
"pref": true,
"type": ["mobile"],
"value": "+31-6-12345678"
}],
category: ["google"],
published: 1406798311748,
updated: 1406798311748
},{
id: 2,
name: ["Bob Banana"],
email: [{
"pref": true,
"type": ["work"],
"value": "bob@gmail.com"
}],
tel: [{
"pref": true,
"type": ["mobile"],
"value": "+1-214-5551234"
}],
category: ["local"],
published: 1406798311748,
updated: 1406798311748
}, {
id: 3,
name: ["Caitlin Cantaloupe"],
email: [{
"pref": true,
"type": ["work"],
"value": "caitlin.cant@hotmail.com"
}],
category: ["local"],
published: 1406798311748,
updated: 1406798311748
}, {
id: 4,
name: ["Dave Dragonfruit"],
email: [{
"pref": true,
"type": ["work"],
"value": "dd@dragons.net"
}],
category: ["google"],
published: 1406798311748,
updated: 1406798311748
}];
const kDanglingContact = {
id: 5,
name: ["Ellie Eggplant"],
email: [{
"pref": true,
"type": ["work"],
"value": "ellie@yahoo.com"
}],
category: ["google"],
blocked: true,
published: 1406798311748,
updated: 1406798311748
};
const promiseLoadContacts = function() {
return new Promise((resolve, reject) => {
LoopContacts.removeAll(err => {
if (err) {
reject(err);
return;
}
gExpectedAdds.push(...kContacts);
LoopContacts.addMany(kContacts, (err, contacts) => {
if (err) {
reject(err);
return;
}
resolve(contacts);
});
});
});
};
// Get a copy of a contact without private properties.
const normalizeContact = function(contact) {
let result = {};
// Get a copy of contact without private properties.
for (let prop of Object.getOwnPropertyNames(contact)) {
if (!prop.startsWith("_")) {
result[prop] = contact[prop];
}
}
return result;
};
const compareContacts = function(contact1, contact2) {
Assert.ok("_guid" in contact1, "First contact should have an ID.");
Assert.deepEqual(normalizeContact(contact1), normalizeContact(contact2));
};
// LoopContacts emits various events. Test if they work as expected here.
let gExpectedAdds = [];
let gExpectedRemovals = [];
let gExpectedUpdates = [];
const onContactAdded = function(e, contact) {
let expectedIds = gExpectedAdds.map(contact => contact.id);
let idx = expectedIds.indexOf(contact.id);
Assert.ok(idx > -1, "Added contact should be expected");
let expected = gExpectedAdds[idx];
compareContacts(contact, expected);
gExpectedAdds.splice(idx, 1);
};
const onContactRemoved = function(e, contact) {
let idx = gExpectedRemovals.indexOf(contact._guid);
Assert.ok(idx > -1, "Removed contact should be expected");
gExpectedRemovals.splice(idx, 1);
};
const onContactUpdated = function(e, contact) {
let idx = gExpectedUpdates.indexOf(contact._guid);
Assert.ok(idx > -1, "Updated contact should be expected");
gExpectedUpdates.splice(idx, 1);
};
LoopContacts.on("add", onContactAdded);
LoopContacts.on("remove", onContactRemoved);
LoopContacts.on("update", onContactUpdated);
registerCleanupFunction(function () {
LoopContacts.removeAll(() => {});
LoopContacts.off("add", onContactAdded);
LoopContacts.off("remove", onContactRemoved);
LoopContacts.off("update", onContactUpdated);
});
// Test adding a contact.
add_task(function* () {
let contacts = yield promiseLoadContacts();
for (let i = 0, l = contacts.length; i < l; ++i) {
compareContacts(contacts[i], kContacts[i]);
}
info("Add a contact.");
yield new Promise((resolve, reject) => {
gExpectedAdds.push(kDanglingContact);
LoopContacts.add(kDanglingContact, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
compareContacts(contact, kDanglingContact);
info("Check if it's persisted.");
LoopContacts.get(contact._guid, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
compareContacts(contact, kDanglingContact);
resolve();
});
});
});
});
add_task(function* () {
info("Test removing all contacts.");
let contacts = yield promiseLoadContacts();
yield new Promise((resolve, reject) => {
LoopContacts.removeAll(function(err) {
Assert.ok(!err, "There shouldn't be an error");
LoopContacts.getAll(function(err, found) {
Assert.ok(!err, "There shouldn't be an error");
Assert.equal(found.length, 0, "There shouldn't be any contacts left");
resolve();
});
});
});
});
// Test retrieving a contact.
add_task(function* () {
let contacts = yield promiseLoadContacts();
info("Get a single contact.");
yield new Promise((resolve, reject) => {
LoopContacts.get(contacts[1]._guid, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
compareContacts(contact, kContacts[1]);
resolve();
});
});
info("Get a single contact by id.");
yield new Promise((resolve, reject) => {
LoopContacts.getByServiceId(2, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
compareContacts(contact, kContacts[1]);
resolve();
});
});
info("Get a couple of contacts.");
yield new Promise((resolve, reject) => {
let toRetrieve = [contacts[0], contacts[2], contacts[3]];
LoopContacts.getMany(toRetrieve.map(contact => contact._guid), (err, result) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.equal(result.length, toRetrieve.length, "Result list should be the same " +
"size as the list of items to retrieve");
function resultFilter(c) {
return c._guid == this._guid;
}
for (let contact of toRetrieve) {
let found = result.filter(resultFilter.bind(contact));
Assert.ok(found.length, "Contact " + contact._guid + " should be in the list");
compareContacts(found[0], contact);
}
resolve();
});
});
info("Get all contacts.");
yield new Promise((resolve, reject) => {
LoopContacts.getAll((err, contacts) => {
Assert.ok(!err, "There shouldn't be an error");
for (let i = 0, l = contacts.length; i < l; ++i) {
compareContacts(contacts[i], kContacts[i]);
}
resolve();
});
});
info("Get a non-existent contact.");
return new Promise((resolve, reject) => {
LoopContacts.get(1000, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.ok(!contact, "There shouldn't be a contact");
resolve();
});
});
});
// Test removing a contact.
add_task(function* () {
let contacts = yield promiseLoadContacts();
info("Remove a single contact.");
yield new Promise((resolve, reject) => {
let toRemove = contacts[2]._guid;
gExpectedRemovals.push(toRemove);
LoopContacts.remove(toRemove, err => {
Assert.ok(!err, "There shouldn't be an error");
LoopContacts.get(toRemove, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.ok(!contact, "There shouldn't be a contact");
resolve();
});
});
});
info("Remove a non-existing contact.");
yield new Promise((resolve, reject) => {
LoopContacts.remove(1000, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.ok(!contact, "There shouldn't be a contact");
resolve();
});
});
info("Remove multiple contacts.");
yield new Promise((resolve, reject) => {
let toRemove = [contacts[0]._guid, contacts[1]._guid];
gExpectedRemovals.push(...toRemove);
LoopContacts.removeMany(toRemove, err => {
Assert.ok(!err, "There shouldn't be an error");
LoopContacts.getAll((err, contacts) => {
Assert.ok(!err, "There shouldn't be an error");
let ids = contacts.map(contact => contact._guid);
Assert.equal(ids.indexOf(toRemove[0]), -1, "Contact '" + toRemove[0] +
"' shouldn't be there");
Assert.equal(ids.indexOf(toRemove[1]), -1, "Contact '" + toRemove[1] +
"' shouldn't be there");
resolve();
});
});
});
});
// Test updating a contact.
add_task(function* () {
let contacts = yield promiseLoadContacts();
const newBday = (new Date(403920000000)).toISOString();
info("Update a single contact.");
yield new Promise((resolve, reject) => {
let toUpdate = {
_guid: contacts[2]._guid,
bday: newBday
};
gExpectedUpdates.push(contacts[2]._guid);
LoopContacts.update(toUpdate, (err, result) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.equal(result, toUpdate._guid, "Result should be the same as the contact ID");
LoopContacts.get(toUpdate._guid, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.equal(contact.bday, newBday, "Birthday should be the same");
info("Check that all other properties were left intact.");
contacts[2].bday = newBday;
compareContacts(contact, contacts[2]);
resolve();
});
});
});
info("Update a non-existing contact.");
yield new Promise((resolve, reject) => {
let toUpdate = {
_guid: 1000,
bday: newBday
};
LoopContacts.update(toUpdate, (err, contact) => {
Assert.ok(err, "There should be an error");
Assert.equal(err.message, "Contact with _guid '1000' could not be found",
"Error message should be correct");
resolve();
});
});
});
// Test blocking and unblocking a contact.
add_task(function* () {
let contacts = yield promiseLoadContacts();
info("Block contact.");
yield new Promise((resolve, reject) => {
let toBlock = contacts[1]._guid;
gExpectedUpdates.push(toBlock);
LoopContacts.block(toBlock, (err, result) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.equal(result, toBlock, "Result should be the same as the contact ID");
LoopContacts.get(toBlock, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.strictEqual(contact.blocked, true, "Blocked status should be set");
info("Check that all other properties were left intact.");
delete contact.blocked;
compareContacts(contact, contacts[1]);
resolve();
});
});
});
info("Block a non-existing contact.");
yield new Promise((resolve, reject) => {
LoopContacts.block(1000, err => {
Assert.ok(err, "There should be an error");
Assert.equal(err.message, "Contact with _guid '1000' could not be found",
"Error message should be correct");
resolve();
});
});
info("Unblock a contact.");
yield new Promise((resolve, reject) => {
let toUnblock = contacts[1]._guid;
gExpectedUpdates.push(toUnblock);
LoopContacts.unblock(toUnblock, (err, result) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.equal(result, toUnblock, "Result should be the same as the contact ID");
LoopContacts.get(toUnblock, (err, contact) => {
Assert.ok(!err, "There shouldn't be an error");
Assert.strictEqual(contact.blocked, false, "Blocked status should be set");
info("Check that all other properties were left intact.");
delete contact.blocked;
compareContacts(contact, contacts[1]);
resolve();
});
});
});
info("Unblock a non-existing contact.");
yield new Promise((resolve, reject) => {
LoopContacts.unblock(1000, err => {
Assert.ok(err, "There should be an error");
Assert.equal(err.message, "Contact with _guid '1000' could not be found",
"Error message should be correct");
resolve();
});
});
});
// Test if the event emitter implementation doesn't leak and is working as expected.
add_task(function* () {
yield promiseLoadContacts();
Assert.strictEqual(gExpectedAdds.length, 0, "No contact additions should be expected anymore");
Assert.strictEqual(gExpectedRemovals.length, 0, "No contact removals should be expected anymore");
Assert.strictEqual(gExpectedUpdates.length, 0, "No contact updates should be expected anymore");
});
// Test switching between different databases.
add_task(function* () {
Assert.equal(LoopStorage.databaseName, "default", "First active partition should be the default");
yield promiseLoadContacts();
let uuid = uuidgen.generateUUID().toString().replace(/[{}]+/g, "");
LoopStorage.switchDatabase(uuid);
Assert.equal(LoopStorage.databaseName, uuid, "The active partition should have changed");
yield promiseLoadContacts();
let contacts = yield promiseLoadContacts();
for (let i = 0, l = contacts.length; i < l; ++i) {
compareContacts(contacts[i], kContacts[i]);
}
LoopStorage.switchDatabase();
Assert.equal(LoopStorage.databaseName, "default", "The active partition should have changed");
contacts = yield LoopContacts.promise("getAll");
for (let i = 0, l = contacts.length; i < l; ++i) {
compareContacts(contacts[i], kContacts[i]);
}
});
// Test searching for contacts.
add_task(function* () {
yield promiseLoadContacts();
let contacts = yield LoopContacts.promise("search", {
q: "bob@gmail.com"
});
Assert.equal(contacts.length, 1, "There should be one contact found");
compareContacts(contacts[0], kContacts[1]);
// Test searching by name.
contacts = yield LoopContacts.promise("search", {
q: "Ally Avocado",
field: "name"
});
Assert.equal(contacts.length, 1, "There should be one contact found");
compareContacts(contacts[0], kContacts[0]);
// Test searching for multiple contacts.
contacts = yield LoopContacts.promise("search", {
q: "google",
field: "category"
});
Assert.equal(contacts.length, 2, "There should be two contacts found");
// Test searching for telephone numbers.
contacts = yield LoopContacts.promise("search", {
q: "+31612345678",
field: "tel"
});
Assert.equal(contacts.length, 1, "There should be one contact found");
compareContacts(contacts[0], kContacts[0]);
// Test searching for telephone numbers without prefixes.
contacts = yield LoopContacts.promise("search", {
q: "5551234",
field: "tel"
});
Assert.equal(contacts.length, 1, "There should be one contact found");
compareContacts(contacts[0], kContacts[1]);
});
@@ -1,201 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/*
* This file contains tests for the window.LoopUI active tab trackers.
*/
"use strict";
const {injectLoopAPI} = Cu.import("resource:///modules/loop/MozLoopAPI.jsm", {});
gMozLoopAPI = injectLoopAPI({});
let handlers = [
{
resolve: null,
windowId: null,
listener: function(err, windowId) {
handlers[0].windowId = windowId;
handlers[0].resolve();
}
},
{
resolve: null,
windowId: null,
listener: function(err, windowId) {
handlers[1].windowId = windowId;
handlers[1].resolve();
}
}
];
function promiseWindowIdReceivedOnAdd(handler) {
return new Promise(resolve => {
handler.resolve = resolve;
gMozLoopAPI.addBrowserSharingListener(handler.listener);
});
}
let createdTabs = [];
function promiseWindowIdReceivedNewTab(handlers = []) {
let promiseHandlers = [];
handlers.forEach(handler => {
promiseHandlers.push(new Promise(resolve => {
handler.resolve = resolve;
}));
});
let createdTab = gBrowser.selectedTab = gBrowser.addTab();
createdTabs.push(createdTab);
promiseHandlers.push(promiseTabLoadEvent(createdTab, "about:mozilla"));
return Promise.all(promiseHandlers);
}
function promiseRemoveTab(tab) {
return new Promise(resolve => {
gBrowser.tabContainer.addEventListener("TabClose", function onTabClose() {
gBrowser.tabContainer.removeEventListener("TabClose", onTabClose);
resolve();
});
gBrowser.removeTab(tab);
});
}
function* removeTabs() {
for (let createdTab of createdTabs) {
yield promiseRemoveTab(createdTab);
}
createdTabs = [];
}
add_task(function* test_singleListener() {
yield promiseWindowIdReceivedOnAdd(handlers[0]);
let initialWindowId = handlers[0].windowId;
Assert.notEqual(initialWindowId, null, "window id should be valid");
// Check that a new tab updates the window id.
yield promiseWindowIdReceivedNewTab([handlers[0]]);
let newWindowId = handlers[0].windowId;
Assert.notEqual(initialWindowId, newWindowId, "Tab contentWindow IDs shouldn't be the same");
// Now remove the listener.
gMozLoopAPI.removeBrowserSharingListener(handlers[0].listener);
yield removeTabs();
});
add_task(function* test_multipleListener() {
yield promiseWindowIdReceivedOnAdd(handlers[0]);
let initialWindowId0 = handlers[0].windowId;
Assert.notEqual(initialWindowId0, null, "window id should be valid");
yield promiseWindowIdReceivedOnAdd(handlers[1]);
let initialWindowId1 = handlers[1].windowId;
Assert.notEqual(initialWindowId1, null, "window id should be valid");
Assert.equal(initialWindowId0, initialWindowId1, "window ids should be the same");
// Check that a new tab updates the window id.
yield promiseWindowIdReceivedNewTab(handlers);
let newWindowId0 = handlers[0].windowId;
let newWindowId1 = handlers[1].windowId;
Assert.ok(newWindowId0, "windowId should not be null anymore");
Assert.equal(newWindowId0, newWindowId1, "Listeners should have the same windowId");
Assert.notEqual(initialWindowId0, newWindowId0, "Tab contentWindow IDs shouldn't be the same");
// Now remove the first listener.
gMozLoopAPI.removeBrowserSharingListener(handlers[0].listener);
// Check that a new tab updates the window id.
yield promiseWindowIdReceivedNewTab([handlers[1]]);
let nextWindowId0 = handlers[0].windowId;
let nextWindowId1 = handlers[1].windowId;
Assert.ok(nextWindowId0, "windowId should not be null anymore");
Assert.equal(newWindowId0, nextWindowId0, "First listener shouldn't have updated");
Assert.notEqual(newWindowId1, nextWindowId1, "Second listener should have updated");
// Cleanup.
gMozLoopAPI.removeBrowserSharingListener(handlers[1].listener);
yield removeTabs();
});
add_task(function* test_infoBar() {
const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const kBrowserSharingNotificationId = "loop-sharing-notification";
const kPrefBrowserSharingInfoBar = "loop.browserSharing.showInfoBar";
Services.prefs.setBoolPref(kPrefBrowserSharingInfoBar, true);
// First we add two tabs.
yield promiseWindowIdReceivedNewTab();
yield promiseWindowIdReceivedNewTab();
Assert.strictEqual(gBrowser.selectedTab, createdTabs[1],
"The second tab created should be selected now");
// Add one sharing listener, which should show the infobar.
yield promiseWindowIdReceivedOnAdd(handlers[0]);
let getInfoBar = function() {
let box = gBrowser.getNotificationBox(gBrowser.selectedBrowser);
return box.getNotificationWithValue(kBrowserSharingNotificationId);
};
let testBarProps = function() {
let bar = getInfoBar();
// Start with some basic assertions for the bar.
Assert.ok(bar, "The notification bar should be visible");
Assert.strictEqual(bar.hidden, false, "Again, the notification bar should be visible");
let button = bar.querySelector(".notification-button");
Assert.ok(button, "There should be a button present");
Assert.strictEqual(button.type, "menu-button", "We're expecting a menu-button");
Assert.strictEqual(button.getAttribute("anchor"), "dropmarker",
"The popup should be opening anchored to the dropmarker");
Assert.strictEqual(button.getElementsByTagNameNS(kNSXUL, "menupopup").length, 1,
"There should be a popup attached to the button");
};
testBarProps();
// When we switch tabs, the infobar should move along with it. We use `selectedIndex`
// here, because that's the setter that triggers the 'select' event. This event
// is what LoopUI listens to and moves the infobar.
gBrowser.selectedIndex = Array.indexOf(gBrowser.tabs, createdTabs[0]);
// We now know that the second tab is selected and should be displaying the
// infobar.
testBarProps();
// Test hiding the infoBar.
getInfoBar().querySelector(".notification-button")
.getElementsByTagNameNS(kNSXUL, "menuitem")[0].click();
Assert.equal(getInfoBar(), null, "The notification should be hidden now");
Assert.strictEqual(Services.prefs.getBoolPref(kPrefBrowserSharingInfoBar), false,
"The pref should be set to false when the menu item is clicked");
gBrowser.selectedIndex = Array.indexOf(gBrowser.tabs, createdTabs[1]);
Assert.equal(getInfoBar(), null, "The notification should still be hidden");
// Cleanup.
gMozLoopAPI.removeBrowserSharingListener(handlers[0].listener);
yield removeTabs();
Services.prefs.clearUserPref(kPrefBrowserSharingInfoBar);
});
@@ -1,41 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* This is an integration test to make sure that passing window IDs is working as
* expected, with or without e10s enabled - rather than just testing MozLoopAPI
* alone.
*/
const {injectLoopAPI} = Cu.import("resource:///modules/loop/MozLoopAPI.jsm");
gMozLoopAPI = injectLoopAPI({});
let promiseTabWindowId = function() {
return new Promise(resolve => {
gMozLoopAPI.getActiveTabWindowId((err, windowId) => {
Assert.equal(null, err, "No error should've occurred.");
Assert.equal(typeof windowId, "number", "We should have a window ID");
resolve(windowId);
});
});
};
add_task(function* test_windowIdFetch_simple() {
Assert.ok(gMozLoopAPI, "mozLoop should exist");
yield promiseTabWindowId();
});
add_task(function* test_windowIdFetch_multipleTabs() {
let previousTab = gBrowser.selectedTab;
let previousTabId = yield promiseTabWindowId();
let tab = gBrowser.selectedTab = gBrowser.addTab();
yield promiseTabLoadEvent(tab, "about:mozilla");
let tabId = yield promiseTabWindowId();
Assert.ok(tabId !== previousTabId, "Tab contentWindow IDs shouldn't be the same");
gBrowser.removeTab(tab);
tabId = yield promiseTabWindowId();
Assert.equal(previousTabId, tabId, "Window IDs should be back to what they were");
});
File diff suppressed because it is too large Load Diff
@@ -1,142 +0,0 @@
/* 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/. */
var expect = chai.expect;
describe("loop.store.StandaloneMetricsStore", function() {
"use strict";
var sandbox, dispatcher, store, fakeActiveRoomStore;
var sharedActions = loop.shared.actions;
var METRICS_GA_CATEGORY = loop.store.METRICS_GA_CATEGORY;
var METRICS_GA_ACTIONS = loop.store.METRICS_GA_ACTIONS;
var ROOM_STATES = loop.store.ROOM_STATES;
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
window.ga = sinon.stub();
var fakeStore = loop.store.createStore({
getInitialStoreState: function() {
return {
audioMuted: false,
roomState: ROOM_STATES.INIT,
videoMuted: false
};
}
});
fakeActiveRoomStore = new fakeStore(dispatcher);
store = new loop.store.StandaloneMetricsStore(dispatcher, {
activeRoomStore: fakeActiveRoomStore
});
});
afterEach(function() {
sandbox.restore();
delete window.ga;
});
describe("Action Handlers", function() {
beforeEach(function() {
});
it("should log an event on GotMediaPermission", function() {
store.gotMediaPermission();
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
"Media granted");
});
it("should log an event on JoinRoom", function() {
store.joinRoom();
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
"Join the conversation");
});
it("should log an event on LeaveRoom", function() {
store.leaveRoom();
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
"Leave conversation");
});
it("should log an event on MediaConnected", function() {
store.mediaConnected();
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
"Media connected");
});
it("should log an event on RecordClick", function() {
store.recordClick(new sharedActions.RecordClick({
linkInfo: "fake"
}));
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.linkClick,
"fake");
});
});
describe("Store Change Handlers", function() {
it("should log an event on room full", function() {
fakeActiveRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.pageLoad,
"Room full");
});
it("should log an event when the room is expired or invalid", function() {
fakeActiveRoomStore.setStoreState({
roomState: ROOM_STATES.FAILED,
failureReason: FAILURE_DETAILS.EXPIRED_OR_INVALID
});
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.pageLoad,
"Link expired or invalid");
});
it("should log an event when video mute is changed", function() {
fakeActiveRoomStore.setStoreState({
videoMuted: true
});
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.faceMute,
"mute");
});
it("should log an event when audio mute is changed", function() {
fakeActiveRoomStore.setStoreState({
audioMuted: true
});
sinon.assert.calledOnce(window.ga);
sinon.assert.calledWithExactly(window.ga,
"send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.audioMute,
"mute");
});
});
});
@@ -1,697 +0,0 @@
/* 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, sinon */
var expect = chai.expect;
describe("loop.standaloneRoomViews", function() {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var sandbox, dispatcher, activeRoomStore, feedbackStore, dispatch;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
dispatch = sandbox.stub(dispatcher, "dispatch");
activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
mozLoop: {},
sdkDriver: {}
});
feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: {}
});
loop.store.StoreMixin.register({feedbackStore: feedbackStore});
sandbox.useFakeTimers();
// Prevents audio request errors in the test console.
sandbox.useFakeXMLHttpRequest();
});
afterEach(function() {
sandbox.restore();
});
describe("StandaloneRoomContextView", function() {
beforeEach(function() {
sandbox.stub(navigator.mozL10n, "get").returnsArg(0);
});
function mountTestComponent(extraProps) {
var props = _.extend({
dispatcher: dispatcher,
receivingScreenShare: false
}, extraProps);
return TestUtils.renderIntoDocument(
React.createElement(
loop.standaloneRoomViews.StandaloneRoomContextView, props));
}
it("should display the room name if no failures are known", function() {
var view = mountTestComponent({
roomName: "Mike's room",
receivingScreenShare: false
});
expect(view.getDOMNode().textContent).eql("Mike's room");
});
it("should log an unsupported browser message if crypto is unsupported", function() {
var view = mountTestComponent({
roomName: "Mark's room",
roomInfoFailure: ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED
});
sinon.assert.called(console.error);
sinon.assert.calledWithMatch(console.error, sinon.match("unsupported"));
});
it("should display a general error message for any other failure", function() {
var view = mountTestComponent({
roomName: "Mark's room",
roomInfoFailure: ROOM_INFO_FAILURES.NO_DATA
});
sinon.assert.called(console.error);
sinon.assert.calledWithMatch(console.error, sinon.match("not_available"));
});
it("should display context information if a url is supplied", function() {
var view = mountTestComponent({
roomName: "Mike's room",
roomContextUrls: [{
description: "Mark's super page",
location: "http://invalid.com",
thumbnail: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
}]
});
expect(view.getDOMNode().querySelector(".standalone-context-url")).not.eql(null);
});
it("should format the url for display", function() {
sandbox.stub(sharedUtils, "formatURL").returns({
location: "location",
hostname: "hostname"
});
var view = mountTestComponent({
roomName: "Mike's room",
roomContextUrls: [{
description: "Mark's super page",
location: "http://invalid.com",
thumbnail: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
}]
});
expect(view.getDOMNode()
.querySelector(".standalone-context-url-description-wrapper > a").textContent)
.eql("hostname");
});
it("should not display context information if no urls are supplied", function() {
var view = mountTestComponent({
roomName: "Mike's room"
});
expect(view.getDOMNode().querySelector(".standalone-context-url")).eql(null);
});
it("should dispatch a RecordClick action when the link is clicked", function() {
var view = mountTestComponent({
roomName: "Mark's room",
roomContextUrls: [{
description: "Mark's super page",
location: "http://invalid.com",
thumbnail: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
}]
});
TestUtils.Simulate.click(view.getDOMNode()
.querySelector(".standalone-context-url-description-wrapper > a"));
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.RecordClick({
linkInfo: "Shared URL"
}));
});
});
describe("StandaloneRoomHeader", function() {
function mountTestComponent() {
return TestUtils.renderIntoDocument(
React.createElement(
loop.standaloneRoomViews.StandaloneRoomHeader, {
dispatcher: dispatcher
}));
}
it("should dispatch a RecordClick action when the support link is clicked", function() {
var view = mountTestComponent();
TestUtils.Simulate.click(view.getDOMNode().querySelector("a"));
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.RecordClick({
linkInfo: "Support link click"
}));
});
});
describe("StandaloneRoomView", function() {
function mountTestComponent() {
return TestUtils.renderIntoDocument(
React.createElement(
loop.standaloneRoomViews.StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
isFirefox: true
}));
}
function expectActionDispatched(view) {
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch,
sinon.match.instanceOf(sharedActions.SetupStreamElements));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getLocalElementFunc() ===
view.getDOMNode().querySelector(".local");
}));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getRemoteElementFunc() ===
view.getDOMNode().querySelector(".remote");
}));
}
describe("#componentWillUpdate", function() {
it("should dispatch a `SetupStreamElements` action when the MEDIA_WAIT state " +
"is entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
var view = mountTestComponent();
activeRoomStore.setStoreState({roomState: ROOM_STATES.MEDIA_WAIT});
expectActionDispatched(view);
});
it("should dispatch a `SetupStreamElements` action on MEDIA_WAIT state is " +
"re-entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
var view = mountTestComponent();
activeRoomStore.setStoreState({roomState: ROOM_STATES.MEDIA_WAIT});
expectActionDispatched(view);
});
it("should updateVideoContainer when the JOINED state is entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
var view = mountTestComponent();
sandbox.stub(view, "updateVideoContainer");
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
sinon.assert.calledOnce(view.updateVideoContainer);
});
it("should updateVideoContainer when the JOINED state is re-entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
var view = mountTestComponent();
sandbox.stub(view, "updateVideoContainer");
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
sinon.assert.calledOnce(view.updateVideoContainer);
});
it("should reset the video dimensions cache when the gather state is entered", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.SESSION_CONNECTED});
var view = mountTestComponent();
activeRoomStore.setStoreState({roomState: ROOM_STATES.GATHER});
expect(view._videoDimensionsCache).eql({
local: {},
remote: {}
});
});
});
describe("#publishStream", function() {
var view;
beforeEach(function() {
view = mountTestComponent();
view.setState({
audioMuted: true,
videoMuted: true
});
});
it("should mute local audio stream", function() {
TestUtils.Simulate.click(
view.getDOMNode().querySelector(".btn-mute-audio"));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.SetMute({
type: "audio",
enabled: true
}));
});
it("should mute local video stream", function() {
TestUtils.Simulate.click(
view.getDOMNode().querySelector(".btn-mute-video"));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.SetMute({
type: "video",
enabled: true
}));
});
});
describe("Local Stream Size Position", function() {
var view, localElement;
beforeEach(function() {
sandbox.stub(window, "matchMedia").returns({
matches: false
});
view = mountTestComponent();
localElement = view._getElement(".local");
});
it("should be a quarter of the width of the main stream", function() {
sandbox.stub(view, "getRemoteVideoDimensions").returns({
streamWidth: 640,
offsetX: 0
});
view.updateLocalCameraPosition({
width: 1,
height: 0.75
});
expect(localElement.style.width).eql("160px");
expect(localElement.style.height).eql("120px");
});
it("should be a quarter of the width reduced for aspect ratio", function() {
sandbox.stub(view, "getRemoteVideoDimensions").returns({
streamWidth: 640,
offsetX: 0
});
view.updateLocalCameraPosition({
width: 0.75,
height: 1
});
expect(localElement.style.width).eql("120px");
expect(localElement.style.height).eql("160px");
});
it("should ensure the height is a minimum of 48px", function() {
sandbox.stub(view, "getRemoteVideoDimensions").returns({
streamWidth: 180,
offsetX: 0
});
view.updateLocalCameraPosition({
width: 1,
height: 0.75
});
expect(localElement.style.width).eql("64px");
expect(localElement.style.height).eql("48px");
});
it("should ensure the width is a minimum of 48px", function() {
sandbox.stub(view, "getRemoteVideoDimensions").returns({
streamWidth: 180,
offsetX: 0
});
view.updateLocalCameraPosition({
width: 0.75,
height: 1
});
expect(localElement.style.width).eql("48px");
expect(localElement.style.height).eql("64px");
});
it("should position the stream to overlap the main stream by a quarter", function() {
sandbox.stub(view, "getRemoteVideoDimensions").returns({
streamWidth: 640,
offsetX: 0
});
view.updateLocalCameraPosition({
width: 1,
height: 0.75
});
expect(localElement.style.width).eql("160px");
expect(localElement.style.left).eql("600px");
});
it("should position the stream to overlap the main stream by a quarter when the aspect ratio is vertical", function() {
sandbox.stub(view, "getRemoteVideoDimensions").returns({
streamWidth: 640,
offsetX: 0
});
view.updateLocalCameraPosition({
width: 0.75,
height: 1
});
expect(localElement.style.width).eql("120px");
expect(localElement.style.left).eql("610px");
});
});
describe("Remote Stream Size Position", function() {
var view, localElement, remoteElement;
beforeEach(function() {
sandbox.stub(window, "matchMedia").returns({
matches: false
});
view = mountTestComponent();
localElement = {
style: {}
};
remoteElement = {
style: {},
removeAttribute: sinon.spy()
};
sandbox.stub(view, "_getElement", function(className) {
return className === ".local" ? localElement : remoteElement;
});
view.setState({"receivingScreenShare": true});
});
it("should do nothing if not receiving screenshare", function() {
view.setState({"receivingScreenShare": false});
remoteElement.style.width = "10px";
view.updateRemoteCameraPosition({
width: 1,
height: 0.75
});
expect(remoteElement.style.width).eql("10px");
});
it("should be the same width as the local video", function() {
localElement.offsetWidth = 100;
view.updateRemoteCameraPosition({
width: 1,
height: 0.75
});
expect(remoteElement.style.width).eql("100px");
});
it("should be the same left edge as the local video", function() {
localElement.offsetLeft = 50;
view.updateRemoteCameraPosition({
width: 1,
height: 0.75
});
expect(remoteElement.style.left).eql("50px");
});
it("should have a height determined by the aspect ratio", function() {
localElement.offsetWidth = 100;
view.updateRemoteCameraPosition({
width: 1,
height: 0.75
});
expect(remoteElement.style.height).eql("75px");
});
it("should have the top be set such that the bottom is 10px above the local video", function() {
localElement.offsetWidth = 100;
localElement.offsetTop = 200;
view.updateRemoteCameraPosition({
width: 1,
height: 0.75
});
// 200 (top) - 75 (height) - 10 (spacing) = 115
expect(remoteElement.style.top).eql("115px");
});
});
describe("#render", function() {
var view;
beforeEach(function() {
view = mountTestComponent();
});
describe("Empty room message", function() {
it("should display an empty room message on JOINED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
expect(view.getDOMNode().querySelector(".empty-room-message"))
.not.eql(null);
});
it("should display an empty room message on SESSION_CONNECTED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.SESSION_CONNECTED});
expect(view.getDOMNode().querySelector(".empty-room-message"))
.not.eql(null);
});
it("shouldn't display an empty room message on HAS_PARTICIPANTS",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
expect(view.getDOMNode().querySelector(".empty-room-message"))
.eql(null);
});
});
describe("Prompt media message", function() {
it("should display a prompt for user media on MEDIA_WAIT",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.MEDIA_WAIT});
expect(view.getDOMNode().querySelector(".prompt-media-message"))
.not.eql(null);
});
});
describe("Full room message", function() {
it("should display a full room message on FULL",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
expect(view.getDOMNode().querySelector(".full-room-message"))
.not.eql(null);
});
});
describe("Failed room message", function() {
it("should display a failed room message on FAILED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
expect(view.getDOMNode().querySelector(".failed-room-message"))
.not.eql(null);
});
it("should display a retry button",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
expect(view.getDOMNode().querySelector(".btn-info"))
.not.eql(null);
});
});
describe("Join button", function() {
function getJoinButton(view) {
return view.getDOMNode().querySelector(".btn-join");
}
it("should render the Join button when room isn't active", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
expect(getJoinButton(view)).not.eql(null);
});
it("should not render the Join button when room is active",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.SESSION_CONNECTED});
expect(getJoinButton(view)).eql(null);
});
it("should join the room when clicking the Join button", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
TestUtils.Simulate.click(getJoinButton(view));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.JoinRoom());
});
});
describe("Leave button", function() {
function getLeaveButton(view) {
return view.getDOMNode().querySelector(".btn-hangup");
}
it("should disable the Leave button when the room state is READY",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
expect(getLeaveButton(view).disabled).eql(true);
});
it("should disable the Leave button when the room state is FAILED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
expect(getLeaveButton(view).disabled).eql(true);
});
it("should disable the Leave button when the room state is FULL",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
expect(getLeaveButton(view).disabled).eql(true);
});
it("should enable the Leave button when the room state is SESSION_CONNECTED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.SESSION_CONNECTED});
expect(getLeaveButton(view).disabled).eql(false);
});
it("should enable the Leave button when the room state is JOINED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
expect(getLeaveButton(view).disabled).eql(false);
});
it("should enable the Leave button when the room state is HAS_PARTICIPANTS",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
expect(getLeaveButton(view).disabled).eql(false);
});
it("should leave the room when clicking the Leave button", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
TestUtils.Simulate.click(getLeaveButton(view));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.LeaveRoom());
});
});
describe("Feedback", function() {
beforeEach(function() {
activeRoomStore.setStoreState({
roomState: ROOM_STATES.ENDED,
used: true
});
});
it("should display a feedback form when the user leaves the room",
function() {
expect(view.getDOMNode().querySelector(".faces")).not.eql(null);
});
it("should dispatch a `FeedbackComplete` action after feedback is sent",
function() {
feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.SENT});
sandbox.clock.tick(
loop.shared.views.WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS * 1000 + 1000);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.FeedbackComplete());
});
it("should NOT display a feedback form if the room has not been used",
function() {
activeRoomStore.setStoreState({used: false});
expect(view.getDOMNode().querySelector(".faces")).eql(null);
});
});
describe("Mute", function() {
it("should render local media as audio-only if video is muted",
function() {
activeRoomStore.setStoreState({
roomState: ROOM_STATES.SESSION_CONNECTED,
videoMuted: true
});
expect(view.getDOMNode().querySelector(".local-stream-audio"))
.not.eql(null);
});
});
describe("Marketplace hidden iframe", function() {
it("should set src when the store state change",
function(done) {
var marketplace = view.getDOMNode().querySelector("#marketplace");
expect(marketplace.src).to.be.equal("");
activeRoomStore.setStoreState({
marketplaceSrc: "http://market/",
onMarketplaceMessage: function () {}
});
view.forceUpdate(function() {
expect(marketplace.src).to.be.equal("http://market/");
done();
});
});
});
});
});
});
@@ -1,182 +0,0 @@
/* 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, sinon, it, beforeEach, afterEach, describe, hawk */
var expect = chai.expect;
describe("loop.StandaloneClient", function() {
"use strict";
var sandbox,
fakeXHR,
requests = [],
callback,
fakeToken;
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeXHR = sandbox.useFakeXMLHttpRequest();
requests = [];
// https://github.com/cjohansen/Sinon.JS/issues/393
fakeXHR.xhr.onCreate = function (xhr) {
requests.push(xhr);
};
callback = sinon.spy();
fakeToken = "fakeTokenText";
});
afterEach(function() {
sandbox.restore();
});
describe("loop.StandaloneClient", function() {
describe("#constructor", function() {
it("should require a baseServerUrl setting", function() {
expect(function() {
new loop.StandaloneClient();
}).to.Throw(Error, /required/);
});
});
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;
beforeEach(function() {
client = new loop.StandaloneClient(
{baseServerUrl: "http://fake.api"}
);
fakeServerErrorDescription = {
code: 401,
errno: 101,
error: "error",
message: "invalid token",
info: "error info"
};
});
it("should prevent launching a conversation when token is missing",
function() {
expect(function() {
client.requestCallInfo();
}).to.Throw(Error, /missing.*[Tt]oken/);
});
it("should post data for the given call", function() {
client.requestCallInfo("fake", "audio", callback);
expect(requests).to.have.length.of(1);
expect(requests[0].url).to.be.equal("http://fake.api/calls/fake");
expect(requests[0].method).to.be.equal("POST");
expect(requests[0].requestBody).to.be.equal('{"callType":"audio","channel":"standalone"}');
});
it("should receive call data for the given call", function() {
client.requestCallInfo("fake", "audio-video", callback);
var sessionData = {
sessionId: "one",
sessionToken: "two",
apiKey: "three"
};
requests[0].respond(200, {"Content-Type": "application/json"},
JSON.stringify(sessionData));
sinon.assert.calledWithExactly(callback, null, sessionData);
});
it("should send an error when the request fails", function() {
client.requestCallInfo("fake", "audio", callback);
requests[0].respond(401, {"Content-Type": "application/json"},
JSON.stringify(fakeServerErrorDescription));
sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
return /HTTP 401 Unauthorized/.test(err.message);
}));
});
it("should attach the server error description object to the error " +
"passed to the callback",
function() {
client.requestCallInfo("fake", "audio", callback);
requests[0].respond(401, {"Content-Type": "application/json"},
JSON.stringify(fakeServerErrorDescription));
sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
return err.errno === fakeServerErrorDescription.errno;
}));
});
it("should send an error if the data is not valid", function() {
client.requestCallInfo("fake", "audio", callback);
requests[0].respond(200, {"Content-Type": "application/json"},
'{"bad": "one"}');
sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
return /Invalid data received/.test(err.message);
}));
});
});
});
});
@@ -1,71 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Unit tests for the hawkRequest API
*/
"use strict";
Cu.import("resource:///modules/loop/MozLoopAPI.jsm");
let sandbox;
function assertInSandbox(expr, msg_opt) {
Assert.ok(Cu.evalInSandbox(expr, sandbox), msg_opt);
}
sandbox = Cu.Sandbox("about:looppanel", { wantXrays: false } );
injectLoopAPI(sandbox, true);
add_task(function* hawk_session_scope_constants() {
assertInSandbox("typeof mozLoop.LOOP_SESSION_TYPE !== 'undefined'");
assertInSandbox("mozLoop.LOOP_SESSION_TYPE.GUEST === 1");
assertInSandbox("mozLoop.LOOP_SESSION_TYPE.FXA === 2");
});
function generateSessionTypeVerificationStub(desiredSessionType) {
function hawkRequestStub(sessionType, path, method, payloadObj, callback) {
return new Promise(function (resolve, reject) {
Assert.equal(desiredSessionType, sessionType);
resolve();
});
}
return hawkRequestStub;
}
const origHawkRequest = MozLoopService.hawkRequest;
do_register_cleanup(function() {
MozLoopService.hawkRequest = origHawkRequest;
});
add_task(function* hawk_request_scope_passthrough() {
// add a stub that verifies the parameter we want
MozLoopService.hawkRequest =
generateSessionTypeVerificationStub(sandbox.mozLoop.LOOP_SESSION_TYPE.FXA);
// call mozLoop.hawkRequest, which calls MozLoopAPI.hawkRequest, which calls
// MozLoopService.hawkRequest
Cu.evalInSandbox(
"mozLoop.hawkRequest(mozLoop.LOOP_SESSION_TYPE.FXA," +
" 'call-url/fakeToken', 'POST', {}, function() {})",
sandbox);
MozLoopService.hawkRequest =
generateSessionTypeVerificationStub(sandbox.mozLoop.LOOP_SESSION_TYPE.GUEST);
Cu.evalInSandbox(
"mozLoop.hawkRequest(mozLoop.LOOP_SESSION_TYPE.GUEST," +
" 'call-url/fakeToken', 'POST', {}, function() {})",
sandbox);
});
function run_test() {
run_next_test();
}
-810
View File
@@ -1,810 +0,0 @@
/** @jsx React.DOM */
/* 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/. */
/* jshint newcap:false */
/* global loop:true, React */
(function() {
"use strict";
// Stop the default init functions running to avoid conflicts.
document.removeEventListener('DOMContentLoaded', loop.panel.init);
document.removeEventListener('DOMContentLoaded', loop.conversation.init);
// 1. Desktop components
// 1.1 Panel
var PanelView = loop.panel.PanelView;
// 1.2. Conversation Window
var AcceptCallView = loop.conversationViews.AcceptCallView;
var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
var CallFailedView = loop.conversationViews.CallFailedView;
var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
// 2. Standalone webapp
var HomeView = loop.webapp.HomeView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var GumPromptConversationView = loop.webapp.GumPromptConversationView;
var WaitingConversationView = loop.webapp.WaitingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
var FailedConversationView = loop.webapp.FailedConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
var StandaloneRoomView = loop.standaloneRoomViews.StandaloneRoomView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Store constants
var ROOM_STATES = loop.store.ROOM_STATES;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
// Local helpers
function returnTrue() {
return true;
}
function returnFalse() {
return false;
}
function noop(){}
// We save the visibility change listeners so that we can fake an event
// to the panel once we've loaded all the views.
var visibilityListeners = [];
var rootObject = window;
rootObject.document.addEventListener = function(eventName, func) {
if (eventName === "visibilitychange") {
visibilityListeners.push(func);
}
window.addEventListener(eventName, func);
};
rootObject.document.removeEventListener = function(eventName, func) {
if (eventName === "visibilitychange") {
var index = visibilityListeners.indexOf(func);
visibilityListeners.splice(index, 1);
}
window.removeEventListener(eventName, func);
};
loop.shared.mixins.setRootObject(rootObject);
// Feedback API client configured to send data to the stage input server,
// which is available at https://input.allizom.org
var stageFeedbackApiClient = new loop.FeedbackAPIClient(
"https://input.allizom.org/api/v1/feedback", {
product: "Loop"
}
);
var mockSDK = _.extend({}, Backbone.Events);
var dispatcher = new loop.Dispatcher();
var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
mozLoop: navigator.mozLoop,
sdkDriver: mockSDK
});
var roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: navigator.mozLoop
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: stageFeedbackApiClient
});
var conversationStore = new loop.store.ConversationStore(dispatcher, {
client: {},
mozLoop: navigator.mozLoop,
sdkDriver: mockSDK
});
loop.store.StoreMixin.register({
conversationStore: conversationStore,
feedbackStore: feedbackStore
});
// Local mocks
var mockMozLoopRooms = _.extend({}, navigator.mozLoop);
var mockContact = {
name: ["Mr Smith"],
email: [{
value: "smith@invalid.com"
}]
};
var mockClient = {
requestCallUrlInfo: noop
};
var mockConversationModel = new loop.shared.models.ConversationModel({
callerId: "Mrs Jones",
urlCreationDate: (new Date() / 1000).toString()
}, {
sdk: mockSDK
});
mockConversationModel.startSession = noop;
var mockWebSocket = new loop.CallConnectionWebSocket({
url: "fake",
callId: "fakeId",
websocketToken: "fakeToken"
});
var notifications = new loop.shared.models.NotificationCollection();
var errNotifications = new loop.shared.models.NotificationCollection();
errNotifications.add({
level: "error",
message: "Could Not Authenticate",
details: "Did you change your password?",
detailsButtonLabel: "Retry"
});
var SVGIcon = React.createClass({displayName: "SVGIcon",
render: function() {
var sizeUnit = this.props.size.split("x")[0] + "px";
return (
React.createElement("span", {className: "svg-icon", style: {
"backgroundImage": "url(../content/shared/img/icons-" + this.props.size +
".svg#" + this.props.shapeId + ")",
"backgroundSize": sizeUnit + " " + sizeUnit
}})
);
}
});
var SVGIcons = React.createClass({displayName: "SVGIcons",
shapes: {
"10x10": ["close", "close-active", "close-disabled", "dropdown",
"dropdown-white", "dropdown-active", "dropdown-disabled", "edit",
"edit-active", "edit-disabled", "expand", "expand-active", "expand-disabled",
"minimize", "minimize-active", "minimize-disabled"
],
"14x14": ["audio", "audio-active", "audio-disabled", "facemute",
"facemute-active", "facemute-disabled", "hangup", "hangup-active",
"hangup-disabled", "incoming", "incoming-active", "incoming-disabled",
"link", "link-active", "link-disabled", "mute", "mute-active",
"mute-disabled", "pause", "pause-active", "pause-disabled", "video",
"video-white", "video-active", "video-disabled", "volume", "volume-active",
"volume-disabled"
],
"16x16": ["add", "add-hover", "add-active", "audio", "audio-hover", "audio-active",
"block", "block-red", "block-hover", "block-active", "contacts", "contacts-hover",
"contacts-active", "copy", "checkmark", "google", "google-hover", "google-active",
"history", "history-hover", "history-active", "leave", "precall", "precall-hover",
"precall-active", "screen-white", "screenmute-white", "settings",
"settings-hover", "settings-active", "share-darkgrey", "tag", "tag-hover",
"tag-active", "trash", "unblock", "unblock-hover", "unblock-active", "video",
"video-hover", "video-active", "tour"
]
},
render: function() {
var icons = this.shapes[this.props.size].map(function(shapeId, i) {
return (
React.createElement("li", {key: this.props.size + "-" + i, className: "svg-icon-entry"},
React.createElement("p", null, React.createElement(SVGIcon, {shapeId: shapeId, size: this.props.size})),
React.createElement("p", null, shapeId)
)
);
}, this);
return (
React.createElement("ul", {className: "svg-icon-list"}, icons)
);
}
});
var Example = React.createClass({displayName: "Example",
makeId: function(prefix) {
return (prefix || "") + this.props.summary.toLowerCase().replace(/\s/g, "-");
},
render: function() {
var cx = React.addons.classSet;
return (
React.createElement("div", {className: "example"},
React.createElement("h3", {id: this.makeId()},
this.props.summary,
React.createElement("a", {href: this.makeId("#")}, " ¶")
),
React.createElement("div", {className: cx({comp: true, dashed: this.props.dashed}),
style: this.props.style},
this.props.children
)
)
);
}
});
var Section = React.createClass({displayName: "Section",
render: function() {
return (
React.createElement("section", {id: this.props.name, className: this.props.className},
React.createElement("h1", null, this.props.name),
this.props.children
)
);
}
});
var ShowCase = React.createClass({displayName: "ShowCase",
render: function() {
return (
React.createElement("div", {className: "showcase"},
React.createElement("header", null,
React.createElement("h1", null, "Loop UI Components Showcase"),
React.createElement("nav", {className: "showcase-menu"},
React.Children.map(this.props.children, function(section) {
return (
React.createElement("a", {className: "btn btn-info", href: "#" + section.props.name},
section.props.name
)
);
})
)
),
this.props.children
)
);
}
});
var App = React.createClass({displayName: "App",
render: function() {
return (
React.createElement(ShowCase, null,
React.createElement(Section, {name: "PanelView"},
React.createElement("p", {className: "note"},
React.createElement("strong", null, "Note:"), " 332px wide."
),
React.createElement(Example, {summary: "Room list tab", dashed: "true", style: {width: "332px"}},
React.createElement(PanelView, {client: mockClient, notifications: notifications,
userProfile: {email: "test@example.com"},
mozLoop: mockMozLoopRooms,
dispatcher: dispatcher,
roomStore: roomStore,
selectedTab: "rooms"})
),
React.createElement(Example, {summary: "Contact list tab", dashed: "true", style: {width: "332px"}},
React.createElement(PanelView, {client: mockClient, notifications: notifications,
userProfile: {email: "test@example.com"},
mozLoop: mockMozLoopRooms,
dispatcher: dispatcher,
roomStore: roomStore,
selectedTab: "contacts"})
),
React.createElement(Example, {summary: "Error Notification", dashed: "true", style: {width: "332px"}},
React.createElement(PanelView, {client: mockClient, notifications: errNotifications,
mozLoop: navigator.mozLoop,
dispatcher: dispatcher,
roomStore: roomStore})
),
React.createElement(Example, {summary: "Error Notification - authenticated", dashed: "true", style: {width: "332px"}},
React.createElement(PanelView, {client: mockClient, notifications: errNotifications,
userProfile: {email: "test@example.com"},
mozLoop: navigator.mozLoop,
dispatcher: dispatcher,
roomStore: roomStore})
),
React.createElement(Example, {summary: "Contact import success", dashed: "true", style: {width: "332px"}},
React.createElement(PanelView, {notifications: new loop.shared.models.NotificationCollection([{level: "success", message: "Import success"}]),
userProfile: {email: "test@example.com"},
mozLoop: mockMozLoopRooms,
dispatcher: dispatcher,
roomStore: roomStore,
selectedTab: "contacts"})
),
React.createElement(Example, {summary: "Contact import error", dashed: "true", style: {width: "332px"}},
React.createElement(PanelView, {notifications: new loop.shared.models.NotificationCollection([{level: "error", message: "Import error"}]),
userProfile: {email: "test@example.com"},
mozLoop: mockMozLoopRooms,
dispatcher: dispatcher,
roomStore: roomStore,
selectedTab: "contacts"})
)
),
React.createElement(Section, {name: "AcceptCallView"},
React.createElement(Example, {summary: "Default / incoming video call", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(AcceptCallView, {callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "Mr Smith",
dispatcher: dispatcher,
mozLoop: mockMozLoopRooms})
)
),
React.createElement(Example, {summary: "Default / incoming audio only call", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(AcceptCallView, {callType: CALL_TYPES.AUDIO_ONLY,
callerId: "Mr Smith",
dispatcher: dispatcher,
mozLoop: mockMozLoopRooms})
)
)
),
React.createElement(Section, {name: "AcceptCallView-ActiveState"},
React.createElement(Example, {summary: "Default", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(AcceptCallView, {callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "Mr Smith",
dispatcher: dispatcher,
mozLoop: mockMozLoopRooms,
showMenu: true})
)
)
),
React.createElement(Section, {name: "ConversationToolbar"},
React.createElement("h2", null, "Desktop Conversation Window"),
React.createElement("div", {className: "fx-embedded override-position"},
React.createElement(Example, {summary: "Default", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement(ConversationToolbar, {video: {enabled: true},
audio: {enabled: true},
hangup: noop,
publishStream: noop})
),
React.createElement(Example, {summary: "Video muted", style: {width: "300px", height: "272px"}},
React.createElement(ConversationToolbar, {video: {enabled: false},
audio: {enabled: true},
hangup: noop,
publishStream: noop})
),
React.createElement(Example, {summary: "Audio muted", style: {width: "300px", height: "272px"}},
React.createElement(ConversationToolbar, {video: {enabled: true},
audio: {enabled: false},
hangup: noop,
publishStream: noop})
)
),
React.createElement("h2", null, "Standalone"),
React.createElement("div", {className: "standalone override-position"},
React.createElement(Example, {summary: "Default"},
React.createElement(ConversationToolbar, {video: {enabled: true},
audio: {enabled: true},
hangup: noop,
publishStream: noop})
),
React.createElement(Example, {summary: "Video muted"},
React.createElement(ConversationToolbar, {video: {enabled: false},
audio: {enabled: true},
hangup: noop,
publishStream: noop})
),
React.createElement(Example, {summary: "Audio muted"},
React.createElement(ConversationToolbar, {video: {enabled: true},
audio: {enabled: false},
hangup: noop,
publishStream: noop})
)
)
),
React.createElement(Section, {name: "GumPromptConversationView"},
React.createElement(Example, {summary: "Gum Prompt conversation view", dashed: "true"},
React.createElement("div", {className: "standalone"},
React.createElement(GumPromptConversationView, null)
)
)
),
React.createElement(Section, {name: "WaitingConversationView"},
React.createElement(Example, {summary: "Waiting conversation view (connecting)", dashed: "true"},
React.createElement("div", {className: "standalone"},
React.createElement(WaitingConversationView, {websocket: mockWebSocket,
dispatcher: dispatcher})
)
),
React.createElement(Example, {summary: "Waiting conversation view (ringing)", dashed: "true"},
React.createElement("div", {className: "standalone"},
React.createElement(WaitingConversationView, {websocket: mockWebSocket,
dispatcher: dispatcher,
callState: "ringing"})
)
)
),
React.createElement(Section, {name: "PendingConversationView (Desktop)"},
React.createElement(Example, {summary: "Connecting", dashed: "true",
style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(DesktopPendingConversationView, {callState: "gather",
contact: mockContact,
dispatcher: dispatcher})
)
)
),
React.createElement(Section, {name: "CallFailedView"},
React.createElement(Example, {summary: "Call Failed - Incoming", dashed: "true",
style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(CallFailedView, {dispatcher: dispatcher,
outgoing: false,
store: conversationStore})
)
),
React.createElement(Example, {summary: "Call Failed - Outgoing", dashed: "true",
style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(CallFailedView, {dispatcher: dispatcher,
outgoing: true,
store: conversationStore})
)
),
React.createElement(Example, {summary: "Call Failed — with call URL error", dashed: "true",
style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(CallFailedView, {dispatcher: dispatcher, emailLinkError: true,
outgoing: true,
store: conversationStore})
)
)
),
React.createElement(Section, {name: "StartConversationView"},
React.createElement(Example, {summary: "Start conversation view", dashed: "true"},
React.createElement("div", {className: "standalone"},
React.createElement(StartConversationView, {conversation: mockConversationModel,
client: mockClient,
notifications: notifications})
)
)
),
React.createElement(Section, {name: "FailedConversationView"},
React.createElement(Example, {summary: "Failed conversation view", dashed: "true"},
React.createElement("div", {className: "standalone"},
React.createElement(FailedConversationView, {conversation: mockConversationModel,
client: mockClient,
notifications: notifications})
)
)
),
React.createElement(Section, {name: "ConversationView"},
React.createElement(Example, {summary: "Desktop conversation window", dashed: "true",
style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(ConversationView, {sdk: mockSDK,
model: mockConversationModel,
video: {enabled: true},
audio: {enabled: true}})
)
),
React.createElement(Example, {summary: "Desktop conversation window large", dashed: "true"},
React.createElement("div", {className: "breakpoint", "data-breakpoint-width": "800px",
"data-breakpoint-height": "600px"},
React.createElement("div", {className: "fx-embedded"},
React.createElement(ConversationView, {sdk: mockSDK,
video: {enabled: true},
audio: {enabled: true},
model: mockConversationModel})
)
)
),
React.createElement(Example, {summary: "Desktop conversation window local audio stream",
dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(ConversationView, {sdk: mockSDK,
video: {enabled: false},
audio: {enabled: true},
model: mockConversationModel})
)
),
React.createElement(Example, {summary: "Standalone version"},
React.createElement("div", {className: "standalone"},
React.createElement(ConversationView, {sdk: mockSDK,
video: {enabled: true},
audio: {enabled: true},
model: mockConversationModel})
)
)
),
React.createElement(Section, {name: "ConversationView-640"},
React.createElement(Example, {summary: "640px breakpoint for conversation view"},
React.createElement("div", {className: "breakpoint",
style: {"text-align":"center"},
"data-breakpoint-width": "400px",
"data-breakpoint-height": "780px"},
React.createElement("div", {className: "standalone"},
React.createElement(ConversationView, {sdk: mockSDK,
video: {enabled: true},
audio: {enabled: true},
model: mockConversationModel})
)
)
)
),
React.createElement(Section, {name: "ConversationView-LocalAudio"},
React.createElement(Example, {summary: "Local stream is audio only"},
React.createElement("div", {className: "standalone"},
React.createElement(ConversationView, {sdk: mockSDK,
video: {enabled: false},
audio: {enabled: true},
model: mockConversationModel})
)
)
),
React.createElement(Section, {name: "FeedbackView"},
React.createElement("p", {className: "note"},
React.createElement("strong", null, "Note:"), " For the useable demo, you can access submitted data at ",
React.createElement("a", {href: "https://input.allizom.org/"}, "input.allizom.org"), "."
),
React.createElement(Example, {summary: "Default (useable demo)", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement(FeedbackView, {feedbackStore: feedbackStore})
),
React.createElement(Example, {summary: "Detailed form", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement(FeedbackView, {feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.DETAILS})
),
React.createElement(Example, {summary: "Thank you!", dashed: "true", style: {width: "300px", height: "272px"}},
React.createElement(FeedbackView, {feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.SENT})
)
),
React.createElement(Section, {name: "CallUrlExpiredView"},
React.createElement(Example, {summary: "Firefox User"},
React.createElement(CallUrlExpiredView, {isFirefox: true})
),
React.createElement(Example, {summary: "Non-Firefox User"},
React.createElement(CallUrlExpiredView, {isFirefox: false})
)
),
React.createElement(Section, {name: "EndedConversationView"},
React.createElement(Example, {summary: "Displays the feedback form"},
React.createElement("div", {className: "standalone"},
React.createElement(EndedConversationView, {sdk: mockSDK,
video: {enabled: true},
audio: {enabled: true},
conversation: mockConversationModel,
feedbackStore: feedbackStore,
onAfterFeedbackReceived: noop})
)
)
),
React.createElement(Section, {name: "AlertMessages"},
React.createElement(Example, {summary: "Various alerts"},
React.createElement("div", {className: "alert alert-warning"},
React.createElement("button", {className: "close"}),
React.createElement("p", {className: "message"},
"The person you were calling has ended the conversation."
)
),
React.createElement("br", null),
React.createElement("div", {className: "alert alert-error"},
React.createElement("button", {className: "close"}),
React.createElement("p", {className: "message"},
"The person you were calling has ended the conversation."
)
)
)
),
React.createElement(Section, {name: "HomeView"},
React.createElement(Example, {summary: "Standalone Home View"},
React.createElement("div", {className: "standalone"},
React.createElement(HomeView, null)
)
)
),
React.createElement(Section, {name: "UnsupportedBrowserView"},
React.createElement(Example, {summary: "Standalone Unsupported Browser"},
React.createElement("div", {className: "standalone"},
React.createElement(UnsupportedBrowserView, {isFirefox: false})
)
)
),
React.createElement(Section, {name: "UnsupportedDeviceView"},
React.createElement(Example, {summary: "Standalone Unsupported Device"},
React.createElement("div", {className: "standalone"},
React.createElement(UnsupportedDeviceView, {platform: "ios"})
)
)
),
React.createElement(Section, {name: "DesktopRoomConversationView"},
React.createElement(Example, {summary: "Desktop room conversation (invitation)", dashed: "true",
style: {width: "260px", height: "265px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(DesktopRoomConversationView, {
roomStore: roomStore,
dispatcher: dispatcher,
mozLoop: navigator.mozLoop,
roomState: ROOM_STATES.INIT})
)
),
React.createElement(Example, {summary: "Desktop room conversation", dashed: "true",
style: {width: "260px", height: "265px"}},
React.createElement("div", {className: "fx-embedded"},
React.createElement(DesktopRoomConversationView, {
roomStore: roomStore,
dispatcher: dispatcher,
mozLoop: navigator.mozLoop,
roomState: ROOM_STATES.HAS_PARTICIPANTS})
)
)
),
React.createElement(Section, {name: "StandaloneRoomView"},
React.createElement(Example, {summary: "Standalone room conversation (ready)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.READY,
isFirefox: true})
)
),
React.createElement(Example, {summary: "Standalone room conversation (joined)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.JOINED,
isFirefox: true})
)
),
React.createElement(Example, {summary: "Standalone room conversation (has-participants)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.HAS_PARTICIPANTS,
isFirefox: true})
)
),
React.createElement(Example, {summary: "Standalone room conversation (full - FFx user)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.FULL,
isFirefox: true})
)
),
React.createElement(Example, {summary: "Standalone room conversation (full - non FFx user)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.FULL,
isFirefox: false})
)
),
React.createElement(Example, {summary: "Standalone room conversation (feedback)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
feedbackStore: feedbackStore,
roomState: ROOM_STATES.ENDED,
isFirefox: false})
)
),
React.createElement(Example, {summary: "Standalone room conversation (failed)"},
React.createElement("div", {className: "standalone"},
React.createElement(StandaloneRoomView, {
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.FAILED,
isFirefox: false})
)
)
),
React.createElement(Section, {name: "SVG icons preview", className: "svg-icons"},
React.createElement(Example, {summary: "10x10"},
React.createElement(SVGIcons, {size: "10x10"})
),
React.createElement(Example, {summary: "14x14"},
React.createElement(SVGIcons, {size: "14x14"})
),
React.createElement(Example, {summary: "16x16"},
React.createElement(SVGIcons, {size: "16x16"})
)
)
)
);
}
});
/**
* Render components that have different styles across
* CSS media rules in their own iframe to mimic the viewport
* */
function _renderComponentsInIframes() {
var parents = document.querySelectorAll('.breakpoint');
[].forEach.call(parents, appendChildInIframe);
/**
* Extracts the component from the DOM and appends in the an iframe
*
* @type {HTMLElement} parent - Parent DOM node of a component & iframe
* */
function appendChildInIframe(parent) {
var styles = document.querySelector('head').children;
var component = parent.children[0];
var iframe = document.createElement('iframe');
var width = parent.dataset.breakpointWidth;
var height = parent.dataset.breakpointHeight;
iframe.style.width = width;
iframe.style.height = height;
parent.appendChild(iframe);
iframe.src = "about:blank";
// Workaround for bug 297685
iframe.onload = function () {
var iframeHead = iframe.contentDocument.querySelector('head');
iframe.contentDocument.documentElement.querySelector('body')
.appendChild(component);
[].forEach.call(styles, function(style) {
iframeHead.appendChild(style.cloneNode(true));
});
};
}
}
window.addEventListener("DOMContentLoaded", function() {
try {
React.renderComponent(React.createElement(App, null), document.getElementById("main"));
for (var listener of visibilityListeners) {
listener({target: {hidden: false}});
}
} catch(err) {
console.error(err);
uncaughtError = err;
}
_renderComponentsInIframes();
// Put the title back, in case views changed it.
document.title = "Loop UI Components Showcase";
// This simulates the mocha layout for errors which means we can run
// this alongside our other unit tests but use the same harness.
if (uncaughtError) {
$("#results").append("<div class='failures'><em>1</em></div>");
$("#results").append("<li class='test fail'>" +
"<h2>Errors rendering UI-Showcase</h2>" +
"<pre class='error'>" + uncaughtError + "\n" + uncaughtError.stack + "</pre>" +
"</li>");
} else {
$("#results").append("<div class='failures'><em>0</em></div>");
}
$("#results").append("<p id='complete'>Complete.</p>");
});
})();
-810
View File
@@ -1,810 +0,0 @@
/** @jsx React.DOM */
/* 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/. */
/* jshint newcap:false */
/* global loop:true, React */
(function() {
"use strict";
// Stop the default init functions running to avoid conflicts.
document.removeEventListener('DOMContentLoaded', loop.panel.init);
document.removeEventListener('DOMContentLoaded', loop.conversation.init);
// 1. Desktop components
// 1.1 Panel
var PanelView = loop.panel.PanelView;
// 1.2. Conversation Window
var AcceptCallView = loop.conversationViews.AcceptCallView;
var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
var CallFailedView = loop.conversationViews.CallFailedView;
var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
// 2. Standalone webapp
var HomeView = loop.webapp.HomeView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var GumPromptConversationView = loop.webapp.GumPromptConversationView;
var WaitingConversationView = loop.webapp.WaitingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
var FailedConversationView = loop.webapp.FailedConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
var StandaloneRoomView = loop.standaloneRoomViews.StandaloneRoomView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
var ConversationView = loop.shared.views.ConversationView;
var FeedbackView = loop.shared.views.FeedbackView;
// Store constants
var ROOM_STATES = loop.store.ROOM_STATES;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
// Local helpers
function returnTrue() {
return true;
}
function returnFalse() {
return false;
}
function noop(){}
// We save the visibility change listeners so that we can fake an event
// to the panel once we've loaded all the views.
var visibilityListeners = [];
var rootObject = window;
rootObject.document.addEventListener = function(eventName, func) {
if (eventName === "visibilitychange") {
visibilityListeners.push(func);
}
window.addEventListener(eventName, func);
};
rootObject.document.removeEventListener = function(eventName, func) {
if (eventName === "visibilitychange") {
var index = visibilityListeners.indexOf(func);
visibilityListeners.splice(index, 1);
}
window.removeEventListener(eventName, func);
};
loop.shared.mixins.setRootObject(rootObject);
// Feedback API client configured to send data to the stage input server,
// which is available at https://input.allizom.org
var stageFeedbackApiClient = new loop.FeedbackAPIClient(
"https://input.allizom.org/api/v1/feedback", {
product: "Loop"
}
);
var mockSDK = _.extend({}, Backbone.Events);
var dispatcher = new loop.Dispatcher();
var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
mozLoop: navigator.mozLoop,
sdkDriver: mockSDK
});
var roomStore = new loop.store.RoomStore(dispatcher, {
mozLoop: navigator.mozLoop
});
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: stageFeedbackApiClient
});
var conversationStore = new loop.store.ConversationStore(dispatcher, {
client: {},
mozLoop: navigator.mozLoop,
sdkDriver: mockSDK
});
loop.store.StoreMixin.register({
conversationStore: conversationStore,
feedbackStore: feedbackStore
});
// Local mocks
var mockMozLoopRooms = _.extend({}, navigator.mozLoop);
var mockContact = {
name: ["Mr Smith"],
email: [{
value: "smith@invalid.com"
}]
};
var mockClient = {
requestCallUrlInfo: noop
};
var mockConversationModel = new loop.shared.models.ConversationModel({
callerId: "Mrs Jones",
urlCreationDate: (new Date() / 1000).toString()
}, {
sdk: mockSDK
});
mockConversationModel.startSession = noop;
var mockWebSocket = new loop.CallConnectionWebSocket({
url: "fake",
callId: "fakeId",
websocketToken: "fakeToken"
});
var notifications = new loop.shared.models.NotificationCollection();
var errNotifications = new loop.shared.models.NotificationCollection();
errNotifications.add({
level: "error",
message: "Could Not Authenticate",
details: "Did you change your password?",
detailsButtonLabel: "Retry"
});
var SVGIcon = React.createClass({
render: function() {
var sizeUnit = this.props.size.split("x")[0] + "px";
return (
<span className="svg-icon" style={{
"backgroundImage": "url(../content/shared/img/icons-" + this.props.size +
".svg#" + this.props.shapeId + ")",
"backgroundSize": sizeUnit + " " + sizeUnit
}} />
);
}
});
var SVGIcons = React.createClass({
shapes: {
"10x10": ["close", "close-active", "close-disabled", "dropdown",
"dropdown-white", "dropdown-active", "dropdown-disabled", "edit",
"edit-active", "edit-disabled", "expand", "expand-active", "expand-disabled",
"minimize", "minimize-active", "minimize-disabled"
],
"14x14": ["audio", "audio-active", "audio-disabled", "facemute",
"facemute-active", "facemute-disabled", "hangup", "hangup-active",
"hangup-disabled", "incoming", "incoming-active", "incoming-disabled",
"link", "link-active", "link-disabled", "mute", "mute-active",
"mute-disabled", "pause", "pause-active", "pause-disabled", "video",
"video-white", "video-active", "video-disabled", "volume", "volume-active",
"volume-disabled"
],
"16x16": ["add", "add-hover", "add-active", "audio", "audio-hover", "audio-active",
"block", "block-red", "block-hover", "block-active", "contacts", "contacts-hover",
"contacts-active", "copy", "checkmark", "google", "google-hover", "google-active",
"history", "history-hover", "history-active", "leave", "precall", "precall-hover",
"precall-active", "screen-white", "screenmute-white", "settings",
"settings-hover", "settings-active", "share-darkgrey", "tag", "tag-hover",
"tag-active", "trash", "unblock", "unblock-hover", "unblock-active", "video",
"video-hover", "video-active", "tour"
]
},
render: function() {
var icons = this.shapes[this.props.size].map(function(shapeId, i) {
return (
<li key={this.props.size + "-" + i} className="svg-icon-entry">
<p><SVGIcon shapeId={shapeId} size={this.props.size} /></p>
<p>{shapeId}</p>
</li>
);
}, this);
return (
<ul className="svg-icon-list">{icons}</ul>
);
}
});
var Example = React.createClass({
makeId: function(prefix) {
return (prefix || "") + this.props.summary.toLowerCase().replace(/\s/g, "-");
},
render: function() {
var cx = React.addons.classSet;
return (
<div className="example">
<h3 id={this.makeId()}>
{this.props.summary}
<a href={this.makeId("#")}>&nbsp;</a>
</h3>
<div className={cx({comp: true, dashed: this.props.dashed})}
style={this.props.style}>
{this.props.children}
</div>
</div>
);
}
});
var Section = React.createClass({
render: function() {
return (
<section id={this.props.name} className={this.props.className}>
<h1>{this.props.name}</h1>
{this.props.children}
</section>
);
}
});
var ShowCase = React.createClass({
render: function() {
return (
<div className="showcase">
<header>
<h1>Loop UI Components Showcase</h1>
<nav className="showcase-menu">{
React.Children.map(this.props.children, function(section) {
return (
<a className="btn btn-info" href={"#" + section.props.name}>
{section.props.name}
</a>
);
})
}</nav>
</header>
{this.props.children}
</div>
);
}
});
var App = React.createClass({
render: function() {
return (
<ShowCase>
<Section name="PanelView">
<p className="note">
<strong>Note:</strong> 332px wide.
</p>
<Example summary="Room list tab" dashed="true" style={{width: "332px"}}>
<PanelView client={mockClient} notifications={notifications}
userProfile={{email: "test@example.com"}}
mozLoop={mockMozLoopRooms}
dispatcher={dispatcher}
roomStore={roomStore}
selectedTab="rooms" />
</Example>
<Example summary="Contact list tab" dashed="true" style={{width: "332px"}}>
<PanelView client={mockClient} notifications={notifications}
userProfile={{email: "test@example.com"}}
mozLoop={mockMozLoopRooms}
dispatcher={dispatcher}
roomStore={roomStore}
selectedTab="contacts" />
</Example>
<Example summary="Error Notification" dashed="true" style={{width: "332px"}}>
<PanelView client={mockClient} notifications={errNotifications}
mozLoop={navigator.mozLoop}
dispatcher={dispatcher}
roomStore={roomStore} />
</Example>
<Example summary="Error Notification - authenticated" dashed="true" style={{width: "332px"}}>
<PanelView client={mockClient} notifications={errNotifications}
userProfile={{email: "test@example.com"}}
mozLoop={navigator.mozLoop}
dispatcher={dispatcher}
roomStore={roomStore} />
</Example>
<Example summary="Contact import success" dashed="true" style={{width: "332px"}}>
<PanelView notifications={new loop.shared.models.NotificationCollection([{level: "success", message: "Import success"}])}
userProfile={{email: "test@example.com"}}
mozLoop={mockMozLoopRooms}
dispatcher={dispatcher}
roomStore={roomStore}
selectedTab="contacts" />
</Example>
<Example summary="Contact import error" dashed="true" style={{width: "332px"}}>
<PanelView notifications={new loop.shared.models.NotificationCollection([{level: "error", message: "Import error"}])}
userProfile={{email: "test@example.com"}}
mozLoop={mockMozLoopRooms}
dispatcher={dispatcher}
roomStore={roomStore}
selectedTab="contacts" />
</Example>
</Section>
<Section name="AcceptCallView">
<Example summary="Default / incoming video call" dashed="true" style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<AcceptCallView callType={CALL_TYPES.AUDIO_VIDEO}
callerId="Mr Smith"
dispatcher={dispatcher}
mozLoop={mockMozLoopRooms} />
</div>
</Example>
<Example summary="Default / incoming audio only call" dashed="true" style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<AcceptCallView callType={CALL_TYPES.AUDIO_ONLY}
callerId="Mr Smith"
dispatcher={dispatcher}
mozLoop={mockMozLoopRooms} />
</div>
</Example>
</Section>
<Section name="AcceptCallView-ActiveState">
<Example summary="Default" dashed="true" style={{width: "300px", height: "272px"}}>
<div className="fx-embedded" >
<AcceptCallView callType={CALL_TYPES.AUDIO_VIDEO}
callerId="Mr Smith"
dispatcher={dispatcher}
mozLoop={mockMozLoopRooms}
showMenu={true} />
</div>
</Example>
</Section>
<Section name="ConversationToolbar">
<h2>Desktop Conversation Window</h2>
<div className="fx-embedded override-position">
<Example summary="Default" dashed="true" style={{width: "300px", height: "272px"}}>
<ConversationToolbar video={{enabled: true}}
audio={{enabled: true}}
hangup={noop}
publishStream={noop} />
</Example>
<Example summary="Video muted" style={{width: "300px", height: "272px"}}>
<ConversationToolbar video={{enabled: false}}
audio={{enabled: true}}
hangup={noop}
publishStream={noop} />
</Example>
<Example summary="Audio muted" style={{width: "300px", height: "272px"}}>
<ConversationToolbar video={{enabled: true}}
audio={{enabled: false}}
hangup={noop}
publishStream={noop} />
</Example>
</div>
<h2>Standalone</h2>
<div className="standalone override-position">
<Example summary="Default">
<ConversationToolbar video={{enabled: true}}
audio={{enabled: true}}
hangup={noop}
publishStream={noop} />
</Example>
<Example summary="Video muted">
<ConversationToolbar video={{enabled: false}}
audio={{enabled: true}}
hangup={noop}
publishStream={noop} />
</Example>
<Example summary="Audio muted">
<ConversationToolbar video={{enabled: true}}
audio={{enabled: false}}
hangup={noop}
publishStream={noop} />
</Example>
</div>
</Section>
<Section name="GumPromptConversationView">
<Example summary="Gum Prompt conversation view" dashed="true">
<div className="standalone">
<GumPromptConversationView />
</div>
</Example>
</Section>
<Section name="WaitingConversationView">
<Example summary="Waiting conversation view (connecting)" dashed="true">
<div className="standalone">
<WaitingConversationView websocket={mockWebSocket}
dispatcher={dispatcher} />
</div>
</Example>
<Example summary="Waiting conversation view (ringing)" dashed="true">
<div className="standalone">
<WaitingConversationView websocket={mockWebSocket}
dispatcher={dispatcher}
callState="ringing"/>
</div>
</Example>
</Section>
<Section name="PendingConversationView (Desktop)">
<Example summary="Connecting" dashed="true"
style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<DesktopPendingConversationView callState={"gather"}
contact={mockContact}
dispatcher={dispatcher} />
</div>
</Example>
</Section>
<Section name="CallFailedView">
<Example summary="Call Failed - Incoming" dashed="true"
style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<CallFailedView dispatcher={dispatcher}
outgoing={false}
store={conversationStore} />
</div>
</Example>
<Example summary="Call Failed - Outgoing" dashed="true"
style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<CallFailedView dispatcher={dispatcher}
outgoing={true}
store={conversationStore} />
</div>
</Example>
<Example summary="Call Failed — with call URL error" dashed="true"
style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<CallFailedView dispatcher={dispatcher} emailLinkError={true}
outgoing={true}
store={conversationStore} />
</div>
</Example>
</Section>
<Section name="StartConversationView">
<Example summary="Start conversation view" dashed="true">
<div className="standalone">
<StartConversationView conversation={mockConversationModel}
client={mockClient}
notifications={notifications} />
</div>
</Example>
</Section>
<Section name="FailedConversationView">
<Example summary="Failed conversation view" dashed="true">
<div className="standalone">
<FailedConversationView conversation={mockConversationModel}
client={mockClient}
notifications={notifications} />
</div>
</Example>
</Section>
<Section name="ConversationView">
<Example summary="Desktop conversation window" dashed="true"
style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<ConversationView sdk={mockSDK}
model={mockConversationModel}
video={{enabled: true}}
audio={{enabled: true}} />
</div>
</Example>
<Example summary="Desktop conversation window large" dashed="true">
<div className="breakpoint" data-breakpoint-width="800px"
data-breakpoint-height="600px">
<div className="fx-embedded">
<ConversationView sdk={mockSDK}
video={{enabled: true}}
audio={{enabled: true}}
model={mockConversationModel} />
</div>
</div>
</Example>
<Example summary="Desktop conversation window local audio stream"
dashed="true" style={{width: "300px", height: "272px"}}>
<div className="fx-embedded">
<ConversationView sdk={mockSDK}
video={{enabled: false}}
audio={{enabled: true}}
model={mockConversationModel} />
</div>
</Example>
<Example summary="Standalone version">
<div className="standalone">
<ConversationView sdk={mockSDK}
video={{enabled: true}}
audio={{enabled: true}}
model={mockConversationModel} />
</div>
</Example>
</Section>
<Section name="ConversationView-640">
<Example summary="640px breakpoint for conversation view">
<div className="breakpoint"
style={{"text-align":"center"}}
data-breakpoint-width="400px"
data-breakpoint-height="780px">
<div className="standalone">
<ConversationView sdk={mockSDK}
video={{enabled: true}}
audio={{enabled: true}}
model={mockConversationModel} />
</div>
</div>
</Example>
</Section>
<Section name="ConversationView-LocalAudio">
<Example summary="Local stream is audio only">
<div className="standalone">
<ConversationView sdk={mockSDK}
video={{enabled: false}}
audio={{enabled: true}}
model={mockConversationModel} />
</div>
</Example>
</Section>
<Section name="FeedbackView">
<p className="note">
<strong>Note:</strong> For the useable demo, you can access submitted data at&nbsp;
<a href="https://input.allizom.org/">input.allizom.org</a>.
</p>
<Example summary="Default (useable demo)" dashed="true" style={{width: "300px", height: "272px"}}>
<FeedbackView feedbackStore={feedbackStore} />
</Example>
<Example summary="Detailed form" dashed="true" style={{width: "300px", height: "272px"}}>
<FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.DETAILS} />
</Example>
<Example summary="Thank you!" dashed="true" style={{width: "300px", height: "272px"}}>
<FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.SENT} />
</Example>
</Section>
<Section name="CallUrlExpiredView">
<Example summary="Firefox User">
<CallUrlExpiredView isFirefox={true} />
</Example>
<Example summary="Non-Firefox User">
<CallUrlExpiredView isFirefox={false} />
</Example>
</Section>
<Section name="EndedConversationView">
<Example summary="Displays the feedback form">
<div className="standalone">
<EndedConversationView sdk={mockSDK}
video={{enabled: true}}
audio={{enabled: true}}
conversation={mockConversationModel}
feedbackStore={feedbackStore}
onAfterFeedbackReceived={noop} />
</div>
</Example>
</Section>
<Section name="AlertMessages">
<Example summary="Various alerts">
<div className="alert alert-warning">
<button className="close"></button>
<p className="message">
The person you were calling has ended the conversation.
</p>
</div>
<br />
<div className="alert alert-error">
<button className="close"></button>
<p className="message">
The person you were calling has ended the conversation.
</p>
</div>
</Example>
</Section>
<Section name="HomeView">
<Example summary="Standalone Home View">
<div className="standalone">
<HomeView />
</div>
</Example>
</Section>
<Section name="UnsupportedBrowserView">
<Example summary="Standalone Unsupported Browser">
<div className="standalone">
<UnsupportedBrowserView isFirefox={false}/>
</div>
</Example>
</Section>
<Section name="UnsupportedDeviceView">
<Example summary="Standalone Unsupported Device">
<div className="standalone">
<UnsupportedDeviceView platform="ios"/>
</div>
</Example>
</Section>
<Section name="DesktopRoomConversationView">
<Example summary="Desktop room conversation (invitation)" dashed="true"
style={{width: "260px", height: "265px"}}>
<div className="fx-embedded">
<DesktopRoomConversationView
roomStore={roomStore}
dispatcher={dispatcher}
mozLoop={navigator.mozLoop}
roomState={ROOM_STATES.INIT} />
</div>
</Example>
<Example summary="Desktop room conversation" dashed="true"
style={{width: "260px", height: "265px"}}>
<div className="fx-embedded">
<DesktopRoomConversationView
roomStore={roomStore}
dispatcher={dispatcher}
mozLoop={navigator.mozLoop}
roomState={ROOM_STATES.HAS_PARTICIPANTS} />
</div>
</Example>
</Section>
<Section name="StandaloneRoomView">
<Example summary="Standalone room conversation (ready)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.READY}
isFirefox={true} />
</div>
</Example>
<Example summary="Standalone room conversation (joined)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.JOINED}
isFirefox={true} />
</div>
</Example>
<Example summary="Standalone room conversation (has-participants)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.HAS_PARTICIPANTS}
isFirefox={true} />
</div>
</Example>
<Example summary="Standalone room conversation (full - FFx user)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.FULL}
isFirefox={true} />
</div>
</Example>
<Example summary="Standalone room conversation (full - non FFx user)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.FULL}
isFirefox={false} />
</div>
</Example>
<Example summary="Standalone room conversation (feedback)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
feedbackStore={feedbackStore}
roomState={ROOM_STATES.ENDED}
isFirefox={false} />
</div>
</Example>
<Example summary="Standalone room conversation (failed)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.FAILED}
isFirefox={false} />
</div>
</Example>
</Section>
<Section name="SVG icons preview" className="svg-icons">
<Example summary="10x10">
<SVGIcons size="10x10"/>
</Example>
<Example summary="14x14">
<SVGIcons size="14x14" />
</Example>
<Example summary="16x16">
<SVGIcons size="16x16"/>
</Example>
</Section>
</ShowCase>
);
}
});
/**
* Render components that have different styles across
* CSS media rules in their own iframe to mimic the viewport
* */
function _renderComponentsInIframes() {
var parents = document.querySelectorAll('.breakpoint');
[].forEach.call(parents, appendChildInIframe);
/**
* Extracts the component from the DOM and appends in the an iframe
*
* @type {HTMLElement} parent - Parent DOM node of a component & iframe
* */
function appendChildInIframe(parent) {
var styles = document.querySelector('head').children;
var component = parent.children[0];
var iframe = document.createElement('iframe');
var width = parent.dataset.breakpointWidth;
var height = parent.dataset.breakpointHeight;
iframe.style.width = width;
iframe.style.height = height;
parent.appendChild(iframe);
iframe.src = "about:blank";
// Workaround for bug 297685
iframe.onload = function () {
var iframeHead = iframe.contentDocument.querySelector('head');
iframe.contentDocument.documentElement.querySelector('body')
.appendChild(component);
[].forEach.call(styles, function(style) {
iframeHead.appendChild(style.cloneNode(true));
});
};
}
}
window.addEventListener("DOMContentLoaded", function() {
try {
React.renderComponent(<App />, document.getElementById("main"));
for (var listener of visibilityListeners) {
listener({target: {hidden: false}});
}
} catch(err) {
console.error(err);
uncaughtError = err;
}
_renderComponentsInIframes();
// Put the title back, in case views changed it.
document.title = "Loop UI Components Showcase";
// This simulates the mocha layout for errors which means we can run
// this alongside our other unit tests but use the same harness.
if (uncaughtError) {
$("#results").append("<div class='failures'><em>1</em></div>");
$("#results").append("<li class='test fail'>" +
"<h2>Errors rendering UI-Showcase</h2>" +
"<pre class='error'>" + uncaughtError + "\n" + uncaughtError.stack + "</pre>" +
"</li>");
} else {
$("#results").append("<div class='failures'><em>0</em></div>");
}
$("#results").append("<p id='complete'>Complete.</p>");
});
})();
@@ -161,7 +161,7 @@ FirefoxProfileMigrator.prototype._getResourcesInternal = function(sourceProfileD
}
}
// FHR related migrations.
// Telemetry related migrations.
let times = {
name: "times", // name is used only by tests.
type: types.OTHERDATA,
@@ -178,69 +178,60 @@ FirefoxProfileMigrator.prototype._getResourcesInternal = function(sourceProfileD
);
}
};
let healthReporter = {
name: "healthreporter", // name is used only by tests...
let telemetry = {
name: "telemetry", // name is used only by tests...
type: types.OTHERDATA,
migrate: aCallback => {
// the health-reporter can't have been initialized yet so it's safe to
// copy the SQL file.
let createSubDir = (name) => {
let dir = currentProfileDir.clone();
dir.append(name);
dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
return dir;
};
// We only support the default database name - copied from healthreporter.jsm
const DEFAULT_DATABASE_NAME = "healthreport.sqlite";
let path = OS.Path.join(sourceProfileDir.path, DEFAULT_DATABASE_NAME);
let sqliteFile = FileUtils.File(path);
if (sqliteFile.exists()) {
sqliteFile.copyTo(currentProfileDir, "");
}
// In unusual cases there may be 2 additional files - a "write ahead log"
// (-wal) file and a "shared memory file" (-shm). The wal file contains
// data that will be replayed when the DB is next opened, while the shm
// file is ignored in that case - the replay happens using only the wal.
// So we *do* copy a wal if it exists, but not a shm.
// See https://www.sqlite.org/tempfiles.html for more.
// (Note also we attempt these copies even if we can't find the DB, and
// rely on FHR itself to do the right thing if it can)
path = OS.Path.join(sourceProfileDir.path, DEFAULT_DATABASE_NAME + "-wal");
let sqliteWal = FileUtils.File(path);
if (sqliteWal.exists()) {
sqliteWal.copyTo(currentProfileDir, "");
}
// If the 'healthreport' directory exists we copy everything from it.
let subdir = this._getFileObject(sourceProfileDir, "healthreport");
// If the 'datareporting' directory exists we migrate files from it.
let haveStateFile = false;
let subdir = this._getFileObject(sourceProfileDir, "datareporting");
if (subdir && subdir.isDirectory()) {
// Copy all regular files.
let dest = currentProfileDir.clone();
dest.append("healthreport");
dest.create(Components.interfaces.nsIFile.DIRECTORY_TYPE,
FileUtils.PERMS_DIRECTORY);
// Copy only specific files.
let toCopy = ["state.json", "session-state.json"];
let dest = createSubDir("datareporting");
let enumerator = subdir.directoryEntries;
while (enumerator.hasMoreElements()) {
let file = enumerator.getNext().QueryInterface(Components.interfaces.nsIFile);
if (file.isDirectory()) {
let file = enumerator.getNext().QueryInterface(Ci.nsIFile);
if (file.isDirectory() || toCopy.indexOf(file.leafName) == -1) {
continue;
}
if (file.leafName == "state.json") {
haveStateFile = true;
}
file.copyTo(dest, "");
}
}
// If the 'datareporting' directory exists we copy just state.json
subdir = this._getFileObject(sourceProfileDir, "datareporting");
if (subdir && subdir.isDirectory()) {
let stateFile = this._getFileObject(subdir, "state.json");
if (stateFile) {
let dest = currentProfileDir.clone();
dest.append("datareporting");
dest.create(Components.interfaces.nsIFile.DIRECTORY_TYPE,
FileUtils.PERMS_DIRECTORY);
stateFile.copyTo(dest, "");
if (!haveStateFile) {
// Fall back to migrating the state file that contains the client id from healthreport/.
// We first moved the client id management from the FHR implementation to the datareporting
// service.
// Consequently, we try to migrate an existing FHR state file here as a fallback.
let subdir = this._getFileObject(sourceProfileDir, "healthreport");
if (subdir && subdir.isDirectory()) {
let stateFile = this._getFileObject(subdir, "state.json");
if (stateFile) {
let dest = createSubDir("healthreport");
stateFile.copyTo(dest, "");
}
}
}
aCallback(true);
}
}
return [places, cookies, passwords, formData, dictionary, bookmarksBackups,
session, times, healthReporter].filter(r => r);
session, times, telemetry].filter(r => r);
};
Object.defineProperty(FirefoxProfileMigrator.prototype, "startupOnlyMigrator", {
@@ -13,7 +13,6 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource:///modules/MigrationUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/LoginHelper.jsm");
Cu.importGlobalProperties(['FileReader']);
@@ -275,9 +274,9 @@ CtypesVaultHelpers.prototype = {
function hostIsIPAddress(aHost) {
try {
Services.eTLD.getBaseDomainFromHost(aHost);
} catch (e if e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS) {
return true;
} catch (e) {}
} catch (e) {
return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS;
}
return false;
}
@@ -514,7 +513,7 @@ Cookies.prototype = {
migrate(aCallback) {
this.ctypesKernelHelpers = new CtypesKernelHelpers();
let cookiesGenerator = (function genCookie() {
let cookiesGenerator = (function* genCookie() {
let success = false;
let folders = this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE ?
this.__cookiesFolders : [this.__cookiesFolder];
@@ -587,19 +586,35 @@ Cookies.prototype = {
* - Creation time least significant integer
* - Record delimiter "*"
*
* Unfortunately, "*" can also occur inside the value of the cookie, so we
* can't rely exclusively on it as a record separator.
*
* @note All the times are in FILETIME format.
*/
_parseCookieBuffer(aTextBuffer) {
// Note the last record is an empty string.
let records = [r for each (r in aTextBuffer.split("*\n")) if (r)];
// Note the last record is an empty string...
let records = [];
let lines = aTextBuffer.split("\n");
while (lines.length > 0) {
let record = lines.splice(0, 9);
// ... which means this is going to be a 1-element array for that record
if (record.length > 1) {
records.push(record);
}
}
for (let record of records) {
let [name, value, hostpath, flags,
expireTimeLo, expireTimeHi] = record.split("\n");
expireTimeLo, expireTimeHi] = record;
// IE stores deleted cookies with a zero-length value, skip them.
if (value.length == 0)
continue;
// IE sometimes has cookies created by apps that use "~~local~~/local/file/path"
// as the hostpath, ignore those:
if (hostpath.startsWith("~~local~~"))
continue;
let hostLen = hostpath.indexOf("/");
let host = hostpath.substr(0, hostLen);
let path = hostpath.substr(hostLen);
@@ -695,8 +710,12 @@ function getTypedURLs(registryKeyPath) {
} catch (ex) {
Cu.reportError("Error reading typed URL history: " + ex);
} finally {
typedURLKey.close();
typedURLTimeKey.close();
if (typedURLKey) {
typedURLKey.close();
}
if (typedURLTimeKey) {
typedURLTimeKey.close();
}
cTypes.finalize();
}
return typedURLs;
@@ -776,12 +795,21 @@ WindowsVaultFormPasswords.prototype = {
if (!_isIEOrEdgePassword(item.contents.schemaId.id)) {
continue;
}
let url = item.contents.pResourceElement.contents.itemValue.readString();
let realURL;
try {
realURL = Services.io.newURI(url, null, null);
} catch (ex) { /* leave realURL as null */ }
if (!realURL || ["http", "https", "ftp"].indexOf(realURL.scheme) == -1) {
// Ignore items for non-URLs or URLs that aren't HTTP(S)/FTP
continue;
}
// if aOnlyCheckExists is set to true, the purpose of the call is to return true if there is at
// least a password which is true in this case because a password was by now already found
if (aOnlyCheckExists) {
return true;
}
let url = item.contents.pResourceElement.contents.itemValue.readString();
let username = item.contents.pIdentityElement.contents.itemValue.readString();
// the current login credential object
let credential = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr;
@@ -808,7 +836,7 @@ WindowsVaultFormPasswords.prototype = {
// create a new login
let login = {
username, password,
hostname: NetUtil.newURI(url).prePath,
hostname: realURL.prePath,
timeCreated: creation,
};
LoginHelper.maybeImportLogin(login);
-1
View File
@@ -12,7 +12,6 @@ DIRS += [
'downloads',
'extensions',
'feeds',
'fuel',
'migration',
'newtab',
'places',
-8
View File
@@ -15,30 +15,22 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
"@mozilla.org/browser/aboutnewtab-service;1",
"nsIAboutNewTabService");
XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
"resource://gre/modules/Deprecated.jsm");
const DepecationURL = "https://bugzilla.mozilla.org/show_bug.cgi?id=1204983#c89";
this.NewTabURL = {
get: function() {
Deprecated.warning("NewTabURL.get is deprecated, please query aboutNewTabService.newTabURL", DepecationURL);
return aboutNewTabService.newTabURL;
},
get overridden() {
Deprecated.warning("NewTabURL.overridden is deprecated, please query aboutNewTabService.overridden", DepecationURL);
return aboutNewTabService.overridden;
},
override: function(newURL) {
Deprecated.warning("NewTabURL.override is deprecated, please set aboutNewTabService.newTabURL", DepecationURL);
aboutNewTabService.newTabURL = newURL;
},
reset: function() {
Deprecated.warning("NewTabURL.reset is deprecated, please use aboutNewTabService.resetNewTabURL()", DepecationURL);
aboutNewTabService.resetNewTabURL();
}
};
+78 -101
View File
@@ -27,6 +27,9 @@ XPCOMUtils.defineLazyGetter(this, "gPrincipal", function() {
return Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri);
});
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
// The maximum number of results PlacesProvider retrieves from history.
const HISTORY_RESULTS_LIMIT = 100;
@@ -65,46 +68,6 @@ let LinkChecker = {
}
};
/**
* Singleton that provides utility functions for links.
* A link is a plain object that looks like this:
*
* {
* url: "http://www.mozilla.org/",
* title: "Mozilla",
* frecency: 1337,
* lastVisitDate: 1394678824766431,
* }
*/
const LinkUtils = {
_sortProperties: [
"frecency",
"lastVisitDate",
"url",
],
/**
* Compares two links.
*
* @param {String} aLink1 The first link.
* @param {String} aLink2 The second link.
* @return {Number} A negative number if aLink1 is ordered before aLink2, zero if
* aLink1 and aLink2 have the same ordering, or a positive number if
* aLink1 is ordered after aLink2.
* Order is ascending.
*/
compareLinks: function LinkUtils_compareLinks(aLink1, aLink2) {
for (let prop of LinkUtils._sortProperties) {
if (!aLink1.hasOwnProperty(prop) || !aLink2.hasOwnProperty(prop)) {
throw new Error("Comparable link missing required property: " + prop);
}
}
return aLink2.frecency - aLink1.frecency ||
aLink2.lastVisitDate - aLink1.lastVisitDate ||
aLink1.url.localeCompare(aLink2.url);
},
};
/* Queries history to retrieve the most visited sites. Emits events when the
* history changes.
* Implements the EventEmitter interface.
@@ -197,71 +160,86 @@ Links.prototype = {
*
* @returns {Promise} Returns a promise with the array of links as payload.
*/
getLinks: function PlacesProvider_getLinks() {
let getLinksPromise = new Promise((resolve, reject) => {
let options = PlacesUtils.history.getNewQueryOptions();
options.maxResults = this.maxNumLinks;
getLinks: Task.async(function*() {
// Select a single page per host with highest frecency, highest recency.
// Choose N top such pages. Note +rev_host, to turn off optimizer per :mak
// suggestion.
let sqlQuery = `SELECT url, title, frecency,
last_visit_date as lastVisitDate,
"history" as type
FROM moz_places
WHERE frecency in (
SELECT MAX(frecency) as frecency
FROM moz_places
WHERE hidden = 0 AND last_visit_date NOTNULL
GROUP BY +rev_host
ORDER BY frecency DESC
LIMIT :limit
)
GROUP BY rev_host HAVING MAX(lastVisitDate)
ORDER BY frecency DESC, lastVisitDate DESC, url`;
// Sort by frecency, descending.
options.sortingMode = Ci.nsINavHistoryQueryOptions
.SORT_BY_FRECENCY_DESCENDING;
let links = yield this.executePlacesQuery(sqlQuery, {
columns: ["url", "title", "lastVisitDate", "frecency", "type"],
params: {limit: this.maxNumLinks}
});
let links = [];
return links.filter(link => LinkChecker.checkLoadURI(link.url));
}),
let queryHandlers = {
handleResult: function(aResultSet) {
for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
let url = row.getResultByIndex(1);
if (LinkChecker.checkLoadURI(url)) {
let link = {
url: url,
title: row.getResultByIndex(2),
frecency: row.getResultByIndex(12),
lastVisitDate: row.getResultByIndex(5),
type: "history",
};
links.push(link);
}
}
},
handleError: function(aError) {
reject(aError);
},
handleCompletion: function(aReason) { // jshint ignore:line
// The Places query breaks ties in frecency by place ID descending, but
// that's different from how Links.compareLinks breaks ties, because
// compareLinks doesn't have access to place IDs. It's very important
// that the initial list of links is sorted in the same order imposed by
// compareLinks, because Links uses compareLinks to perform binary
// searches on the list. So, ensure the list is so ordered.
let i = 1;
let outOfOrder = [];
while (i < links.length) {
if (LinkUtils.compareLinks(links[i - 1], links[i]) > 0) {
outOfOrder.push(links.splice(i, 1)[0]);
} else {
i++;
}
}
for (let link of outOfOrder) {
i = BinarySearch.insertionIndexOf(LinkUtils.compareLinks, links, link);
links.splice(i, 0, link);
}
resolve(links);
/**
* Executes arbitrary query against places database
*
* @param {String} aSql
* SQL query to execute
* @param {Object} [optional] aOptions
* aOptions.columns - an array of column names. if supplied the returned
* items will consist of objects keyed on column names. Otherwise
* an array of raw values is returned in the select order
* aOptions.param - an object of SQL binding parameters
* aOptions.callback - a callback to handle query rows
*
* @returns {Promise} Returns a promise with the array of retrieved items
*/
executePlacesQuery: Task.async(function*(aSql, aOptions={}) {
let {columns, params, callback} = aOptions;
let items = [];
let queryError = null;
let conn = yield PlacesUtils.promiseDBConnection();
yield conn.executeCached(aSql, params, aRow => {
try {
// check if caller wants to handle query raws
if (callback) {
callback(aRow);
}
};
// Execute the query.
let query = PlacesUtils.history.getNewQuery();
let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase);
db.asyncExecuteLegacyQueries([query], 1, options, queryHandlers);
// otherwise fill in the item and add items array
else {
let item = null;
// if columns array is given construct an object
if (columns && Array.isArray(columns)) {
item = {};
columns.forEach(column => {
item[column] = aRow.getResultByName(column);
});
} else {
// if no columns - make an array of raw values
item = [];
for (let i = 0; i < aRow.numEntries; i++) {
item.push(aRow.getResultByIndex(i));
}
}
items.push(item);
}
} catch (e) {
queryError = e;
throw StopIteration;
}
});
return getLinksPromise;
}
if (queryError) {
throw new Error(queryError);
}
return items;
}),
};
/**
@@ -271,6 +249,5 @@ const gLinks = new Links(); // jshint ignore:line
let PlacesProvider = {
LinkChecker: LinkChecker,
LinkUtils: LinkUtils,
links: gLinks,
};
@@ -25,7 +25,7 @@ XPCOMUtils.defineLazyModuleGetter(this, "NewTabRemoteResources",
const LOCAL_NEWTAB_URL = "chrome://browser/content/newtab/newTab.xhtml";
const REMOTE_NEWTAB_PATH = "/v%VERSION%/%CHANNEL%/%LOCALE%/index.html";
const REMOTE_NEWTAB_PATH = "/newtab/v%VERSION%/%CHANNEL%/%LOCALE%/index.html";
const ABOUT_URL = "about:newtab";
+14 -9
View File
@@ -12,6 +12,8 @@ const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/AppConstants.jsm");
// Set us up to use async prefs in the parent process.
Cu.import("resource://gre/modules/AsyncPrefs.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AboutHome",
"resource:///modules/AboutHome.jsm");
@@ -85,6 +87,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "OS",
XPCOMUtils.defineLazyModuleGetter(this, "RemotePrompt",
"resource:///modules/RemotePrompt.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ContentPrefServiceParent",
"resource://gre/modules/ContentPrefServiceParent.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Feeds",
"resource:///modules/Feeds.jsm");
@@ -726,6 +731,7 @@ BrowserGlue.prototype = {
RemotePrompt.init();
}
Feeds.init();
ContentPrefServiceParent.init();
LoginManagerParent.init();
ReaderParent.init();
@@ -835,7 +841,7 @@ BrowserGlue.prototype = {
label: win.gNavigatorBundle.getString("slowStartup.helpButton.label"),
accessKey: win.gNavigatorBundle.getString("slowStartup.helpButton.accesskey"),
callback: function () {
win.openUILinkIn(Services.prefs.getCharPref("browser.slowstartup.help.url"), "tab");
win.openUILinkIn("https://support.mozilla.org/kb/reset-firefox-easily-fix-most-problems", "tab");
}
},
{
@@ -954,13 +960,6 @@ BrowserGlue.prototype = {
// passively.
Services.ppmm.loadProcessScript("resource://pdf.js/pdfjschildbootstrap.js", true);
if (0 && AppConstants.NIGHTLY_BUILD) {
// Registering Shumway bootstrap script the child processes.
Services.ppmm.loadProcessScript("chrome://shumway/content/bootstrap-content.js", true);
// Initializing Shumway (shall be run after child script registration).
ShumwayUtils.init();
}
if (AppConstants.platform == "win") {
// For Windows 7, initialize the jump list module.
const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1";
@@ -1746,7 +1745,7 @@ BrowserGlue.prototype = {
},
_migrateUI: function BG__migrateUI() {
const UI_VERSION = 35;
const UI_VERSION = 36;
const BROWSER_DOCURL = "chrome://browser/content/browser.xul";
let currentUIVersion;
@@ -2105,6 +2104,12 @@ BrowserGlue.prototype = {
this._maybeMigrateTabGroups();
}
if (currentUIVersion < 36) {
xulStore.removeValue("chrome://passwordmgr/content/passwordManager.xul",
"passwordCol",
"hidden");
}
// Update the migration version.
Services.prefs.setIntPref("browser.migration.version", UI_VERSION);
},
@@ -0,0 +1,77 @@
/* 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/. */
// This test makes sure that the window title changes correctly while switching
// from and to private browsing mode.
add_task(function test() {
const testPageURL = "http://mochi.test:8888/browser/" +
"browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle_page.html";
requestLongerTimeout(2);
// initialization of expected titles
let test_title = "Test title";
let app_name = document.documentElement.getAttribute("title");
const isOSX = ("nsILocalFileMac" in Ci);
let page_with_title;
let page_without_title;
let about_pb_title;
let pb_page_with_title;
let pb_page_without_title;
let pb_about_pb_title;
if (isOSX) {
page_with_title = test_title;
page_without_title = app_name;
about_pb_title = "Open a private window?";
pb_page_with_title = test_title + " - (Private Browsing)";
pb_page_without_title = app_name + " - (Private Browsing)";
pb_about_pb_title = "Private Browsing - (Private Browsing)";
}
else {
page_with_title = test_title + " - " + app_name;
page_without_title = app_name;
about_pb_title = "Open a private window?" + " - " + app_name;
pb_page_with_title = test_title + " - " + app_name + " (Private Browsing)";
pb_page_without_title = app_name + " (Private Browsing)";
pb_about_pb_title = "Private Browsing - " + app_name + " (Private Browsing)";
}
function* testTabTitle(aWindow, url, insidePB, expected_title) {
let tab = (yield BrowserTestUtils.openNewForegroundTab(aWindow.gBrowser));
yield BrowserTestUtils.loadURI(tab.linkedBrowser, url);
yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
yield BrowserTestUtils.waitForCondition(() => {
return aWindow.document.title === expected_title;
}, `Window title should be ${expected_title}, got ${aWindow.document.title}`);
is(aWindow.document.title, expected_title, "The window title for " + url +
" is correct (" + (insidePB ? "inside" : "outside") +
" private browsing mode)");
let win = aWindow.gBrowser.replaceTabWithWindow(tab);
yield BrowserTestUtils.waitForEvent(win, "load", false);
yield BrowserTestUtils.waitForCondition(() => {
return win.document.title === expected_title;
}, `Window title should be ${expected_title}, got ${aWindow.document.title}`);
is(win.document.title, expected_title, "The window title for " + url +
" detached tab is correct (" + (insidePB ? "inside" : "outside") +
" private browsing mode)");
yield Promise.all([ BrowserTestUtils.closeWindow(win),
BrowserTestUtils.closeWindow(aWindow) ]);
}
function openWin(private) {
return BrowserTestUtils.openNewBrowserWindow({ private });
}
yield Task.spawn(testTabTitle((yield openWin(false)), "about:blank", false, page_without_title));
yield Task.spawn(testTabTitle((yield openWin(false)), testPageURL, false, page_with_title));
yield Task.spawn(testTabTitle((yield openWin(false)), "about:privatebrowsing", false, about_pb_title));
yield Task.spawn(testTabTitle((yield openWin(true)), "about:blank", true, pb_page_without_title));
yield Task.spawn(testTabTitle((yield openWin(true)), testPageURL, true, pb_page_with_title));
yield Task.spawn(testTabTitle((yield openWin(true)), "about:privatebrowsing", true, pb_about_pb_title));
});
@@ -0,0 +1,37 @@
/* 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/. */
// This test makes sure that private browsing turns off doesn't cause zoom
// settings to be reset on tab switch (bug 464962)
add_task(function* test() {
let win = (yield BrowserTestUtils.openNewBrowserWindow({ private: true }));
let tabAbout = (yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:"));
let tabMozilla = (yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:"));
let mozillaZoom = win.ZoomManager.zoom;
// change the zoom on the mozilla page
win.FullZoom.enlarge();
// make sure the zoom level has been changed
isnot(win.ZoomManager.zoom, mozillaZoom, "Zoom level can be changed");
mozillaZoom = win.ZoomManager.zoom;
// switch to about: tab
yield BrowserTestUtils.switchTab(win.gBrowser, tabAbout);
// switch back to mozilla tab
yield BrowserTestUtils.switchTab(win.gBrowser, tabMozilla);
// make sure the zoom level has not changed
is(win.ZoomManager.zoom, mozillaZoom,
"Entering private browsing should not reset the zoom on a tab");
// cleanup
win.FullZoom.reset();
yield Promise.all([ BrowserTestUtils.removeTab(tabMozilla),
BrowserTestUtils.removeTab(tabAbout) ]);
yield BrowserTestUtils.closeWindow(win);
});
@@ -0,0 +1,64 @@
/* 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/. */
// This test makes sure that about:privatebrowsing does not appear zoomed in
// if there is already a zoom site pref for about:blank (bug 487656).
add_task(function* test() {
// initialization
let windowsToClose = [];
let windowsToReset = [];
function promiseLocationChange() {
return new Promise(resolve => {
Services.obs.addObserver(function onLocationChange(subj, topic, data) {
Services.obs.removeObserver(onLocationChange, topic);
resolve();
}, "browser-fullZoom:location-change", false);
});
}
function promiseTestReady(aIsZoomedWindow, aWindow) {
// Need to wait on two things, the ordering of which is not guaranteed:
// (1) the page load, and (2) FullZoom's update to the new page's zoom
// level. FullZoom broadcasts "browser-fullZoom:location-change" when its
// update is done. (See bug 856366 for details.)
let browser = aWindow.gBrowser.selectedBrowser;
return BrowserTestUtils.loadURI(browser, "about:blank").then(() => {
return Promise.all([ BrowserTestUtils.browserLoaded(browser),
promiseLocationChange() ]);
}).then(() => doTest(aIsZoomedWindow, aWindow));
}
function doTest(aIsZoomedWindow, aWindow) {
if (aIsZoomedWindow) {
is(aWindow.ZoomManager.zoom, 1,
"Zoom level for freshly loaded about:blank should be 1");
// change the zoom on the blank page
aWindow.FullZoom.enlarge();
isnot(aWindow.ZoomManager.zoom, 1, "Zoom level for about:blank should be changed");
return;
}
// make sure the zoom level is set to 1
is(aWindow.ZoomManager.zoom, 1, "Zoom level for about:privatebrowsing should be reset");
}
function testOnWindow(options, callback) {
return BrowserTestUtils.openNewBrowserWindow(options).then((win) => {
windowsToClose.push(win);
windowsToReset.push(win);
return win;
});
}
yield testOnWindow({}).then(win => promiseTestReady(true, win));
yield testOnWindow({private: true}).then(win => promiseTestReady(false, win));
// cleanup
windowsToReset.forEach((win) => win.FullZoom.reset());
yield Promise.all(windowsToClose.map(win => BrowserTestUtils.closeWindow(win)));
});
@@ -809,11 +809,14 @@ var SessionStoreInternal = {
} else {
// If the user was typing into the URL bar when we crashed, but hadn't hit
// enter yet, then we just need to write that value to the URL bar without
// loading anything. This must happen after the load, since it will clear
// loading anything. This must happen after the load, as the load will clear
// userTypedValue.
let tabData = TabState.collect(tab);
if (tabData.userTypedValue && !tabData.userTypedClear) {
browser.userTypedValue = tabData.userTypedValue;
if (data.didStartLoad) {
browser.userTypedClear++;
}
win.URLBarSetURI();
}
@@ -833,7 +836,7 @@ var SessionStoreInternal = {
SessionStoreInternal._resetLocalTabRestoringState(tab);
SessionStoreInternal.restoreNextTab();
this._sendTabRestoredNotification(tab);
this._sendTabRestoredNotification(tab, data.isRemotenessUpdate);
break;
case "SessionStore:crashedTabRevived":
// The browser was revived by navigating to a different page
@@ -2506,7 +2509,6 @@ var SessionStoreInternal = {
tabState.index = historyIndex + 1;
tabState.index = Math.max(1, Math.min(tabState.index, tabState.entries.length));
} else {
tabState.userTypedValue = null;
options.loadArguments = recentLoadArguments;
}
@@ -3264,6 +3266,9 @@ var SessionStoreInternal = {
* the tab to restore
* @param aLoadArguments
* optional load arguments used for loadURI()
* @param aRemotenessSwitch
* true if we're restoring a tab's content because we flipped
* its remoteness (out-of-process) state.
*/
restoreTabContent: function (aTab, aLoadArguments = null) {
if (aTab.hasAttribute("customizemode")) {
@@ -3287,7 +3292,8 @@ var SessionStoreInternal = {
// flip the remoteness of any browser that is not being displayed.
this.markTabAsRestoring(aTab);
if (tabbrowser.updateBrowserRemotenessByURL(browser, uri)) {
let isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL(browser, uri);
if (isRemotenessUpdate) {
// We updated the remoteness, so we need to send the history down again.
//
// Start a new epoch to discard all frame script messages relating to a
@@ -3310,7 +3316,7 @@ var SessionStoreInternal = {
}
browser.messageManager.sendAsyncMessage("SessionStore:restoreTabContent",
{loadArguments: aLoadArguments});
{loadArguments: aLoadArguments, isRemotenessUpdate});
},
/**
@@ -3955,11 +3961,17 @@ var SessionStoreInternal = {
/**
* Dispatch the SSTabRestored event for the given tab.
* @param aTab the which has been restored
* @param aTab
* The tab which has been restored
* @param aIsRemotenessUpdate
* True if this tab was restored due to flip from running from
* out-of-main-process to in-main-process or vice-versa.
*/
_sendTabRestoredNotification: function ssi_sendTabRestoredNotification(aTab) {
let event = aTab.ownerDocument.createEvent("Events");
event.initEvent("SSTabRestored", true, false);
_sendTabRestoredNotification(aTab, aIsRemotenessUpdate) {
let event = aTab.ownerDocument.createEvent("CustomEvent");
event.initCustomEvent("SSTabRestored", true, false, {
isRemotenessUpdate: aIsRemotenessUpdate,
});
aTab.dispatchEvent(event);
},
@@ -199,9 +199,16 @@ this.StartupPerformance = {
// to reach that point.
let win = subject;
let observer = () => {
this._latestRestoredTimeStamp = Date.now();
this._totalNumberOfEagerTabs += 1;
let observer = (event) => {
// We don't care about tab restorations that are due to
// a browser flipping from out-of-main-process to in-main-process
// or vice-versa. We only care about restorations that are due
// to the user switching to a lazily restored tab, or for tabs
// that are restoring eagerly.
if (!event.detail.isRemotenessUpdate) {
this._latestRestoredTimeStamp = Date.now();
this._totalNumberOfEagerTabs += 1;
}
};
win.gBrowser.tabContainer.addEventListener("SSTabRestored", observer);
this._totalNumberOfTabs += win.gBrowser.tabContainer.itemCount;
@@ -167,21 +167,21 @@ var MessageListener = {
sendSyncMessage("SessionStore:restoreHistoryComplete", {epoch});
},
restoreTabContent({loadArguments}) {
restoreTabContent({loadArguments, isRemotenessUpdate}) {
let epoch = gCurrentEpoch;
// We need to pass the value of didStartLoad back to SessionStore.jsm.
let didStartLoad = gContentRestore.restoreTabContent(loadArguments, () => {
// Tell SessionStore.jsm that it may want to restore some more tabs,
// since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time.
sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch});
sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch, isRemotenessUpdate});
});
sendAsyncMessage("SessionStore:restoreTabContentStarted", {epoch});
sendAsyncMessage("SessionStore:restoreTabContentStarted", {epoch, didStartLoad});
if (!didStartLoad) {
// Pretend that the load succeeded so that event handlers fire correctly.
sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch});
sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch, isRemotenessUpdate});
}
},
@@ -100,49 +100,51 @@ add_task(function* () {
let glue = Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsIObserver)
glue.observe(null, TOPIC_BROWSERGLUE_TEST, TOPICDATA_DISTRIBUTION_CUSTOMIZATION);
Assert.equal(Services.prefs.getCharPref("distribution.id"), "disttest");
Assert.equal(Services.prefs.getCharPref("distribution.version"), "1.0");
Assert.equal(Services.prefs.getComplexValue("distribution.about", Ci.nsISupportsString).data, "Tèƨƭ δïƨƭřïβúƭïôñ ƒïℓè");
var defaultBranch = Services.prefs.getDefaultBranch(null);
Assert.equal(Services.prefs.getCharPref("distribution.test.string"), "Test String");
Assert.equal(Services.prefs.getCharPref("distribution.test.string.noquotes"), "Test String");
Assert.equal(Services.prefs.getIntPref("distribution.test.int"), 777);
Assert.equal(Services.prefs.getBoolPref("distribution.test.bool.true"), true);
Assert.equal(Services.prefs.getBoolPref("distribution.test.bool.false"), false);
Assert.equal(defaultBranch.getCharPref("distribution.id"), "disttest");
Assert.equal(defaultBranch.getCharPref("distribution.version"), "1.0");
Assert.equal(defaultBranch.getComplexValue("distribution.about", Ci.nsISupportsString).data, "Tèƨƭ δïƨƭřïβúƭïôñ ƒïℓè");
Assert.throws(() => Services.prefs.getCharPref("distribution.test.empty"));
Assert.throws(() => Services.prefs.getIntPref("distribution.test.empty"));
Assert.throws(() => Services.prefs.getBoolPref("distribution.test.empty"));
Assert.equal(defaultBranch.getCharPref("distribution.test.string"), "Test String");
Assert.equal(defaultBranch.getCharPref("distribution.test.string.noquotes"), "Test String");
Assert.equal(defaultBranch.getIntPref("distribution.test.int"), 777);
Assert.equal(defaultBranch.getBoolPref("distribution.test.bool.true"), true);
Assert.equal(defaultBranch.getBoolPref("distribution.test.bool.false"), false);
Assert.equal(Services.prefs.getCharPref("distribution.test.pref.locale"), "en-US");
Assert.equal(Services.prefs.getCharPref("distribution.test.pref.language.en"), "en");
Assert.equal(Services.prefs.getCharPref("distribution.test.pref.locale.en-US"), "en-US");
Assert.throws(() => Services.prefs.getCharPref("distribution.test.pref.language.de"));
Assert.throws(() => defaultBranch.getCharPref("distribution.test.empty"));
Assert.throws(() => defaultBranch.getIntPref("distribution.test.empty"));
Assert.throws(() => defaultBranch.getBoolPref("distribution.test.empty"));
Assert.equal(defaultBranch.getCharPref("distribution.test.pref.locale"), "en-US");
Assert.equal(defaultBranch.getCharPref("distribution.test.pref.language.en"), "en");
Assert.equal(defaultBranch.getCharPref("distribution.test.pref.locale.en-US"), "en-US");
Assert.throws(() => defaultBranch.getCharPref("distribution.test.pref.language.de"));
// This value was never set because of the empty language specific pref
Assert.throws(() => Services.prefs.getCharPref("distribution.test.pref.language.reset"));
Assert.throws(() => defaultBranch.getCharPref("distribution.test.pref.language.reset"));
// This value was never set because of the empty locale specific pref
Assert.throws(() => Services.prefs.getCharPref("distribution.test.pref.locale.reset"));
Assert.throws(() => defaultBranch.getCharPref("distribution.test.pref.locale.reset"));
// This value was overridden by a locale specific setting
Assert.equal(Services.prefs.getCharPref("distribution.test.pref.locale.set"), "Locale Set");
Assert.equal(defaultBranch.getCharPref("distribution.test.pref.locale.set"), "Locale Set");
// This value was overridden by a language specific setting
Assert.equal(Services.prefs.getCharPref("distribution.test.pref.language.set"), "Language Set");
Assert.equal(defaultBranch.getCharPref("distribution.test.pref.language.set"), "Language Set");
// Language should not override locale
Assert.notEqual(Services.prefs.getCharPref("distribution.test.pref.locale.set"), "Language Set");
Assert.notEqual(defaultBranch.getCharPref("distribution.test.pref.locale.set"), "Language Set");
Assert.equal(Services.prefs.getComplexValue("distribution.test.locale", Ci.nsIPrefLocalizedString).data, "en-US");
Assert.equal(Services.prefs.getComplexValue("distribution.test.language.en", Ci.nsIPrefLocalizedString).data, "en");
Assert.equal(Services.prefs.getComplexValue("distribution.test.locale.en-US", Ci.nsIPrefLocalizedString).data, "en-US");
Assert.throws(() => Services.prefs.getComplexValue("distribution.test.language.de", Ci.nsIPrefLocalizedString));
Assert.equal(defaultBranch.getComplexValue("distribution.test.locale", Ci.nsIPrefLocalizedString).data, "en-US");
Assert.equal(defaultBranch.getComplexValue("distribution.test.language.en", Ci.nsIPrefLocalizedString).data, "en");
Assert.equal(defaultBranch.getComplexValue("distribution.test.locale.en-US", Ci.nsIPrefLocalizedString).data, "en-US");
Assert.throws(() => defaultBranch.getComplexValue("distribution.test.language.de", Ci.nsIPrefLocalizedString));
// This value was never set because of the empty language specific pref
Assert.throws(() => Services.prefs.getComplexValue("distribution.test.language.reset", Ci.nsIPrefLocalizedString));
Assert.throws(() => defaultBranch.getComplexValue("distribution.test.language.reset", Ci.nsIPrefLocalizedString));
// This value was never set because of the empty locale specific pref
Assert.throws(() => Services.prefs.getComplexValue("distribution.test.locale.reset", Ci.nsIPrefLocalizedString));
Assert.throws(() => defaultBranch.getComplexValue("distribution.test.locale.reset", Ci.nsIPrefLocalizedString));
// This value was overridden by a locale specific setting
Assert.equal(Services.prefs.getComplexValue("distribution.test.locale.set", Ci.nsIPrefLocalizedString).data, "Locale Set");
Assert.equal(defaultBranch.getComplexValue("distribution.test.locale.set", Ci.nsIPrefLocalizedString).data, "Locale Set");
// This value was overridden by a language specific setting
Assert.equal(Services.prefs.getComplexValue("distribution.test.language.set", Ci.nsIPrefLocalizedString).data, "Language Set");
Assert.equal(defaultBranch.getComplexValue("distribution.test.language.set", Ci.nsIPrefLocalizedString).data, "Language Set");
// Language should not override locale
Assert.notEqual(Services.prefs.getComplexValue("distribution.test.locale.set", Ci.nsIPrefLocalizedString).data, "Language Set");
Assert.notEqual(defaultBranch.getComplexValue("distribution.test.locale.set", Ci.nsIPrefLocalizedString).data, "Language Set");
do_test_pending();

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