Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 this.EXPORTED_SYMBOLS = ["Social", "CreateSocialStatusWidget",
8 "CreateSocialMarkWidget", "OpenGraphBuilder",
9 "DynamicResizeWatcher", "sizeSocialPanelToContent"];
11 const Ci = Components.interfaces;
12 const Cc = Components.classes;
13 const Cu = Components.utils;
15 // The minimum sizes for the auto-resize panel code.
16 const PANEL_MIN_HEIGHT = 100;
17 const PANEL_MIN_WIDTH = 330;
19 Cu.import("resource://gre/modules/Services.jsm");
20 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
22 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
23 "resource:///modules/CustomizableUI.jsm");
24 XPCOMUtils.defineLazyModuleGetter(this, "SocialService",
25 "resource://gre/modules/SocialService.jsm");
26 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
27 "resource://gre/modules/PlacesUtils.jsm");
28 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
29 "resource://gre/modules/PrivateBrowsingUtils.jsm");
30 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
31 "resource://gre/modules/Promise.jsm");
33 XPCOMUtils.defineLazyServiceGetter(this, "unescapeService",
34 "@mozilla.org/feed-unescapehtml;1",
35 "nsIScriptableUnescapeHTML");
37 function promiseSetAnnotation(aURI, providerList) {
38 let deferred = Promise.defer();
40 // Delaying to catch issues with asynchronous behavior while waiting
41 // to implement asynchronous annotations in bug 699844.
42 Services.tm.mainThread.dispatch(function() {
43 try {
44 if (providerList && providerList.length > 0) {
45 PlacesUtils.annotations.setPageAnnotation(
46 aURI, "social/mark", JSON.stringify(providerList), 0,
47 PlacesUtils.annotations.EXPIRE_WITH_HISTORY);
48 } else {
49 PlacesUtils.annotations.removePageAnnotation(aURI, "social/mark");
50 }
51 } catch(e) {
52 Cu.reportError("SocialAnnotation failed: " + e);
53 }
54 deferred.resolve();
55 }, Ci.nsIThread.DISPATCH_NORMAL);
57 return deferred.promise;
58 }
60 function promiseGetAnnotation(aURI) {
61 let deferred = Promise.defer();
63 // Delaying to catch issues with asynchronous behavior while waiting
64 // to implement asynchronous annotations in bug 699844.
65 Services.tm.mainThread.dispatch(function() {
66 let val = null;
67 try {
68 val = PlacesUtils.annotations.getPageAnnotation(aURI, "social/mark");
69 } catch (ex) { }
71 deferred.resolve(val);
72 }, Ci.nsIThread.DISPATCH_NORMAL);
74 return deferred.promise;
75 }
77 this.Social = {
78 initialized: false,
79 lastEventReceived: 0,
80 providers: [],
81 _disabledForSafeMode: false,
83 init: function Social_init() {
84 this._disabledForSafeMode = Services.appinfo.inSafeMode && this.enabled;
85 let deferred = Promise.defer();
87 if (this.initialized) {
88 deferred.resolve(true);
89 return deferred.promise;
90 }
91 this.initialized = true;
92 // if SocialService.hasEnabledProviders, retreive the providers so the
93 // front-end can generate UI
94 if (SocialService.hasEnabledProviders) {
95 // Retrieve the current set of providers, and set the current provider.
96 SocialService.getOrderedProviderList(function (providers) {
97 Social._updateProviderCache(providers);
98 Social._updateWorkerState(SocialService.enabled);
99 deferred.resolve(false);
100 });
101 } else {
102 deferred.resolve(false);
103 }
105 // Register an observer for changes to the provider list
106 SocialService.registerProviderListener(function providerListener(topic, origin, providers) {
107 // An engine change caused by adding/removing a provider should notify.
108 // any providers we receive are enabled in the AddonsManager
109 if (topic == "provider-installed" || topic == "provider-uninstalled") {
110 // installed/uninstalled do not send the providers param
111 Services.obs.notifyObservers(null, "social:" + topic, origin);
112 return;
113 }
114 if (topic == "provider-enabled") {
115 Social._updateProviderCache(providers);
116 Social._updateWorkerState(true);
117 Services.obs.notifyObservers(null, "social:" + topic, origin);
118 return;
119 }
120 if (topic == "provider-disabled") {
121 // a provider was removed from the list of providers, that does not
122 // affect worker state for other providers
123 Social._updateProviderCache(providers);
124 Social._updateWorkerState(providers.length > 0);
125 Services.obs.notifyObservers(null, "social:" + topic, origin);
126 return;
127 }
128 if (topic == "provider-update") {
129 // a provider has self-updated its manifest, we need to update our cache
130 // and reload the provider.
131 Social._updateProviderCache(providers);
132 let provider = Social._getProviderFromOrigin(origin);
133 provider.reload();
134 }
135 });
136 return deferred.promise;
137 },
139 _updateWorkerState: function(enable) {
140 [p.enabled = enable for (p of Social.providers) if (p.enabled != enable)];
141 },
143 // Called to update our cache of providers and set the current provider
144 _updateProviderCache: function (providers) {
145 this.providers = providers;
146 Services.obs.notifyObservers(null, "social:providers-changed", null);
147 },
149 get enabled() {
150 return !this._disabledForSafeMode && this.providers.length > 0;
151 },
153 toggleNotifications: function SocialNotifications_toggle() {
154 let prefValue = Services.prefs.getBoolPref("social.toast-notifications.enabled");
155 Services.prefs.setBoolPref("social.toast-notifications.enabled", !prefValue);
156 },
158 _getProviderFromOrigin: function (origin) {
159 for (let p of this.providers) {
160 if (p.origin == origin) {
161 return p;
162 }
163 }
164 return null;
165 },
167 getManifestByOrigin: function(origin) {
168 return SocialService.getManifestByOrigin(origin);
169 },
171 installProvider: function(doc, data, installCallback) {
172 SocialService.installProvider(doc, data, installCallback);
173 },
175 uninstallProvider: function(origin, aCallback) {
176 SocialService.uninstallProvider(origin, aCallback);
177 },
179 // Activation functionality
180 activateFromOrigin: function (origin, callback) {
181 // For now only "builtin" providers can be activated. It's OK if the
182 // provider has already been activated - we still get called back with it.
183 SocialService.addBuiltinProvider(origin, callback);
184 },
186 // Page Marking functionality
187 isURIMarked: function(origin, aURI, aCallback) {
188 promiseGetAnnotation(aURI).then(function(val) {
189 if (val) {
190 let providerList = JSON.parse(val);
191 val = providerList.indexOf(origin) >= 0;
192 }
193 aCallback(!!val);
194 }).then(null, Cu.reportError);
195 },
197 markURI: function(origin, aURI, aCallback) {
198 // update or set our annotation
199 promiseGetAnnotation(aURI).then(function(val) {
201 let providerList = val ? JSON.parse(val) : [];
202 let marked = providerList.indexOf(origin) >= 0;
203 if (marked)
204 return;
205 providerList.push(origin);
206 // we allow marking links in a page that may not have been visited yet.
207 // make sure there is a history entry for the uri, then annotate it.
208 let place = {
209 uri: aURI,
210 visits: [{
211 visitDate: Date.now() + 1000,
212 transitionType: Ci.nsINavHistoryService.TRANSITION_LINK
213 }]
214 };
215 PlacesUtils.asyncHistory.updatePlaces(place, {
216 handleError: function () Cu.reportError("couldn't update history for socialmark annotation"),
217 handleResult: function () {},
218 handleCompletion: function () {
219 promiseSetAnnotation(aURI, providerList).then(function() {
220 if (aCallback)
221 schedule(function() { aCallback(true); } );
222 }).then(null, Cu.reportError);
223 }
224 });
225 }).then(null, Cu.reportError);
226 },
228 unmarkURI: function(origin, aURI, aCallback) {
229 // this should not be called if this.provider or the port is null
230 // set our annotation
231 promiseGetAnnotation(aURI).then(function(val) {
232 let providerList = val ? JSON.parse(val) : [];
233 let marked = providerList.indexOf(origin) >= 0;
234 if (marked) {
235 // remove the annotation
236 providerList.splice(providerList.indexOf(origin), 1);
237 promiseSetAnnotation(aURI, providerList).then(function() {
238 if (aCallback)
239 schedule(function() { aCallback(false); } );
240 }).then(null, Cu.reportError);
241 }
242 }).then(null, Cu.reportError);
243 },
245 setErrorListener: function(iframe, errorHandler) {
246 if (iframe.socialErrorListener)
247 return iframe.socialErrorListener;
248 return new SocialErrorListener(iframe, errorHandler);
249 }
250 };
252 function schedule(callback) {
253 Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL);
254 }
256 function CreateSocialStatusWidget(aId, aProvider) {
257 if (!aProvider.statusURL)
258 return;
259 let widget = CustomizableUI.getWidget(aId);
260 // The widget is only null if we've created then destroyed the widget.
261 // Once we've actually called createWidget the provider will be set to
262 // PROVIDER_API.
263 if (widget && widget.provider == CustomizableUI.PROVIDER_API)
264 return;
266 CustomizableUI.createWidget({
267 id: aId,
268 type: 'custom',
269 removable: true,
270 defaultArea: CustomizableUI.AREA_NAVBAR,
271 onBuild: function(aDocument) {
272 let node = aDocument.createElement('toolbarbutton');
273 node.id = this.id;
274 node.setAttribute('class', 'toolbarbutton-1 chromeclass-toolbar-additional social-status-button');
275 node.setAttribute('type', "badged");
276 node.style.listStyleImage = "url(" + (aProvider.icon32URL || aProvider.iconURL) + ")";
277 node.setAttribute("origin", aProvider.origin);
278 node.setAttribute("label", aProvider.name);
279 node.setAttribute("tooltiptext", aProvider.name);
280 node.setAttribute("oncommand", "SocialStatus.showPopup(this);");
282 if (PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView))
283 node.setAttribute("disabled", "true");
285 return node;
286 }
287 });
288 };
290 function CreateSocialMarkWidget(aId, aProvider) {
291 if (!aProvider.markURL)
292 return;
293 let widget = CustomizableUI.getWidget(aId);
294 // The widget is only null if we've created then destroyed the widget.
295 // Once we've actually called createWidget the provider will be set to
296 // PROVIDER_API.
297 if (widget && widget.provider == CustomizableUI.PROVIDER_API)
298 return;
300 CustomizableUI.createWidget({
301 id: aId,
302 type: 'custom',
303 removable: true,
304 defaultArea: CustomizableUI.AREA_NAVBAR,
305 onBuild: function(aDocument) {
306 let node = aDocument.createElement('toolbarbutton');
307 node.id = this.id;
308 node.setAttribute('class', 'toolbarbutton-1 chromeclass-toolbar-additional social-mark-button');
309 node.setAttribute('type', "socialmark");
310 node.style.listStyleImage = "url(" + (aProvider.unmarkedIcon || aProvider.icon32URL || aProvider.iconURL) + ")";
311 node.setAttribute("origin", aProvider.origin);
312 node.setAttribute("oncommand", "this.markCurrentPage();");
314 let window = aDocument.defaultView;
315 let menuLabel = window.gNavigatorBundle.getFormattedString("social.markpageMenu.label", [aProvider.name]);
316 node.setAttribute("label", menuLabel);
317 node.setAttribute("tooltiptext", menuLabel);
319 return node;
320 }
321 });
322 };
324 // Error handling class used to listen for network errors in the social frames
325 // and replace them with a social-specific error page
326 function SocialErrorListener(iframe, errorHandler) {
327 this.setErrorMessage = errorHandler;
328 this.iframe = iframe;
329 iframe.socialErrorListener = this;
330 iframe.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
331 .getInterface(Ci.nsIWebProgress)
332 .addProgressListener(this,
333 Ci.nsIWebProgress.NOTIFY_STATE_REQUEST |
334 Ci.nsIWebProgress.NOTIFY_LOCATION);
335 }
337 SocialErrorListener.prototype = {
338 QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
339 Ci.nsISupportsWeakReference,
340 Ci.nsISupports]),
342 remove: function() {
343 this.iframe.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
344 .getInterface(Ci.nsIWebProgress)
345 .removeProgressListener(this);
346 delete this.iframe.socialErrorListener;
347 },
349 onStateChange: function SPL_onStateChange(aWebProgress, aRequest, aState, aStatus) {
350 let failure = false;
351 if ((aState & Ci.nsIWebProgressListener.STATE_STOP)) {
352 if (aRequest instanceof Ci.nsIHttpChannel) {
353 try {
354 // Change the frame to an error page on 4xx (client errors)
355 // and 5xx (server errors)
356 failure = aRequest.responseStatus >= 400 &&
357 aRequest.responseStatus < 600;
358 } catch (e) {}
359 }
360 }
362 // Calling cancel() will raise some OnStateChange notifications by itself,
363 // so avoid doing that more than once
364 if (failure && aStatus != Components.results.NS_BINDING_ABORTED) {
365 aRequest.cancel(Components.results.NS_BINDING_ABORTED);
366 let provider = Social._getProviderFromOrigin(this.iframe.getAttribute("origin"));
367 provider.errorState = "content-error";
368 this.setErrorMessage(aWebProgress.QueryInterface(Ci.nsIDocShell)
369 .chromeEventHandler);
370 }
371 },
373 onLocationChange: function SPL_onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
374 if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
375 aRequest.cancel(Components.results.NS_BINDING_ABORTED);
376 let provider = Social._getProviderFromOrigin(this.iframe.getAttribute("origin"));
377 if (!provider.errorState)
378 provider.errorState = "content-error";
379 schedule(function() {
380 this.setErrorMessage(aWebProgress.QueryInterface(Ci.nsIDocShell)
381 .chromeEventHandler);
382 }.bind(this));
383 }
384 },
386 onProgressChange: function SPL_onProgressChange() {},
387 onStatusChange: function SPL_onStatusChange() {},
388 onSecurityChange: function SPL_onSecurityChange() {},
389 };
392 function sizeSocialPanelToContent(panel, iframe) {
393 let doc = iframe.contentDocument;
394 if (!doc || !doc.body) {
395 return;
396 }
397 // We need an element to use for sizing our panel. See if the body defines
398 // an id for that element, otherwise use the body itself.
399 let body = doc.body;
400 let bodyId = body.getAttribute("contentid");
401 if (bodyId) {
402 body = doc.getElementById(bodyId) || doc.body;
403 }
404 // offsetHeight/Width don't include margins, so account for that.
405 let cs = doc.defaultView.getComputedStyle(body);
406 let width = PANEL_MIN_WIDTH;
407 let height = PANEL_MIN_HEIGHT;
408 // if the panel is preloaded prior to being shown, cs will be null. in that
409 // case use the minimum size for the panel until it is shown.
410 if (cs) {
411 let computedHeight = parseInt(cs.marginTop) + body.offsetHeight + parseInt(cs.marginBottom);
412 height = Math.max(computedHeight, height);
413 let computedWidth = parseInt(cs.marginLeft) + body.offsetWidth + parseInt(cs.marginRight);
414 width = Math.max(computedWidth, width);
415 }
416 iframe.style.width = width + "px";
417 iframe.style.height = height + "px";
418 // since we do not use panel.sizeTo, we need to adjust the arrow ourselves
419 if (panel.state == "open")
420 panel.adjustArrowPosition();
421 }
423 function DynamicResizeWatcher() {
424 this._mutationObserver = null;
425 }
427 DynamicResizeWatcher.prototype = {
428 start: function DynamicResizeWatcher_start(panel, iframe) {
429 this.stop(); // just in case...
430 let doc = iframe.contentDocument;
431 this._mutationObserver = new iframe.contentWindow.MutationObserver(function(mutations) {
432 sizeSocialPanelToContent(panel, iframe);
433 });
434 // Observe anything that causes the size to change.
435 let config = {attributes: true, characterData: true, childList: true, subtree: true};
436 this._mutationObserver.observe(doc, config);
437 // and since this may be setup after the load event has fired we do an
438 // initial resize now.
439 sizeSocialPanelToContent(panel, iframe);
440 },
441 stop: function DynamicResizeWatcher_stop() {
442 if (this._mutationObserver) {
443 try {
444 this._mutationObserver.disconnect();
445 } catch (ex) {
446 // may get "TypeError: can't access dead object" which seems strange,
447 // but doesn't seem to indicate a real problem, so ignore it...
448 }
449 this._mutationObserver = null;
450 }
451 }
452 }
455 this.OpenGraphBuilder = {
456 generateEndpointURL: function(URLTemplate, pageData) {
457 // support for existing oexchange style endpoints by supporting their
458 // querystring arguments. parse the query string template and do
459 // replacements where necessary the query names may be different than ours,
460 // so we could see u=%{url} or url=%{url}
461 let [endpointURL, queryString] = URLTemplate.split("?");
462 let query = {};
463 if (queryString) {
464 queryString.split('&').forEach(function (val) {
465 let [name, value] = val.split('=');
466 let p = /%\{(.+)\}/.exec(value);
467 if (!p) {
468 // preserve non-template query vars
469 query[name] = value;
470 } else if (pageData[p[1]]) {
471 query[name] = pageData[p[1]];
472 } else if (p[1] == "body") {
473 // build a body for emailers
474 let body = "";
475 if (pageData.title)
476 body += pageData.title + "\n\n";
477 if (pageData.description)
478 body += pageData.description + "\n\n";
479 if (pageData.text)
480 body += pageData.text + "\n\n";
481 body += pageData.url;
482 query["body"] = body;
483 }
484 });
485 }
486 var str = [];
487 for (let p in query)
488 str.push(p + "=" + encodeURIComponent(query[p]));
489 if (str.length)
490 endpointURL = endpointURL + "?" + str.join("&");
491 return endpointURL;
492 },
494 getData: function(browser) {
495 let res = {
496 url: this._validateURL(browser, browser.currentURI.spec),
497 title: browser.contentDocument.title,
498 previews: []
499 };
500 this._getMetaData(browser, res);
501 this._getLinkData(browser, res);
502 this._getPageData(browser, res);
503 return res;
504 },
506 _getMetaData: function(browser, o) {
507 // query for standardized meta data
508 let els = browser.contentDocument
509 .querySelectorAll("head > meta[property], head > meta[name]");
510 if (els.length < 1)
511 return;
512 let url;
513 for (let el of els) {
514 let value = el.getAttribute("content")
515 if (!value)
516 continue;
517 value = unescapeService.unescape(value.trim());
518 switch (el.getAttribute("property") || el.getAttribute("name")) {
519 case "title":
520 case "og:title":
521 o.title = value;
522 break;
523 case "description":
524 case "og:description":
525 o.description = value;
526 break;
527 case "og:site_name":
528 o.siteName = value;
529 break;
530 case "medium":
531 case "og:type":
532 o.medium = value;
533 break;
534 case "og:video":
535 url = this._validateURL(browser, value);
536 if (url)
537 o.source = url;
538 break;
539 case "og:url":
540 url = this._validateURL(browser, value);
541 if (url)
542 o.url = url;
543 break;
544 case "og:image":
545 url = this._validateURL(browser, value);
546 if (url)
547 o.previews.push(url);
548 break;
549 }
550 }
551 },
553 _getLinkData: function(browser, o) {
554 let els = browser.contentDocument
555 .querySelectorAll("head > link[rel], head > link[id]");
556 for (let el of els) {
557 let url = el.getAttribute("href");
558 if (!url)
559 continue;
560 url = this._validateURL(browser, unescapeService.unescape(url.trim()));
561 switch (el.getAttribute("rel") || el.getAttribute("id")) {
562 case "shorturl":
563 case "shortlink":
564 o.shortUrl = url;
565 break;
566 case "canonicalurl":
567 case "canonical":
568 o.url = url;
569 break;
570 case "image_src":
571 o.previews.push(url);
572 break;
573 }
574 }
575 },
577 // scrape through the page for data we want
578 _getPageData: function(browser, o) {
579 if (o.previews.length < 1)
580 o.previews = this._getImageUrls(browser);
581 },
583 _validateURL: function(browser, url) {
584 let uri = Services.io.newURI(browser.currentURI.resolve(url), null, null);
585 if (["http", "https", "ftp", "ftps"].indexOf(uri.scheme) < 0)
586 return null;
587 uri.userPass = "";
588 return uri.spec;
589 },
591 _getImageUrls: function(browser) {
592 let l = [];
593 let els = browser.contentDocument.querySelectorAll("img");
594 for (let el of els) {
595 let content = el.getAttribute("src");
596 if (content) {
597 l.push(this._validateURL(browser, unescapeService.unescape(content)));
598 // we don't want a billion images
599 if (l.length > 5)
600 break;
601 }
602 }
603 return l;
604 }
605 };