Source code

Revision control

Other Tools

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
3
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
var EXPORTED_SYMBOLS = ["PopupNotifications"];
6
7
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8
const { PrivateBrowsingUtils } = ChromeUtils.import(
10
);
11
const { PromiseUtils } = ChromeUtils.import(
13
);
14
15
const NOTIFICATION_EVENT_DISMISSED = "dismissed";
16
const NOTIFICATION_EVENT_REMOVED = "removed";
17
const NOTIFICATION_EVENT_SHOWING = "showing";
18
const NOTIFICATION_EVENT_SHOWN = "shown";
19
const NOTIFICATION_EVENT_SWAPPING = "swapping";
20
21
const ICON_SELECTOR = ".notification-anchor-icon";
22
const ICON_ATTRIBUTE_SHOWING = "showing";
23
const ICON_ANCHOR_ATTRIBUTE = "popupnotificationanchor";
24
25
const PREF_SECURITY_DELAY = "security.notification_enable_delay";
26
27
// Enumerated values for the POPUP_NOTIFICATION_STATS telemetry histogram.
28
const TELEMETRY_STAT_OFFERED = 0;
29
const TELEMETRY_STAT_ACTION_1 = 1;
30
const TELEMETRY_STAT_ACTION_2 = 2;
31
// const TELEMETRY_STAT_ACTION_3 = 3;
32
const TELEMETRY_STAT_ACTION_LAST = 4;
33
// const TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE = 5;
34
const TELEMETRY_STAT_REMOVAL_LEAVE_PAGE = 6;
35
// const TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON = 7;
36
const TELEMETRY_STAT_OPEN_SUBMENU = 10;
37
const TELEMETRY_STAT_LEARN_MORE = 11;
38
39
const TELEMETRY_STAT_REOPENED_OFFSET = 20;
40
41
var popupNotificationsMap = new WeakMap();
42
var gNotificationParents = new WeakMap();
43
44
function getAnchorFromBrowser(aBrowser, aAnchorID) {
45
let attrPrefix = aAnchorID ? aAnchorID.replace("notification-icon", "") : "";
46
let anchor =
47
aBrowser.getAttribute(attrPrefix + ICON_ANCHOR_ATTRIBUTE) ||
48
aBrowser[attrPrefix + ICON_ANCHOR_ATTRIBUTE] ||
49
aBrowser.getAttribute(ICON_ANCHOR_ATTRIBUTE) ||
50
aBrowser[ICON_ANCHOR_ATTRIBUTE];
51
if (anchor) {
52
if (ChromeUtils.getClassName(anchor) == "XULElement") {
53
return anchor;
54
}
55
return aBrowser.ownerDocument.getElementById(anchor);
56
}
57
return null;
58
}
59
60
/**
61
* Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
62
*/
63
function getNotificationFromElement(aElement) {
64
return aElement.closest("popupnotification");
65
}
66
67
/**
68
* Notification object describes a single popup notification.
69
*
70
* @see PopupNotifications.show()
71
*/
72
function Notification(
73
id,
74
message,
75
anchorID,
76
mainAction,
77
secondaryActions,
78
browser,
79
owner,
80
options
81
) {
82
this.id = id;
83
this.message = message;
84
this.anchorID = anchorID;
85
this.mainAction = mainAction;
86
this.secondaryActions = secondaryActions || [];
87
this.browser = browser;
88
this.owner = owner;
89
this.options = options || {};
90
91
this._dismissed = false;
92
// Will become a boolean when manually toggled by the user.
93
this._checkboxChecked = null;
94
this.wasDismissed = false;
95
this.recordedTelemetryStats = new Set();
96
this.isPrivate = PrivateBrowsingUtils.isWindowPrivate(
97
this.browser.ownerGlobal
98
);
99
this.timeCreated = this.owner.window.performance.now();
100
}
101
102
Notification.prototype = {
103
id: null,
104
message: null,
105
anchorID: null,
106
mainAction: null,
107
secondaryActions: null,
108
browser: null,
109
owner: null,
110
options: null,
111
timeShown: null,
112
113
/**
114
* Indicates whether the notification is currently dismissed.
115
*/
116
set dismissed(value) {
117
this._dismissed = value;
118
if (value) {
119
// Keep the dismissal into account when recording telemetry.
120
this.wasDismissed = true;
121
}
122
},
123
get dismissed() {
124
return this._dismissed;
125
},
126
127
/**
128
* Removes the notification and updates the popup accordingly if needed.
129
*/
130
remove: function Notification_remove() {
131
this.owner.remove(this);
132
},
133
134
get anchorElement() {
135
let iconBox = this.owner.iconBox;
136
137
let anchorElement = getAnchorFromBrowser(this.browser, this.anchorID);
138
if (!iconBox) {
139
return anchorElement;
140
}
141
142
if (!anchorElement && this.anchorID) {
143
anchorElement = iconBox.querySelector("#" + this.anchorID);
144
}
145
146
// Use a default anchor icon if it's available
147
if (!anchorElement) {
148
anchorElement =
149
iconBox.querySelector("#default-notification-icon") || iconBox;
150
}
151
152
return anchorElement;
153
},
154
155
reshow() {
156
this.owner._reshowNotifications(this.anchorElement, this.browser);
157
},
158
159
/**
160
* Adds a value to the specified histogram, that must be keyed by ID.
161
*/
162
_recordTelemetry(histogramId, value) {
163
if (this.isPrivate) {
164
// The reason why we don't record telemetry in private windows is because
165
// the available actions can be different from regular mode. The main
166
// difference is that all of the persistent permission options like
167
// "Always remember" aren't there, so they really need to be handled
168
// separately to avoid skewing results. For notifications with the same
169
// choices, there would be no reason not to record in private windows as
170
// well, but it's just simpler to use the same check for everything.
171
return;
172
}
173
let histogram = Services.telemetry.getKeyedHistogramById(histogramId);
174
histogram.add("(all)", value);
175
histogram.add(this.id, value);
176
},
177
178
/**
179
* Adds an enumerated value to the POPUP_NOTIFICATION_STATS histogram,
180
* ensuring that it is recorded at most once for each distinct Notification.
181
*
182
* Statistics for reopened notifications are recorded in separate buckets.
183
*
184
* @param value
185
* One of the TELEMETRY_STAT_ constants.
186
*/
187
_recordTelemetryStat(value) {
188
if (this.wasDismissed) {
189
value += TELEMETRY_STAT_REOPENED_OFFSET;
190
}
191
if (!this.recordedTelemetryStats.has(value)) {
192
this.recordedTelemetryStats.add(value);
193
this._recordTelemetry("POPUP_NOTIFICATION_STATS", value);
194
}
195
},
196
};
197
198
/**
199
* The PopupNotifications object manages popup notifications for a given browser
200
* window.
201
* @param tabbrowser
202
* window's TabBrowser. Used to observe tab switching events and
203
* for determining the active browser element.
204
* @param panel
205
* The <xul:panel/> element to use for notifications. The panel is
206
* populated with <popupnotification> children and displayed it as
207
* needed.
208
* @param iconBox
209
* Reference to a container element that should be hidden or
210
* unhidden when notifications are hidden or shown. It should be the
211
* parent of anchor elements whose IDs are passed to show().
212
* It is used as a fallback popup anchor if notifications specify
213
* invalid or non-existent anchor IDs.
214
* @param options
215
* An optional object with the following optional properties:
216
* {
217
* shouldSuppress:
218
* If this function returns true, then all notifications are
219
* suppressed for this window. This state is checked on construction
220
* and when the "anchorVisibilityChange" method is called.
221
* }
222
*/
223
function PopupNotifications(tabbrowser, panel, iconBox, options = {}) {
224
if (!tabbrowser) {
225
throw new Error("Invalid tabbrowser");
226
}
227
if (iconBox && ChromeUtils.getClassName(iconBox) != "XULElement") {
228
throw new Error("Invalid iconBox");
229
}
230
if (ChromeUtils.getClassName(panel) != "XULPopupElement") {
231
throw new Error("Invalid panel");
232
}
233
234
this._shouldSuppress = options.shouldSuppress || (() => false);
235
this._suppress = this._shouldSuppress();
236
237
this.window = tabbrowser.ownerGlobal;
238
this.panel = panel;
239
this.tabbrowser = tabbrowser;
240
this.iconBox = iconBox;
241
this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
242
243
this.panel.addEventListener("popuphidden", this, true);
244
this.panel.classList.add("popup-notification-panel", "panel-no-padding");
245
246
// This listener will be attached to the chrome window whenever a notification
247
// is showing, to allow the user to dismiss notifications using the escape key.
248
this._handleWindowKeyPress = aEvent => {
249
if (aEvent.keyCode != aEvent.DOM_VK_ESCAPE) {
250
return;
251
}
252
253
// Esc key cancels the topmost notification, if there is one.
254
let notification = this.panel.firstElementChild;
255
if (!notification) {
256
return;
257
}
258
259
let doc = this.window.document;
260
let focusedElement = Services.focus.focusedElement;
261
262
// If the chrome window has a focused element, let it handle the ESC key instead.
263
if (
264
!focusedElement ||
265
focusedElement == doc.body ||
266
focusedElement == this.tabbrowser.selectedBrowser ||
267
// Ignore focused elements inside the notification.
268
notification.contains(focusedElement)
269
) {
270
let escAction = notification.notification.options.escAction;
271
this._onButtonEvent(aEvent, escAction, "esc-press", notification);
272
}
273
};
274
275
let documentElement = this.window.document.documentElement;
276
let locationBarHidden = documentElement
277
.getAttribute("chromehidden")
278
.includes("location");
279
let isFullscreen = !!this.window.document.fullscreenElement;
280
281
this.panel.setAttribute("followanchor", !locationBarHidden && !isFullscreen);
282
283
// There are no anchor icons in DOM fullscreen mode, but we would
284
// still like to show the popup notification. To avoid an infinite
285
// loop of showing and hiding, we have to disable followanchor
286
// (which hides the element without an anchor) in fullscreen.
287
this.window.addEventListener(
288
"MozDOMFullscreen:Entered",
289
() => {
290
this.panel.setAttribute("followanchor", "false");
291
},
292
true
293
);
294
this.window.addEventListener(
295
"MozDOMFullscreen:Exited",
296
() => {
297
this.panel.setAttribute("followanchor", !locationBarHidden);
298
},
299
true
300
);
301
302
this.window.addEventListener("activate", this, true);
303
if (this.tabbrowser.tabContainer) {
304
this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true);
305
306
this.tabbrowser.tabContainer.addEventListener("TabClose", aEvent => {
307
// If the tab was just closed and we have notifications associated with it,
308
// then the notifications were closed because of the tab removal. We need to
309
// record this event in telemetry and fire the removal callback.
310
this.nextRemovalReason = TELEMETRY_STAT_REMOVAL_LEAVE_PAGE;
311
let notifications = this._getNotificationsForBrowser(
312
aEvent.target.linkedBrowser
313
);
314
for (let notification of notifications) {
315
this._fireCallback(
316
notification,
317
NOTIFICATION_EVENT_REMOVED,
318
this.nextRemovalReason
319
);
320
notification._recordTelemetryStat(this.nextRemovalReason);
321
}
322
});
323
}
324
}
325
326
PopupNotifications.prototype = {
327
window: null,
328
panel: null,
329
tabbrowser: null,
330
331
_iconBox: null,
332
set iconBox(iconBox) {
333
// Remove the listeners on the old iconBox, if needed
334
if (this._iconBox) {
335
this._iconBox.removeEventListener("click", this);
336
this._iconBox.removeEventListener("keypress", this);
337
}
338
this._iconBox = iconBox;
339
if (iconBox) {
340
iconBox.addEventListener("click", this);
341
iconBox.addEventListener("keypress", this);
342
}
343
},
344
get iconBox() {
345
return this._iconBox;
346
},
347
348
/**
349
* Retrieve one or many Notification object/s associated with the browser/ID pair.
350
* @param {string|string[]} id
351
* The Notification ID or an array of IDs to search for.
352
* @param [browser]
353
* The browser whose notifications should be searched. If null, the
354
* currently selected browser's notifications will be searched.
355
*
356
* @returns {Notification|Notification[]|null} If passed a single id, returns the corresponding Notification object, or null if no such
357
* notification exists.
358
* If passed an id array, returns an array of Notification objects which match the ids.
359
*/
360
getNotification: function PopupNotifications_getNotification(id, browser) {
361
let notifications = this._getNotificationsForBrowser(
362
browser || this.tabbrowser.selectedBrowser
363
);
364
if (Array.isArray(id)) {
365
return notifications.filter(x => id.includes(x.id));
366
}
367
return notifications.find(x => x.id == id) || null;
368
},
369
370
/**
371
* Adds a new popup notification.
372
* @param browser
373
* The <xul:browser> element associated with the notification. Must not
374
* be null.
375
* @param id
376
* A unique ID that identifies the type of notification (e.g.
377
* "geolocation"). Only one notification with a given ID can be visible
378
* at a time. If a notification already exists with the given ID, it
379
* will be replaced.
380
* @param message
381
* A string containing the text to be displayed as the notification
382
* header. The string may optionally contain one or two "<>" as a
383
* placeholder which is later replaced by a host name or an addon name
384
* that is formatted to look bold, in which case the options.name
385
* property (as well as options.secondName if passing a "<>" and a "{}"
386
* placeholder) needs to be specified. "<>" will be considered as the
387
* first and "{}" as the second placeholder.
388
* @param anchorID
389
* The ID of the element that should be used as this notification
390
* popup's anchor. May be null, in which case the notification will be
391
* anchored to the iconBox.
392
* @param mainAction
393
* A JavaScript object literal describing the notification button's
394
* action. If present, it must have the following properties:
395
* - label (string): the button's label.
396
* - accessKey (string): the button's accessKey.
397
* - callback (function): a callback to be invoked when the button is
398
* pressed, is passed an object that contains the following fields:
399
* - checkboxChecked: (boolean) If the optional checkbox is checked.
400
* - source: (string): the source of the action that initiated the
401
* callback, either:
402
* - "button" if popup buttons were directly activated, or
403
* - "esc-press" if the user pressed the escape key, or
404
* - "menucommand" if a menu was activated.
405
* - [optional] dismiss (boolean): If this is true, the notification
406
* will be dismissed instead of removed after running the callback.
407
* - [optional] disabled (boolean): If this is true, the button
408
* will be disabled.
409
* - [optional] disableHighlight (boolean): If this is true, the button
410
* will not apply the default highlight style.
411
* If null, the notification will have a default "OK" action button
412
* that can be used to dismiss the popup and secondaryActions will be ignored.
413
* @param secondaryActions
414
* An optional JavaScript array describing the notification's alternate
415
* actions. The array should contain objects with the same properties
416
* as mainAction. These are used to populate the notification button's
417
* dropdown menu.
418
* @param options
419
* An options JavaScript object holding additional properties for the
420
* notification. The following properties are currently supported:
421
* persistence: An integer. The notification will not automatically
422
* dismiss for this many page loads.
423
* timeout: A time in milliseconds. The notification will not
424
* automatically dismiss before this time.
425
* persistWhileVisible:
426
* A boolean. If true, a visible notification will always
427
* persist across location changes.
428
* persistent: A boolean. If true, the notification will always
429
* persist even across tab and app changes (but not across
430
* location changes), until the user accepts or rejects
431
* the request. The notification will never be implicitly
432
* dismissed.
433
* dismissed: Whether the notification should be added as a dismissed
434
* notification. Dismissed notifications can be activated
435
* by clicking on their anchorElement.
436
* autofocus: Whether the notification should be autofocused on
437
* showing, stealing focus from any other focused element.
438
* eventCallback:
439
* Callback to be invoked when the notification changes
440
* state. The callback's first argument is a string
441
* identifying the state change:
442
* "dismissed": notification has been dismissed by the
443
* user (e.g. by clicking away or switching
444
* tabs)
445
* "removed": notification has been removed (due to
446
* location change or user action)
447
* "showing": notification is about to be shown
448
* (this can be fired multiple times as
449
* notifications are dismissed and re-shown)
450
* If the callback returns true, the notification
451
* will be dismissed.
452
* "shown": notification has been shown (this can be fired
453
* multiple times as notifications are dismissed
454
* and re-shown)
455
* "swapping": the docshell of the browser that created
456
* the notification is about to be swapped to
457
* another browser. A second parameter contains
458
* the browser that is receiving the docshell,
459
* so that the event callback can transfer stuff
460
* specific to this notification.
461
* If the callback returns true, the notification
462
* will be moved to the new browser.
463
* If the callback isn't implemented, returns false,
464
* or doesn't return any value, the notification
465
* will be removed.
466
* neverShow: Indicate that no popup should be shown for this
467
* notification. Useful for just showing the anchor icon.
468
* removeOnDismissal:
469
* Notifications with this parameter set to true will be
470
* removed when they would have otherwise been dismissed
471
* (i.e. any time the popup is closed due to user
472
* interaction).
473
* hideClose: Indicate that the little close button in the corner of
474
* the panel should be hidden.
475
* checkbox: An object that allows you to add a checkbox and
476
* control its behavior with these fields:
477
* label:
478
* (required) Label to be shown next to the checkbox.
479
* checked:
480
* (optional) Whether the checkbox should be checked
481
* by default. Defaults to false.
482
* checkedState:
483
* (optional) An object that allows you to customize
484
* the notification state when the checkbox is checked.
485
* disableMainAction:
486
* (optional) Whether the mainAction is disabled.
487
* Defaults to false.
488
* warningLabel:
489
* (optional) A (warning) text that is shown below the
490
* checkbox. Pass null to hide.
491
* uncheckedState:
492
* (optional) An object that allows you to customize
493
* the notification state when the checkbox is not checked.
494
* Has the same attributes as checkedState.
495
* popupIconClass:
496
* A string. A class (or space separated list of classes)
497
* that will be applied to the icon in the popup so that
498
* several notifications using the same panel can use
499
* different icons.
500
* popupIconURL:
501
* A string. URL of the image to be displayed in the popup.
502
* Normally specified in CSS using list-style-image and the
503
* .popup-notification-icon[popupid=...] selector.
504
* learnMoreURL:
505
* A string URL. Setting this property will make the
506
* prompt display a "Learn More" link that, when clicked,
507
* opens the URL in a new tab.
508
* displayURI:
509
* The nsIURI of the page the notification came
510
* from. If present, this will be displayed above the message.
511
* If the nsIURI represents a file, the path will be displayed,
512
* otherwise the hostPort will be displayed.
513
* name:
514
* An optional string formatted to look bold and used in the
515
* notifiation description header text. Usually a host name or
516
* addon name.
517
* secondName:
518
* An optional string formatted to look bold and used in the
519
* notification description header text. Usually a host name or
520
* addon name. This is similar to name, and only used in case
521
* where message contains a "<>" and a "{}" placeholder. "<>"
522
* is considered the first and "{}" is considered the second
523
* placeholder.
524
* escAction:
525
* An optional string indicating the action to take when the
526
* Esc key is pressed. This should be set to the name of the
527
* command to run. If not provided, "secondarybuttoncommand"
528
* will be used.
529
* extraAttr:
530
* An optional string value which will be given to the
531
* extraAttr attribute on the notification's anchorElement
532
* @returns the Notification object corresponding to the added notification.
533
*/
534
show: function PopupNotifications_show(
535
browser,
536
id,
537
message,
538
anchorID,
539
mainAction,
540
secondaryActions,
541
options
542
) {
543
function isInvalidAction(a) {
544
return (
545
!a || !(typeof a.callback == "function") || !a.label || !a.accessKey
546
);
547
}
548
549
if (!browser) {
550
throw new Error("PopupNotifications_show: invalid browser");
551
}
552
if (!id) {
553
throw new Error("PopupNotifications_show: invalid ID");
554
}
555
if (mainAction && isInvalidAction(mainAction)) {
556
throw new Error("PopupNotifications_show: invalid mainAction");
557
}
558
if (secondaryActions && secondaryActions.some(isInvalidAction)) {
559
throw new Error("PopupNotifications_show: invalid secondaryActions");
560
}
561
562
let notification = new Notification(
563
id,
564
message,
565
anchorID,
566
mainAction,
567
secondaryActions,
568
browser,
569
this,
570
options
571
);
572
573
if (options) {
574
let escAction = options.escAction;
575
if (
576
escAction != "buttoncommand" &&
577
escAction != "secondarybuttoncommand"
578
) {
579
escAction = "secondarybuttoncommand";
580
}
581
notification.options.escAction = escAction;
582
}
583
584
if (options && options.dismissed) {
585
notification.dismissed = true;
586
}
587
588
let existingNotification = this.getNotification(id, browser);
589
if (existingNotification) {
590
this._remove(existingNotification);
591
}
592
593
let notifications = this._getNotificationsForBrowser(browser);
594
notifications.push(notification);
595
596
let isActiveBrowser = this._isActiveBrowser(browser);
597
let isActiveWindow = Services.focus.activeWindow == this.window;
598
599
if (isActiveBrowser) {
600
if (isActiveWindow) {
601
// Autofocus if the notification requests focus.
602
if (options && !options.dismissed && options.autofocus) {
603
this.panel.removeAttribute("noautofocus");
604
} else {
605
this.panel.setAttribute("noautofocus", "true");
606
}
607
608
// show panel now
609
this._update(
610
notifications,
611
new Set([notification.anchorElement]),
612
true
613
);
614
} else {
615
// indicate attention and update the icon if necessary
616
if (!notification.dismissed) {
617
this.window.getAttention();
618
}
619
this._updateAnchorIcons(
620
notifications,
621
this._getAnchorsForNotifications(
622
notifications,
623
notification.anchorElement
624
)
625
);
626
this._notify("backgroundShow");
627
}
628
} else {
629
// Notify observers that we're not showing the popup (useful for testing)
630
this._notify("backgroundShow");
631
}
632
633
return notification;
634
},
635
636
/**
637
* Returns true if the notification popup is currently being displayed.
638
*/
639
get isPanelOpen() {
640
let panelState = this.panel.state;
641
642
return panelState == "showing" || panelState == "open";
643
},
644
645
/**
646
* Called by the consumer to indicate that a browser's location has changed,
647
* so that we can update the active notifications accordingly.
648
*/
649
locationChange: function PopupNotifications_locationChange(aBrowser) {
650
if (!aBrowser) {
651
throw new Error("PopupNotifications_locationChange: invalid browser");
652
}
653
654
let notifications = this._getNotificationsForBrowser(aBrowser);
655
656
this.nextRemovalReason = TELEMETRY_STAT_REMOVAL_LEAVE_PAGE;
657
658
notifications = notifications.filter(function(notification) {
659
// The persistWhileVisible option allows an open notification to persist
660
// across location changes
661
if (notification.options.persistWhileVisible && this.isPanelOpen) {
662
if (
663
"persistence" in notification.options &&
664
notification.options.persistence
665
) {
666
notification.options.persistence--;
667
}
668
return true;
669
}
670
671
// The persistence option allows a notification to persist across multiple
672
// page loads
673
if (
674
"persistence" in notification.options &&
675
notification.options.persistence
676
) {
677
notification.options.persistence--;
678
return true;
679
}
680
681
// The timeout option allows a notification to persist until a certain time
682
if (
683
"timeout" in notification.options &&
684
Date.now() <= notification.options.timeout
685
) {
686
return true;
687
}
688
689
notification._recordTelemetryStat(this.nextRemovalReason);
690
this._fireCallback(
691
notification,
692
NOTIFICATION_EVENT_REMOVED,
693
this.nextRemovalReason
694
);
695
return false;
696
}, this);
697
698
this._setNotificationsForBrowser(aBrowser, notifications);
699
700
if (this._isActiveBrowser(aBrowser)) {
701
this.anchorVisibilityChange();
702
}
703
},
704
705
/**
706
* Called by the consumer to indicate that the visibility of the notification
707
* anchors may have changed, but the location has not changed. This also
708
* checks whether all notifications are suppressed for this window.
709
*
710
* Calling this method may result in the "showing" and "shown" events for
711
* visible notifications to be invoked even if the anchor has not changed.
712
*/
713
anchorVisibilityChange() {
714
let suppress = this._shouldSuppress();
715
if (!suppress) {
716
// If notifications are not suppressed, always update the visibility.
717
this._suppress = false;
718
let notifications = this._getNotificationsForBrowser(
719
this.tabbrowser.selectedBrowser
720
);
721
this._update(
722
notifications,
723
this._getAnchorsForNotifications(
724
notifications,
725
getAnchorFromBrowser(this.tabbrowser.selectedBrowser)
726
)
727
);
728
return;
729
}
730
731
// Notifications are suppressed, ensure that the panel is hidden.
732
if (!this._suppress) {
733
this._suppress = true;
734
this._hidePanel().catch(Cu.reportError);
735
}
736
},
737
738
/**
739
* Removes one or many Notifications.
740
* @param {Notification|Notification[]} notification - The Notification object/s to remove.
741
* @param {Boolean} [isCancel] - Whether to signal, in the notification event, that removal
742
* should be treated as cancel. This is currently used to cancel permission requests
743
* when their Notifications are removed.
744
*/
745
remove: function PopupNotifications_remove(notification, isCancel = false) {
746
let notificationArray = Array.isArray(notification)
747
? notification
748
: [notification];
749
let activeBrowser;
750
751
notificationArray.forEach(n => {
752
this._remove(n, isCancel);
753
if (!activeBrowser && this._isActiveBrowser(n.browser)) {
754
activeBrowser = n.browser;
755
}
756
});
757
758
if (activeBrowser) {
759
let browserNotifications = this._getNotificationsForBrowser(
760
activeBrowser
761
);
762
this._update(browserNotifications);
763
}
764
},
765
766
handleEvent(aEvent) {
767
switch (aEvent.type) {
768
case "popuphidden":
769
this._onPopupHidden(aEvent);
770
break;
771
case "activate":
772
if (this.isPanelOpen) {
773
for (let elt of this.panel.children) {
774
elt.notification.timeShown = this.window.performance.now();
775
}
776
break;
777
}
778
// fall through
779
case "TabSelect":
780
let self = this;
781
// setTimeout(..., 0) needed, otherwise openPopup from "activate" event
782
// handler results in the popup being hidden again for some reason...
783
this.window.setTimeout(function() {
784
self._update();
785
}, 0);
786
break;
787
case "click":
788
case "keypress":
789
this._onIconBoxCommand(aEvent);
790
break;
791
}
792
},
793
794
// Utility methods
795
796
_ignoreDismissal: null,
797
_currentAnchorElement: null,
798
799
/**
800
* Gets notifications for the currently selected browser.
801
*/
802
get _currentNotifications() {
803
return this.tabbrowser.selectedBrowser
804
? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser)
805
: [];
806
},
807
808
_remove: function PopupNotifications_removeHelper(
809
notification,
810
isCancel = false
811
) {
812
// This notification may already be removed, in which case let's just fail
813
// silently.
814
let notifications = this._getNotificationsForBrowser(notification.browser);
815
if (!notifications) {
816
return;
817
}
818
819
var index = notifications.indexOf(notification);
820
if (index == -1) {
821
return;
822
}
823
824
if (this._isActiveBrowser(notification.browser)) {
825
notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
826
}
827
828
// remove the notification
829
notifications.splice(index, 1);
830
this._fireCallback(
831
notification,
832
NOTIFICATION_EVENT_REMOVED,
833
this.nextRemovalReason,
834
isCancel
835
);
836
},
837
838
/**
839
* Dismisses the notification without removing it.
840
*
841
* @param {Event} the event associated with the user interaction that
842
* caused the dismissal
843
* @param {boolean} whether to disable persistent status. Normally,
844
* persistent prompts can not be dismissed. You can
845
* use this argument to force dismissal.
846
*/
847
_dismiss: function PopupNotifications_dismiss(
848
event,
849
disablePersistent = false
850
) {
851
if (disablePersistent) {
852
let notificationEl = getNotificationFromElement(event.target);
853
if (notificationEl) {
854
notificationEl.notification.options.persistent = false;
855
}
856
}
857
858
let browser =
859
this.panel.firstElementChild &&
860
this.panel.firstElementChild.notification.browser;
861
this.panel.hidePopup();
862
if (browser) {
863
browser.focus();
864
}
865
},
866
867
/**
868
* Hides the notification popup.
869
*/
870
_hidePanel: function PopupNotifications_hide() {
871
if (this.panel.state == "closed") {
872
return Promise.resolve();
873
}
874
if (this._ignoreDismissal) {
875
return this._ignoreDismissal.promise;
876
}
877
let deferred = PromiseUtils.defer();
878
this._ignoreDismissal = deferred;
879
this.panel.hidePopup();
880
return deferred.promise;
881
},
882
883
/**
884
* Removes all notifications from the notification popup.
885
*/
886
_clearPanel() {
887
let popupnotification;
888
while ((popupnotification = this.panel.lastElementChild)) {
889
this.panel.removeChild(popupnotification);
890
891
// If this notification was provided by the chrome document rather than
892
// created ad hoc, move it back to where we got it from.
893
let originalParent = gNotificationParents.get(popupnotification);
894
if (originalParent) {
895
popupnotification.notification = null;
896
897
// Re-hide the notification such that it isn't rendered in the chrome
898
// document. _refreshPanel will unhide it again when needed.
899
popupnotification.hidden = true;
900
901
originalParent.appendChild(popupnotification);
902
}
903
}
904
},
905
906
/**
907
* Formats the notification description message before we display it
908
* and splits it into three parts if the message contains "<>" as
909
* placeholder.
910
*
911
* param notification
912
* The Notification object which contains the message to format.
913
*
914
* @returns a Javascript object that has the following properties:
915
* start: A start label string containing the first part of the message.
916
* It may contain the whole string if the description message
917
* does not have "<>" as a placeholder. For example, local
918
* file URIs with description messages that don't display hostnames.
919
* name: A string that is formatted to look bold. It replaces the
920
* placeholder with the options.name property from the notification
921
* object which is usually an addon name or a host name.
922
* end: The last part of the description message.
923
*/
924
_formatDescriptionMessage(n) {
925
let text = {};
926
let array = n.message.split(/<>|{}/);
927
text.start = array[0] || "";
928
text.name = n.options.name || "";
929
text.end = array[1] || "";
930
if (array.length == 3) {
931
text.secondName = n.options.secondName || "";
932
text.secondEnd = array[2] || "";
933
934
// name and secondName should be in logical positions. Swap them in case
935
// the second placeholder came before the first one in the original string.
936
if (n.message.indexOf("{}") < n.message.indexOf("<>")) {
937
let tmp = text.name;
938
text.name = text.secondName;
939
text.secondName = tmp;
940
}
941
} else if (array.length > 3) {
942
Cu.reportError(
943
"Unexpected array length encountered in " +
944
"_formatDescriptionMessage: " +
945
array.length
946
);
947
}
948
return text;
949
},
950
951
_refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) {
952
this._clearPanel();
953
954
notificationsToShow.forEach(function(n) {
955
let doc = this.window.document;
956
957
// Append "-notification" to the ID to try to avoid ID conflicts with other stuff
958
// in the document.
959
let popupnotificationID = n.id + "-notification";
960
961
// If the chrome document provides a popupnotification with this id, use
962
// that. Otherwise create it ad-hoc.
963
let popupnotification = doc.getElementById(popupnotificationID);
964
if (popupnotification) {
965
gNotificationParents.set(
966
popupnotification,
967
popupnotification.parentNode
968
);
969
} else {
970
popupnotification = doc.createXULElement("popupnotification");
971
}
972
973
// Create the notification description element.
974
let desc = this._formatDescriptionMessage(n);
975
popupnotification.setAttribute("label", desc.start);
976
popupnotification.setAttribute("name", desc.name);
977
popupnotification.setAttribute("endlabel", desc.end);
978
if ("secondName" in desc && "secondEnd" in desc) {
979
popupnotification.setAttribute("secondname", desc.secondName);
980
popupnotification.setAttribute("secondendlabel", desc.secondEnd);
981
}
982
983
popupnotification.setAttribute("id", popupnotificationID);
984
popupnotification.setAttribute("popupid", n.id);
985
popupnotification.setAttribute(
986
"oncommand",
987
"PopupNotifications._onCommand(event);"
988
);
989
popupnotification.setAttribute(
990
"closebuttoncommand",
991
`PopupNotifications._dismiss(event, true);`
992
);
993
if (n.mainAction) {
994
popupnotification.setAttribute("buttonlabel", n.mainAction.label);
995
popupnotification.setAttribute(
996
"buttonaccesskey",
997
n.mainAction.accessKey
998
);
999
popupnotification.toggleAttribute(
1000
"buttonhighlight",
1001
!n.mainAction.disableHighlight
1002
);
1003
popupnotification.setAttribute(
1004
"buttoncommand",
1005
"PopupNotifications._onButtonEvent(event, 'buttoncommand');"
1006
);
1007
popupnotification.setAttribute(
1008
"dropmarkerpopupshown",
1009
"PopupNotifications._onButtonEvent(event, 'dropmarkerpopupshown');"
1010
);
1011
popupnotification.setAttribute(
1012
"learnmoreclick",
1013
"PopupNotifications._onButtonEvent(event, 'learnmoreclick');"
1014
);
1015
popupnotification.setAttribute(
1016
"menucommand",
1017
"PopupNotifications._onMenuCommand(event);"
1018
);
1019
} else {
1020
// Enable the default button to let the user close the popup if the close button is hidden
1021
popupnotification.setAttribute(
1022
"buttoncommand",
1023
"PopupNotifications._onButtonEvent(event, 'buttoncommand');"
1024
);
1025
popupnotification.toggleAttribute("buttonhighlight", true);
1026
popupnotification.removeAttribute("buttonlabel");
1027
popupnotification.removeAttribute("buttonaccesskey");
1028
popupnotification.removeAttribute("dropmarkerpopupshown");
1029
popupnotification.removeAttribute("learnmoreclick");
1030
popupnotification.removeAttribute("menucommand");
1031
}
1032
1033
let classes = "popup-notification-icon";
1034
if (n.options.popupIconClass) {
1035
classes += " " + n.options.popupIconClass;
1036
}
1037
popupnotification.setAttribute("iconclass", classes);
1038
1039
if (n.options.popupIconURL) {
1040
popupnotification.setAttribute("icon", n.options.popupIconURL);
1041
} else {
1042
popupnotification.removeAttribute("icon");
1043
}
1044
1045
if (n.options.learnMoreURL) {
1046
popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL);
1047
} else {
1048
popupnotification.removeAttribute("learnmoreurl");
1049
}
1050
1051
if (n.options.displayURI) {
1052
let uri;
1053
try {
1054
if (n.options.displayURI instanceof Ci.nsIFileURL) {
1055
uri = n.options.displayURI.pathQueryRef;
1056
} else {
1057
try {
1058
uri = n.options.displayURI.hostPort;
1059
} catch (e) {
1060
uri = n.options.displayURI.spec;
1061
}
1062
}
1063
popupnotification.setAttribute("origin", uri);
1064
} catch (e) {
1065
Cu.reportError(e);
1066
popupnotification.removeAttribute("origin");
1067
}
1068
} else {
1069
popupnotification.removeAttribute("origin");
1070
}
1071
1072
if (n.options.hideClose) {
1073
popupnotification.setAttribute("closebuttonhidden", "true");
1074
}
1075
1076
popupnotification.notification = n;
1077
let menuitems = [];
1078
1079
if (n.mainAction && n.secondaryActions && n.secondaryActions.length) {
1080
let telemetryStatId = TELEMETRY_STAT_ACTION_2;
1081
1082
let secondaryAction = n.secondaryActions[0];
1083
popupnotification.setAttribute(
1084
"secondarybuttonlabel",
1085
secondaryAction.label
1086
);
1087
popupnotification.setAttribute(
1088
"secondarybuttonaccesskey",
1089
secondaryAction.accessKey
1090
);
1091
popupnotification.setAttribute(
1092
"secondarybuttoncommand",
1093
"PopupNotifications._onButtonEvent(event, 'secondarybuttoncommand');"
1094
);
1095
1096
for (let i = 1; i < n.secondaryActions.length; i++) {
1097
let action = n.secondaryActions[i];
1098
let item = doc.createXULElement("menuitem");
1099
item.setAttribute("label", action.label);
1100
item.setAttribute("accesskey", action.accessKey);
1101
item.notification = n;
1102
item.action = action;
1103
1104
menuitems.push(item);
1105
1106
// We can only record a limited number of actions in telemetry. If
1107
// there are more, the latest are all recorded in the last bucket.
1108
item.action.telemetryStatId = telemetryStatId;
1109
if (telemetryStatId < TELEMETRY_STAT_ACTION_LAST) {
1110
telemetryStatId++;
1111
}
1112
}
1113
popupnotification.setAttribute("secondarybuttonhidden", "false");
1114
} else {
1115
popupnotification.setAttribute("secondarybuttonhidden", "true");
1116
}
1117
popupnotification.setAttribute(
1118
"dropmarkerhidden",
1119
n.secondaryActions.length < 2 ? "true" : "false"
1120
);
1121
1122
let checkbox = n.options.checkbox;
1123
if (checkbox && checkbox.label) {
1124
let checked =
1125
n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked;
1126
popupnotification.checkboxState = {
1127
checked,
1128
label: checkbox.label,
1129
};
1130
1131
if (checked) {
1132
this._setNotificationUIState(
1133
popupnotification,
1134
checkbox.checkedState
1135
);
1136
} else {
1137
this._setNotificationUIState(
1138
popupnotification,
1139
checkbox.uncheckedState
1140
);
1141
}
1142
} else {
1143
popupnotification.checkboxState = null;
1144
// Reset the UI state to avoid previous state bleeding into this prompt.
1145
this._setNotificationUIState(popupnotification);
1146
}
1147
1148
this.panel.appendChild(popupnotification);
1149
1150
// The popupnotification may be hidden if we got it from the chrome
1151
// document rather than creating it ad hoc.
1152
popupnotification.show();
1153
1154
popupnotification.menupopup.textContent = "";
1155
popupnotification.menupopup.append(...menuitems);
1156
}, this);
1157
},
1158
1159
_setNotificationUIState(notification, state = {}) {
1160
let mainAction = notification.notification.mainAction;
1161
if (
1162
(mainAction && mainAction.disabled) ||
1163
state.disableMainAction ||
1164
notification.hasAttribute("invalidselection")
1165
) {
1166
notification.setAttribute("mainactiondisabled", "true");
1167
} else {
1168
notification.removeAttribute("mainactiondisabled");
1169
}
1170
if (state.warningLabel) {
1171
notification.setAttribute("warninglabel", state.warningLabel);
1172
notification.removeAttribute("warninghidden");
1173
} else {
1174
notification.setAttribute("warninghidden", "true");
1175
}
1176
},
1177
1178
_showPanel: function PopupNotifications_showPanel(
1179
notificationsToShow,
1180
anchorElement
1181
) {
1182
this.panel.hidden = false;
1183
1184
notificationsToShow = notificationsToShow.filter(n => {
1185
if (anchorElement != n.anchorElement) {
1186
return false;
1187
}
1188
1189
let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING);
1190
if (dismiss) {
1191
n.dismissed = true;
1192
}
1193
return !dismiss;
1194
});
1195
if (!notificationsToShow.length) {
1196
return;
1197
}
1198
let notificationIds = notificationsToShow.map(n => n.id);
1199
1200
this._refreshPanel(notificationsToShow);
1201
1202
function isNullOrHidden(elem) {
1203
if (!elem) {
1204
return true;
1205
}
1206
1207
let anchorRect = elem.getBoundingClientRect();
1208
return anchorRect.width == 0 && anchorRect.height == 0;
1209
}
1210
1211
// If the anchor element is hidden or null, fall back to the identity icon.
1212
if (isNullOrHidden(anchorElement)) {
1213
anchorElement = this.window.document.getElementById("identity-icon");
1214
1215
if (isNullOrHidden(anchorElement)) {
1216
anchorElement = this.window.document.getElementById(
1217
"urlbar-search-icon"
1218
);
1219
}
1220
1221
// If the identity and search icons are not available in this window, use
1222
// the tab as the anchor. We only ever show notifications for the current
1223
// browser, so we can just use the current tab.
1224
if (isNullOrHidden(anchorElement)) {
1225
anchorElement = this.tabbrowser.selectedTab;
1226
1227
// If we're in an entirely chromeless environment, set the anchorElement
1228
// to null and let openPopup show the notification at (0,0) later.
1229
if (isNullOrHidden(anchorElement)) {
1230
anchorElement = null;
1231
}
1232
}
1233
}
1234
1235
if (this.isPanelOpen && this._currentAnchorElement == anchorElement) {
1236
notificationsToShow.forEach(function(n) {
1237
this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
1238
}, this);
1239
1240
// Make sure we update the noautohide attribute on the panel, in case it changed.
1241
if (notificationsToShow.some(n => n.options.persistent)) {
1242
this.panel.setAttribute("noautohide", "true");
1243
} else {
1244
this.panel.removeAttribute("noautohide");
1245
}
1246
1247
// Let tests know that the panel was updated and what notifications it was
1248
// updated with so that tests can wait for the correct notifications to be
1249
// added.
1250
let event = new this.window.CustomEvent("PanelUpdated", {
1251
detail: notificationIds,
1252
});
1253
this.panel.dispatchEvent(event);
1254
return;
1255
}
1256
1257
// If the panel is already open but we're changing anchors, we need to hide
1258
// it first. Otherwise it can appear in the wrong spot. (_hidePanel is
1259
// safe to call even if the panel is already hidden.)
1260
this._hidePanel().then(() => {
1261
this._currentAnchorElement = anchorElement;
1262
1263
if (notificationsToShow.some(n => n.options.persistent)) {
1264
this.panel.setAttribute("noautohide", "true");
1265
} else {
1266
this.panel.removeAttribute("noautohide");
1267
}
1268
1269
notificationsToShow.forEach(function(n) {
1270
// Record that the notification was actually displayed on screen.
1271
// Notifications that were opened a second time or that were originally
1272
// shown with "options.dismissed" will be recorded in a separate bucket.
1273
n._recordTelemetryStat(TELEMETRY_STAT_OFFERED);
1274
// Remember the time the notification was shown for the security delay.
1275
n.timeShown = this.window.performance.now();
1276
}, this);
1277
1278
let target = this.panel;
1279
if (target.parentNode) {
1280
// NOTIFICATION_EVENT_SHOWN should be fired for the panel before
1281
// anyone listening for popupshown on the panel gets run. Otherwise,
1282
// the panel will not be initialized when the popupshown event
1283
// listeners run.
1284
// By targeting the panel's parent and using a capturing listener, we
1285
// can have our listener called before others waiting for the panel to
1286
// be shown (which probably expect the panel to be fully initialized)
1287
target = target.parentNode;
1288
}
1289
if (this._popupshownListener) {
1290
target.removeEventListener(
1291
"popupshown",
1292
this._popupshownListener,
1293
true
1294
);
1295
}
1296
this._popupshownListener = function(e) {
1297
target.removeEventListener(
1298
"popupshown",
1299
this._popupshownListener,
1300
true
1301
);
1302
this._popupshownListener = null;
1303
1304
notificationsToShow.forEach(function(n) {
1305
this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
1306
}, this);
1307
// These notifications are used by tests to know when all the processing
1308
// required to display the panel has happened.
1309
this.panel.dispatchEvent(new this.window.CustomEvent("Shown"));
1310
let event = new this.window.CustomEvent("PanelUpdated", {
1311
detail: notificationIds,
1312
});
1313
this.panel.dispatchEvent(event);
1314
};
1315
this._popupshownListener = this._popupshownListener.bind(this);
1316
target.addEventListener("popupshown", this._popupshownListener, true);
1317
1318
this.panel.openPopup(anchorElement, "bottomcenter topleft", 0, 0);
1319
});
1320
},
1321
1322
/**
1323
* Updates the notification state in response to window activation or tab
1324
* selection changes.
1325
*
1326
* @param notifications an array of Notification instances. if null,
1327
* notifications will be retrieved off the current
1328
* browser tab
1329
* @param anchors is a XUL element or a Set of XUL elements that the
1330
* notifications panel(s) will be anchored to.
1331
* @param dismissShowing if true, dismiss any currently visible notifications
1332
* if there are no notifications to show. Otherwise,
1333
* currently displayed notifications will be left alone.
1334
*/
1335
_update: function PopupNotifications_update(
1336
notifications,
1337
anchors = new Set(),
1338
dismissShowing = false
1339
) {
1340
if (ChromeUtils.getClassName(anchors) == "XULElement") {
1341
anchors = new Set([anchors]);
1342
}
1343
1344
if (!notifications) {
1345
notifications = this._currentNotifications;
1346
}
1347
1348
let haveNotifications = !!notifications.length;
1349
if (!anchors.size && haveNotifications) {
1350
anchors = this._getAnchorsForNotifications(notifications);
1351
}
1352
1353
let useIconBox = !!this.iconBox;
1354
if (useIconBox && anchors.size) {
1355
for (let anchor of anchors) {
1356
if (anchor.parentNode == this.iconBox) {
1357
continue;
1358
}
1359
useIconBox = false;
1360
break;
1361
}
1362
}
1363
1364
// Filter out notifications that have been dismissed, unless they are
1365
// persistent. Also check if we should not show any notification.
1366
let notificationsToShow = [];
1367
if (!this._suppress) {
1368
notificationsToShow = notifications.filter(
1369
n => (!n.dismissed || n.options.persistent) && !n.options.neverShow
1370
);
1371
}
1372
1373
if (useIconBox) {
1374
// Hide icons of the previous tab.
1375
this._hideIcons();
1376
}
1377
1378
if (haveNotifications) {
1379
// Also filter out notifications that are for a different anchor.
1380
notificationsToShow = notificationsToShow.filter(function(n) {
1381
return anchors.has(n.anchorElement);
1382
});
1383
1384
if (useIconBox) {
1385
this._showIcons(notifications);
1386
this.iconBox.hidden = false;
1387
// Make sure that panels can only be attached to anchors of shown
1388
// notifications inside an iconBox.
1389
anchors = this._getAnchorsForNotifications(notificationsToShow);
1390
} else if (anchors.size) {
1391
this._updateAnchorIcons(notifications, anchors);
1392
}
1393
}
1394
1395
if (notificationsToShow.length) {
1396
let anchorElement = anchors.values().next().value;
1397
if (anchorElement) {
1398
this._showPanel(notificationsToShow, anchorElement);
1399
}
1400
1401
// Setup a capturing event listener on the whole window to catch the
1402
// escape key while persistent notifications are visible.
1403
this.window.addEventListener(
1404
"keypress",
1405
this._handleWindowKeyPress,
1406
true
1407
);
1408
} else {
1409
// Notify observers that we're not showing the popup (useful for testing)
1410
this._notify("updateNotShowing");
1411
1412
// Close the panel if there are no notifications to show.
1413
// When called from PopupNotifications.show() we should never close the
1414
// panel, however. It may just be adding a dismissed notification, in
1415
// which case we want to continue showing any existing notifications.
1416
if (!dismissShowing) {
1417
this._dismiss();
1418
}
1419
1420
// Only hide the iconBox if we actually have no notifications (as opposed
1421
// to not having any showable notifications)
1422
if (!haveNotifications) {
1423
if (useIconBox) {
1424
this.iconBox.hidden = true;
1425
} else if (anchors.size) {
1426
for (let anchorElement of anchors) {
1427
anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
1428
}
1429
}
1430
}
1431
1432
// Stop listening to keyboard events for notifications.
1433
this.window.removeEventListener(
1434
"keypress",
1435
this._handleWindowKeyPress,
1436
true
1437
);
1438
}
1439
},
1440
1441
_updateAnchorIcons: function PopupNotifications_updateAnchorIcons(
1442
notifications,
1443
anchorElements
1444
) {
1445
for (let anchorElement of anchorElements) {
1446
anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
1447
// Use the anchorID as a class along with the default icon class as a
1448
// fallback if anchorID is not defined in CSS. We always use the first
1449
// notifications icon, so in the case of multiple notifications we'll
1450
// only use the default icon.
1451
if (anchorElement.classList.contains("notification-anchor-icon")) {
1452
// remove previous icon classes
1453
let className = anchorElement.className.replace(
1454
/([-\w]+-notification-icon\s?)/g,
1455
""
1456
);
1457
if (notifications.length) {
1458
// Find the first notification this anchor used for.
1459
let notification = notifications[0];
1460
for (let n of notifications) {
1461
if (n.anchorElement == anchorElement) {
1462
notification = n;
1463
break;
1464
}
1465
}
1466
// With this notification we can better approximate the most fitting
1467
// style.
1468
className = notification.anchorID + " " + className;
1469
}
1470
anchorElement.className = className;
1471
}
1472
}
1473
},
1474
1475
_showIcons: function PopupNotifications_showIcons(aCurrentNotifications) {
1476
for (let notification of aCurrentNotifications) {
1477
let anchorElm = notification.anchorElement;
1478
if (anchorElm) {
1479
anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
1480
1481
if (notification.options.extraAttr) {
1482
anchorElm.setAttribute("extraAttr", notification.options.extraAttr);
1483
}
1484
}
1485
}
1486
},
1487
1488
_hideIcons: function PopupNotifications_hideIcons() {
1489
let icons = this.iconBox.querySelectorAll(ICON_SELECTOR);
1490
for (let icon of icons) {
1491
icon.removeAttribute(ICON_ATTRIBUTE_SHOWING);
1492
}
1493
},
1494
1495
/**
1496
* Gets and sets notifications for the browser.
1497
*/
1498
_getNotificationsForBrowser: function PopupNotifications_getNotifications(
1499
browser
1500
) {
1501
let notifications = popupNotificationsMap.get(browser);
1502
if (!notifications) {
1503
// Initialize the WeakMap for the browser so callers can reference/manipulate the array.
1504
notifications = [];
1505
popupNotificationsMap.set(browser, notifications);
1506
}
1507
return notifications;
1508
},
1509
_setNotificationsForBrowser: function PopupNotifications_setNotifications(
1510
browser,
1511
notifications
1512
) {
1513
popupNotificationsMap.set(browser, notifications);
1514
return notifications;
1515
},
1516
1517
_getAnchorsForNotifications: function PopupNotifications_getAnchorsForNotifications(
1518
notifications,
1519
defaultAnchor
1520
) {
1521
let anchors = new Set();
1522
for (let notification of notifications) {
1523
if (notification.anchorElement) {
1524
anchors.add(notification.anchorElement);
1525
}
1526
}
1527
if (defaultAnchor && !anchors.size) {
1528
anchors.add(defaultAnchor);
1529
}
1530
return anchors;
1531
},
1532
1533
_isActiveBrowser(browser) {
1534
// We compare on frameLoader instead of just comparing the
1535
// selectedBrowser and browser directly because browser tabs in
1536
// Responsive Design Mode put the actual web content into a
1537
// mozbrowser iframe and proxy property read/write and method
1538
// calls from the tab to that iframe. This is so that attempts
1539
// to reload the tab end up reloading the content in
1540
// Responsive Design Mode, and not the Responsive Design Mode
1541
// viewer itself.
1542
//
1543
// This means that PopupNotifications can come up from a browser
1544
// in Responsive Design Mode, but the selectedBrowser will not match
1545
// the browser being passed into this function, despite the browser
1546
// actually being within the selected tab. We workaround this by
1547
// comparing frameLoader instead, which is proxied from the outer
1548
// <xul:browser> to the inner mozbrowser <iframe>.
1549
return this.tabbrowser.selectedBrowser.frameLoader == browser.frameLoader;
1550
},
1551
1552
_onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) {
1553
// Left click, space or enter only
1554
let type = event.type;
1555
if (type == "click" && event.button != 0) {
1556
return;
1557
}
1558
1559
if (
1560
type == "keypress" &&
1561
!(
1562
event.charCode == event.DOM_VK_SPACE ||
1563
event.keyCode == event.DOM_VK_RETURN
1564
)
1565
) {
1566
return;
1567
}
1568
1569
if (!this._currentNotifications.length) {
1570
return;
1571
}
1572
1573
event.stopPropagation();
1574
1575
// Get the anchor that is the immediate child of the icon box
1576
let anchor = event.target;
1577
while (anchor && anchor.parentNode != this.iconBox) {
1578
anchor = anchor.parentNode;
1579
}
1580
1581
if (!anchor) {
1582
return;
1583
}
1584
1585
// If the panel is not closed, and the anchor is different, immediately mark all
1586
// active notifications for the previous anchor as dismissed
1587
if (this.panel.state != "closed" && anchor != this._currentAnchorElement) {
1588
this._dismissOrRemoveCurrentNotifications();
1589
}
1590
1591
// Avoid reshowing notifications that are already shown and have not been dismissed.
1592
if (this.panel.state == "closed" || anchor != this._currentAnchorElement) {
1593
// As soon as the panel is shown, focus the first element in the selected notification.
1594
this.panel.addEventListener(
1595
"popupshown",
1596
() =>
1597
this.window.document.commandDispatcher.advanceFocusIntoSubtree(
1598
this.panel
1599
),
1600
{ once: true }
1601
);
1602
1603
this._reshowNotifications(anchor);
1604
} else {
1605
// Focus the first element in the selected notification.
1606
this.window.document.commandDispatcher.advanceFocusIntoSubtree(
1607
this.panel
1608
);
1609
}
1610
},
1611
1612
_reshowNotifications: function PopupNotifications_reshowNotifications(
1613
anchor,
1614
browser
1615
) {
1616
// Mark notifications anchored to this anchor as un-dismissed
1617
browser = browser || this.tabbrowser.selectedBrowser;
1618
let notifications = this._getNotificationsForBrowser(browser);
1619
notifications.forEach(function(n) {
1620
if (n.anchorElement == anchor) {
1621
n.dismissed = false;
1622
}
1623
});
1624
1625
if (this._isActiveBrowser(browser)) {
1626
// ...and then show them.
1627
this._update(notifications, anchor);
1628
}
1629
},
1630
1631
_swapBrowserNotifications: function PopupNotifications_swapBrowserNoficications(
1632
ourBrowser,
1633
otherBrowser
1634
) {
1635
// When swaping browser docshells (e.g. dragging tab to new window) we need
1636
// to update our notification map.
1637
1638
let ourNotifications = this._getNotificationsForBrowser(ourBrowser);
1639
let other = otherBrowser.ownerGlobal.PopupNotifications;
1640
if (!other) {
1641
if (ourNotifications.length) {
1642
Cu.reportError(
1643
"unable to swap notifications: otherBrowser doesn't support notifications"
1644
);
1645
}
1646
return;
1647
}
1648
let otherNotifications = other._getNotificationsForBrowser(otherBrowser);
1649
if (ourNotifications.length < 1 && otherNotifications.length < 1) {
1650
// No notification to swap.
1651
return;
1652
}
1653
1654
otherNotifications = otherNotifications.filter(n => {
1655
if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) {
1656
n.browser = ourBrowser;
1657
n.owner = this;
1658
return true;
1659
}
1660
other._fireCallback(
1661
n,
1662
NOTIFICATION_EVENT_REMOVED,
1663
this.nextRemovalReason
1664
);
1665
return false;
1666
});
1667
1668
ourNotifications = ourNotifications.filter(n => {
1669
if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) {
1670
n.browser = otherBrowser;
1671
n.owner = other;
1672
return true;
1673
}
1674
this._fireCallback(n, NOTIFICATION_EVENT_REMOVED, this.nextRemovalReason);
1675
return false;
1676
});
1677
1678
this._setNotificationsForBrowser(otherBrowser, ourNotifications);
1679
other._setNotificationsForBrowser(ourBrowser, otherNotifications);
1680
1681
if (otherNotifications.length) {
1682
this._update(otherNotifications);
1683
}
1684
if (ourNotifications.length) {
1685
other._update(ourNotifications);
1686
}
1687
},
1688
1689
_fireCallback: function PopupNotifications_fireCallback(n, event, ...args) {
1690
try {
1691
if (n.options.eventCallback) {
1692
return n.options.eventCallback.call(n, event, ...args);
1693
}
1694
} catch (error) {
1695
Cu.reportError(error);
1696
}
1697
return undefined;
1698
},
1699
1700
_onPopupHidden: function PopupNotifications_onPopupHidden(event) {
1701
if (event.target != this.panel) {
1702
return;
1703
}
1704
1705
// We may have removed the "noautofocus" attribute before showing the panel
1706
// if the notification specified it wants to autofocus on first show.
1707
// When the panel is closed, we have to restore the attribute to its default
1708
// value, so we don't autofocus it if it's subsequently opened from a different code path.
1709
this.panel.setAttribute("noautofocus", "true");
1710
1711
// Handle the case where the panel was closed programmatically.
1712
if (this._ignoreDismissal) {
1713
this._ignoreDismissal.resolve();
1714
this._ignoreDismissal = null;
1715
return;
1716
}
1717
1718
this._dismissOrRemoveCurrentNotifications();
1719
1720
this._clearPanel();
1721
1722
this._update();
1723
},
1724
1725
_dismissOrRemoveCurrentNotifications() {
1726
let browser =
1727
this.panel.firstElementChild &&
1728
this.panel.firstElementChild.notification.browser;
1729
if (!browser) {
1730
return;
1731
}
1732
1733
let notifications = this._getNotificationsForBrowser(browser);
1734
// Mark notifications as dismissed and call dismissal callbacks
1735
for (let nEl of this.panel.children) {
1736
let notificationObj = nEl.notification;
1737
// Never call a dismissal handler on a notification that's been removed.
1738
if (!notifications.includes(notificationObj)) {
1739
return;
1740
}
1741
1742
// Record the time of the first notification dismissal if the main action
1743
// was not triggered in the meantime.
1744
let timeSinceShown =
1745
this.window.performance.now() - notificationObj.timeShown;
1746
if (
1747
!notificationObj.wasDismissed &&
1748
!notificationObj.recordedTelemetryMainAction
1749
) {
1750
notificationObj._recordTelemetry(
1751
"POPUP_NOTIFICATION_DISMISSAL_MS",
1752
timeSinceShown
1753
);
1754
}
1755
1756
// Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED
1757
// if the notification is removed.
1758
if (notificationObj.options.removeOnDismissal) {
1759
notificationObj._recordTelemetryStat(this.nextRemovalReason);
1760
this._remove(notificationObj);
1761
} else {
1762
notificationObj.dismissed = true;
1763
this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
1764
}
1765
}
1766
},
1767
1768
_onCheckboxCommand(event) {
1769
let notificationEl = getNotificationFromElement(event.originalTarget);
1770
let checked = notificationEl.checkbox.checked;
1771
let notification = notificationEl.notification;
1772
1773
// Save checkbox state to be able to persist it when re-opening the doorhanger.
1774
notification._checkboxChecked = checked;
1775
1776
if (checked) {
1777
this._setNotificationUIState(
1778
notificationEl,
1779
notification.options.checkbox.checkedState
1780
);
1781
} else {
1782
this._setNotificationUIState(
1783
notificationEl,
1784
notification.options.checkbox.uncheckedState
1785
);
1786
}
1787
event.stopPropagation();
1788
},
1789
1790
_onCommand(event) {
1791
// Ignore events from buttons as they are submitting and so don't need checks
1792
if (event.originalTarget.localName == "button") {
1793
return;
1794
}
1795
let notificationEl = getNotificationFromElement(event.target);
1796
1797
let notification = notificationEl.notification;
1798
if (!notification.options.checkbox) {
1799
this._setNotificationUIState(notificationEl);
1800
return;
1801
}
1802
1803
if (notificationEl.checkbox.checked) {
1804
this._setNotificationUIState(
1805
notificationEl,
1806
notification.options.checkbox.checkedState
1807
);
1808
} else {
1809
this._setNotificationUIState(
1810
notificationEl,
1811
notification.options.checkbox.uncheckedState
1812
);
1813
}
1814
},
1815
1816
_onButtonEvent(event, type, source = "button", notificationEl = null) {
1817
if (!notificationEl) {
1818
notificationEl = getNotificationFromElement(event.originalTarget);
1819
}
1820
1821
if (!notificationEl) {
1822
throw new Error(
1823
"PopupNotifications._onButtonEvent: couldn't find notification element"
1824
);
1825
}
1826
1827
if (!notificationEl.notification) {
1828
throw new Error(
1829
"PopupNotifications._onButtonEvent: couldn't find notification"
1830
);
1831
}
1832
1833
let notification = notificationEl.notification;
1834
1835
if (type == "dropmarkerpopupshown") {
1836
notification._recordTelemetryStat(TELEMETRY_STAT_OPEN_SUBMENU);
1837
return;
1838
}
1839
1840
if (type == "learnmoreclick") {
1841
notification._recordTelemetryStat(TELEMETRY_STAT_LEARN_MORE);
1842
return;
1843
}
1844
1845
if (type == "buttoncommand") {
1846
// Record the total timing of the main action since the notification was
1847
// created, even if the notification was dismissed in the meantime.
1848
let timeSinceCreated =
1849
this.window.performance.now() - notification.timeCreated;
1850
if (!notification.recordedTelemetryMainAction) {
1851
notification.recordedTelemetryMainAction = true;
1852
notification._recordTelemetry(
1853
"POPUP_NOTIFICATION_MAIN_ACTION_MS",
1854
timeSinceCreated
1855
);
1856
}
1857
}
1858
1859
if (type == "buttoncommand" || type == "secondarybuttoncommand") {
1860
if (Services.focus.activeWindow != this.window) {
1861
Services.console.logStringMessage(
1862
"PopupNotifications._onButtonEvent: " +
1863
"Button click happened before the window was focused"
1864
);
1865
this.window.focus();
1866
return;
1867
}
1868
1869
let timeSinceShown =
1870
this.window.performance.now() - notification.timeShown;
1871
if (timeSinceShown < this.buttonDelay) {
1872
Services.console.logStringMessage(
1873
"PopupNotifications._onButtonEvent: " +
1874
"Button click happened before the security delay: " +
1875
timeSinceShown +
1876
"ms"
1877
);
1878
return;
1879
}
1880
}
1881
1882
let action = notification.mainAction;
1883
let telemetryStatId = TELEMETRY_STAT_ACTION_1;
1884
1885
if (type == "secondarybuttoncommand") {
1886
action = notification.secondaryActions[0];
1887
telemetryStatId = TELEMETRY_STAT_ACTION_2;
1888
}
1889
1890
notification._recordTelemetryStat(telemetryStatId);
1891
1892
if (action) {
1893
try {
1894
action.callback.call(undefined, {
1895
checkboxChecked: notificationEl.checkbox.checked,
1896
source,
1897
event,
1898
});
1899
} catch (error) {
1900
Cu.reportError(error);
1901
}
1902
1903
if (action.dismiss) {
1904
this._dismiss();
1905
return;
1906
}
1907
}
1908
1909
this._remove(notification);
1910
this._update();
1911
},
1912
1913
_onMenuCommand: function PopupNotifications_onMenuCommand(event) {
1914
let target = event.originalTarget;
1915
if (!target.action || !target.notification) {
1916
throw new Error(
1917
"menucommand target has no associated action/notification"
1918
);
1919
}
1920
1921
let notificationEl = getNotificationFromElement(target);
1922
event.stopPropagation();
1923
1924
target.notification._recordTelemetryStat(target.action.telemetryStatId);
1925
1926
try {
1927
target.action.callback.call(undefined, {
1928
checkboxChecked: notificationEl.checkbox.checked,
1929
source: "menucommand",
1930
});
1931
} catch (error) {
1932
Cu.reportError(error);
1933
}
1934
1935
if (target.action.dismiss) {
1936
this._dismiss();
1937
return;
1938
}
1939
1940
this._remove(target.notification);
1941
this._update();
1942
},
1943
1944
_notify: function PopupNotifications_notify(topic) {
1945
Services.obs.notifyObservers(null, "PopupNotifications-" + topic);
1946
},
1947
};