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
3 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
5 "use strict";
7 this.EXPORTED_SYMBOLS = ["BrowserUITelemetry"];
9 const {interfaces: Ci, utils: Cu} = Components;
11 Cu.import("resource://gre/modules/Services.jsm");
12 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
14 XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
15 "resource://gre/modules/UITelemetry.jsm");
16 XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
17 "resource:///modules/RecentWindow.jsm");
18 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
19 "resource:///modules/CustomizableUI.jsm");
20 XPCOMUtils.defineLazyModuleGetter(this, "UITour",
21 "resource:///modules/UITour.jsm");
22 XPCOMUtils.defineLazyGetter(this, "Timer", function() {
23 let timer = {};
24 Cu.import("resource://gre/modules/Timer.jsm", timer);
25 return timer;
26 });
28 const MS_SECOND = 1000;
29 const MS_MINUTE = MS_SECOND * 60;
30 const MS_HOUR = MS_MINUTE * 60;
32 XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREA_PLACEMENTS", function() {
33 let result = {
34 "PanelUI-contents": [
35 "edit-controls",
36 "zoom-controls",
37 "new-window-button",
38 "privatebrowsing-button",
39 "save-page-button",
40 "print-button",
41 "history-panelmenu",
42 "fullscreen-button",
43 "find-button",
44 "preferences-button",
45 "add-ons-button",
46 "developer-button",
47 ],
48 "nav-bar": [
49 "urlbar-container",
50 "search-container",
51 "webrtc-status-button",
52 "bookmarks-menu-button",
53 "downloads-button",
54 "home-button",
55 "social-share-button",
56 ],
57 // It's true that toolbar-menubar is not visible
58 // on OS X, but the XUL node is definitely present
59 // in the document.
60 "toolbar-menubar": [
61 "menubar-items",
62 ],
63 "TabsToolbar": [
64 "tabbrowser-tabs",
65 "new-tab-button",
66 "alltabs-button",
67 ],
68 "PersonalToolbar": [
69 "personal-bookmarks",
70 ],
71 };
73 let showCharacterEncoding = Services.prefs.getComplexValue(
74 "browser.menu.showCharacterEncoding",
75 Ci.nsIPrefLocalizedString
76 ).data;
77 if (showCharacterEncoding == "true") {
78 result["PanelUI-contents"].push("characterencoding-button");
79 }
81 if (Services.metro && Services.metro.supported) {
82 result["PanelUI-contents"].push("switch-to-metro-button");
83 }
85 return result;
86 });
88 XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREAS", function() {
89 return Object.keys(DEFAULT_AREA_PLACEMENTS);
90 });
92 XPCOMUtils.defineLazyGetter(this, "PALETTE_ITEMS", function() {
93 let result = [
94 "open-file-button",
95 "developer-button",
96 "feed-button",
97 "email-link-button",
98 "sync-button",
99 "tabview-button",
100 ];
102 let panelPlacements = DEFAULT_AREA_PLACEMENTS["PanelUI-contents"];
103 if (panelPlacements.indexOf("characterencoding-button") == -1) {
104 result.push("characterencoding-button");
105 }
107 return result;
108 });
110 XPCOMUtils.defineLazyGetter(this, "DEFAULT_ITEMS", function() {
111 let result = [];
112 for (let [, buttons] of Iterator(DEFAULT_AREA_PLACEMENTS)) {
113 result = result.concat(buttons);
114 }
115 return result;
116 });
118 XPCOMUtils.defineLazyGetter(this, "ALL_BUILTIN_ITEMS", function() {
119 // These special cases are for click events on built-in items that are
120 // contained within customizable items (like the navigation widget).
121 const SPECIAL_CASES = [
122 "back-button",
123 "forward-button",
124 "urlbar-stop-button",
125 "urlbar-go-button",
126 "urlbar-reload-button",
127 "searchbar",
128 "cut-button",
129 "copy-button",
130 "paste-button",
131 "zoom-out-button",
132 "zoom-reset-button",
133 "zoom-in-button",
134 "BMB_bookmarksPopup",
135 "BMB_unsortedBookmarksPopup",
136 "BMB_bookmarksToolbarPopup",
137 ]
138 return DEFAULT_ITEMS.concat(PALETTE_ITEMS)
139 .concat(SPECIAL_CASES);
140 });
142 const OTHER_MOUSEUP_MONITORED_ITEMS = [
143 "PlacesChevron",
144 "PlacesToolbarItems",
145 "menubar-items",
146 ];
148 // Items that open arrow panels will often be overlapped by
149 // the panel that they're opening by the time the mouseup
150 // event is fired, so for these items, we monitor mousedown.
151 const MOUSEDOWN_MONITORED_ITEMS = [
152 "PanelUI-menu-button",
153 ];
155 // Weakly maps browser windows to objects whose keys are relative
156 // timestamps for when some kind of session started. For example,
157 // when a customization session started. That way, when the window
158 // exits customization mode, we can determine how long the session
159 // lasted.
160 const WINDOW_DURATION_MAP = new WeakMap();
162 // Default bucket name, when no other bucket is active.
163 const BUCKET_DEFAULT = "__DEFAULT__";
164 // Bucket prefix, for named buckets.
165 const BUCKET_PREFIX = "bucket_";
166 // Standard separator to use between different parts of a bucket name, such
167 // as primary name and the time step string.
168 const BUCKET_SEPARATOR = "|";
170 this.BrowserUITelemetry = {
171 init: function() {
172 UITelemetry.addSimpleMeasureFunction("toolbars",
173 this.getToolbarMeasures.bind(this));
174 // Ensure that UITour.jsm remains lazy-loaded, yet always registers its
175 // simple measure function with UITelemetry.
176 UITelemetry.addSimpleMeasureFunction("UITour",
177 () => UITour.getTelemetry());
179 Services.obs.addObserver(this, "sessionstore-windows-restored", false);
180 Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
181 CustomizableUI.addListener(this);
182 },
184 observe: function(aSubject, aTopic, aData) {
185 switch(aTopic) {
186 case "sessionstore-windows-restored":
187 this._gatherFirstWindowMeasurements();
188 break;
189 case "browser-delayed-startup-finished":
190 this._registerWindow(aSubject);
191 break;
192 }
193 },
195 /**
196 * For the _countableEvents object, constructs a chain of
197 * Javascript Objects with the keys in aKeys, with the final
198 * key getting the value in aEndWith. If the final key already
199 * exists in the final object, its value is not set. In either
200 * case, a reference to the second last object in the chain is
201 * returned.
202 *
203 * Example - suppose I want to store:
204 * _countableEvents: {
205 * a: {
206 * b: {
207 * c: 0
208 * }
209 * }
210 * }
211 *
212 * And then increment the "c" value by 1, you could call this
213 * function like this:
214 *
215 * let example = this._ensureObjectChain([a, b, c], 0);
216 * example["c"]++;
217 *
218 * Subsequent repetitions of these last two lines would
219 * simply result in the c value being incremented again
220 * and again.
221 *
222 * @param aKeys the Array of keys to chain Objects together with.
223 * @param aEndWith the value to assign to the last key.
224 * @returns a reference to the second last object in the chain -
225 * so in our example, that'd be "b".
226 */
227 _ensureObjectChain: function(aKeys, aEndWith) {
228 let current = this._countableEvents;
229 let parent = null;
230 aKeys.unshift(this._bucket);
231 for (let [i, key] of Iterator(aKeys)) {
232 if (!(key in current)) {
233 if (i == aKeys.length - 1) {
234 current[key] = aEndWith;
235 } else {
236 current[key] = {};
237 }
238 }
239 parent = current;
240 current = current[key];
241 }
242 return parent;
243 },
245 _countableEvents: {},
246 _countEvent: function(aKeyArray) {
247 let countObject = this._ensureObjectChain(aKeyArray, 0);
248 let lastItemKey = aKeyArray[aKeyArray.length - 1];
249 countObject[lastItemKey]++;
250 },
252 _countMouseUpEvent: function(aCategory, aAction, aButton) {
253 const BUTTONS = ["left", "middle", "right"];
254 let buttonKey = BUTTONS[aButton];
255 if (buttonKey) {
256 this._countEvent([aCategory, aAction, buttonKey]);
257 }
258 },
260 _firstWindowMeasurements: null,
261 _gatherFirstWindowMeasurements: function() {
262 // We'll gather measurements as soon as the session has restored.
263 // We do this here instead of waiting for UITelemetry to ask for
264 // our measurements because at that point all browser windows have
265 // probably been closed, since the vast majority of saved-session
266 // pings are gathered during shutdown.
267 let win = RecentWindow.getMostRecentBrowserWindow({
268 private: false,
269 allowPopups: false,
270 });
272 // If there are no such windows, we're out of luck. :(
273 this._firstWindowMeasurements = win ? this._getWindowMeasurements(win)
274 : {};
275 },
277 _registerWindow: function(aWindow) {
278 aWindow.addEventListener("unload", this);
279 let document = aWindow.document;
281 for (let areaID of CustomizableUI.areas) {
282 let areaNode = document.getElementById(areaID);
283 if (areaNode) {
284 (areaNode.customizationTarget || areaNode).addEventListener("mouseup", this);
285 }
286 }
288 for (let itemID of OTHER_MOUSEUP_MONITORED_ITEMS) {
289 let item = document.getElementById(itemID);
290 if (item) {
291 item.addEventListener("mouseup", this);
292 }
293 }
295 for (let itemID of MOUSEDOWN_MONITORED_ITEMS) {
296 let item = document.getElementById(itemID);
297 if (item) {
298 item.addEventListener("mousedown", this);
299 }
300 }
302 WINDOW_DURATION_MAP.set(aWindow, {});
303 },
305 _unregisterWindow: function(aWindow) {
306 aWindow.removeEventListener("unload", this);
307 let document = aWindow.document;
309 for (let areaID of CustomizableUI.areas) {
310 let areaNode = document.getElementById(areaID);
311 if (areaNode) {
312 (areaNode.customizationTarget || areaNode).removeEventListener("mouseup", this);
313 }
314 }
316 for (let itemID of OTHER_MOUSEUP_MONITORED_ITEMS) {
317 let item = document.getElementById(itemID);
318 if (item) {
319 item.removeEventListener("mouseup", this);
320 }
321 }
323 for (let itemID of MOUSEDOWN_MONITORED_ITEMS) {
324 let item = document.getElementById(itemID);
325 if (item) {
326 item.removeEventListener("mousedown", this);
327 }
328 }
329 },
331 handleEvent: function(aEvent) {
332 switch(aEvent.type) {
333 case "unload":
334 this._unregisterWindow(aEvent.currentTarget);
335 break;
336 case "mouseup":
337 this._handleMouseUp(aEvent);
338 break;
339 case "mousedown":
340 this._handleMouseDown(aEvent);
341 break;
342 }
343 },
345 _handleMouseUp: function(aEvent) {
346 let targetID = aEvent.currentTarget.id;
348 switch (targetID) {
349 case "PlacesToolbarItems":
350 this._PlacesToolbarItemsMouseUp(aEvent);
351 break;
352 case "PlacesChevron":
353 this._PlacesChevronMouseUp(aEvent);
354 break;
355 case "menubar-items":
356 this._menubarMouseUp(aEvent);
357 break;
358 default:
359 this._checkForBuiltinItem(aEvent);
360 }
361 },
363 _handleMouseDown: function(aEvent) {
364 if (aEvent.currentTarget.id == "PanelUI-menu-button") {
365 // _countMouseUpEvent expects a detail for the second argument,
366 // but we don't really have any details to give. Just passing in
367 // "button" is probably simpler than trying to modify
368 // _countMouseUpEvent for this particular case.
369 this._countMouseUpEvent("click-menu-button", "button", aEvent.button);
370 }
371 },
373 _PlacesChevronMouseUp: function(aEvent) {
374 let target = aEvent.originalTarget;
375 let result = target.id == "PlacesChevron" ? "chevron" : "overflowed-item";
376 this._countMouseUpEvent("click-bookmarks-bar", result, aEvent.button);
377 },
379 _PlacesToolbarItemsMouseUp: function(aEvent) {
380 let target = aEvent.originalTarget;
381 // If this isn't a bookmark-item, we don't care about it.
382 if (!target.classList.contains("bookmark-item")) {
383 return;
384 }
386 let result = target.hasAttribute("container") ? "container" : "item";
387 this._countMouseUpEvent("click-bookmarks-bar", result, aEvent.button);
388 },
390 _menubarMouseUp: function(aEvent) {
391 let target = aEvent.originalTarget;
392 let tag = target.localName
393 let result = (tag == "menu" || tag == "menuitem") ? tag : "other";
394 this._countMouseUpEvent("click-menubar", result, aEvent.button);
395 },
397 _bookmarksMenuButtonMouseUp: function(aEvent) {
398 let bookmarksWidget = CustomizableUI.getWidget("bookmarks-menu-button");
399 if (bookmarksWidget.areaType == CustomizableUI.TYPE_MENU_PANEL) {
400 // In the menu panel, only the star is visible, and that opens up the
401 // bookmarks subview.
402 this._countMouseUpEvent("click-bookmarks-menu-button", "in-panel",
403 aEvent.button);
404 } else {
405 let clickedItem = aEvent.originalTarget;
406 // Did we click on the star, or the dropmarker? The star
407 // has an anonid of "button". If we don't find that, we'll
408 // assume we clicked on the dropmarker.
409 let action = "menu";
410 if (clickedItem.getAttribute("anonid") == "button") {
411 // We clicked on the star - now we just need to record
412 // whether or not we're adding a bookmark or editing an
413 // existing one.
414 let bookmarksMenuNode =
415 bookmarksWidget.forWindow(aEvent.target.ownerGlobal).node;
416 action = bookmarksMenuNode.hasAttribute("starred") ? "edit" : "add";
417 }
418 this._countMouseUpEvent("click-bookmarks-menu-button", action,
419 aEvent.button);
420 }
421 },
423 _checkForBuiltinItem: function(aEvent) {
424 let item = aEvent.originalTarget;
426 // We special-case the bookmarks-menu-button, since we want to
427 // monitor more than just clicks on it.
428 if (item.id == "bookmarks-menu-button" ||
429 getIDBasedOnFirstIDedAncestor(item) == "bookmarks-menu-button") {
430 this._bookmarksMenuButtonMouseUp(aEvent);
431 return;
432 }
434 // Perhaps we're seeing one of the default toolbar items
435 // being clicked.
436 if (ALL_BUILTIN_ITEMS.indexOf(item.id) != -1) {
437 // Base case - we clicked directly on one of our built-in items,
438 // and we can go ahead and register that click.
439 this._countMouseUpEvent("click-builtin-item", item.id, aEvent.button);
440 return;
441 }
443 // If not, we need to check if one of the ancestors of the clicked
444 // item is in our list of built-in items to check.
445 let candidate = getIDBasedOnFirstIDedAncestor(item);
446 if (ALL_BUILTIN_ITEMS.indexOf(candidate) != -1) {
447 this._countMouseUpEvent("click-builtin-item", candidate, aEvent.button);
448 }
449 },
451 _getWindowMeasurements: function(aWindow) {
452 let document = aWindow.document;
453 let result = {};
455 // Determine if the window is in the maximized, normal or
456 // fullscreen state.
457 result.sizemode = document.documentElement.getAttribute("sizemode");
459 // Determine if the Bookmarks bar is currently visible
460 let bookmarksBar = document.getElementById("PersonalToolbar");
461 result.bookmarksBarEnabled = bookmarksBar && !bookmarksBar.collapsed;
463 // Determine if the menubar is currently visible. On OS X, the menubar
464 // is never shown, despite not having the collapsed attribute set.
465 let menuBar = document.getElementById("toolbar-menubar");
466 result.menuBarEnabled =
467 menuBar && Services.appinfo.OS != "Darwin"
468 && menuBar.getAttribute("autohide") != "true";
470 // Determine if the titlebar is currently visible.
471 result.titleBarEnabled = !Services.prefs.getBoolPref("browser.tabs.drawInTitlebar");
473 // Examine all customizable areas and see what default items
474 // are present and missing.
475 let defaultKept = [];
476 let defaultMoved = [];
477 let nondefaultAdded = [];
479 for (let areaID of CustomizableUI.areas) {
480 let items = CustomizableUI.getWidgetIdsInArea(areaID);
481 for (let item of items) {
482 // Is this a default item?
483 if (DEFAULT_ITEMS.indexOf(item) != -1) {
484 // Ok, it's a default item - but is it in its default
485 // toolbar? We use Array.isArray instead of checking for
486 // toolbarID in DEFAULT_AREA_PLACEMENTS because an add-on might
487 // be clever and give itself the id of "toString" or something.
488 if (Array.isArray(DEFAULT_AREA_PLACEMENTS[areaID]) &&
489 DEFAULT_AREA_PLACEMENTS[areaID].indexOf(item) != -1) {
490 // The item is in its default toolbar
491 defaultKept.push(item);
492 } else {
493 defaultMoved.push(item);
494 }
495 } else if (PALETTE_ITEMS.indexOf(item) != -1) {
496 // It's a palette item that's been moved into a toolbar
497 nondefaultAdded.push(item);
498 }
499 // else, it's provided by an add-on, and we won't record it.
500 }
501 }
503 // Now go through the items in the palette to see what default
504 // items are in there.
505 let paletteItems =
506 CustomizableUI.getUnusedWidgets(aWindow.gNavToolbox.palette);
507 let defaultRemoved = [item.id for (item of paletteItems)
508 if (DEFAULT_ITEMS.indexOf(item.id) != -1)];
510 result.defaultKept = defaultKept;
511 result.defaultMoved = defaultMoved;
512 result.nondefaultAdded = nondefaultAdded;
513 result.defaultRemoved = defaultRemoved;
515 // Next, determine how many add-on provided toolbars exist.
516 let addonToolbars = 0;
517 let toolbars = document.querySelectorAll("toolbar[customizable=true]");
518 for (let toolbar of toolbars) {
519 if (DEFAULT_AREAS.indexOf(toolbar.id) == -1) {
520 addonToolbars++;
521 }
522 }
523 result.addonToolbars = addonToolbars;
525 // Find out how many open tabs we have in each window
526 let winEnumerator = Services.wm.getEnumerator("navigator:browser");
527 let visibleTabs = [];
528 let hiddenTabs = [];
529 while (winEnumerator.hasMoreElements()) {
530 let someWin = winEnumerator.getNext();
531 if (someWin.gBrowser) {
532 let visibleTabsNum = someWin.gBrowser.visibleTabs.length;
533 visibleTabs.push(visibleTabsNum);
534 hiddenTabs.push(someWin.gBrowser.tabs.length - visibleTabsNum);
535 }
536 }
537 result.visibleTabs = visibleTabs;
538 result.hiddenTabs = hiddenTabs;
540 return result;
541 },
543 getToolbarMeasures: function() {
544 let result = this._firstWindowMeasurements || {};
545 result.countableEvents = this._countableEvents;
546 result.durations = this._durations;
547 return result;
548 },
550 countCustomizationEvent: function(aEventType) {
551 this._countEvent(["customize", aEventType]);
552 },
554 _durations: {
555 customization: [],
556 },
558 onCustomizeStart: function(aWindow) {
559 this._countEvent(["customize", "start"]);
560 let durationMap = WINDOW_DURATION_MAP.get(aWindow);
561 if (!durationMap) {
562 durationMap = {};
563 WINDOW_DURATION_MAP.set(aWindow, durationMap);
564 }
566 durationMap.customization = {
567 start: aWindow.performance.now(),
568 bucket: this._bucket,
569 };
570 },
572 onCustomizeEnd: function(aWindow) {
573 let durationMap = WINDOW_DURATION_MAP.get(aWindow);
574 if (durationMap && "customization" in durationMap) {
575 let duration = aWindow.performance.now() - durationMap.customization.start;
576 this._durations.customization.push({
577 duration: duration,
578 bucket: durationMap.customization.bucket,
579 });
580 delete durationMap.customization;
581 }
582 },
584 _bucket: BUCKET_DEFAULT,
585 _bucketTimer: null,
587 /**
588 * Default bucket name, when no other bucket is active.
589 */
590 get BUCKET_DEFAULT() BUCKET_DEFAULT,
592 /**
593 * Bucket prefix, for named buckets.
594 */
595 get BUCKET_PREFIX() BUCKET_PREFIX,
597 /**
598 * Standard separator to use between different parts of a bucket name, such
599 * as primary name and the time step string.
600 */
601 get BUCKET_SEPARATOR() BUCKET_SEPARATOR,
603 get currentBucket() {
604 return this._bucket;
605 },
607 /**
608 * Sets a named bucket for all countable events and select durections to be
609 * put into.
610 *
611 * @param aName Name of bucket, or null for default bucket name (__DEFAULT__)
612 */
613 setBucket: function(aName) {
614 if (this._bucketTimer) {
615 Timer.clearTimeout(this._bucketTimer);
616 this._bucketTimer = null;
617 }
619 if (aName)
620 this._bucket = BUCKET_PREFIX + aName;
621 else
622 this._bucket = BUCKET_DEFAULT;
623 },
625 /**
626 * Sets a bucket that expires at the rate of a given series of time steps.
627 * Once the bucket expires, the current bucket will automatically revert to
628 * the default bucket. While the bucket is expiring, it's name is postfixed
629 * by '|' followed by a short string representation of the time step it's
630 * currently in.
631 * If any other bucket (expiring or normal) is set while an expiring bucket is
632 * still expiring, the old expiring bucket stops expiring and the new bucket
633 * immediately takes over.
634 *
635 * @param aName Name of bucket.
636 * @param aTimeSteps An array of times in milliseconds to count up to before
637 * reverting back to the default bucket. The array of times
638 * is expected to be pre-sorted in ascending order.
639 * For example, given a bucket name of 'bucket', the times:
640 * [60000, 300000, 600000]
641 * will result in the following buckets:
642 * * bucket|1m - for the first 1 minute
643 * * bucket|5m - for the following 4 minutes
644 * (until 5 minutes after the start)
645 * * bucket|10m - for the following 5 minutes
646 * (until 10 minutes after the start)
647 * * __DEFAULT__ - until a new bucket is set
648 * @param aTimeOffset Time offset, in milliseconds, from which to start
649 * counting. For example, if the first time step is 1000ms,
650 * and the time offset is 300ms, then the next time step
651 * will become active after 700ms. This affects all
652 * following time steps also, meaning they will also all be
653 * timed as though they started expiring 300ms before
654 * setExpiringBucket was called.
655 */
656 setExpiringBucket: function(aName, aTimeSteps, aTimeOffset = 0) {
657 if (aTimeSteps.length === 0) {
658 this.setBucket(null);
659 return;
660 }
662 if (this._bucketTimer) {
663 Timer.clearTimeout(this._bucketTimer);
664 this._bucketTimer = null;
665 }
667 // Make a copy of the time steps array, so we can safely modify it without
668 // modifying the original array that external code has passed to us.
669 let steps = [...aTimeSteps];
670 let msec = steps.shift();
671 let postfix = this._toTimeStr(msec);
672 this.setBucket(aName + BUCKET_SEPARATOR + postfix);
674 this._bucketTimer = Timer.setTimeout(() => {
675 this._bucketTimer = null;
676 this.setExpiringBucket(aName, steps, aTimeOffset + msec);
677 }, msec - aTimeOffset);
678 },
680 /**
681 * Formats a time interval, in milliseconds, to a minimal non-localized string
682 * representation. Format is: 'h' for hours, 'm' for minutes, 's' for seconds,
683 * 'ms' for milliseconds.
684 * Examples:
685 * 65 => 65ms
686 * 1000 => 1s
687 * 60000 => 1m
688 * 61000 => 1m01s
689 *
690 * @param aTimeMS Time in milliseconds
691 *
692 * @return Minimal string representation.
693 */
694 _toTimeStr: function(aTimeMS) {
695 let timeStr = "";
697 function reduce(aUnitLength, aSymbol) {
698 if (aTimeMS >= aUnitLength) {
699 let units = Math.floor(aTimeMS / aUnitLength);
700 aTimeMS = aTimeMS - (units * aUnitLength)
701 timeStr += units + aSymbol;
702 }
703 }
705 reduce(MS_HOUR, "h");
706 reduce(MS_MINUTE, "m");
707 reduce(MS_SECOND, "s");
708 reduce(1, "ms");
710 return timeStr;
711 },
712 };
714 /**
715 * Returns the id of the first ancestor of aNode that has an id. If aNode
716 * has no parent, or no ancestor has an id, returns null.
717 *
718 * @param aNode the node to find the first ID'd ancestor of
719 */
720 function getIDBasedOnFirstIDedAncestor(aNode) {
721 while (!aNode.id) {
722 aNode = aNode.parentNode;
723 if (!aNode) {
724 return null;
725 }
726 }
728 return aNode.id;
729 }