summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/content/widgets
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/base/content/widgets
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--comm/calendar/base/content/widgets/calendar-alarm-widget.js402
-rw-r--r--comm/calendar/base/content/widgets/calendar-dnd-widgets.js192
-rw-r--r--comm/calendar/base/content/widgets/calendar-filter-tree-view.js371
-rw-r--r--comm/calendar/base/content/widgets/calendar-filter.js1365
-rw-r--r--comm/calendar/base/content/widgets/calendar-invitation-panel.js799
-rw-r--r--comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml96
-rw-r--r--comm/calendar/base/content/widgets/calendar-item-summary.js761
-rw-r--r--comm/calendar/base/content/widgets/calendar-minidate.js83
-rw-r--r--comm/calendar/base/content/widgets/calendar-minidate.xhtml17
-rw-r--r--comm/calendar/base/content/widgets/calendar-minimonth.js1055
-rw-r--r--comm/calendar/base/content/widgets/calendar-modebox.js244
-rw-r--r--comm/calendar/base/content/widgets/calendar-notifications-setting.js259
-rw-r--r--comm/calendar/base/content/widgets/datetimepickers.js1529
-rw-r--r--comm/calendar/base/content/widgets/mouseoverPreviews.js439
14 files changed, 7612 insertions, 0 deletions
diff --git a/comm/calendar/base/content/widgets/calendar-alarm-widget.js b/comm/calendar/base/content/widgets/calendar-alarm-widget.js
new file mode 100644
index 0000000000..58300255bd
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-alarm-widget.js
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global Cr MozElements MozXULElement PluralForm Services */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ /**
+ * Represents an alarm in the alarms dialog. It appears there when an alarm is fired, and
+ * allows the alarm to be snoozed, dismissed, etc.
+ *
+ * @augments MozElements.MozRichlistitem
+ */
+ class MozCalendarAlarmWidgetRichlistitem extends MozElements.MozRichlistitem {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <vbox pack="start">
+ <html:img class="alarm-calendar-image"
+ src="chrome://calendar/skin/shared/icons/icon32.svg"
+ alt="" />
+ </vbox>
+ <vbox class="alarm-calendar-event">
+ <label class="alarm-title-label" crop="end"/>
+ <vbox class="additional-information-box">
+ <label class="alarm-date-label"/>
+ <description class="alarm-location-description"
+ crop="end"
+ flex="1"/>
+ <hbox pack="start">
+ <label class="text-link alarm-details-label"
+ value="&calendar.alarm.details.label;"
+ onclick="showDetails(event)"
+ onkeypress="showDetails(event)"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ <spacer flex="1"/>
+ <label class="alarm-relative-date-label"/>
+ <vbox class="alarm-action-buttons" pack="center">
+ <button class="alarm-snooze-button"
+ type="menu"
+ label="&calendar.alarm.snoozefor.label;">
+ <menupopup is="calendar-snooze-popup" ignorekeys="true"/>
+ </button>
+ <button class="alarm-dismiss-button"
+ label="&calendar.alarm.dismiss.label;"
+ oncommand="dismissAlarm()"/>
+ </vbox>
+ `,
+ ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+ this.mItem = null;
+ this.mAlarm = null;
+ this.setAttribute("is", "calendar-alarm-widget-richlistitem");
+ }
+
+ set item(val) {
+ this.mItem = val;
+ this.updateLabels();
+ }
+
+ get item() {
+ return this.mItem;
+ }
+
+ set alarm(val) {
+ this.mAlarm = val;
+ this.updateLabels();
+ }
+
+ get alarm() {
+ return this.mAlarm;
+ }
+
+ /**
+ * Refresh UI text (dates, titles, locations) when the data has changed.
+ */
+ updateLabels() {
+ if (!this.mItem || !this.mAlarm) {
+ // Setup not complete, do nothing for now.
+ return;
+ }
+ const formatter = cal.dtz.formatter;
+ let titleLabel = this.querySelector(".alarm-title-label");
+ let locationDescription = this.querySelector(".alarm-location-description");
+ let dateLabel = this.querySelector(".alarm-date-label");
+
+ // Dates
+ if (this.mItem.isEvent()) {
+ dateLabel.value = formatter.formatItemInterval(this.mItem);
+ } else if (this.mItem.isTodo()) {
+ let startDate = this.mItem.entryDate || this.mItem.dueDate;
+ if (startDate) {
+ // A task with a start or due date, show with label.
+ startDate = startDate.getInTimezone(cal.dtz.defaultTimezone);
+ dateLabel.value = cal.l10n.getCalString("alarmStarts", [
+ formatter.formatDateTime(startDate),
+ ]);
+ } else {
+ // If the task has no start date, then format the alarm date.
+ dateLabel.value = formatter.formatDateTime(this.mAlarm.alarmDate);
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ // Relative Date
+ this.updateRelativeDateLabel();
+
+ // Title, Location
+ titleLabel.value = this.mItem.title || "";
+ locationDescription.value = this.mItem.getProperty("LOCATION") || "";
+ if (locationDescription.value.length) {
+ let urlMatch = locationDescription.value.match(/(https?:\/\/[^ ]*)/);
+ let url = urlMatch && urlMatch[1];
+ if (url) {
+ locationDescription.setAttribute("link", url);
+ locationDescription.setAttribute(
+ "onclick",
+ "launchBrowser(this.getAttribute('link'), event)"
+ );
+ locationDescription.setAttribute(
+ "oncommand",
+ "launchBrowser(this.getAttribute('link'), event)"
+ );
+ locationDescription.classList.add("text-link", "alarm-details-label");
+ }
+ } else {
+ locationDescription.hidden = true;
+ }
+ // Hide snooze button if read-only.
+ let snoozeButton = this.querySelector(".alarm-snooze-button");
+ if (
+ !cal.acl.isCalendarWritable(this.mItem.calendar) ||
+ !cal.acl.userCanModifyItem(this.mItem)
+ ) {
+ let tooltip = "reminderDisabledSnoozeButtonTooltip";
+ snoozeButton.disabled = true;
+ snoozeButton.setAttribute("tooltiptext", cal.l10n.getString("calendar-alarms", tooltip));
+ } else {
+ snoozeButton.disabled = false;
+ snoozeButton.removeAttribute("tooltiptext");
+ }
+ }
+
+ /**
+ * Refresh UI text for relative date when the data has changed.
+ */
+ updateRelativeDateLabel() {
+ const formatter = cal.dtz.formatter;
+ const item = this.mItem;
+ let relativeDateLabel = this.querySelector(".alarm-relative-date-label");
+ let relativeDateString;
+ let startDate = item[cal.dtz.startDateProp(item)] || item[cal.dtz.endDateProp(item)];
+
+ if (startDate) {
+ startDate = startDate.getInTimezone(cal.dtz.defaultTimezone);
+ let currentDate = cal.dtz.now();
+
+ const sinceDayStart = currentDate.hour * 3600 + currentDate.minute * 60;
+
+ currentDate.second = 0;
+ startDate.second = 0;
+
+ const sinceAlarm = currentDate.subtractDate(startDate).inSeconds;
+
+ this.mAlarmToday = sinceAlarm < sinceDayStart && sinceAlarm > sinceDayStart - 86400;
+
+ if (this.mAlarmToday) {
+ // The alarm is today.
+ relativeDateString = cal.l10n.getCalString("alarmTodayAt", [
+ formatter.formatTime(startDate),
+ ]);
+ } else if (sinceAlarm <= sinceDayStart - 86400 && sinceAlarm > sinceDayStart - 172800) {
+ // The alarm is tomorrow.
+ relativeDateString = cal.l10n.getCalString("alarmTomorrowAt", [
+ formatter.formatTime(startDate),
+ ]);
+ } else if (sinceAlarm < sinceDayStart + 86400 && sinceAlarm > sinceDayStart) {
+ // The alarm is yesterday.
+ relativeDateString = cal.l10n.getCalString("alarmYesterdayAt", [
+ formatter.formatTime(startDate),
+ ]);
+ } else {
+ // The alarm is way back.
+ relativeDateString = [formatter.formatDateTime(startDate)];
+ }
+ } else {
+ // No start or end date, therefore the alarm must be absolute
+ // and have an alarm date.
+ relativeDateString = [formatter.formatDateTime(this.mAlarm.alarmDate)];
+ }
+
+ relativeDateLabel.value = relativeDateString;
+ }
+
+ /**
+ * Click/keypress handler for "Details" link. Dispatches an event to open an item dialog.
+ *
+ * @param event {Event} The click or keypress event.
+ */
+ showDetails(event) {
+ if (event.type == "click" || (event.type == "keypress" && event.key == "Enter")) {
+ const detailsEvent = new Event("itemdetails", { bubbles: true, cancelable: false });
+ this.dispatchEvent(detailsEvent);
+ }
+ }
+
+ /**
+ * Click handler for "Dismiss" button. Dispatches an event to dismiss the alarm.
+ */
+ dismissAlarm() {
+ const dismissEvent = new Event("dismiss", { bubbles: true, cancelable: false });
+ this.dispatchEvent(dismissEvent);
+ }
+ }
+
+ customElements.define("calendar-alarm-widget-richlistitem", MozCalendarAlarmWidgetRichlistitem, {
+ extends: "richlistitem",
+ });
+
+ /**
+ * A popup panel for selecting how long to snooze alarms/reminders.
+ * It appears when a snooze button is clicked.
+ *
+ * @augments MozElements.MozMenuPopup
+ */
+ class MozCalendarSnoozePopup extends MozElements.MozMenuPopup {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menuitem label="&calendar.alarm.snooze.5minutes.label;"
+ value="5"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.10minutes.label;"
+ value="10"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.15minutes.label;"
+ value="15"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.30minutes.label;"
+ value="30"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.45minutes.label;"
+ value="45"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.1hour.label;"
+ value="60"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.2hours.label;"
+ value="120"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.1day.label;"
+ value="1440"
+ oncommand="snoozeItem(event)"/>
+ <menuseparator/>
+ <hbox class="snooze-options-box">
+ <html:input type="number"
+ class="size3 snooze-value-textbox"
+ oninput="updateUIText()"
+ onselect="updateUIText()"/>
+ <menulist class="snooze-unit-menulist" allowevents="true">
+ <menupopup class="snooze-unit-menupopup menulist-menupopup"
+ position="after_start"
+ ignorekeys="true">
+ <menuitem closemenu="single" class="unit-menuitem" value="1"></menuitem>
+ <menuitem closemenu="single" class="unit-menuitem" value="60"></menuitem>
+ <menuitem closemenu="single" class="unit-menuitem" value="1440"></menuitem>
+ </menupopup>
+ </menulist>
+ <toolbarbutton class="snooze-popup-button snooze-popup-ok-button"
+ oncommand="snoozeOk()"/>
+ <toolbarbutton class="snooze-popup-button snooze-popup-cancel-button"
+ aria-label="&calendar.alarm.snooze.cancel;"
+ oncommand="snoozeCancel()"/>
+ </hbox>
+ `,
+ ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+ const defaultSnoozeLength = Services.prefs.getIntPref(
+ "calendar.alarms.defaultsnoozelength",
+ 0
+ );
+ const snoozeLength = defaultSnoozeLength <= 0 ? 5 : defaultSnoozeLength;
+
+ let unitList = this.querySelector(".snooze-unit-menulist");
+ let unitValue = this.querySelector(".snooze-value-textbox");
+
+ if ((snoozeLength / 60) % 24 == 0) {
+ // Days
+ unitValue.value = snoozeLength / 60 / 24;
+ unitList.selectedIndex = 2;
+ } else if (snoozeLength % 60 == 0) {
+ // Hours
+ unitValue.value = snoozeLength / 60;
+ unitList.selectedIndex = 1;
+ } else {
+ // Minutes
+ unitValue.value = snoozeLength;
+ unitList.selectedIndex = 0;
+ }
+
+ this.updateUIText();
+ }
+
+ /**
+ * Dispatch a snooze event when an alarm is snoozed.
+ *
+ * @param minutes {number|string} The number of minutes to snooze for.
+ */
+ snoozeAlarm(minutes) {
+ let snoozeEvent = new Event("snooze", { bubbles: true, cancelable: false });
+ snoozeEvent.detail = minutes;
+
+ // For single alarms the event.target has to be the calendar-alarm-widget element,
+ // (so call dispatchEvent on that). For snoozing all alarms the event.target is not
+ // relevant but the snooze all popup is not inside a calendar-alarm-widget (so call
+ // dispatchEvent on 'this').
+ const eventTarget = this.id == "alarm-snooze-all-popup" ? this : this.closest("richlistitem");
+ eventTarget.dispatchEvent(snoozeEvent);
+ }
+
+ /**
+ * Click handler for snooze popup menu items (like "5 Minutes", "1 Hour", etc.).
+ *
+ * @param event {Event} The click event.
+ */
+ snoozeItem(event) {
+ this.snoozeAlarm(event.target.value);
+ }
+
+ /**
+ * Click handler for the "OK" (checkmark) button when snoozing for a custom amount of time.
+ */
+ snoozeOk() {
+ const unitList = this.querySelector(".snooze-unit-menulist");
+ const unitValue = this.querySelector(".snooze-value-textbox");
+ const minutes = (unitList.value || 1) * unitValue.value;
+ this.snoozeAlarm(minutes);
+ }
+
+ /**
+ * Click handler for the "cancel" ("X") button for not snoozing a custom amount of time.
+ */
+ snoozeCancel() {
+ this.hidePopup();
+ }
+
+ /**
+ * Initializes and updates the dynamic UI text. This text can change depending on
+ * input, like for plurals, when you change from "[1] [minute]" to "[2] [minutes]".
+ */
+ updateUIText() {
+ const unitList = this.querySelector(".snooze-unit-menulist");
+ const unitPopup = this.querySelector(".snooze-unit-menupopup");
+ const unitValue = this.querySelector(".snooze-value-textbox");
+ let okButton = this.querySelector(".snooze-popup-ok-button");
+
+ function unitName(list) {
+ return { 1: "unitMinutes", 60: "unitHours", 1440: "unitDays" }[list.value] || "unitMinutes";
+ }
+
+ let pluralString = cal.l10n.getCalString(unitName(unitList));
+
+ const unitPlural = PluralForm.get(unitValue.value, pluralString).replace(
+ "#1",
+ unitValue.value
+ );
+
+ let okButtonAriaLabel = cal.l10n.getString("calendar-alarms", "reminderSnoozeOkA11y", [
+ unitPlural,
+ ]);
+ okButton.setAttribute("aria-label", okButtonAriaLabel);
+
+ const items = unitPopup.getElementsByTagName("menuitem");
+ for (let menuItem of items) {
+ pluralString = cal.l10n.getCalString(unitName(menuItem));
+
+ menuItem.label = PluralForm.get(unitValue.value, pluralString).replace("#1", "").trim();
+ }
+ }
+ }
+
+ customElements.define("calendar-snooze-popup", MozCalendarSnoozePopup, { extends: "menupopup" });
+}
diff --git a/comm/calendar/base/content/widgets/calendar-dnd-widgets.js b/comm/calendar/base/content/widgets/calendar-dnd-widgets.js
new file mode 100644
index 0000000000..f0d75745b6
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-dnd-widgets.js
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals currentView MozElements MozXULElement */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ /**
+ * An abstract class to handle drag on drop for calendar.
+ *
+ * @abstract
+ */
+ class CalendarDnDContainer extends MozXULElement {
+ constructor() {
+ super();
+ this.addEventListener("dragstart", this.onDragStart);
+ this.addEventListener("dragover", this.onDragOver);
+ this.addEventListener("dragenter", this.onDragEnter);
+ this.addEventListener("drop", this.onDrop);
+ this.addEventListener("dragend", this.onDragEnd);
+ this.mCalendarView = null;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ this.hasConnected = true;
+ }
+
+ /**
+ * The ViewController that supports the interface 'calICalendarView'.
+ *
+ * @returns {calICalendarView}
+ */
+ get calendarView() {
+ return this.mCalendarView;
+ }
+
+ set calendarView(val) {
+ this.mCalendarView = val;
+ }
+
+ /**
+ * Method to add individual code e.g to set up the new item during 'ondrop'.
+ */
+ onDropItem(aItem) {
+ // method that may be overridden by derived bindings...
+ }
+
+ /**
+ * Adds the dropshadows to the children of the binding.
+ * The dropshadows are added at the first position of the children.
+ */
+ addDropShadows() {
+ let offset = this.calendarView.mShadowOffset;
+ let shadowStartDate = this.date.clone();
+ shadowStartDate.addDuration(offset);
+ this.calendarView.mDropShadows = [];
+ for (let i = 0; i < this.calendarView.mDropShadowsLength; i++) {
+ let box = this.calendarView.findDayBoxForDate(shadowStartDate);
+ if (box) {
+ box.setDropShadow(true);
+ this.calendarView.mDropShadows.push(box);
+ }
+ shadowStartDate.day += 1;
+ }
+ }
+
+ /**
+ * Removes all dropShadows from the binding.
+ * Dropshadows are recognized as such by carrying an attribute "dropshadow".
+ */
+ removeDropShadows() {
+ // method that may be overwritten by derived bindings...
+ if (this.calendarView.mDropShadows) {
+ for (let box of this.calendarView.mDropShadows) {
+ box.setDropShadow(false);
+ }
+ }
+ this.calendarView.mDropShadows = null;
+ }
+
+ /**
+ * By setting the attribute "dropbox" to "true" or "false" the
+ * dropshadows are added or removed.
+ */
+ setAttribute(aAttr, aVal) {
+ if (aAttr == "dropbox") {
+ let session = cal.dragService.getCurrentSession();
+ if (session) {
+ session.canDrop = true;
+ // no shadows when dragging in the initial position
+ if (aVal == "true" && !this.contains(session.sourceNode)) {
+ this.addDropShadows();
+ } else {
+ this.removeDropShadows();
+ }
+ }
+ }
+ return XULElement.prototype.setAttribute.call(this, aAttr, aVal);
+ }
+
+ onDragStart(event) {
+ let draggedDOMNode = document.monthDragEvent || event.target;
+ if (!draggedDOMNode?.occurrence || !this.contains(draggedDOMNode)) {
+ return;
+ }
+ let item = draggedDOMNode.occurrence.clone();
+ let beginMoveDate = draggedDOMNode.mParentBox.date;
+ let itemStartDate = (item.startDate || item.entryDate || item.dueDate).getInTimezone(
+ this.calendarView.mTimezone
+ );
+ let itemEndDate = (item.endDate || item.dueDate || item.entryDate).getInTimezone(
+ this.calendarView.mTimezone
+ );
+ let oneMoreDay = itemEndDate.hour > 0 || itemEndDate.minute > 0;
+ itemStartDate.isDate = true;
+ itemEndDate.isDate = true;
+ let offsetDuration = itemStartDate.subtractDate(beginMoveDate);
+ let lenDuration = itemEndDate.subtractDate(itemStartDate);
+ let len = lenDuration.weeks * 7 + lenDuration.days;
+
+ this.calendarView.mShadowOffset = offsetDuration;
+ this.calendarView.mDropShadowsLength = oneMoreDay ? len + 1 : len;
+ }
+
+ onDragOver(event) {
+ let session = cal.dragService.getCurrentSession();
+ if (!session?.sourceNode?.sourceObject) {
+ // No source item? Then this is not for us.
+ return;
+ }
+
+ // We handled the event.
+ event.preventDefault();
+ }
+
+ onDragEnter(event) {
+ let session = cal.dragService.getCurrentSession();
+ if (!session?.sourceNode?.sourceObject) {
+ // No source item? Then this is not for us.
+ return;
+ }
+
+ // We can drop now, tell the drag service.
+ event.preventDefault();
+
+ if (!this.hasAttribute("dropbox") || this.getAttribute("dropbox") == "false") {
+ // As it turned out it was not possible to remove the remaining dropshadows
+ // at the "dragleave" event, majorly because it was not reliably
+ // fired.
+ // So we have to remove them at the currentView(). The restriction of course is
+ // that these containers so far may not be used for drag and drop from/to e.g.
+ // the today-pane.
+ currentView().removeDropShadows();
+ }
+ this.setAttribute("dropbox", "true");
+ }
+
+ onDrop(event) {
+ let session = cal.dragService.getCurrentSession();
+ let item = session?.sourceNode?.sourceObject;
+ if (!item) {
+ // No source node? Not our drag.
+ return;
+ }
+ this.setAttribute("dropbox", "false");
+ let newItem = this.onDropItem(item).clone();
+ let newStart = newItem.startDate || newItem.entryDate || newItem.dueDate;
+ let newEnd = newItem.endDate || newItem.dueDate || newItem.entryDate;
+ let offset = this.calendarView.mShadowOffset;
+ newStart.addDuration(offset);
+ newEnd.addDuration(offset);
+ this.calendarView.controller.modifyOccurrence(item, newStart, newEnd);
+
+ // We handled the event.
+ event.stopPropagation();
+ }
+
+ onDragEnd(event) {
+ currentView().removeDropShadows();
+ }
+ }
+
+ MozElements.CalendarDnDContainer = CalendarDnDContainer;
+}
diff --git a/comm/calendar/base/content/widgets/calendar-filter-tree-view.js b/comm/calendar/base/content/widgets/calendar-filter-tree-view.js
new file mode 100644
index 0000000000..8c2804baf0
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-filter-tree-view.js
@@ -0,0 +1,371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal, getEventStatusString, CalendarFilteredViewMixin, PROTO_TREE_VIEW */
+
+class CalendarFilteredTreeView extends CalendarFilteredViewMixin(PROTO_TREE_VIEW) {
+ /**
+ * A function to, given a calendar item, determine whether it matches some
+ * condition, and should therefore be displayed.
+ *
+ * @callback filterFunction
+ * @param {calIItemBase} item The item to compute filter for
+ * @returns {boolean} Whether the item matches the filter
+ */
+
+ #collator = new Intl.Collator(undefined, { numeric: true });
+ #sortColumn = "startDate";
+ #sortDirection = "ascending";
+
+ /** @type {filterFunction?} */
+ #filterFunction = null;
+
+ /** @type {CalendarFilteredTreeViewRow[]} */
+ #allRows = [];
+
+ /**
+ * Set the function used to filter displayed rows and update the current view.
+ *
+ * @param {filterFunction} filterFunction The function to use as a filter
+ */
+ setFilterFunction(filterFunction) {
+ this.#filterFunction = filterFunction;
+
+ this._tree?.beginUpdateBatch();
+
+ if (this.#filterFunction) {
+ this._rowMap = this.#allRows.filter(row => this.#filterFunction(row.item));
+ } else {
+ // With no filter function, all rows should be displayed.
+ this._rowMap = Array.from(this.#allRows);
+ }
+
+ this._tree?.endUpdateBatch();
+
+ // Ensure that no items remain selected after filter change.
+ this.selection.clearSelection();
+ }
+
+ /**
+ * Clear the filter on the current view.
+ */
+ clearFilter() {
+ this.setFilterFunction(null);
+ }
+
+ /**
+ * Given a calendar item, determine whether it matches the current filter.
+ *
+ * @param {calIItemBase} item The item to compute filter for
+ * @returns {boolean} Whether the item matches the filter, or true if filter
+ * is unset
+ */
+ #itemMatchesFilterIfAny(item) {
+ return !this.#filterFunction || this.#filterFunction(item);
+ }
+
+ /**
+ * Save currently selected rows so that they can be restored after
+ * modifications to the tree.
+ */
+ #saveSelection() {
+ const selection = this.selection;
+ if (selection) {
+ // Mark rows which are selected.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selection.isSelected(i);
+ this._rowMap[i].wasCurrent = selection.currentIndex == i;
+ }
+ }
+ }
+
+ /**
+ * Reselect rows which were selected before modifications were made to the
+ * tree.
+ */
+ #restoreSelection() {
+ const selection = this.selection;
+ if (selection) {
+ selection.selectEventsSuppressed = true;
+
+ let newCurrent;
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasSelected != selection.isSelected(i)) {
+ selection.toggleSelect(i);
+ }
+
+ if (this._rowMap[i].wasCurrent) {
+ newCurrent = i;
+ }
+ }
+
+ selection.currentIndex = newCurrent;
+
+ this.selectionChanged();
+ selection.selectEventsSuppressed = false;
+ }
+ }
+
+ // CalendarFilteredViewMixin implementation
+
+ clearItems() {
+ this.#allRows.length = 0;
+
+ this._tree?.beginUpdateBatch();
+ this._rowMap.length = 0;
+ this._tree?.endUpdateBatch();
+ }
+
+ addItems(items) {
+ let anyItemsMatchedFilter = false;
+
+ for (const item of items) {
+ const row = new CalendarFilteredTreeViewRow(item);
+
+ const sortValue = row.getValue(this.#sortColumn);
+
+ let addIndex = null;
+ for (let i = 0; addIndex === null && i < this.#allRows.length; i++) {
+ const comparison = this.#collator.compare(
+ sortValue,
+ this.#allRows[i].getValue(this.#sortColumn)
+ );
+ if (
+ (comparison < 0 && this.#sortDirection == "ascending") ||
+ (comparison >= 0 && this.#sortDirection == "descending")
+ ) {
+ addIndex = i;
+ }
+ }
+
+ if (addIndex === null) {
+ addIndex = this.#allRows.length;
+ }
+ this.#allRows.splice(addIndex, 0, row);
+
+ if (this.#itemMatchesFilterIfAny(item)) {
+ anyItemsMatchedFilter = true;
+ }
+ }
+
+ if (anyItemsMatchedFilter) {
+ this.#saveSelection();
+
+ this._tree?.beginUpdateBatch();
+ this._rowMap = this.#allRows.filter(row => this.#itemMatchesFilterIfAny(row.item));
+ this._tree?.endUpdateBatch();
+
+ this.#restoreSelection();
+ }
+ }
+
+ removeItems(items) {
+ const hashIDsToRemove = items.map(i => i.hashId);
+ for (let i = this.#allRows.length - 1; i >= 0; i--) {
+ if (hashIDsToRemove.includes(this.#allRows[i].item.hashId)) {
+ this.#allRows.splice(i, 1);
+ }
+ }
+
+ this.#saveSelection();
+
+ this._tree?.beginUpdateBatch();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (hashIDsToRemove.includes(this._rowMap[i].item.hashId)) {
+ this._rowMap.splice(i, 1);
+ }
+ }
+ this._tree?.endUpdateBatch();
+
+ this.#restoreSelection();
+ }
+
+ removeItemsFromCalendar(calendarId) {
+ const itemsToRemove = this.#allRows
+ .filter(row => row.calendar.id == calendarId)
+ .map(row => row.item);
+ this.removeItems(itemsToRemove);
+ }
+
+ // nsITreeView implementation
+
+ isSorted() {
+ return true;
+ }
+
+ cycleHeader(column) {
+ let direction = "ascending";
+ if (column.id == this.#sortColumn && this.#sortDirection == "ascending") {
+ direction = "descending";
+ }
+
+ this.#sortBy(column.id, direction);
+ }
+
+ #sortBy(sortColumn, sortDirection) {
+ // Sort underlying array of rows first.
+ if (sortColumn == this.#sortColumn) {
+ if (sortDirection == this.#sortDirection) {
+ // Sort order hasn't changed; do nothing.
+ return;
+ }
+
+ this.#allRows.reverse();
+ } else {
+ this.#allRows.sort((a, b) => {
+ const aValue = a.getValue(sortColumn);
+ const bValue = b.getValue(sortColumn);
+
+ if (sortDirection == "descending") {
+ return this.#collator.compare(bValue, aValue);
+ }
+
+ return this.#collator.compare(aValue, bValue);
+ });
+ }
+
+ this.#saveSelection();
+
+ // Refilter displayed rows from newly-sorted underlying array.
+ this._tree?.beginUpdateBatch();
+ this._rowMap = this.#allRows.filter(row => this.#itemMatchesFilterIfAny(row.item));
+ this._tree?.endUpdateBatch();
+
+ this.#restoreSelection();
+
+ this.#sortColumn = sortColumn;
+ this.#sortDirection = sortDirection;
+ }
+}
+
+class CalendarFilteredTreeViewRow {
+ static listFormatter = new Services.intl.ListFormat(
+ Services.appinfo.name == "xpcshell" ? "en-US" : Services.locale.appLocalesAsBCP47,
+ { type: "unit" }
+ );
+
+ #columnTextCache = {};
+ #columnValueCache = {};
+ #item = null;
+ #calendar = null;
+ wasSelected = false;
+ wasCurrent = false;
+
+ constructor(item) {
+ this.#item = item;
+ this.#calendar = item.calendar;
+ }
+
+ #getTextByColumnID(columnID) {
+ switch (columnID) {
+ case "calendarName":
+ case "unifinder-search-results-tree-col-calendarname":
+ return this.#calendar.name;
+ case "categories":
+ case "unifinder-search-results-tree-col-categories":
+ return CalendarFilteredTreeViewRow.listFormatter.format(this.#item.getCategories());
+ case "color":
+ case "unifinder-search-results-tree-col-color":
+ return cal.view.formatStringForCSSRule(this.#calendar.id);
+ case "endDate":
+ case "unifinder-search-results-tree-col-enddate": {
+ const endDate = this.#item.endDate.getInTimezone(cal.dtz.defaultTimezone);
+ if (endDate.isDate) {
+ endDate.day--;
+ }
+
+ return cal.dtz.formatter.formatDateTime(endDate);
+ }
+ case "location":
+ case "unifinder-search-results-tree-col-location":
+ return this.#item.getProperty("LOCATION");
+ case "startDate":
+ case "unifinder-search-results-tree-col-startdate":
+ return cal.dtz.formatter.formatDateTime(
+ this.#item.startDate.getInTimezone(cal.dtz.defaultTimezone)
+ );
+ case "status":
+ case "unifinder-search-results-tree-col-status":
+ return getEventStatusString(this.#item);
+ case "title":
+ case "unifinder-search-results-tree-col-title":
+ return this.#item.title?.replace(/\n/g, " ") || "";
+ }
+
+ return "";
+ }
+
+ getText(columnID) {
+ if (!(columnID in this.#columnTextCache)) {
+ this.#columnTextCache[columnID] = this.#getTextByColumnID(columnID);
+ }
+
+ return this.#columnTextCache[columnID];
+ }
+
+ #getValueByColumnID(columnID) {
+ switch (columnID) {
+ case "startDate":
+ case "unifinder-search-results-tree-col-startdate":
+ return this.#item.startDate.icalString;
+ case "endDate":
+ case "unifinder-search-results-tree-col-enddate":
+ return this.#item.endDate.icalString;
+ }
+
+ return this.getText(columnID);
+ }
+
+ getValue(columnID) {
+ if (!(columnID in this.#columnValueCache)) {
+ this.#columnValueCache[columnID] = this.#getValueByColumnID(columnID);
+ }
+
+ return this.#columnValueCache[columnID];
+ }
+
+ getProperties() {
+ let properties = [];
+ if (this.#item.priority > 0 && this.#item.priority < 5) {
+ properties.push("highpriority");
+ } else if (this.#item.priority > 5 && this.#item.priority < 10) {
+ properties.push("lowpriority");
+ }
+
+ properties.push("calendar-" + cal.view.formatStringForCSSRule(this.#calendar.name));
+
+ if (this.#item.status) {
+ properties.push("status-" + this.#item.status.toLowerCase());
+ }
+
+ if (this.#item.getAlarms().length) {
+ properties.push("alarm");
+ }
+
+ properties = properties.concat(this.#item.getCategories().map(cal.view.formatStringForCSSRule));
+ return properties.join(" ");
+ }
+
+ /** @type {calIItemBase} */
+ get item() {
+ return this.#item;
+ }
+
+ /** @type {calICalendar} */
+ get calendar() {
+ return this.#calendar;
+ }
+
+ get open() {
+ return false;
+ }
+
+ get level() {
+ return 0;
+ }
+
+ get children() {
+ return [];
+ }
+}
diff --git a/comm/calendar/base/content/widgets/calendar-filter.js b/comm/calendar/base/content/widgets/calendar-filter.js
new file mode 100644
index 0000000000..d49ea0fe76
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-filter.js
@@ -0,0 +1,1365 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../calendar-views-utils.js */
+
+/* exported CalendarFilteredViewMixin */
+
+var { PromiseUtils } = ChromeUtils.importESModule("resource://gre/modules/PromiseUtils.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * Object that contains a set of filter properties that may be used by a calFilter object
+ * to filter a set of items.
+ * Supported filter properties:
+ * start, end: Specifies the relative date range to use when calculating the filter date
+ * range. The relative date range may relative to the current date and time, the
+ * currently selected date, or the dates range of the current view. The actual
+ * date range used to filter items will be calculated by the calFilter object
+ * by using the updateFilterDates function, which may be called multiple times
+ * to reflect changes in the current date and time, and changes to the view.
+ *
+ *
+ * The properties may be set to one of the following values:
+ * - FILTER_DATE_ALL: An unbound date range.
+ * - FILTER_DATE_XXX: One of the defined relative date ranges.
+ * - A string that may be converted to a calIDuration object that will be used
+ * as an offset to the current date and time.
+ *
+ * The start and end properties may have values representing different relative
+ * date ranges, in which case the filter start date will be calculated as the start
+ * of the relative range specified by the start property, while the filter end date
+ * will be calculated as the end of the relative range specified by the end
+ * property.
+ *
+ * due: Specifies the filter property for the due date of tasks. This filter has no
+ * effect when filtering events.
+ *
+ * The property has a bit field value, with the FILTER_DUE_XXX bit flags set
+ * to indicate that tasks with the corresponding due property value should match
+ * the filter.
+ *
+ * If the value is set to null the due date will not be considered when filtering.
+ *
+ * status: Specifies the filter property for the status of tasks. This filter has no
+ * effect when filtering events.
+ *
+ * The property has a bit field value, with the FILTER_STATUS_XXX bit flags set
+ * to indicate that tasks with the corresponding status property value should match
+ * the filter.
+ *
+ * If the value is set to null the status will not be considered when filtering.
+ *
+ * category: Specifies the filter property for the item category.
+ *
+ * The property may be set to one of the following values:
+ * - null: The item category will not be considered when filtering.
+ * - A string: The item will match the filter if any of it's categories match the
+ * category specified by the property.
+ * - An array: The item will match the filter if any of it's categories match any
+ * of the categories contained in the Array specified by the property.
+ *
+ * occurrences: Specifies the filter property for returning occurrences of repeating items.
+ *
+ * The property may be set to one of the following values:
+ * - null, FILTER_OCCURRENCES_BOUND: The default occurrence handling. Occurrences
+ * will be returned only for date ranges with a bound end date.
+ * - FILTER_OCCURRENCES_NONE: Only the parent items will be returned.
+ * - FILTER_OCCURRENCES_PAST_AND_NEXT: Returns past occurrences and the next future
+ * matching occurrence if one is found.
+ *
+ * onfilter: A callback function that may be used to apply additional custom filter
+ * constraints. If specified, the callback function will be called after any other
+ * specified filter properties are tested.
+ *
+ * The callback function will be called with the following parameters:
+ * - function(aItem, aResults, aFilterProperties, aFilter)
+ *
+ * @param aItem The item being tested.
+ * @param aResults The results of the test of the other specified
+ * filter properties.
+ * @param aFilterProperties The current filter properties being tested.
+ * @param aFilter The calFilter object performing the filter test.
+ *
+ * If specified, the callback function is responsible for returning a value that
+ * can be converted to true if the item should match the filter, or a value that
+ * can be converted to false otherwise. The return value will override the results
+ * of the testing of any other specified filter properties.
+ */
+function calFilterProperties() {
+ this.wrappedJSObject = this;
+}
+
+calFilterProperties.prototype = {
+ FILTER_DATE_ALL: 0,
+ FILTER_DATE_VIEW: 1,
+ FILTER_DATE_SELECTED: 2,
+ FILTER_DATE_SELECTED_OR_NOW: 3,
+ FILTER_DATE_NOW: 4,
+ FILTER_DATE_TODAY: 5,
+ FILTER_DATE_CURRENT_WEEK: 6,
+ FILTER_DATE_CURRENT_MONTH: 7,
+ FILTER_DATE_CURRENT_YEAR: 8,
+
+ FILTER_STATUS_INCOMPLETE: 1,
+ FILTER_STATUS_IN_PROGRESS: 2,
+ FILTER_STATUS_COMPLETED_TODAY: 4,
+ FILTER_STATUS_COMPLETED_BEFORE: 8,
+ FILTER_STATUS_ALL: 15,
+
+ FILTER_DUE_PAST: 1,
+ FILTER_DUE_TODAY: 2,
+ FILTER_DUE_FUTURE: 4,
+ FILTER_DUE_NONE: 8,
+ FILTER_DUE_ALL: 15,
+
+ FILTER_OCCURRENCES_BOUND: 0,
+ FILTER_OCCURRENCES_NONE: 1,
+ FILTER_OCCURRENCES_PAST_AND_NEXT: 2,
+
+ start: null,
+ end: null,
+ due: null,
+ status: null,
+ category: null,
+ occurrences: null,
+
+ onfilter: null,
+
+ equals(aFilterProps) {
+ if (!(aFilterProps instanceof calFilterProperties)) {
+ return false;
+ }
+ let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"];
+ return props.every(function (prop) {
+ return this[prop] == aFilterProps[prop];
+ }, this);
+ },
+
+ clone() {
+ let cloned = new calFilterProperties();
+ let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"];
+ props.forEach(function (prop) {
+ cloned[prop] = this[prop];
+ }, this);
+
+ return cloned;
+ },
+
+ LOG(aString) {
+ cal.LOG(
+ "[calFilterProperties] " +
+ (aString || "") +
+ " start=" +
+ this.start +
+ " end=" +
+ this.end +
+ " status=" +
+ this.status +
+ " due=" +
+ this.due +
+ " category=" +
+ this.category
+ );
+ },
+};
+
+/**
+ * Object that allows filtering of a set of items using a set of filter properties. A set
+ * of property filters may be defined by a filter name, which may then be used to apply
+ * the defined filter properties. A set of commonly used property filters are predefined.
+ */
+function calFilter() {
+ this.wrappedJSObject = this;
+ this.mFilterProperties = new calFilterProperties();
+ this.initDefinedFilters();
+ this.mMaxIterations = Services.prefs.getIntPref("calendar.filter.maxiterations", 50);
+}
+
+calFilter.prototype = {
+ mStartDate: null,
+ mEndDate: null,
+ mItemType: Ci.calICalendar.ITEM_FILTER_TYPE_ALL,
+ mSelectedDate: null,
+ mFilterText: "",
+ mDefinedFilters: {},
+ mFilterProperties: null,
+ mToday: null,
+ mTomorrow: null,
+ mMaxIterations: 50,
+
+ /**
+ * Initializes the predefined filters.
+ */
+ initDefinedFilters() {
+ let filters = [
+ "all",
+ "notstarted",
+ "overdue",
+ "open",
+ "completed",
+ "throughcurrent",
+ "throughtoday",
+ "throughsevendays",
+ "today",
+ "thisCalendarMonth",
+ "future",
+ "current",
+ "currentview",
+ ];
+ filters.forEach(function (filter) {
+ if (!(filter in this.mDefinedFilters)) {
+ this.defineFilter(filter, this.getPreDefinedFilterProperties(filter));
+ }
+ }, this);
+ },
+
+ /**
+ * Gets the filter properties for a predefined filter.
+ *
+ * @param aFilter The name of the filter to retrieve the filter properties for.
+ * @result The filter properties for the specified filter, or null if the filter
+ * not predefined.
+ */
+ getPreDefinedFilterProperties(aFilter) {
+ let props = new calFilterProperties();
+
+ if (!aFilter) {
+ return props;
+ }
+
+ switch (aFilter) {
+ // Predefined Task filters
+ case "notstarted":
+ props.status = props.FILTER_STATUS_INCOMPLETE;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "overdue":
+ props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS;
+ props.due = props.FILTER_DUE_PAST;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "open":
+ props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_ALL;
+ props.occurrences = props.FILTER_OCCURRENCES_PAST_AND_NEXT;
+ break;
+ case "completed":
+ props.status = props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "throughcurrent":
+ props.status =
+ props.FILTER_STATUS_INCOMPLETE |
+ props.FILTER_STATUS_IN_PROGRESS |
+ props.FILTER_STATUS_COMPLETED_TODAY;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "throughtoday":
+ props.status =
+ props.FILTER_STATUS_INCOMPLETE |
+ props.FILTER_STATUS_IN_PROGRESS |
+ props.FILTER_STATUS_COMPLETED_TODAY;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_TODAY;
+ break;
+ case "throughsevendays":
+ props.status =
+ props.FILTER_STATUS_INCOMPLETE |
+ props.FILTER_STATUS_IN_PROGRESS |
+ props.FILTER_STATUS_COMPLETED_TODAY;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = "P7D";
+ break;
+
+ // Predefined Event filters
+ case "today":
+ props.start = props.FILTER_DATE_TODAY;
+ props.end = props.FILTER_DATE_TODAY;
+ break;
+ case "thisCalendarMonth":
+ props.start = props.FILTER_DATE_CURRENT_MONTH;
+ props.end = props.FILTER_DATE_CURRENT_MONTH;
+ break;
+ case "future":
+ props.start = props.FILTER_DATE_NOW;
+ props.end = props.FILTER_DATE_ALL;
+ break;
+ case "current":
+ props.start = props.FILTER_DATE_SELECTED;
+ props.end = props.FILTER_DATE_SELECTED;
+ break;
+ case "currentview":
+ props.start = props.FILTER_DATE_VIEW;
+ props.end = props.FILTER_DATE_VIEW;
+ break;
+
+ case "all":
+ default:
+ props.status = props.FILTER_STATUS_ALL;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_ALL;
+ }
+
+ return props;
+ },
+
+ /**
+ * Defines a set of filter properties so that they may be applied by the filter name. If
+ * the specified filter name is already defined, it's associated filter properties will be
+ * replaced.
+ *
+ * @param aFilterName The name to define the filter properties as.
+ * @param aFilterProperties The filter properties to define.
+ */
+ defineFilter(aFilterName, aFilterProperties) {
+ if (!(aFilterProperties instanceof calFilterProperties)) {
+ return;
+ }
+
+ this.mDefinedFilters[aFilterName] = aFilterProperties;
+ },
+
+ /**
+ * Returns the set of filter properties that were previously defined by a filter name.
+ *
+ * @param aFilter The filter name of the defined filter properties.
+ * @returns The properties defined by the filter name, or null if
+ * the filter name was not previously defined.
+ */
+ getDefinedFilterProperties(aFilter) {
+ if (aFilter in this.mDefinedFilters) {
+ return this.mDefinedFilters[aFilter].clone();
+ }
+ return null;
+ },
+
+ /**
+ * Returns the filter name that a set of filter properties were previously defined as.
+ *
+ * @param aFilterProperties The filter properties previously defined.
+ * @returns The name of the first filter name that the properties
+ * were defined as, or null if the filter properties were
+ * not previously defined.
+ */
+ getDefinedFilterName(aFilterProperties) {
+ for (let filter in this.mDefinedFilters) {
+ if (this.mDefinedFilters[filter].equals(aFilterProperties)) {
+ return filter;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Checks if the item matches the current filter text
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item matches the filter text or no
+ * filter text has been set, false otherwise.
+ */
+ textFilter(aItem) {
+ if (!this.mFilterText) {
+ return true;
+ }
+
+ let searchText = this.mFilterText.toLowerCase();
+
+ if (!searchText.length || searchText.match(/^\s*$/)) {
+ return true;
+ }
+
+ // TODO: Support specifying which fields to search on
+ for (let field of ["SUMMARY", "DESCRIPTION", "LOCATION", "URL"]) {
+ let val = aItem.getProperty(field);
+ if (val && val.toLowerCase().includes(searchText)) {
+ return true;
+ }
+ }
+
+ return aItem.getCategories().some(cat => cat.toLowerCase().includes(searchText));
+ },
+
+ /**
+ * Checks if the item matches the current filter date range.
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item falls within the date range
+ * specified by mStartDate and mEndDate, false otherwise.
+ */
+ dateRangeFilter(aItem) {
+ return !!cal.item.checkIfInRange(aItem, this.mStartDate, this.mEndDate);
+ },
+
+ /**
+ * Checks if the item matches the currently applied filter properties. Filter properties
+ * with a value of null or that are not applicable to the item's type are not tested.
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item matches the filter properties
+ * currently applied, false otherwise.
+ */
+ propertyFilter(aItem) {
+ let result;
+ let props = this.mFilterProperties;
+ if (!props) {
+ return false;
+ }
+
+ // the today and tomorrow properties are precalculated in the updateFilterDates function
+ // for better performance when filtering batches of items.
+ let today = this.mToday;
+ if (!today) {
+ today = cal.dtz.now();
+ today.isDate = true;
+ }
+
+ let tomorrow = this.mTomorrow;
+ if (!tomorrow) {
+ tomorrow = today.clone();
+ tomorrow.day++;
+ }
+
+ // test the date range of the applied filter.
+ result = this.dateRangeFilter(aItem);
+
+ // test the category property. If the property value is an array, only one category must
+ // match.
+ if (result && props.category) {
+ let cats = [];
+
+ if (typeof props.category == "string") {
+ cats.push(props.category);
+ } else if (Array.isArray(props.category)) {
+ cats = props.category;
+ }
+ result = cats.some(cat => aItem.getCategories().includes(cat));
+ }
+
+ // test the status property. Only applies to tasks.
+ if (result && props.status != null && aItem.isTodo()) {
+ let completed = aItem.isCompleted;
+ let current = !aItem.completedDate || today.compare(aItem.completedDate) <= 0;
+ let percent = aItem.percentComplete || 0;
+
+ result =
+ (props.status & props.FILTER_STATUS_INCOMPLETE || !(!completed && percent == 0)) &&
+ (props.status & props.FILTER_STATUS_IN_PROGRESS || !(!completed && percent > 0)) &&
+ (props.status & props.FILTER_STATUS_COMPLETED_TODAY || !(completed && current)) &&
+ (props.status & props.FILTER_STATUS_COMPLETED_BEFORE || !(completed && !current));
+ }
+
+ // test the due property. Only applies to tasks.
+ if (result && props.due != null && aItem.isTodo()) {
+ let due = aItem.dueDate;
+ let now = cal.dtz.now();
+
+ result =
+ (props.due & props.FILTER_DUE_PAST || !(due && due.compare(now) < 0)) &&
+ (props.due & props.FILTER_DUE_TODAY ||
+ !(due && due.compare(now) >= 0 && due.compare(tomorrow) < 0)) &&
+ (props.due & props.FILTER_DUE_FUTURE || !(due && due.compare(tomorrow) >= 0)) &&
+ (props.due & props.FILTER_DUE_NONE || !(due == null));
+ }
+
+ // Call the filter properties onfilter callback if set. The return value of the
+ // callback function will override the result of this function.
+ if (props.onfilter && typeof props.onfilter == "function") {
+ return props.onfilter(aItem, result, props, this);
+ }
+
+ return result;
+ },
+
+ /**
+ * Checks if the item matches the expected item type.
+ *
+ * @param {calIItemBase} aItem - The item to check.
+ * @returns {boolean} - True if the item matches the item type, false otherwise.
+ */
+ itemTypeFilter(aItem) {
+ if (aItem.isTodo() && this.mItemType & Ci.calICalendar.ITEM_FILTER_TYPE_TODO) {
+ // If `mItemType` doesn't specify a completion status, the item passes.
+ if ((this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL) == 0) {
+ return true;
+ }
+
+ // Otherwise, check it matches the completion status(es).
+ if (aItem.isCompleted) {
+ return (this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_YES) != 0;
+ }
+ return (this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_NO) != 0;
+ }
+ if (aItem.isEvent() && this.mItemType & Ci.calICalendar.ITEM_FILTER_TYPE_EVENT) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Calculates the date from a date filter property.
+ *
+ * @param prop The value of the date filter property to calculate for. May
+ * be a constant specifying a relative date range, or a string
+ * representing a duration offset from the current date time.
+ * @param start If true, the function will return the date value for the
+ * start of the relative date range, otherwise it will return the
+ * date value for the end of the date range.
+ * @returns The calculated date for the property.
+ */
+ getDateForProperty(prop, start) {
+ let props = this.mFilterProperties || new calFilterProperties();
+ let result = null;
+ let selectedDate = this.mSelectedDate || currentView().selectedDay || cal.dtz.now();
+ let nowDate = cal.dtz.now();
+
+ if (typeof prop == "string") {
+ let duration = cal.createDuration(prop);
+ if (duration) {
+ result = nowDate;
+ result.addDuration(duration);
+ }
+ } else {
+ switch (prop) {
+ case props.FILTER_DATE_ALL:
+ result = null;
+ break;
+ case props.FILTER_DATE_VIEW:
+ result = start ? currentView().startDay.clone() : currentView().endDay.clone();
+ break;
+ case props.FILTER_DATE_SELECTED:
+ result = selectedDate.clone();
+ result.isDate = true;
+ break;
+ case props.FILTER_DATE_SELECTED_OR_NOW: {
+ result = selectedDate.clone();
+ let resultJSDate = cal.dtz.dateTimeToJsDate(result);
+ let nowJSDate = cal.dtz.dateTimeToJsDate(nowDate);
+ if ((start && resultJSDate > nowJSDate) || (!start && resultJSDate < nowJSDate)) {
+ result = nowDate;
+ }
+ result.isDate = true;
+ break;
+ }
+ case props.FILTER_DATE_NOW:
+ result = nowDate;
+ break;
+ case props.FILTER_DATE_TODAY:
+ result = nowDate;
+ result.isDate = true;
+ break;
+ case props.FILTER_DATE_CURRENT_WEEK:
+ result = start ? nowDate.startOfWeek : nowDate.endOfWeek;
+ break;
+ case props.FILTER_DATE_CURRENT_MONTH:
+ result = start ? nowDate.startOfMonth : nowDate.endOfMonth;
+ break;
+ case props.FILTER_DATE_CURRENT_YEAR:
+ result = start ? nowDate.startOfYear : nowDate.endOfYear;
+ break;
+ }
+
+ // date ranges are inclusive, so we need to include the day for the end date
+ if (!start && result && prop != props.FILTER_DATE_NOW) {
+ result.day++;
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Calculates the current start and end dates for the currently applied filter.
+ *
+ * @returns The current [startDate, endDate] for the applied filter.
+ */
+ getDatesForFilter() {
+ let startDate = null;
+ let endDate = null;
+
+ if (this.mFilterProperties) {
+ startDate = this.getDateForProperty(this.mFilterProperties.start, true);
+ endDate = this.getDateForProperty(this.mFilterProperties.end, false);
+
+ // swap the start and end dates if necessary
+ if (startDate && endDate && startDate.compare(endDate) > 0) {
+ let swap = startDate;
+ endDate = startDate;
+ startDate = swap;
+ }
+ }
+
+ return [startDate, endDate];
+ },
+
+ /**
+ * Gets the start date for the current filter date range.
+ *
+ * @return: The start date of the current filter date range, or null if
+ * the date range has an unbound start date.
+ */
+ get startDate() {
+ return this.mStartDate;
+ },
+
+ /**
+ * Sets the start date for the current filter date range. This will override the date range
+ * calculated from the filter properties by the getDatesForFilter function.
+ */
+ set startDate(aStartDate) {
+ this.mStartDate = aStartDate;
+ },
+
+ /**
+ * Gets the end date for the current filter date range.
+ *
+ * @return: The end date of the current filter date range, or null if
+ * the date range has an unbound end date.
+ */
+ get endDate() {
+ return this.mEndDate;
+ },
+
+ /**
+ * Sets the end date for the current filter date range. This will override the date range
+ * calculated from the filter properties by the getDatesForFilter function.
+ */
+ set endDate(aEndDate) {
+ this.mEndDate = aEndDate;
+ },
+
+ /**
+ * Gets the current item type filter.
+ */
+ get itemType() {
+ return this.mItemType;
+ },
+
+ /**
+ * One of the calICalendar.ITEM_FILTER_TYPE constants, optionally bitwise-OR-ed with a
+ * calICalendar.ITEM_FILTER_COMPLETED value. Only items of this type will pass the filter.
+ *
+ * If an ITEM_FILTER_COMPLETED bit is set it will will take priority over applyFilter.
+ */
+ set itemType(aItemType) {
+ this.mItemType = aItemType;
+ },
+
+ /**
+ * Gets the value used to perform the text filter.
+ */
+ get filterText() {
+ return this.mFilterText;
+ },
+
+ /**
+ * Sets the value used to perform the text filter.
+ *
+ * @param aValue The string value to use for the text filter.
+ */
+ set filterText(aValue) {
+ this.mFilterText = aValue;
+ },
+
+ /**
+ * Gets the selected date used by the getDatesForFilter function to calculate date ranges
+ * that are relative to the selected date.
+ */
+ get selectedDate() {
+ return this.mSelectedDate;
+ },
+
+ /**
+ * Sets the selected date used by the getDatesForFilter function to calculate date ranges
+ * that are relative to the selected date.
+ */
+ set selectedDate(aSelectedDate) {
+ this.mSelectedDate = aSelectedDate;
+ },
+
+ /**
+ * Gets the currently applied filter properties.
+ *
+ * @returns The currently applied filter properties.
+ */
+ get filterProperties() {
+ return this.mFilterProperties ? this.mFilterProperties.clone() : null;
+ },
+
+ /**
+ * Gets the name of the currently applied filter.
+ *
+ * @returns The current defined name of the currently applied filter
+ * properties, or null if the current properties were not
+ * previously defined.
+ */
+ get filterName() {
+ if (!this.mFilterProperties) {
+ return null;
+ }
+
+ return this.getDefinedFilterName(this.mFilterProperties);
+ },
+
+ /**
+ * Applies the specified filter.
+ *
+ * @param aFilter The filter to apply. May be one of the following types:
+ * - a calFilterProperties object specifying the filter properties
+ * - a String representing a previously defined filter name
+ * - a String representing a duration offset from now
+ * - a Function to use for the onfilter callback for a custom filter
+ */
+ applyFilter(aFilter) {
+ this.mFilterProperties = null;
+
+ if (typeof aFilter == "string") {
+ if (aFilter in this.mDefinedFilters) {
+ this.mFilterProperties = this.getDefinedFilterProperties(aFilter);
+ } else {
+ let dur = cal.createDuration(aFilter);
+ if (dur.inSeconds > 0) {
+ this.mFilterProperties = new calFilterProperties();
+ this.mFilterProperties.start = this.mFilterProperties.FILTER_DATE_NOW;
+ this.mFilterProperties.end = aFilter;
+ }
+ }
+ } else if (typeof aFilter == "object" && aFilter instanceof calFilterProperties) {
+ this.mFilterProperties = aFilter;
+ } else if (typeof aFilter == "function") {
+ this.mFilterProperties = new calFilterProperties();
+ this.mFilterProperties.onfilter = aFilter;
+ } else {
+ this.mFilterProperties = new calFilterProperties();
+ }
+
+ if (this.mFilterProperties) {
+ this.updateFilterDates();
+ // this.mFilterProperties.LOG("Applying filter:");
+ } else {
+ cal.WARN("[calFilter] Unable to apply filter " + aFilter);
+ }
+ },
+
+ /**
+ * Calculates the current start and end dates for the currently applied filter, and updates
+ * the current filter start and end dates. This function can be used to update the date range
+ * for date range filters that are relative to the selected date or current date and time.
+ *
+ * @returns The current [startDate, endDate] for the applied filter.
+ */
+ updateFilterDates() {
+ let [startDate, endDate] = this.getDatesForFilter();
+ this.mStartDate = startDate;
+ this.mEndDate = endDate;
+
+ // the today and tomorrow properties are precalculated here
+ // for better performance when filtering batches of items.
+ this.mToday = cal.dtz.now();
+ this.mToday.isDate = true;
+
+ this.mTomorrow = this.mToday.clone();
+ this.mTomorrow.day++;
+
+ return [startDate, endDate];
+ },
+
+ /**
+ * Filters an array of items, returning a new array containing the items that match
+ * the currently applied filter properties and text filter.
+ *
+ * @param aItems The array of items to check.
+ * @param aCallback An optional callback function to be called with each item and
+ * the result of it's filter test.
+ * @returns A new array containing the items that match the filters, or
+ * null if no filter has been applied.
+ */
+ filterItems(aItems, aCallback) {
+ if (!this.mFilterProperties) {
+ return null;
+ }
+
+ return aItems.filter(function (aItem) {
+ let result = this.isItemInFilters(aItem);
+
+ if (aCallback && typeof aCallback == "function") {
+ aCallback(aItem, result, this.mFilterProperties, this);
+ }
+
+ return result;
+ }, this);
+ },
+
+ /**
+ * Checks if the item matches the currently applied filter properties and text filter.
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item matches the filters,
+ * false otherwise.
+ */
+ isItemInFilters(aItem) {
+ return this.itemTypeFilter(aItem) && this.propertyFilter(aItem) && this.textFilter(aItem);
+ },
+
+ /**
+ * Finds the next occurrence of a repeating item that matches the currently applied
+ * filter properties.
+ *
+ * @param aItem The parent item to find the next occurrence of.
+ * @returns Returns the next occurrence that matches the filters,
+ * or null if no match is found.
+ */
+ getNextOccurrence(aItem) {
+ if (!aItem.recurrenceInfo) {
+ return this.isItemInFilters(aItem) ? aItem : null;
+ }
+
+ let count = 0;
+ let start = cal.dtz.now();
+
+ // If the base item matches the filter, we need to check each future occurrence.
+ // Otherwise, we only need to check the exceptions.
+ if (this.isItemInFilters(aItem)) {
+ while (count++ < this.mMaxIterations) {
+ let next = aItem.recurrenceInfo.getNextOccurrence(start);
+ if (!next) {
+ // there are no more occurrences
+ return null;
+ }
+
+ if (this.isItemInFilters(next)) {
+ return next;
+ }
+ start = next.startDate || next.entryDate;
+ }
+
+ // we've hit the maximum number of iterations without finding a match
+ cal.WARN("[calFilter] getNextOccurrence: reached maximum iterations for " + aItem.title);
+ return null;
+ }
+ // the parent item doesn't match the filter, we can return the first future exception
+ // that matches the filter
+ let exMatch = null;
+ aItem.recurrenceInfo.getExceptionIds().forEach(function (rID) {
+ let ex = aItem.recurrenceInfo.getExceptionFor(rID);
+ if (
+ ex &&
+ cal.dtz.now().compare(ex.startDate || ex.entryDate) < 0 &&
+ this.isItemInFilters(ex)
+ ) {
+ exMatch = ex;
+ }
+ }, this);
+ return exMatch;
+ },
+
+ /**
+ * Gets the occurrences of a repeating item that match the currently applied
+ * filter properties and date range.
+ *
+ * @param aItem The parent item to find occurrence of.
+ * @returns Returns an array containing the occurrences that
+ * match the filters, an empty array if there are no
+ * matches, or null if the filter is not initialized.
+ */
+ getOccurrences(aItem) {
+ if (!this.mFilterProperties) {
+ return null;
+ }
+ let props = this.mFilterProperties;
+ let occs;
+
+ if (
+ !aItem.recurrenceInfo ||
+ (!props.occurrences && !this.mEndDate) ||
+ props.occurrences == props.FILTER_OCCURRENCES_NONE
+ ) {
+ // either this isn't a repeating item, the occurrence filter specifies that
+ // we don't want occurrences, or we have a default occurrence filter with an
+ // unbound date range, so we return just the unexpanded item.
+ occs = [aItem];
+ } else {
+ occs = aItem.getOccurrencesBetween(
+ this.mStartDate || cal.createDateTime(),
+ this.mEndDate || cal.dtz.now()
+ );
+ if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT && !this.mEndDate) {
+ // we have an unbound date range and the occurrence filter specifies
+ // that we also want the next matching occurrence if available.
+ let next = this.getNextOccurrence(aItem);
+ if (next) {
+ occs.push(next);
+ }
+ }
+ }
+
+ return this.filterItems(occs);
+ },
+
+ /**
+ * Gets the items matching the currently applied filter properties from a calendar.
+ *
+ * @param {calICalendar} aCalendar - The calendar to get items from.
+ * @returns {ReadableStream<calIItemBase>} A stream of returned values.
+ */
+ getItems(aCalendar) {
+ if (!this.mFilterProperties) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+ let props = this.mFilterProperties;
+
+ // Build the filter argument for calICalendar.getItems() from the filter properties.
+ let filter = this.mItemType;
+
+ // For tasks, if `mItemType` doesn't specify a completion status, add one.
+ if (
+ filter & Ci.calICalendar.ITEM_FILTER_TYPE_TODO &&
+ (filter & Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL) == 0
+ ) {
+ if (
+ !props.status ||
+ props.status & (props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE)
+ ) {
+ filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_YES;
+ }
+ if (
+ !props.status ||
+ props.status & (props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS)
+ ) {
+ filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ }
+ }
+
+ if (!filter) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ let startDate = this.startDate;
+ let endDate = this.endDate;
+
+ // We only want occurrences returned from calICalendar.getItems() with a default
+ // occurrence filter property and a bound date range, otherwise the local listener
+ // will handle occurrence expansion.
+ if (!props.occurrences && this.endDate) {
+ filter |= Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ startDate = startDate || cal.createDateTime();
+ endDate = endDate || cal.dtz.now();
+ }
+
+ // We use a local ReadableStream for the calICalendar.getItems() call, and use it
+ // to handle occurrence expansion and filter the results before forwarding them
+ // upstream.
+ return CalReadableStreamFactory.createMappedReadableStream(
+ aCalendar.getItems(filter, 0, startDate, endDate),
+ chunk => {
+ let items;
+ if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT) {
+ // with the FILTER_OCCURRENCES_PAST_AND_NEXT occurrence filter we will
+ // get parent items returned here, so we need to let the getOccurrences
+ // function handle occurrence expansion.
+ items = [];
+ for (let item of chunk) {
+ items = items.concat(this.getOccurrences(item));
+ }
+ } else {
+ // with other occurrence filters the calICalendar.getItems() function will
+ // return expanded occurrences appropriately, we only need to filter them.
+ items = this.filterItems(chunk);
+ }
+ return items;
+ }
+ );
+ },
+};
+
+/**
+ * A mixin to use as a base class for calendar widgets.
+ *
+ * With startDate, endDate, and itemType set this mixin will inform the widget
+ * of any calendar item within the range that needs to be added to, or removed
+ * from, the UI. Widgets should implement clearItems, addItems, removeItems,
+ * and removeItemsFromCalendar to receive this information.
+ *
+ * To update the display (e.g. if the user wants to display a different month),
+ * just set the new date values and call refreshItems().
+ *
+ * This mixin handles disabled and/or hidden calendars, so you don't have to.
+ *
+ * @note Instances must have an `id` for logging purposes.
+ */
+let CalendarFilteredViewMixin = Base =>
+ class extends Base {
+ /**
+ * The filter responsible for collecting items when this view is refreshed,
+ * and checking new items as they appear.
+ *
+ * @type {calFilter}
+ */
+ #filter = null;
+
+ /**
+ * An object representing the most recent refresh job.
+ * This is used to check if a job that completes is still the most recent.
+ *
+ * @type {?object}
+ */
+ #currentRefresh = null;
+
+ /**
+ * The current PromiseUtils.jsm `Deferred` object (containing a Promise
+ * and methods to resolve/reject it).
+ *
+ * @type {object}
+ */
+ #deferred = PromiseUtils.defer();
+
+ /**
+ * Any async iterator currently reading from a calendar.
+ *
+ * @type {Set<CalReadableStreamIterator>}
+ */
+ #iterators = new Set();
+
+ constructor(...args) {
+ super(...args);
+
+ this.#filter = new calFilter();
+ this.#filter.itemType = 0;
+ }
+
+ /**
+ * A Promise that resolves when the next refreshing of items is complete,
+ * or instantly if refreshing is already complete and still valid.
+ *
+ * Changes to the startDate, endDate, or itemType properties, or a call to
+ * refreshItems with the force argument, will delay this Promise until the
+ * refresh settles for the new values.
+ *
+ * @type {Promise}
+ */
+ get ready() {
+ return this.#deferred.promise;
+ }
+
+ /**
+ * The start of the filter range. Can be either a date or a datetime.
+ *
+ * @type {calIDateTime}
+ */
+ get startDate() {
+ return this.#filter.startDate;
+ }
+
+ set startDate(value) {
+ if (
+ this.startDate?.compare(value) == 0 &&
+ this.startDate.timezone.tzid == value.timezone.tzid
+ ) {
+ return;
+ }
+
+ this.#filter.startDate = value.clone();
+ this.#filter.startDate.makeImmutable();
+ this.#invalidate();
+ }
+
+ /**
+ * The end of the filter range. Can be either a date or a datetime.
+ * If it is a date, the filter won't include items on that date, so use the
+ * day after the last day to be displayed.
+ *
+ * @type {calIDateTime}
+ */
+ get endDate() {
+ return this.#filter.endDate;
+ }
+
+ set endDate(value) {
+ if (this.endDate?.compare(value) == 0 && this.endDate.timezone.tzid == value.timezone.tzid) {
+ return;
+ }
+
+ this.#filter.endDate = value.clone();
+ this.#filter.endDate.makeImmutable();
+ this.#invalidate();
+ }
+
+ /**
+ * One of the calICalendar.ITEM_FILTER_TYPE constants.
+ * This must be set to a non-zero value in order to display any items.
+ *
+ * @type {number}
+ */
+ get itemType() {
+ return this.#filter.itemType;
+ }
+
+ set itemType(value) {
+ if (this.itemType == value) {
+ return;
+ }
+
+ this.#filter.itemType = value;
+ this.#invalidate();
+ }
+
+ #isActive = false;
+
+ /**
+ * Whether the view is active.
+ *
+ * Whilst the view is active, it will listen for item changes. Otherwise,
+ * if the view is set to be inactive, it will stop listening for changes.
+ *
+ * @type {boolean}
+ */
+ get isActive() {
+ return this.#isActive;
+ }
+
+ /**
+ * Activate the view, refreshing items and listening for changes.
+ *
+ * @returns {Promise} a promise which resolves when refresh is complete
+ */
+ activate() {
+ if (this.#isActive) {
+ return Promise.resolve();
+ }
+
+ this.#isActive = true;
+ this.#calendarObserver.self = this;
+
+ cal.manager.addCalendarObserver(this.#calendarObserver);
+ return this.refreshItems();
+ }
+
+ /**
+ * Deactivate the view, cancelling any in-progress refresh and causing it to
+ * no longer listen for changes.
+ */
+ deactivate() {
+ if (!this.#isActive) {
+ return;
+ }
+
+ this.#isActive = false;
+ this.#calendarObserver.self = this;
+
+ cal.manager.removeCalendarObserver(this.#calendarObserver);
+ this.#invalidate();
+ }
+
+ /**
+ * Clears the display and adds items that match the filter from all enabled
+ * and visible calendars.
+ *
+ * @param {boolean} force - Start refreshing again, even if a refresh is already in progress.
+ * @returns {Promise} A Promise resolved when all calendars have refreshed. This is the same
+ * Promise as returned from the `ready` getter.
+ */
+ refreshItems(force = false) {
+ if (!this.#isActive) {
+ // If we're inactive, calling #refreshCalendar() will do nothing, but we
+ // will have created a refresh job with no effect and subsequent refresh
+ // attempts will fail.
+ return Promise.resolve();
+ } else if (force) {
+ // Refresh, even if already refreshing or refreshed.
+ this.#invalidate();
+ } else if (this.#currentRefresh) {
+ // We already have an ongoing refresh job, or one that has already completed.
+ return this.#deferred.promise;
+ }
+
+ // Create a new refresh job.
+ let refresh = (this.#currentRefresh = { completed: false });
+
+ // Collect items from all of the calendars.
+ this.clearItems();
+ let promises = [];
+ for (let calendar of cal.manager.getCalendars()) {
+ promises.push(this.#refreshCalendar(calendar));
+ }
+
+ Promise.all(promises).then(() => {
+ refresh.completed = true;
+ // Resolve the Promise if the current job is still the most recent one.
+ // In other words, if nothing has called `#invalidate` since `currentRefresh` was created.
+ if (this.#currentRefresh == refresh) {
+ this.#deferred.resolve();
+ }
+ });
+
+ return this.#deferred.promise;
+ }
+
+ /**
+ * Cancels any refresh in progress.
+ */
+ #invalidate() {
+ for (let iterator of this.#iterators) {
+ iterator.cancel();
+ }
+ this.#iterators.clear();
+ if (this.#currentRefresh?.completed) {
+ // If a previous refresh completed, start a new Promise that resolves when the next refresh
+ // completes. Otherwise, continue with the current Promise.
+ // If #currentRefresh is completed, #deferred is already resolved, so we can safely discard it.
+ this.#deferred = PromiseUtils.defer();
+ }
+ this.#currentRefresh = null;
+ }
+
+ /**
+ * Checks if the given calendar is both enabled and visible.
+ *
+ * @param {calICalendar} calendar
+ * @returns {boolean} True if both enabled and visible.
+ */
+ #isCalendarVisible(calendar) {
+ if (!calendar) {
+ // If this happens then something's wrong, but it's not our problem so just ignore it.
+ return false;
+ }
+
+ return (
+ !calendar.getProperty("disabled") && calendar.getProperty("calendar-main-in-composite")
+ );
+ }
+
+ /**
+ * Adds items that match the filter from a specific calendar. Does NOT
+ * remove existing items first, use removeItemsFromCalendar for that.
+ *
+ * @param {calICalendar} calendar
+ * @returns {Promise} A promise resolved when this calendar has refreshed.
+ */
+ async #refreshCalendar(calendar) {
+ if (!this.#isActive || !this.itemType || !this.#isCalendarVisible(calendar)) {
+ return;
+ }
+ let iterator = cal.iterate.streamValues(this.#filter.getItems(calendar));
+ this.#iterators.add(iterator);
+ for await (let chunk of iterator) {
+ this.addItems(chunk);
+ }
+ this.#iterators.delete(iterator);
+ }
+
+ /**
+ * Implement this method to remove all items from the UI.
+ */
+ clearItems() {}
+
+ /**
+ * Implement this method to add items to the UI.
+ *
+ * @param {calIItemBase[]} items
+ */
+ addItems(items) {}
+
+ /**
+ * Implement this method to remove items from the UI.
+ *
+ * @param {calIItemBase[]} items
+ */
+ removeItems(items) {}
+
+ /**
+ * Implement this method to remove all items from a specific calendar from
+ * the UI.
+ *
+ * @param {string} calendarId
+ */
+ removeItemsFromCalendar(calendarId) {}
+
+ /**
+ * @implements {calIObserver}
+ */
+ #calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ onStartBatch(calendar) {},
+ onEndBatch(calendar) {},
+ onLoad(calendar) {
+ if (calendar.type == "ics") {
+ // ICS doesn't bother telling us about events that disappeared when
+ // sync'ing, so just throw them all out and reload. This should get
+ // fixed somehow, and this hack removed.
+ this.self.removeItemsFromCalendar(calendar.id);
+ this.self.#refreshCalendar(calendar);
+ }
+ },
+ onAddItem(item) {
+ if (!this.self.#isCalendarVisible(item.calendar)) {
+ return;
+ }
+
+ let occurrences = this.self.#filter.getOccurrences(item);
+ if (occurrences.length) {
+ this.self.addItems(occurrences);
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ if (!this.self.#isCalendarVisible(newItem.calendar)) {
+ return;
+ }
+
+ // Ideally we'd calculate the intersection between oldOccurrences and
+ // newOccurrences, then call a modifyItems function, but it proved
+ // unreliable in some situations, so instead we remove and replace
+ // the occurrences.
+
+ let oldOccurrences = this.self.#filter.getOccurrences(oldItem);
+ if (oldOccurrences.length) {
+ this.self.removeItems(oldOccurrences);
+ }
+
+ let newOccurrences = this.self.#filter.getOccurrences(newItem);
+ if (newOccurrences.length) {
+ this.self.addItems(newOccurrences);
+ }
+ },
+ onDeleteItem(deletedItem) {
+ if (!this.self.#isCalendarVisible(deletedItem.calendar)) {
+ return;
+ }
+
+ this.self.removeItems(this.self.#filter.getOccurrences(deletedItem));
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, newValue, oldValue) {
+ if (!["calendar-main-in-composite", "disabled"].includes(name)) {
+ return;
+ }
+
+ if (
+ (name == "disabled" && newValue) ||
+ (name == "calendar-main-in-composite" && !newValue)
+ ) {
+ this.self.removeItemsFromCalendar(calendar.id);
+ return;
+ }
+
+ this.self.#refreshCalendar(calendar);
+ },
+ onPropertyDeleting(calendar, name) {},
+ };
+ };
diff --git a/comm/calendar/base/content/widgets/calendar-invitation-panel.js b/comm/calendar/base/content/widgets/calendar-invitation-panel.js
new file mode 100644
index 0000000000..aa2be5e29f
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-invitation-panel.js
@@ -0,0 +1,799 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal, openLinkExternally, MozXULElement, MozElements */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { recurrenceRule2String } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+ );
+
+ // calendar-invitation-panel.ftl is not globally loaded until now.
+ MozXULElement.insertFTLIfNeeded("calendar/calendar-invitation-panel.ftl");
+
+ const PROPERTY_REMOVED = -1;
+ const PROPERTY_UNCHANGED = 0;
+ const PROPERTY_ADDED = 1;
+ const PROPERTY_MODIFIED = 2;
+
+ /**
+ * InvitationPanel displays the details of an iTIP event invitation in an
+ * interactive panel.
+ */
+ class InvitationPanel extends HTMLElement {
+ static MODE_NEW = "New";
+ static MODE_ALREADY_PROCESSED = "Processed";
+ static MODE_UPDATE_MAJOR = "UpdateMajor";
+ static MODE_UPDATE_MINOR = "UpdateMinor";
+ static MODE_CANCELLED = "Cancelled";
+ static MODE_CANCELLED_NOT_FOUND = "CancelledNotFound";
+
+ /**
+ * Used to retrieve a property value from an event.
+ *
+ * @callback GetValue
+ * @param {calIEvent} event
+ * @returns {string}
+ */
+
+ /**
+ * A function used to make a property value visible in to the user.
+ *
+ * @callback PropertyShow
+ * @param {HTMLElement} node - The element responsible for displaying the
+ * value.
+ * @param {string} value - The value of property to display.
+ * @param {string} oldValue - The previous value of the property if the
+ * there is a prior copy of the event.
+ * @param {calIEvent} item - The event item the property belongs to.
+ * @param {string} oldItem - The prior version of the event if there is one.
+ */
+
+ /**
+ * @typedef {Object} InvitationPropertyDescriptor
+ * @property {string} id - The id of the HTMLElement that displays
+ * the property.
+ * @property {GetValue} getValue - Function used to retrieve the displayed
+ * value of the property from the item.
+ * @property {boolean?} isList - Indicates the value of the property is a
+ * list.
+ * @property {PropertyShow?} show - Function to use to display the property
+ * value if it is not a list.
+ */
+
+ /**
+ * A static list of objects used in determining how to display each of the
+ * properties.
+ *
+ * @type {PropertyDescriptor[]}
+ */
+ static propertyDescriptors = [
+ {
+ id: "when",
+ getValue(item) {
+ let tz = cal.dtz.defaultTimezone;
+ let startDate = item.startDate?.getInTimezone(tz) ?? null;
+ let endDate = item.endDate?.getInTimezone(tz) ?? null;
+ return `${startDate.icalString}-${endDate?.icalString}`;
+ },
+ show(intervalNode, newValue, oldValue, item) {
+ intervalNode.item = item;
+ },
+ },
+ {
+ id: "recurrence",
+ getValue(item) {
+ let parent = item.parentItem;
+ if (!parent.recurrenceInfo) {
+ return null;
+ }
+ return recurrenceRule2String(parent.recurrenceInfo, parent.recurrenceStartDate);
+ },
+ show(recurrence, value) {
+ recurrence.appendChild(document.createTextNode(value));
+ },
+ },
+ {
+ id: "location",
+ getValue(item) {
+ return item.getProperty("LOCATION");
+ },
+ show(location, value) {
+ location.appendChild(cal.view.textToHtmlDocumentFragment(value, document));
+ },
+ },
+ {
+ id: "summary",
+ getValue(item) {
+ return item.getAttendees();
+ },
+ show(summary, value) {
+ summary.attendees = value;
+ },
+ },
+ {
+ id: "attendees",
+ isList: true,
+ getValue(item) {
+ return item.getAttendees();
+ },
+ },
+ {
+ id: "attachments",
+ isList: true,
+ getValue(item) {
+ return item.getAttachments();
+ },
+ },
+ {
+ id: "description",
+ getValue(item) {
+ return item.descriptionText;
+ },
+ show(description, value) {
+ description.appendChild(cal.view.textToHtmlDocumentFragment(value, document));
+ },
+ },
+ ];
+
+ /**
+ * mode determines how the UI should display the received invitation. It
+ * must be set to one of the MODE_* constants, defaults to MODE_NEW.
+ *
+ * @type {string}
+ */
+ mode = InvitationPanel.MODE_NEW;
+
+ /**
+ * A previous copy of the event item if found on an existing calendar.
+ *
+ * @type {calIEvent?}
+ */
+ foundItem;
+
+ /**
+ * The event item to be displayed.
+ *
+ * @type {calIEvent?}
+ */
+ item;
+
+ constructor(id) {
+ super();
+ this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(this.shadowRoot);
+
+ let link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "chrome://calendar/skin/shared/widgets/calendar-invitation-panel.css";
+ this.shadowRoot.appendChild(link);
+ }
+
+ /**
+ * Compares two like property values, an old and a new one, to determine
+ * what type of change has been made (if any).
+ *
+ * @param {any} oldValue
+ * @param {any} newValue
+ * @returns {number} - One of the PROPERTY_* constants.
+ */
+ compare(oldValue, newValue) {
+ if (!oldValue && newValue) {
+ return PROPERTY_ADDED;
+ }
+ if (oldValue && !newValue) {
+ return PROPERTY_REMOVED;
+ }
+ return oldValue != newValue ? PROPERTY_MODIFIED : PROPERTY_UNCHANGED;
+ }
+
+ connectedCallback() {
+ if (this.item && this.mode) {
+ let template = document.getElementById(`calendarInvitationPanel`);
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+ if (this.foundItem && this.foundItem.title != this.item.title) {
+ let indicator = this.shadowRoot.getElementById("titleChangeIndicator");
+ indicator.status = PROPERTY_MODIFIED;
+ indicator.hidden = false;
+ }
+ this.shadowRoot.getElementById("title").textContent = this.item.title;
+
+ let statusBar = this.shadowRoot.querySelector("calendar-invitation-panel-status-bar");
+ statusBar.status = this.mode;
+
+ this.shadowRoot.querySelector("calendar-minidate").date = this.item.startDate;
+
+ for (let prop of InvitationPanel.propertyDescriptors) {
+ let el = this.shadowRoot.getElementById(prop.id);
+ let value = prop.getValue(this.item);
+ let result = PROPERTY_UNCHANGED;
+
+ if (prop.isList) {
+ let oldValue = this.foundItem ? prop.getValue(this.foundItem) : [];
+ if (value.length || oldValue.length) {
+ el.oldValue = oldValue;
+ el.value = value;
+ el.closest(".calendar-invitation-row").hidden = false;
+ }
+ continue;
+ }
+
+ let oldValue = this.foundItem ? prop.getValue(this.foundItem) : null;
+ if (this.foundItem) {
+ result = this.compare(oldValue, value);
+ if (result) {
+ let indicator = this.shadowRoot.getElementById(`${prop.id}ChangeIndicator`);
+ if (indicator) {
+ indicator.type = result;
+ indicator.hidden = false;
+ }
+ }
+ }
+ if (value || oldValue) {
+ prop.show(el, value, oldValue, this.item, this.foundItem, result);
+ el.closest(".calendar-invitation-row").hidden = false;
+ }
+ }
+
+ if (
+ this.mode == InvitationPanel.MODE_NEW ||
+ this.mode == InvitationPanel.MODE_UPDATE_MAJOR
+ ) {
+ for (let button of this.shadowRoot.querySelectorAll("#actionButtons > button")) {
+ button.addEventListener("click", e =>
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ detail: { type: button.dataset.action },
+ })
+ )
+ );
+ }
+ this.shadowRoot.getElementById("footer").hidden = false;
+ }
+ }
+ }
+ }
+ customElements.define("calendar-invitation-panel", InvitationPanel);
+
+ /**
+ * Object used to describe relevant arguments to MozElements.NotificationBox.
+ * appendNotification().
+ * @type {Object} InvitationStatusBarDescriptor
+ * @property {string} label - An l10n id used used to generate the notification
+ * bar text.
+ * @property {number} priority - One of the notification box constants that
+ * indicate the priority of a notification.
+ * @property {object[]} buttons - An array of objects corresponding to the
+ * "buttons" argument of MozElements.NotificationBox.appendNotification().
+ * See that method for details.
+ */
+
+ /**
+ * InvitationStatusBar generates a notification bar that informs the user about
+ * the status of the received invitation and possible actions they may take.
+ */
+ class InvitationPanelStatusBar extends HTMLElement {
+ /**
+ * @type {NotificationBox}
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ this.append(element);
+ });
+ }
+ return this._notificationBox;
+ }
+
+ /**
+ * Map-like object where each key is an InvitationPanel mode and the values
+ * are descriptors used to generate the notification bar for that mode.
+ *
+ * @type {Object.<string, InvitationStatusBarDescriptor>
+ */
+ notices = {
+ [InvitationPanel.MODE_NEW]: {
+ label: "calendar-invitation-panel-status-new",
+ buttons: [
+ {
+ "l10n-id": "calendar-invitation-panel-more-button",
+ callback: (notification, opts, button, event) =>
+ this._showMoreMenu(event, [
+ {
+ l10nId: "calendar-invitation-panel-menu-item-save-copy",
+ name: "save",
+ command: e =>
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ details: { type: "x-savecopy" },
+ bubbles: true,
+ composed: true,
+ })
+ ),
+ },
+ ]),
+ },
+ ],
+ },
+ [InvitationPanel.MODE_ALREADY_PROCESSED]: {
+ label: "calendar-invitation-panel-status-processed",
+ buttons: [
+ {
+ "l10n-id": "calendar-invitation-panel-view-button",
+ callback: () => {
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ detail: { type: "x-showdetails" },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return true;
+ },
+ },
+ ],
+ },
+ [InvitationPanel.MODE_UPDATE_MINOR]: {
+ label: "calendar-invitation-panel-status-updateminor",
+ priority: this.notificationBox.PRIORITY_WARNING_LOW,
+ buttons: [
+ {
+ "l10n-id": "calendar-invitation-panel-update-button",
+ callback: () => {
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ detail: { type: "update" },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return true;
+ },
+ },
+ ],
+ },
+ [InvitationPanel.MODE_UPDATE_MAJOR]: {
+ label: "calendar-invitation-panel-status-updatemajor",
+ priority: this.notificationBox.PRIORITY_WARNING_LOW,
+ },
+ [InvitationPanel.MODE_CANCELLED]: {
+ label: "calendar-invitation-panel-status-cancelled",
+ buttons: [{ "l10n-id": "calendar-invitation-panel-delete-button" }],
+ priority: this.notificationBox.PRIORITY_CRITICAL_LOW,
+ },
+ [InvitationPanel.MODE_CANCELLED_NOT_FOUND]: {
+ label: "calendar-invitation-panel-status-cancelled-notfound",
+ priority: this.notificationBox.PRIORITY_CRITICAL_LOW,
+ },
+ };
+
+ /**
+ * status corresponds to one of the MODE_* constants and will trigger
+ * rendering of the notification box.
+ *
+ * @type {string} status
+ */
+ set status(value) {
+ let opts = this.notices[value];
+ let priority = opts.priority || this.notificationBox.PRIORITY_INFO_LOW;
+ let buttons = opts.buttons || [];
+ let notification = this.notificationBox.appendNotification(
+ "invitationStatus",
+ {
+ label: { "l10n-id": opts.label },
+ priority,
+ },
+ buttons
+ );
+ notification.removeAttribute("dismissable");
+ }
+
+ _showMoreMenu(event, menuitems) {
+ let menu = document.getElementById("calendarInvitationPanelMoreMenu");
+ menu.replaceChildren();
+ for (let { type, l10nId, name, command } of menuitems) {
+ let menuitem = document.createXULElement("menuitem");
+ if (type) {
+ menuitem.type = type;
+ }
+ if (name) {
+ menuitem.name = name;
+ }
+ if (command) {
+ menuitem.addEventListener("command", command);
+ }
+ document.l10n.setAttributes(menuitem, l10nId);
+ menu.appendChild(menuitem);
+ }
+ menu.openPopup(event.originalTarget, "after_start", 0, 0, false, false, event);
+ return true;
+ }
+ }
+ customElements.define("calendar-invitation-panel-status-bar", InvitationPanelStatusBar);
+
+ /**
+ * InvitationInterval displays the formatted interval of the event. Formatting
+ * relies on cal.dtz.formatter.formatIntervalParts().
+ */
+ class InvitationInterval extends HTMLElement {
+ /**
+ * The item whose interval to show.
+ *
+ * @type {calIEvent}
+ */
+ set item(value) {
+ let [startDate, endDate] = cal.dtz.formatter.getItemDates(value);
+ let timezone = startDate.timezone.displayName;
+ let parts = cal.dtz.formatter.formatIntervalParts(startDate, endDate);
+ document.l10n.setAttributes(this, `calendar-invitation-interval-${parts.type}`, {
+ ...parts,
+ timezone,
+ });
+ }
+ }
+ customElements.define("calendar-invitation-interval", InvitationInterval);
+
+ const partStatOrder = ["ACCEPTED", "DECLINED", "TENTATIVE", "NEEDS-ACTION"];
+
+ /**
+ * InvitationPartStatSummary generates text indicating the aggregated
+ * participation status of each attendee in the event's attendees list.
+ */
+ class InvitationPartStatSummary extends HTMLElement {
+ constructor() {
+ super();
+ this.appendChild(
+ document.getElementById("calendarInvitationPartStatSummary").content.cloneNode(true)
+ );
+ }
+
+ /**
+ * Setting this property will trigger an update of the text displayed.
+ *
+ * @type {calIAttendee[]}
+ */
+ set attendees(attendees) {
+ let counts = {
+ ACCEPTED: 0,
+ DECLINED: 0,
+ TENTATIVE: 0,
+ "NEEDS-ACTION": 0,
+ TOTAL: attendees.length,
+ OTHER: 0,
+ };
+
+ for (let { participationStatus } of attendees) {
+ if (counts.hasOwnProperty(participationStatus)) {
+ counts[participationStatus]++;
+ } else {
+ counts.OTHER++;
+ }
+ }
+ document.l10n.setAttributes(
+ this.querySelector("#partStatTotal"),
+ "calendar-invitation-panel-partstat-total",
+ { count: counts.TOTAL }
+ );
+
+ let shownPartStats = partStatOrder.filter(partStat => counts[partStat]);
+ let breakdown = this.querySelector("#partStatBreakdown");
+ for (let partStat of shownPartStats) {
+ let span = document.createElement("span");
+ span.setAttribute("class", "calendar-invitation-panel-partstat-summary");
+
+ // calendar-invitation-panel-partstat-accepted
+ // calendar-invitation-panel-partstat-declined
+ // calendar-invitation-panel-partstat-tentative
+ // calendar-invitation-panel-partstat-needs-action
+ document.l10n.setAttributes(
+ span,
+ `calendar-invitation-panel-partstat-${partStat.toLowerCase()}`,
+ {
+ count: counts[partStat],
+ }
+ );
+ breakdown.appendChild(span);
+ }
+ }
+ }
+ customElements.define("calendar-invitation-partstat-summary", InvitationPartStatSummary);
+
+ /**
+ * BaseInvitationChangeList is a <ul> element that can visually show changes
+ * between elements of a list value.
+ *
+ * @template T
+ */
+ class BaseInvitationChangeList extends HTMLUListElement {
+ /**
+ * An array containing the old values to be compared against for changes.
+ *
+ * @type {T[]}
+ */
+ oldValue = [];
+
+ /**
+ * String indicating the type of list items to create. This is passed
+ * directly to the "is" argument of document.createElement().
+ *
+ * @abstract
+ */
+ listItem;
+
+ _createListItem(value, status) {
+ let li = document.createElement("li", { is: this.listItem });
+ li.changeStatus = status;
+ li.value = value;
+ return li;
+ }
+
+ /**
+ * Setting this property will trigger rendering of the list. If no prior
+ * values are detected, change indicators are not touched.
+ *
+ * @type {T[]}
+ */
+ set value(list) {
+ if (!this.oldValue.length) {
+ for (let value of list) {
+ this.append(this._createListItem(value));
+ }
+ return;
+ }
+ for (let [value, status] of this.getChanges(this.oldValue, list)) {
+ this.appendChild(this._createListItem(value, status));
+ }
+ }
+
+ /**
+ * Implemented by sub-classes to generate a list of changes for each element
+ * of the new list.
+ *
+ * @param {T[]} oldValue
+ * @param {T[]} newValue
+ * @return {[T, number][]}
+ */
+ getChanges(oldValue, newValue) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ }
+
+ /**
+ * BaseInvitationChangeListItem is the <li> element used for change lists.
+ *
+ * @template {T}
+ */
+ class BaseInvitationChangeListItem extends HTMLLIElement {
+ /**
+ * Indicates whether the item value has changed and should be displayed as
+ * such. Its value is one of the PROPERTY_* constants.
+ *
+ * @type {number}
+ */
+ changeStatus = PROPERTY_UNCHANGED;
+
+ /**
+ * Settings this property will render the list item including a change
+ * indicator if the changeStatus property != PROPERTY_UNCHANGED.
+ *
+ * @type {T}
+ */
+ set value(itemValue) {
+ this.build(itemValue);
+ if (this.changeStatus) {
+ let changeIndicator = document.createElement("calendar-invitation-change-indicator");
+ changeIndicator.type = this.changeStatus;
+ this.append(changeIndicator);
+ }
+ }
+
+ /**
+ * Implemented by sub-classes to build the <li> inner DOM structure.
+ *
+ * @param {T} value
+ * @abstract
+ */
+ build(value) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ }
+
+ /**
+ * InvitationAttendeeList displays a list of all the attendees on an event's
+ * attendee list.
+ */
+ class InvitationAttendeeList extends BaseInvitationChangeList {
+ listItem = "calendar-invitation-panel-attendee-list-item";
+
+ getChanges(oldValue, newValue) {
+ let diff = [];
+ for (let att of newValue) {
+ let oldAtt = oldValue.find(oldAtt => oldAtt.id == att.id);
+ if (!oldAtt) {
+ diff.push([att, PROPERTY_ADDED]); // New attendee.
+ } else if (oldAtt.participationStatus != att.participationStatus) {
+ diff.push([att, PROPERTY_MODIFIED]); // Participation status changed.
+ } else {
+ diff.push([att, PROPERTY_UNCHANGED]); // No change.
+ }
+ }
+
+ // Insert removed attendees into the diff.
+ for (let [idx, att] of oldValue.entries()) {
+ let found = newValue.find(newAtt => newAtt.id == att.id);
+ if (!found) {
+ diff.splice(idx, 0, [att, PROPERTY_REMOVED]);
+ }
+ }
+ return diff;
+ }
+ }
+ customElements.define("calendar-invitation-panel-attendee-list", InvitationAttendeeList, {
+ extends: "ul",
+ });
+
+ /**
+ * InvitationAttendeeListItem displays a single attendee from the attendee
+ * list.
+ */
+ class InvitationAttendeeListItem extends BaseInvitationChangeListItem {
+ build(value) {
+ let span = document.createElement("span");
+ if (this.changeStatus == PROPERTY_REMOVED) {
+ span.setAttribute("class", "removed");
+ }
+ span.textContent = value;
+ this.appendChild(span);
+ }
+ }
+ customElements.define(
+ "calendar-invitation-panel-attendee-list-item",
+ InvitationAttendeeListItem,
+ {
+ extends: "li",
+ }
+ );
+
+ /**
+ * InvitationAttachmentList displays a list of all attachments in the invitation
+ * that have URIs. Binary attachments are not supported.
+ */
+ class InvitationAttachmentList extends BaseInvitationChangeList {
+ listItem = "calendar-invitation-panel-attachment-list-item";
+
+ getChanges(oldValue, newValue) {
+ let diff = [];
+ for (let attch of newValue) {
+ if (!attch.uri) {
+ continue;
+ }
+ let oldAttch = oldValue.find(
+ oldAttch => oldAttch.uri && oldAttch.uri.spec == attch.uri.spec
+ );
+
+ if (!oldAttch) {
+ // New attachment.
+ diff.push([attch, PROPERTY_ADDED]);
+ continue;
+ }
+ if (
+ attch.hashId != oldAttch.hashId ||
+ attch.getParameter("FILENAME") != oldAttch.getParameter("FILENAME")
+ ) {
+ // Contents changed or renamed.
+ diff.push([attch, PROPERTY_MODIFIED]);
+ continue;
+ }
+ // No change.
+ diff.push([attch, PROPERTY_UNCHANGED]);
+ }
+
+ // Insert removed attachments into the diff.
+ for (let [idx, attch] of oldValue.entries()) {
+ if (!attch.uri) {
+ continue;
+ }
+ let found = newValue.find(newAtt => newAtt.uri && newAtt.uri.spec == attch.uri.spec);
+ if (!found) {
+ diff.splice(idx, 0, [attch, PROPERTY_REMOVED]);
+ }
+ }
+ return diff;
+ }
+ }
+ customElements.define("calendar-invitation-panel-attachment-list", InvitationAttachmentList, {
+ extends: "ul",
+ });
+
+ /**
+ * InvitationAttachmentListItem displays a link to an attachment attached to the
+ * event.
+ */
+ class InvitationAttachmentListItem extends BaseInvitationChangeListItem {
+ /**
+ * Indicates whether the attachment has changed and should be displayed as
+ * such. Its value is one of the PROPERTY_* constants.
+ *
+ * @type {number}
+ */
+ changeStatus = PROPERTY_UNCHANGED;
+
+ /**
+ * Sets up the attachment to be displayed as a link with appropriate icon.
+ * Links are opened externally.
+ *
+ * @param {calIAttachment}
+ */
+ build(value) {
+ let icon = document.createElement("img");
+ let iconSrc = value.uri.spec.length ? value.uri.spec : "dummy.html";
+ if (!value.uri.schemeIs("file")) {
+ // Using an uri directly, with e.g. a http scheme, wouldn't render any icon.
+ if (value.formatType) {
+ iconSrc = "goat?contentType=" + value.formatType;
+ } else {
+ // Let's try to auto-detect.
+ let parts = iconSrc.substr(value.uri.scheme.length + 2).split("/");
+ if (parts.length) {
+ iconSrc = parts[parts.length - 1];
+ }
+ }
+ }
+ icon.setAttribute("src", "moz-icon://" + iconSrc);
+ this.append(icon);
+
+ let title = value.getParameter("FILENAME") || value.uri.spec;
+ if (this.changeStatus == PROPERTY_REMOVED) {
+ let span = document.createElement("span");
+ span.setAttribute("class", "removed");
+ span.textContent = title;
+ this.append(span);
+ } else {
+ let link = document.createElement("a");
+ link.textContent = title;
+ link.setAttribute("href", value.uri.spec);
+ link.addEventListener("click", event => {
+ event.preventDefault();
+ openLinkExternally(event.target.href);
+ });
+ this.append(link);
+ }
+ }
+ }
+ customElements.define(
+ "calendar-invitation-panel-attachment-list-item",
+ InvitationAttachmentListItem,
+ {
+ extends: "li",
+ }
+ );
+
+ /**
+ * InvitationChangeIndicator is a visual indicator for indicating some piece
+ * of data has changed.
+ */
+ class InvitationChangeIndicator extends HTMLElement {
+ _typeMap = {
+ [PROPERTY_REMOVED]: "removed",
+ [PROPERTY_ADDED]: "added",
+ [PROPERTY_MODIFIED]: "modified",
+ };
+
+ /**
+ * One of the PROPERTY_* constants that indicates what kind of change we
+ * are indicating (add/modify/delete) etc.
+ *
+ * @type {number}
+ */
+ set type(value) {
+ let key = this._typeMap[value];
+ document.l10n.setAttributes(this, `calendar-invitation-change-indicator-${key}`);
+ }
+ }
+ customElements.define("calendar-invitation-change-indicator", InvitationChangeIndicator);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml b/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml
new file mode 100644
index 0000000000..aaca3c1a17
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml
@@ -0,0 +1,96 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!-- Template for <calendar-invitation-panel/> -->
+<template id="calendarInvitationPanel" xmlns="http://www.w3.org/1999/xhtml">
+ <calendar-invitation-panel-status-bar/>
+ <div class="calendar-invitation-panel-wrapper">
+ <div class="calendar-invitation-panel-preview">
+ <calendar-minidate/>
+ </div>
+ <div class="calendar-invitation-panel-details">
+ <div class="calendar-invitation-panel-title">
+ <calendar-invitation-change-indicator id="titleChangeIndicator"
+ hidden="hidden"/>
+ <h1 class="calendar-invitation-panel-title" id="title"></h1>
+ </div>
+ <table id="props" class="calendar-invitation-panel-props">
+ <tbody>
+ <tr class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-when"></th>
+ <td class="calendar-invitation-when">
+ <calendar-invitation-change-indicator id="intervalChangeIndicator"
+ hidden="hidden"/>
+ <calendar-invitation-interval id="when"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-recurrence"></th>
+ <td id="recurrence" class="calendar-invitation-recurrence">
+ <calendar-invitation-change-indicator id="recurrenceChangeIndicator"
+ hidden="hidden"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-location"></th>
+ <td id="location" class="content">
+ <calendar-invitation-change-indicator id="locationChangeIndicator"
+ hidden="hidden"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-attendees"></th>
+ <td>
+ <calendar-invitation-partstat-summary id="summary"/>
+ <ul id="attendees"
+ is="calendar-invitation-panel-attendee-list"
+ class="calendar-invitation-panel-list"></ul>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-description"></th>
+ <td id="description" class="content">
+ <calendar-invitation-change-indicator id="descriptionChangeIndicator"
+ hidden="hidden"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-attachments"></th>
+ <td class="content">
+ <ul id="attachments"
+ is="calendar-invitation-panel-attachment-list"
+ class="calendar-invitation-panel-list"></ul>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div id="footer" class="calendar-invitation-panel-details-footer" hidden="hidden">
+ <div id="actionButtons" class="calendar-invitation-panel-response-buttons">
+ <button id="acceptButton"
+ data-action="accepted"
+ class="primary"
+ data-l10n-id="calendar-invitation-panel-accept-button"></button>
+ <button id="declineButton"
+ data-action="declined"
+ data-l10n-id="calendar-invitation-panel-decline-button"></button>
+ <button id="tentativeButton"
+ data-action="tentative"
+ data-l10n-id="calendar-invitation-panel-tentative-button"></button>
+ </div>
+ </div>
+</template>
+
+<!-- Template for <calendar-invitation-partstat-summary/> -->
+<template id="calendarInvitationPartStatSummary" xmlns="http://www.w3.org/1999/xhtml">
+ <div class="calendar-invitation-attendees-summary">
+ <span id="partStatTotal"
+ class="calendar-invitation-panel-partstat-summary-total"></span>
+ <span id="partStatBreakdown" class="calendar-invitation-panel-partstat-breakdown"></span>
+ </div>
+</template>
+
+<!-- Menu for the "More" button in the invitation panel. Populated via JavaScript.-->
+<menupopup id="calendarInvitationPanelMoreMenu"></menupopup>
diff --git a/comm/calendar/base/content/widgets/calendar-item-summary.js b/comm/calendar/base/content/widgets/calendar-item-summary.js
new file mode 100644
index 0000000000..747c5e1d5d
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-item-summary.js
@@ -0,0 +1,761 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozElements MozXULElement */
+
+/* import-globals-from ../../src/calApplicationUtils.js */
+/* import-globals-from ../dialogs/calendar-summary-dialog.js */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ var { recurrenceStringFromItem } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+ );
+
+ /**
+ * Represents a mostly read-only summary of a calendar item. Used in places
+ * like the calendar summary dialog and calendar import dialog. All instances
+ * should have an ID attribute.
+ */
+ class CalendarItemSummary extends MozXULElement {
+ static get markup() {
+ return `<vbox class="item-summary-box" flex="1">
+ <!-- General -->
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.general.label;" class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <html:table class="calendar-summary-table">
+ <html:tr>
+ <html:th>
+ &read.only.title.label;
+ </html:th>
+ <html:td class="item-title">
+ </html:td>
+ </html:tr>
+ <html:tr class="calendar-row" hidden="hidden">
+ <html:th>
+ &read.only.calendar.label;
+ </html:th>
+ <html:td class="item-calendar">
+ </html:td>
+ </html:tr>
+ <html:tr class="item-date-row">
+ <html:th class="item-start-row-label"
+ taskStartLabel="&read.only.task.start.label;"
+ eventStartLabel="&read.only.event.start.label;">
+ </html:th>
+ <html:td class="item-date-row-start-date">
+ </html:td>
+ </html:tr>
+ <html:tr class="item-date-row">
+ <html:th class="item-due-row-label"
+ taskDueLabel="&read.only.task.due.label;"
+ eventEndLabel="&read.only.event.end.label;">
+ </html:th>
+ <html:td class="item-date-row-end-date">
+ </html:td>
+ </html:tr>
+ <html:tr class="repeat-row" hidden="hidden">
+ <html:th>
+ &read.only.repeat.label;
+ </html:th>
+ <html:td class="repeat-details">
+ </html:td>
+ </html:tr>
+ <html:tr class="location-row" hidden="hidden">
+ <html:th>
+ &read.only.location.label;
+ </html:th>
+ <html:td class="item-location">
+ </html:td>
+ </html:tr>
+ <html:tr class="category-row" hidden="hidden">
+ <html:th>
+ &read.only.category.label;
+ </html:th>
+ <html:td class="item-category">
+ </html:td>
+ </html:tr>
+ <html:tr class="item-organizer-row" hidden="hidden">
+ <html:th>
+ &read.only.organizer.label;
+ </html:th>
+ <html:td class="item-organizer-cell">
+ </html:td>
+ </html:tr>
+ <html:tr class="status-row" hidden="hidden">
+ <html:th>
+ &task.status.label;
+ </html:th>
+ <html:td class="status-row-td">
+ <html:div hidden="true" status="TENTATIVE">&newevent.status.tentative.label;</html:div>
+ <html:div hidden="true" status="CONFIRMED">&newevent.status.confirmed.label;</html:div>
+ <html:div hidden="true" status="CANCELLED">&newevent.eventStatus.cancelled.label;</html:div>
+ <html:div hidden="true" status="CANCELLED">&newevent.todoStatus.cancelled.label;</html:div>
+ <html:div hidden="true" status="NEEDS-ACTION">&newevent.status.needsaction.label;</html:div>
+ <html:div hidden="true" status="IN-PROCESS">&newevent.status.inprogress.label;</html:div>
+ <html:div hidden="true" status="COMPLETED">&newevent.status.completed.label;</html:div>
+ </html:td>
+ </html:tr>
+ <separator class="groove" flex="1" hidden="true"/>
+ <html:tr class="reminder-row" hidden="hidden">
+ <html:th class="reminder-label">
+ &read.only.reminder.label;
+ </html:th>
+ <html:td class="reminder-details">
+ </html:td>
+ </html:tr>
+ <html:tr class="attachments-row item-attachments-row" hidden="hidden" >
+ <html:th class="attachments-label">
+ &read.only.attachments.label;
+ </html:th>
+ <html:td>
+ <vbox class="item-attachment-cell">
+ <!-- attachment box template -->
+ <hbox class="attachment-template"
+ hidden="true"
+ align="center"
+ disable-on-readonly="true">
+ <html:img class="attachment-icon invisible-on-broken"
+ alt="" />
+ <label class="text-link item-attachment-cell-label"
+ crop="end"
+ flex="1" />
+ </hbox>
+ </vbox>
+ </html:td>
+ </html:tr>
+ </html:table>
+ <!-- Attendees -->
+ <box class="item-attendees-description">
+ <box class="item-attendees" orient="vertical" hidden="true">
+ <spacer class="default-spacer"/>
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.attendees.label;"
+ class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <vbox class="item-attendees-list-container"
+ flex="1"
+ context="attendee-popup"
+ oncontextmenu="onAttendeeContextMenu(event)">
+ </vbox>
+ </box>
+
+ <splitter id="attendeeDescriptionSplitter"
+ class="item-summary-splitter"
+ collapse="after"
+ orient="vertical"
+ state="open"/>
+
+ <!-- Description -->
+ <box class="item-description-box" hidden="true" orient="vertical">
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.description.label;"
+ class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <iframe class="item-description"
+ type="content"
+ flex="1"
+ oncontextmenu="openDescriptionContextMenu(event);">
+ </iframe>
+ </box>
+ </box>
+
+ <!-- URL link -->
+ <box class="event-grid-link-row" hidden="true" orient="vertical">
+ <spacer class="default-spacer"/>
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.link.label;"
+ class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <label class="url-link text-link default-indent"
+ crop="end"/>
+ </box>
+ </vbox>`;
+ }
+
+ static get entities() {
+ return [
+ "chrome://calendar/locale/global.dtd",
+ "chrome://calendar/locale/calendar.dtd",
+ "chrome://calendar/locale/calendar-event-dialog.dtd",
+ "chrome://branding/locale/brand.dtd",
+ ];
+ }
+
+ static get alarmMenulistFragment() {
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(
+ `<hbox align="center">
+ <menulist class="item-alarm"
+ disable-on-readonly="true">
+ <menupopup>
+ <menuitem label="&event.reminder.none.label;"
+ selected="true"
+ value="none"/>
+ <menuseparator/>
+ <menuitem label="&event.reminder.0minutes.before.label;"
+ length="0"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuitem label="&event.reminder.5minutes.before.label;"
+ length="5"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuitem label="&event.reminder.15minutes.before.label;"
+ length="15"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuitem label="&event.reminder.30minutes.before.label;"
+ length="30"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuseparator/>
+ <menuitem label="&event.reminder.1hour.before.label;"
+ length="1"
+ origin="before"
+ relation="START"
+ unit="hours"/>
+ <menuitem label="&event.reminder.2hours.before.label;"
+ length="2"
+ origin="before"
+ relation="START"
+ unit="hours"/>
+ <menuitem label="&event.reminder.12hours.before.label;"
+ length="12"
+ origin="before"
+ relation="START"
+ unit="hours"/>
+ <menuseparator/>
+ <menuitem label="&event.reminder.1day.before.label;"
+ length="1"
+ origin="before"
+ relation="START"
+ unit="days"/>
+ <menuitem label="&event.reminder.2days.before.label;"
+ length="2"
+ origin="before"
+ relation="START"
+ unit="days"/>
+ <menuitem label="&event.reminder.1week.before.label;"
+ length="7"
+ origin="before"
+ relation="START"
+ unit="days"/>
+ <menuseparator/>
+ <menuitem class="reminder-custom-menuitem"
+ label="&event.reminder.custom.label;"
+ value="custom"/>
+ </menupopup>
+ </menulist>
+ <hbox class="reminder-details">
+ <hbox class="alarm-icons-box" align="center"/>
+ <!-- TODO oncommand? onkeypress? -->
+ <label class="reminder-multiple-alarms-label text-link"
+ hidden="true"
+ value="&event.reminder.multiple.label;"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"/>
+ <label class="reminder-single-alarms-label text-link"
+ hidden="true"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"/>
+ </hbox>
+ </hbox>`,
+ CalendarItemSummary.entities
+ ),
+ true
+ );
+ Object.defineProperty(this, "alarmMenulistFragment", { value: frag });
+ return frag;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.appendChild(this.constructor.fragment);
+
+ this.mItem = null;
+ this.mCalendar = null;
+ this.mReadOnly = true;
+ this.mIsInvitation = false;
+
+ this.mIsToDoItem = null;
+
+ let urlLink = this.querySelector(".url-link");
+ urlLink.addEventListener("click", event => {
+ launchBrowser(urlLink.getAttribute("href"), event);
+ });
+ urlLink.addEventListener("command", event => {
+ launchBrowser(urlLink.getAttribute("href"), event);
+ });
+ }
+
+ set item(item) {
+ this.mItem = item;
+ this.mIsToDoItem = item.isTodo();
+
+ // When used in places like the import dialog, there is no calendar (yet).
+ if (item.calendar) {
+ this.mCalendar = item.calendar;
+
+ this.mIsInvitation =
+ item.calendar.supportsScheduling &&
+ item.calendar.getSchedulingSupport()?.isInvitation(item);
+
+ this.mReadOnly = !(
+ cal.acl.isCalendarWritable(this.mCalendar) &&
+ (cal.acl.userCanModifyItem(item) ||
+ (this.mIsInvitation && cal.acl.userCanRespondToInvitation(item)))
+ );
+ }
+
+ if (!item.descriptionHTML || !item.getAttendees().length) {
+ // Hide the splitter when there is no description or attendees.
+ document.getElementById("attendeeDescriptionSplitter").setAttribute("hidden", "true");
+ }
+ }
+
+ get item() {
+ return this.mItem;
+ }
+
+ get calendar() {
+ return this.mCalendar;
+ }
+
+ get readOnly() {
+ return this.mReadOnly;
+ }
+
+ get isInvitation() {
+ return this.mIsInvitation;
+ }
+
+ /**
+ * Update the item details in the UI. To be called when this element is
+ * first rendered and when the item changes.
+ */
+ updateItemDetails() {
+ if (!this.item) {
+ // Setup not complete, do nothing for now.
+ return;
+ }
+ let item = this.item;
+ let isToDoItem = this.mIsToDoItem;
+
+ this.querySelector(".item-title").textContent = item.title;
+
+ if (this.calendar) {
+ this.querySelector(".calendar-row").removeAttribute("hidden");
+ this.querySelector(".item-calendar").textContent = this.calendar.name;
+ }
+
+ // Show start date.
+ let itemStartDate = item[cal.dtz.startDateProp(item)];
+
+ let itemStartRowLabel = this.querySelector(".item-start-row-label");
+ let itemDateRowStartDate = this.querySelector(".item-date-row-start-date");
+
+ itemStartRowLabel.style.visibility = itemStartDate ? "visible" : "collapse";
+ itemDateRowStartDate.style.visibility = itemStartDate ? "visible" : "collapse";
+
+ if (itemStartDate) {
+ itemStartRowLabel.textContent = itemStartRowLabel.getAttribute(
+ isToDoItem ? "taskStartLabel" : "eventStartLabel"
+ );
+ itemDateRowStartDate.textContent = cal.dtz.getStringForDateTime(itemStartDate);
+ }
+
+ // Show due date / end date.
+ let itemDueDate = item[cal.dtz.endDateProp(item)];
+
+ let itemDueRowLabel = this.querySelector(".item-due-row-label");
+ let itemDateRowEndDate = this.querySelector(".item-date-row-end-date");
+
+ itemDueRowLabel.style.visibility = itemDueDate ? "visible" : "collapse";
+ itemDateRowEndDate.style.visibility = itemDueDate ? "visible" : "collapse";
+
+ if (itemDueDate) {
+ // For all-day events, display the last day, not the finish time.
+ if (itemDueDate.isDate) {
+ itemDueDate = itemDueDate.clone();
+ itemDueDate.day--;
+ }
+ itemDueRowLabel.textContent = itemDueRowLabel.getAttribute(
+ isToDoItem ? "taskDueLabel" : "eventEndLabel"
+ );
+ itemDateRowEndDate.textContent = cal.dtz.getStringForDateTime(itemDueDate);
+ }
+
+ let alarms = item.getAlarms();
+ let hasAlarms = alarms && alarms.length;
+ let canShowReadOnlyReminders = hasAlarms && item.calendar;
+ let shouldShowReminderMenu =
+ !this.readOnly &&
+ this.isInvitation &&
+ item.calendar &&
+ item.calendar.getProperty("capabilities.alarms.oninvitations.supported") !== false;
+
+ // For invitations where the reminders can be edited, show a menu to
+ // allow setting the reminder, because you can't edit an invitation in
+ // the edit item dialog. For all other cases, show a plain text
+ // representation of the reminders but only if there are any.
+ if (shouldShowReminderMenu) {
+ if (!this.mAlarmsMenu) {
+ // Attempt to vertically align the label. It's not perfect but it's the best we've got.
+ let reminderLabel = this.querySelector(".reminder-label");
+ reminderLabel.style.verticalAlign = "middle";
+ let reminderCell = this.querySelector(".reminder-details");
+ while (reminderCell.lastChild) {
+ reminderCell.lastChild.remove();
+ }
+
+ // Add the menulist dynamically only if it's going to be used. This removes a
+ // significant performance penalty in most use cases.
+ reminderCell.append(this.constructor.alarmMenulistFragment.cloneNode(true));
+ this.mAlarmsMenu = this.querySelector(".item-alarm");
+ this.mLastAlarmSelection = 0;
+
+ this.mAlarmsMenu.addEventListener("command", () => {
+ this.updateReminder();
+ });
+
+ this.querySelector(".reminder-multiple-alarms-label").addEventListener("click", () => {
+ this.updateReminder();
+ });
+
+ this.querySelector(".reminder-single-alarms-label").addEventListener("click", () => {
+ this.updateReminder();
+ });
+ }
+
+ if (hasAlarms) {
+ this.mLastAlarmSelection = loadReminders(alarms, this.mAlarmsMenu, this.mItem.calendar);
+ }
+ this.updateReminder();
+ } else if (canShowReadOnlyReminders) {
+ this.updateReminderReadOnly(alarms);
+ }
+
+ if (shouldShowReminderMenu || canShowReadOnlyReminders) {
+ this.querySelector(".reminder-row").removeAttribute("hidden");
+ }
+
+ let recurrenceDetails = recurrenceStringFromItem(
+ item,
+ "calendar-event-dialog",
+ "ruleTooComplexSummary"
+ );
+ this.updateRecurrenceDetails(recurrenceDetails);
+ this.updateAttendees(item);
+
+ let url = item.getProperty("URL")?.trim() || "";
+
+ let link = this.querySelector(".url-link");
+ link.setAttribute("href", url);
+ link.setAttribute("value", url);
+ // Hide the row if there is no url.
+ this.querySelector(".event-grid-link-row").hidden = !url;
+
+ let location = item.getProperty("LOCATION");
+ if (location) {
+ this.updateLocation(location);
+ }
+
+ let categories = item.getCategories();
+ if (categories.length > 0) {
+ this.querySelector(".category-row").removeAttribute("hidden");
+ // TODO: this join is unfriendly for l10n (categories.join(", ")).
+ this.querySelector(".item-category").textContent = categories.join(", ");
+ }
+
+ if (item.organizer && item.organizer.id) {
+ this.updateOrganizer(item);
+ }
+
+ let status = item.getProperty("STATUS");
+ if (status && status.length) {
+ this.updateStatus(status, isToDoItem);
+ }
+
+ let descriptionText = item.descriptionText?.trim();
+ if (descriptionText) {
+ this.updateDescription(descriptionText, item.descriptionHTML);
+ }
+
+ let attachments = item.getAttachments();
+ if (attachments.length) {
+ this.updateAttachments(attachments);
+ }
+ }
+
+ /**
+ * Updates the reminder, called when a reminder has been selected in the
+ * menulist.
+ */
+ updateReminder() {
+ this.mLastAlarmSelection = commonUpdateReminder(
+ this.mAlarmsMenu,
+ this.mItem,
+ this.mLastAlarmSelection,
+ this.mItem.calendar,
+ this.querySelector(".reminder-details"),
+ null,
+ false
+ );
+ }
+
+ /**
+ * Updates the reminder to display the set reminders as read-only text.
+ * Depends on updateReminder() to get the text to display.
+ */
+ updateReminderReadOnly(alarms) {
+ let reminderLabel = this.querySelector(".reminder-label");
+ reminderLabel.style.verticalAlign = null;
+ let reminderCell = this.querySelector(".reminder-details");
+ while (reminderCell.lastChild) {
+ reminderCell.lastChild.remove();
+ }
+ delete this.mAlarmsMenu;
+
+ switch (alarms.length) {
+ case 0:
+ reminderCell.textContent = "";
+ break;
+ case 1:
+ reminderCell.textContent = alarms[0].toString(this.item);
+ break;
+ default:
+ for (let a of alarms) {
+ reminderCell.appendChild(document.createTextNode(a.toString(this.item)));
+ reminderCell.appendChild(document.createElement("br"));
+ }
+ break;
+ }
+ }
+
+ /**
+ * Updates the item's recurrence details, i.e. shows text describing them,
+ * or hides the recurrence row if the item does not recur.
+ *
+ * @param {string | null} details - Recurrence details as a string or null.
+ * Passing null hides the recurrence row.
+ */
+ updateRecurrenceDetails(details) {
+ let repeatRow = this.querySelector(".repeat-row");
+ let repeatDetails = repeatRow.querySelector(".repeat-details");
+
+ repeatRow.toggleAttribute("hidden", !details);
+ repeatDetails.textContent = details ? details.replace(/\n/g, " ") : "";
+ }
+
+ /**
+ * Updates the attendee listbox, displaying all attendees invited to the item.
+ */
+ updateAttendees(item) {
+ let attendees = item.getAttendees();
+ if (attendees && attendees.length) {
+ this.querySelector(".item-attendees").removeAttribute("hidden");
+ this.querySelector(".item-attendees-list-container").appendChild(
+ cal.invitation.createAttendeesList(document, attendees)
+ );
+ }
+ }
+
+ /**
+ * Updates the location, creating a link if the value is a URL.
+ *
+ * @param {string} location - The value of the location property.
+ */
+ updateLocation(location) {
+ this.querySelector(".location-row").removeAttribute("hidden");
+ let urlMatch = location.match(/(https?:\/\/[^ ]*)/);
+ let url = urlMatch && urlMatch[1];
+ let itemLocation = this.querySelector(".item-location");
+ if (url) {
+ let link = document.createElementNS("http://www.w3.org/1999/xhtml", "a");
+ link.setAttribute("class", "item-location-link text-link");
+ link.setAttribute("href", url);
+ link.title = url;
+ link.setAttribute("onclick", "launchBrowser(this.getAttribute('href'), event)");
+ link.setAttribute("oncommand", "launchBrowser(this.getAttribute('href'), event)");
+
+ let label = document.createXULElement("label");
+ label.setAttribute("context", "location-link-context-menu");
+ label.textContent = location;
+ link.appendChild(label);
+
+ itemLocation.replaceChildren(link);
+ } else {
+ itemLocation.textContent = location;
+ }
+ }
+
+ /**
+ * Update the organizer part of the UI.
+ *
+ * @param {calIItemBase} item - The calendar item.
+ */
+ updateOrganizer(item) {
+ this.querySelector(".item-organizer-row").removeAttribute("hidden");
+ let organizerLabel = cal.invitation.createAttendeeLabel(
+ document,
+ item.organizer,
+ item.getAttendees()
+ );
+ let organizerName = organizerLabel.querySelector(".attendee-name");
+ organizerName.classList.add("text-link");
+ organizerName.addEventListener("click", () => sendMailToOrganizer(this.mItem));
+ this.querySelector(".item-organizer-cell").appendChild(organizerLabel);
+ }
+
+ /**
+ * Update the status part of the UI.
+ *
+ * @param {string} status - The status of the calendar item.
+ * @param {boolean} isToDoItem - True if the calendar item is a todo, false if an event.
+ */
+ updateStatus(status, isToDoItem) {
+ let statusRow = this.querySelector(".status-row");
+ let statusRowData = this.querySelector(".status-row-td");
+
+ for (let i = 0; i < statusRowData.children.length; i++) {
+ if (statusRowData.children[i].getAttribute("status") == status) {
+ statusRow.removeAttribute("hidden");
+
+ if (status == "CANCELLED" && isToDoItem) {
+ // There are two status elements for CANCELLED, the second one is for
+ // todo items. Increment the counter here.
+ i++;
+ }
+ statusRowData.children[i].removeAttribute("hidden");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Update the description part of the UI.
+ *
+ * @param {string} descriptionText - The value of the DESCRIPTION property.
+ * @param {string} descriptionHTML - HTML description if available.
+ */
+ async updateDescription(descriptionText, descriptionHTML) {
+ this.querySelector(".item-description-box").removeAttribute("hidden");
+ let itemDescription = this.querySelector(".item-description");
+ if (itemDescription.contentDocument.readyState != "complete") {
+ // The iframe's document hasn't loaded yet. If we add to it now, what we add will be
+ // overwritten. Wait for the initial document to load.
+ await new Promise(resolve => {
+ itemDescription._listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ itemDescription.browsingContext.webProgress.removeProgressListener(this);
+ delete itemDescription._listener;
+ resolve();
+ }
+ },
+ };
+ itemDescription.browsingContext.webProgress.addProgressListener(
+ itemDescription._listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+ });
+ }
+ let docFragment = cal.view.textToHtmlDocumentFragment(
+ descriptionText,
+ itemDescription.contentDocument,
+ descriptionHTML
+ );
+
+ // Make any links open in the user's default browser, not in Thunderbird.
+ for (let anchor of docFragment.querySelectorAll("a")) {
+ anchor.addEventListener("click", function (event) {
+ event.preventDefault();
+ if (event.isTrusted) {
+ launchBrowser(anchor.getAttribute("href"), event);
+ }
+ });
+ }
+
+ itemDescription.contentDocument.body.appendChild(docFragment);
+
+ const link = itemDescription.contentDocument.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "chrome://messenger/skin/shared/editorContent.css";
+ itemDescription.contentDocument.head.appendChild(link);
+ }
+
+ /**
+ * Update the attachments part of the UI.
+ *
+ * @param {calIAttachment[]} attachments - Array of attachment objects.
+ */
+ updateAttachments(attachments) {
+ // We only want to display URI type attachments and no ones received inline with the
+ // invitation message (having a CID: prefix results in about:blank) here.
+ let attCounter = 0;
+ attachments.forEach(aAttachment => {
+ if (aAttachment.uri && aAttachment.uri.spec != "about:blank") {
+ let attachment = this.querySelector(".attachment-template").cloneNode(true);
+ attachment.removeAttribute("id");
+ attachment.removeAttribute("hidden");
+
+ let label = attachment.querySelector("label");
+ label.setAttribute("value", aAttachment.uri.spec);
+
+ label.addEventListener("click", () => {
+ openAttachmentFromItemSummary(aAttachment.hashId, this.mItem);
+ });
+
+ let icon = attachment.querySelector("img");
+ let iconSrc = aAttachment.uri.spec.length ? aAttachment.uri.spec : "dummy.html";
+ if (aAttachment.uri && !aAttachment.uri.schemeIs("file")) {
+ // Using an uri directly, with e.g. a http scheme, wouldn't render any icon.
+ if (aAttachment.formatType) {
+ iconSrc = "goat?contentType=" + aAttachment.formatType;
+ } else {
+ // Let's try to auto-detect.
+ let parts = iconSrc.substr(aAttachment.uri.scheme.length + 2).split("/");
+ if (parts.length) {
+ iconSrc = parts[parts.length - 1];
+ }
+ }
+ }
+ icon.setAttribute("src", "moz-icon://" + iconSrc);
+
+ this.querySelector(".item-attachment-cell").appendChild(attachment);
+ attCounter++;
+ }
+ });
+
+ if (attCounter > 0) {
+ this.querySelector(".attachments-row").removeAttribute("hidden");
+ }
+ }
+ }
+
+ customElements.define("calendar-item-summary", CalendarItemSummary);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-minidate.js b/comm/calendar/base/content/widgets/calendar-minidate.js
new file mode 100644
index 0000000000..ebc270bd5b
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-minidate.js
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const format = new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ });
+
+ const parts = ["month", "day", "year"];
+
+ function getParts(date) {
+ return format.formatToParts(date).reduce((prev, curr) => {
+ if (parts.includes(curr.type)) {
+ prev[curr.type] = curr.value;
+ }
+ return prev;
+ }, {});
+ }
+
+ /**
+ * CalendarMinidate displays a date in a visually appealing box meant to be
+ * glanced at quickly to figure out the date of an event.
+ */
+ class CalendarMinidate extends HTMLElement {
+ /**
+ * @type {HTMLElement}
+ */
+ _monthSpan;
+
+ /**
+ * @type {HTMLElement}
+ */
+ _daySpan;
+
+ /**
+ * @type {HTMLElement}
+ */
+ _yearSpan;
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(this.shadowRoot);
+ this.shadowRoot.appendChild(
+ document.getElementById("calendarMinidate").content.cloneNode(true)
+ );
+ this._monthSpan = this.shadowRoot.querySelector(".calendar-minidate-month");
+ this._daySpan = this.shadowRoot.querySelector(".calendar-minidate-day");
+ this._yearSpan = this.shadowRoot.querySelector(".calendar-minidate-year");
+ }
+
+ /**
+ * Setting the date property will trigger the rendering of this widget.
+ *
+ * @type {calIDateTime}
+ */
+ set date(value) {
+ let { month, day, year } = getParts(cal.dtz.dateTimeToJsDate(value));
+ this._monthSpan.textContent = month;
+ this._daySpan.textContent = day;
+ this._yearSpan.textContent = year;
+ }
+
+ /**
+ * Provides the displayed date as a string in the format
+ * "month day year".
+ *
+ * @type {string}
+ */
+ get fullDate() {
+ return `${this._monthSpan.textContent} ${this._daySpan.textContent} ${this._yearSpan.textContent}`;
+ }
+ }
+ customElements.define("calendar-minidate", CalendarMinidate);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-minidate.xhtml b/comm/calendar/base/content/widgets/calendar-minidate.xhtml
new file mode 100644
index 0000000000..ab8f4ecba2
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-minidate.xhtml
@@ -0,0 +1,17 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!-- Template for <calendar-minidate /> -->
+<template id="calendarMinidate" xmlns="http://www.w3.org/1999/xhtml">
+ <div class="calendar-minidate-wrapper">
+ <link rel="stylesheet" href="chrome://calendar/skin/shared/widgets/calendar-minidate.css"/>
+ <div class="calendar-minidate-header">
+ <span class="calendar-minidate-month"></span>
+ </div>
+ <div class="calendar-minidate-body">
+ <span class="calendar-minidate-day"></span>
+ <span class="calendar-minidate-year"></span>
+ </div>
+ </div>
+</template>
diff --git a/comm/calendar/base/content/widgets/calendar-minimonth.js b/comm/calendar/base/content/widgets/calendar-minimonth.js
new file mode 100644
index 0000000000..403841e69c
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-minimonth.js
@@ -0,0 +1,1055 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal MozXULElement */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+ const lazy = {};
+ ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm");
+
+ let dayFormatter = new Services.intl.DateTimeFormat(undefined, { day: "numeric" });
+ let dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "long" });
+
+ /**
+ * MiniMonth Calendar: day-of-month grid component.
+ * Displays month name and year above grid of days of month by week rows.
+ * Arrows move forward or back a month or a year.
+ * Clicking on a day cell selects that day.
+ * At site, can provide id, and code to run when value changed by picker.
+ * <calendar-minimonth id="my-date-picker" onchange="myDatePick( this );"/>
+ *
+ * May get/set value in javascript with
+ * document.querySelector("#my-date-picker").value = new Date();
+ *
+ * @implements {calIObserver}
+ * @implements {calICompositeObserver}
+ */
+ class CalendarMinimonth extends MozXULElement {
+ constructor() {
+ super();
+ // Set up custom interfaces.
+ this.calIObserver = this.getCustomInterfaceCallback(Ci.calIObserver);
+ this.calICompositeObserver = this.getCustomInterfaceCallback(Ci.calICompositeObserver);
+
+ let onPreferenceChanged = () => {
+ this.dayBoxes.clear(); // Days have moved, force a refresh of the grid.
+ this.refreshDisplay();
+ };
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "weekStart",
+ "calendar.week.start",
+ 0,
+ onPreferenceChanged
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "showWeekNumber",
+ "calendar.view-minimonth.showWeekNumber",
+ true,
+ onPreferenceChanged
+ );
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".minimonth-header": "readonly,month,year",
+ ".minimonth-year-name": "value=year",
+ };
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ MozXULElement.insertFTLIfNeeded("calendar/calendar-widgets.ftl");
+
+ const minimonthHeader = `
+ <html:div class="minimonth-header minimonth-month-box"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <div class="minimonth-nav-section">
+ <button class="button icon-button icon-only minimonth-nav-btn today-button"
+ data-l10n-id="calendar-today-button-tooltip"
+ type="button"
+ dir="0">
+ </button>
+ </div>
+ <div class="minimonth-nav-section">
+ <button class="button icon-button icon-only minimonth-nav-btn months-back-button"
+ data-l10n-id="calendar-nav-button-prev-tooltip-month"
+ type="button"
+ dir="-1">
+ </button>
+ <div class="minimonth-nav-item">
+ <input class="minimonth-month-name" tabindex="-1" readonly="true" disabled="disabled" />
+ </div>
+ <button class="button icon-button icon-only minimonth-nav-btn months-forward-button"
+ data-l10n-id="calendar-nav-button-next-tooltip-month"
+ type="button"
+ dir="1">
+ </button>
+ </div>
+ <div class="minimonth-nav-section">
+ <button class="button icon-button icon-only minimonth-nav-btn years-back-button"
+ data-l10n-id="calendar-nav-button-prev-tooltip-year"
+ type="button"
+ dir="-1">
+ </button>
+ <div class="minimonth-nav-item">
+ <input class="yearcell minimonth-year-name" tabindex="-1" readonly="true" disabled="disabled" />
+ </div>
+ <button class="button icon-button icon-only minimonth-nav-btn years-forward-button"
+ data-l10n-id="calendar-nav-button-next-tooltip-year"
+ type="button"
+ dir="1">
+ </button>
+ </div>
+ </html:div>
+ `;
+
+ const minimonthWeekRow = `
+ <html:tr class="minimonth-row-body">
+ <html:th class="minimonth-week" scope="row"></html:th>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ </html:tr>
+ `;
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ ${minimonthHeader}
+ <html:div class="minimonth-readonly-header minimonth-month-box"></html:div>
+ <html:table class="minimonth-calendar minimonth-cal-box">
+ <html:tr class="minimonth-row-head">
+ <html:th class="minimonth-row-header-week" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ </html:tr>
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ </html:table>
+ `,
+ ["chrome://calendar/locale/global.dtd"]
+ )
+ );
+ this.initializeAttributeInheritance();
+ this.setAttribute("orient", "vertical");
+
+ // Set up header buttons.
+ this.querySelector(".months-back-button").addEventListener("click", () =>
+ this.advanceMonth(-1)
+ );
+ this.querySelector(".months-forward-button").addEventListener("click", () =>
+ this.advanceMonth(1)
+ );
+ this.querySelector(".years-back-button").addEventListener("click", () =>
+ this.advanceYear(-1)
+ );
+ this.querySelector(".years-forward-button").addEventListener("click", () =>
+ this.advanceYear(1)
+ );
+ this.querySelector(".today-button").addEventListener("click", () => {
+ this.value = new Date();
+ });
+
+ this.dayBoxes = new Map();
+ this.mValue = null;
+ this.mEditorDate = null;
+ this.mExtraDate = null;
+ this.mPixelScrollDelta = 0;
+ this.mObservesComposite = false;
+ this.mToday = null;
+ this.mSelected = null;
+ this.mExtra = null;
+ this.mValue = new Date(); // Default to "today".
+ this.mFocused = null;
+
+ let width = 0;
+ // Start loop from 1 as it is needed to get the first month name string
+ // and avoid extra computation of adding one.
+ for (let i = 1; i <= 12; i++) {
+ let dateString = cal.l10n.getDateFmtString(`month.${i}.name`);
+ width = Math.max(dateString.length, width);
+ }
+ this.querySelector(".minimonth-month-name").style.width = `${width + 1}ch`;
+
+ this.refreshDisplay();
+ if (this.hasAttribute("freebusy")) {
+ this._setFreeBusy(this.getAttribute("freebusy") == "true");
+ }
+
+ // Add event listeners.
+ this.addEventListener("click", event => {
+ if (event.button == 0 && event.target.classList.contains("minimonth-day")) {
+ this.onDayActivate(event);
+ }
+ });
+
+ this.addEventListener("keypress", event => {
+ if (event.target.classList.contains("minimonth-day")) {
+ if (event.altKey || event.metaKey) {
+ return;
+ }
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_LEFT:
+ this.onDayMovement(event, 0, 0, -1);
+ break;
+ case KeyEvent.DOM_VK_RIGHT:
+ this.onDayMovement(event, 0, 0, 1);
+ break;
+ case KeyEvent.DOM_VK_UP:
+ this.onDayMovement(event, 0, 0, -7);
+ break;
+ case KeyEvent.DOM_VK_DOWN:
+ this.onDayMovement(event, 0, 0, 7);
+ break;
+ case KeyEvent.DOM_VK_PAGE_UP:
+ if (event.shiftKey) {
+ this.onDayMovement(event, -1, 0, 0);
+ } else {
+ this.onDayMovement(event, 0, -1, 0);
+ }
+ break;
+ case KeyEvent.DOM_VK_PAGE_DOWN:
+ if (event.shiftKey) {
+ this.onDayMovement(event, 1, 0, 0);
+ } else {
+ this.onDayMovement(event, 0, 1, 0);
+ }
+ break;
+ case KeyEvent.DOM_VK_ESCAPE:
+ this.focusDate(this.mValue || this.mExtraDate);
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ case KeyEvent.DOM_VK_HOME: {
+ const today = new Date();
+ this.update(today);
+ this.focusDate(today);
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ case KeyEvent.DOM_VK_RETURN:
+ this.onDayActivate(event);
+ break;
+ }
+ }
+ });
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 150;
+ let deltaView = 0;
+ if (this.getAttribute("readonly") == "true") {
+ // No scrolling on readonly months.
+ return;
+ }
+ if (event.deltaMode == event.DOM_DELTA_LINE || event.deltaMode == event.DOM_DELTA_PAGE) {
+ if (event.deltaY != 0) {
+ deltaView = event.deltaY > 0 ? 1 : -1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ this.mPixelScrollDelta += event.deltaY;
+ if (this.mPixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.mPixelScrollDelta = 0;
+ } else if (this.mPixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.mPixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ const classList = event.target.classList;
+
+ if (
+ classList.contains("years-forward-button") ||
+ classList.contains("yearcell") ||
+ classList.contains("years-back-button")
+ ) {
+ this.advanceYear(deltaView);
+ } else if (!classList.contains("today-button")) {
+ this.advanceMonth(deltaView);
+ }
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ });
+ }
+
+ set value(val) {
+ this.update(val);
+ }
+
+ get value() {
+ return this.mValue;
+ }
+
+ set extra(val) {
+ this.mExtraDate = val;
+ }
+
+ get extra() {
+ return this.mExtraDate;
+ }
+
+ /**
+ * Returns the first (inclusive) date of the minimonth as a calIDateTime object.
+ */
+ get firstDate() {
+ let date = this._getCalBoxNode(1, 1).date;
+ return cal.dtz.jsDateToDateTime(date);
+ }
+
+ /**
+ * Returns the last (exclusive) date of the minimonth as a calIDateTime object.
+ */
+ get lastDate() {
+ let date = this._getCalBoxNode(6, 7).date;
+ let lastDateTime = cal.dtz.jsDateToDateTime(date);
+ lastDateTime.day = lastDateTime.day + 1;
+ return lastDateTime;
+ }
+
+ get mReadOnlyHeader() {
+ return this.querySelector(".minimonth-readonly-header");
+ }
+
+ setBusyDaysForItem(aItem, aState) {
+ let items = aItem.recurrenceInfo
+ ? aItem.getOccurrencesBetween(this.firstDate, this.lastDate)
+ : [aItem];
+ items.forEach(item => this.setBusyDaysForOccurrence(item, aState));
+ }
+
+ parseBoxBusy(aBox) {
+ let boxBusy = {};
+
+ let busyStr = aBox.getAttribute("busy");
+ if (busyStr && busyStr.length > 0) {
+ let calChunks = busyStr.split("\u001A");
+ for (let chunk of calChunks) {
+ let expr = chunk.split("=");
+ boxBusy[expr[0]] = parseInt(expr[1], 10);
+ }
+ }
+
+ return boxBusy;
+ }
+
+ updateBoxBusy(aBox, aBoxBusy) {
+ let calChunks = [];
+
+ for (let calId in aBoxBusy) {
+ if (aBoxBusy[calId]) {
+ calChunks.push(calId + "=" + aBoxBusy[calId]);
+ }
+ }
+
+ if (calChunks.length > 0) {
+ let busyStr = calChunks.join("\u001A");
+ aBox.setAttribute("busy", busyStr);
+ } else {
+ aBox.removeAttribute("busy");
+ }
+ }
+
+ removeCalendarFromBoxBusy(aBox, aCalendar) {
+ let boxBusy = this.parseBoxBusy(aBox);
+ if (boxBusy[aCalendar.id]) {
+ delete boxBusy[aCalendar.id];
+ }
+ this.updateBoxBusy(aBox, boxBusy);
+ }
+
+ setBusyDaysForOccurrence(aOccurrence, aState) {
+ if (aOccurrence.getProperty("TRANSP") == "TRANSPARENT") {
+ // Skip transparent events.
+ return;
+ }
+ let start = aOccurrence[cal.dtz.startDateProp(aOccurrence)] || aOccurrence.dueDate;
+ let end = aOccurrence[cal.dtz.endDateProp(aOccurrence)] || start;
+ if (!start) {
+ return;
+ }
+
+ if (start.compare(this.firstDate) < 0) {
+ start = this.firstDate.clone();
+ }
+
+ if (end.compare(this.lastDate) > 0) {
+ end = this.lastDate.clone();
+ end.day++;
+ }
+
+ // We need to compare with midnight of the current day, so reset the
+ // time here.
+ let current = start.clone().getInTimezone(cal.dtz.defaultTimezone);
+ current.hour = 0;
+ current.minute = 0;
+ current.second = 0;
+
+ // Cache the result so the compare isn't called in each iteration.
+ let compareResult = start.compare(end) == 0 ? 1 : 0;
+
+ // Setup the busy days.
+ while (current.compare(end) < compareResult) {
+ let box = this.getBoxForDate(current);
+ if (box) {
+ let busyCalendars = this.parseBoxBusy(box);
+ if (!busyCalendars[aOccurrence.calendar.id]) {
+ busyCalendars[aOccurrence.calendar.id] = 0;
+ }
+ busyCalendars[aOccurrence.calendar.id] += aState ? 1 : -1;
+ this.updateBoxBusy(box, busyCalendars);
+ }
+ current.day++;
+ }
+ }
+
+ // calIObserver methods.
+ calendarsInBatch = new Set();
+
+ onStartBatch(aCalendar) {
+ this.calendarsInBatch.add(aCalendar);
+ }
+
+ onEndBatch(aCalendar) {
+ this.calendarsInBatch.delete(aCalendar);
+ }
+
+ onLoad(aCalendar) {
+ this.getItems(aCalendar);
+ }
+
+ onAddItem(aItem) {
+ if (this.calendarsInBatch.has(aItem.calendar)) {
+ return;
+ }
+
+ this.setBusyDaysForItem(aItem, true);
+ }
+
+ onDeleteItem(aItem) {
+ this.setBusyDaysForItem(aItem, false);
+ }
+
+ onModifyItem(aNewItem, aOldItem) {
+ if (this.calendarsInBatch.has(aNewItem.calendar)) {
+ return;
+ }
+
+ this.setBusyDaysForItem(aOldItem, false);
+ this.setBusyDaysForItem(aNewItem, true);
+ }
+
+ onError(aCalendar, aErrNo, aMessage) {}
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ switch (aName) {
+ case "disabled":
+ this.resetAttributesForDate();
+ this.getItems();
+ break;
+ }
+ }
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.onPropertyChanged(aCalendar, aName, null, null);
+ }
+
+ // End of calIObserver methods.
+ // calICompositeObserver methods.
+
+ onCalendarAdded(aCalendar) {
+ if (!aCalendar.getProperty("disabled")) {
+ this.getItems(aCalendar);
+ }
+ }
+
+ onCalendarRemoved(aCalendar) {
+ if (!aCalendar.getProperty("disabled")) {
+ for (let box of this.dayBoxes.values()) {
+ this.removeCalendarFromBoxBusy(box, aCalendar);
+ }
+ }
+ }
+
+ onDefaultCalendarChanged(aCalendar) {}
+
+ // End calICompositeObserver methods.
+
+ refreshDisplay() {
+ if (!this.mValue) {
+ this.mValue = new Date();
+ }
+ this.setHeader();
+ this.showMonth(this.mValue);
+ this.updateAccessibleLabel();
+ }
+
+ _getCalBoxNode(aRow, aCol) {
+ if (!this.mCalBox) {
+ this.mCalBox = this.querySelector(".minimonth-calendar");
+ }
+ return this.mCalBox.children[aRow].children[aCol];
+ }
+
+ setHeader() {
+ // Reset the headers.
+ let dayList = new Array(7);
+ let longDayList = new Array(7);
+ let tempDate = new Date();
+ let i, j;
+ let useOSFormat;
+ tempDate.setDate(tempDate.getDate() - (tempDate.getDay() - this.weekStart));
+ for (i = 0; i < 7; i++) {
+ // If available, use UILocale days, else operating system format.
+ try {
+ dayList[i] = cal.l10n.getDateFmtString(`day.${tempDate.getDay() + 1}.short`);
+ } catch (e) {
+ dayList[i] = tempDate.toLocaleDateString(undefined, { weekday: "short" });
+ useOSFormat = true;
+ }
+ longDayList[i] = tempDate.toLocaleDateString(undefined, { weekday: "long" });
+ tempDate.setDate(tempDate.getDate() + 1);
+ }
+
+ if (useOSFormat) {
+ // To keep datepicker popup compact, shrink localized weekday
+ // abbreviations down to 1 or 2 chars so each column of week can
+ // be as narrow as 2 digits.
+ //
+ // 1. Compute the minLength of the day name abbreviations.
+ let minLength = dayList.map(name => name.length).reduce((min, len) => Math.min(min, len));
+
+ // 2. If some day name abbrev. is longer than 2 chars (not Catalan),
+ // and ALL localized day names share same prefix (as in Chinese),
+ // then trim shared "day-" prefix.
+ if (dayList.some(dayAbbr => dayAbbr.length > 2)) {
+ for (let endPrefix = 0; endPrefix < minLength; endPrefix++) {
+ let suffix = dayList[0][endPrefix];
+ if (dayList.some(dayAbbr => dayAbbr[endPrefix] != suffix)) {
+ if (endPrefix > 0) {
+ for (i = 0; i < dayList.length; i++) {
+ // trim prefix chars.
+ dayList[i] = dayList[i].substring(endPrefix);
+ }
+ }
+ break;
+ }
+ }
+ }
+ // 3. Trim each day abbreviation to 1 char if unique, else 2 chars.
+ for (i = 0; i < dayList.length; i++) {
+ let foundMatch = 1;
+ for (j = 0; j < dayList.length; j++) {
+ if (i != j) {
+ if (dayList[i].substring(0, 1) == dayList[j].substring(0, 1)) {
+ foundMatch = 2;
+ break;
+ }
+ }
+ }
+ dayList[i] = dayList[i].substring(0, foundMatch);
+ }
+ }
+
+ this._getCalBoxNode(0, 0).hidden = !this.showWeekNumber;
+ for (let column = 1; column < 8; column++) {
+ let node = this._getCalBoxNode(0, column);
+ node.textContent = dayList[column - 1];
+ node.setAttribute("aria-label", longDayList[column - 1]);
+ }
+ }
+
+ showMonth(aDate) {
+ // Use mExtraDate if aDate is null.
+ aDate = new Date(aDate || this.mExtraDate);
+
+ aDate.setDate(1);
+ // We set the hour and minute to something highly unlikely to be the
+ // exact change point of DST, so timezones like America/Sao Paulo
+ // don't display some days twice.
+ aDate.setHours(12);
+ aDate.setMinutes(34);
+ aDate.setSeconds(0);
+ aDate.setMilliseconds(0);
+ // Don't fire onmonthchange event upon initialization
+ let monthChanged = this.mEditorDate && this.mEditorDate.valueOf() != aDate.valueOf();
+ this.mEditorDate = aDate; // Only place mEditorDate is set.
+
+ if (this.mSelected) {
+ this.mSelected.removeAttribute("selected");
+ this.mSelected = null;
+ }
+
+ // Get today's date.
+ let today = new Date();
+
+ if (!monthChanged && this.dayBoxes.size > 0) {
+ this.mSelected = this.getBoxForDate(this.value);
+ if (this.mSelected) {
+ this.mSelected.setAttribute("selected", "true");
+ }
+
+ let todayBox = this.getBoxForDate(today);
+ if (this.mToday != todayBox) {
+ if (this.mToday) {
+ this.mToday.removeAttribute("today");
+ }
+ this.mToday = todayBox;
+ if (this.mToday) {
+ this.mToday.setAttribute("today", "true");
+ }
+ }
+ return;
+ }
+
+ if (this.mToday) {
+ this.mToday.removeAttribute("today");
+ this.mToday = null;
+ }
+
+ if (this.mExtra) {
+ this.mExtra.removeAttribute("extra");
+ this.mExtra = null;
+ }
+
+ // Update the month and year title.
+ this.setAttribute("year", aDate.getFullYear());
+ this.setAttribute("month", aDate.getMonth());
+
+ let miniMonthName = this.querySelector(".minimonth-month-name");
+ let dateString = cal.l10n.getDateFmtString(`month.${aDate.getMonth() + 1}.name`);
+ miniMonthName.setAttribute("value", dateString);
+ miniMonthName.setAttribute("monthIndex", aDate.getMonth());
+ this.mReadOnlyHeader.textContent = dateString + " " + aDate.getFullYear();
+
+ // Update the calendar.
+ let calbox = this.querySelector(".minimonth-calendar");
+ let date = this._getStartDate(aDate);
+
+ if (aDate.getFullYear() == (this.mValue || this.mExtraDate).getFullYear()) {
+ calbox.setAttribute("aria-label", dateString);
+ } else {
+ let monthName = cal.l10n.formatMonth(aDate.getMonth() + 1, "calendar", "monthInYear");
+ let label = cal.l10n.getCalString("monthInYear", [monthName, aDate.getFullYear()]);
+ calbox.setAttribute("aria-label", label);
+ }
+
+ this.dayBoxes.clear();
+ let defaultTz = cal.dtz.defaultTimezone;
+ for (let k = 1; k < 7; k++) {
+ // Set the week number.
+ let firstElement = this._getCalBoxNode(k, 0);
+ firstElement.hidden = !this.showWeekNumber;
+ if (this.showWeekNumber) {
+ let weekNumber = cal.weekInfoService.getWeekTitle(
+ cal.dtz.jsDateToDateTime(date, defaultTz)
+ );
+ let weekTitle = cal.l10n.getCalString("WeekTitle", [weekNumber]);
+ firstElement.textContent = weekNumber;
+ firstElement.setAttribute("aria-label", weekTitle);
+ }
+
+ for (let i = 1; i < 8; i++) {
+ let day = this._getCalBoxNode(k, i);
+ this.setBoxForDate(date, day);
+
+ if (this.getAttribute("readonly") != "true") {
+ day.setAttribute("interactive", "true");
+ }
+
+ if (aDate.getMonth() == date.getMonth()) {
+ day.removeAttribute("othermonth");
+ } else {
+ day.setAttribute("othermonth", "true");
+ }
+
+ // Highlight today.
+ if (this._sameDay(today, date)) {
+ this.mToday = day;
+ day.setAttribute("today", "true");
+ }
+
+ // Highlight the current date.
+ let val = this.value;
+ if (this._sameDay(val, date)) {
+ this.mSelected = day;
+ day.setAttribute("selected", "true");
+ }
+
+ // Highlight the extra date.
+ if (this._sameDay(this.mExtraDate, date)) {
+ this.mExtra = day;
+ day.setAttribute("extra", "true");
+ }
+
+ if (aDate.getMonth() == date.getMonth() && aDate.getFullYear() == date.getFullYear()) {
+ day.setAttribute("aria-label", dayFormatter.format(date));
+ } else {
+ day.setAttribute("aria-label", dateFormatter.format(date));
+ }
+
+ day.removeAttribute("busy");
+
+ day.date = new Date(date);
+ day.textContent = date.getDate();
+ date.setDate(date.getDate() + 1);
+
+ this.resetAttributesForBox(day);
+ }
+ }
+
+ if (!this.mFocused) {
+ this.setFocusedDate(this.mValue || this.mExtraDate);
+ }
+
+ this.fireEvent("monthchange");
+
+ if (this.getAttribute("freebusy") == "true") {
+ this.getItems();
+ }
+ }
+
+ /**
+ * Attention - duplicate!!!!
+ */
+ fireEvent(aEventName) {
+ this.dispatchEvent(new CustomEvent(aEventName, { bubbles: true }));
+ }
+
+ _boxKeyForDate(aDate) {
+ if (aDate instanceof lazy.CalDateTime || aDate instanceof Ci.calIDateTime) {
+ return aDate.getInTimezone(cal.dtz.defaultTimezone).toString().substring(0, 10);
+ }
+ return [
+ aDate.getFullYear(),
+ (aDate.getMonth() + 1).toString().padStart(2, "0"),
+ aDate.getDate().toString().padStart(2, "0"),
+ ].join("-");
+ }
+
+ /**
+ * Fetches the table cell for the given date, or null if the date isn't displayed.
+ *
+ * @param {calIDateTime|Date} aDate
+ * @returns {HTMLTableCellElement|null}
+ */
+ getBoxForDate(aDate) {
+ return this.dayBoxes.get(this._boxKeyForDate(aDate)) ?? null;
+ }
+
+ /**
+ * Stores the table cell for the given date.
+ *
+ * @param {Date} aDate
+ * @param {HTMLTableCellElement} aBox
+ */
+ setBoxForDate(aDate, aBox) {
+ this.dayBoxes.set(this._boxKeyForDate(aDate), aBox);
+ }
+
+ /**
+ * Remove attributes that may have been added to a table cell.
+ *
+ * @param {HTMLTableCellElement} aBox
+ */
+ resetAttributesForBox(aBox) {
+ let allowedAttributes = 0;
+ while (aBox.attributes.length > allowedAttributes) {
+ switch (aBox.attributes[allowedAttributes].nodeName) {
+ case "selected":
+ case "othermonth":
+ case "today":
+ case "extra":
+ case "interactive":
+ case "class":
+ case "tabindex":
+ case "role":
+ case "aria-label":
+ allowedAttributes++;
+ break;
+ default:
+ aBox.removeAttribute(aBox.attributes[allowedAttributes].nodeName);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Remove attributes that may have been added to a table cell, or all table cells.
+ *
+ * @param {Date} [aDate] - If specified, the date of the cell to reset,
+ * otherwise all date cells will be reset.
+ */
+ resetAttributesForDate(aDate) {
+ if (aDate) {
+ let box = this.getBoxForDate(aDate);
+ if (box) {
+ this.resetAttributesForBox(box);
+ }
+ } else {
+ for (let k = 1; k < 7; k++) {
+ for (let i = 1; i < 8; i++) {
+ this.resetAttributesForBox(this._getCalBoxNode(k, i));
+ }
+ }
+ }
+ }
+
+ _setFreeBusy(aFreeBusy) {
+ if (aFreeBusy) {
+ if (!this.mObservesComposite) {
+ cal.view.getCompositeCalendar(window).addObserver(this.calICompositeObserver);
+ this.mObservesComposite = true;
+ this.getItems();
+ }
+ } else if (this.mObservesComposite) {
+ cal.view.getCompositeCalendar(window).removeObserver(this.calICompositeObserver);
+ this.mObservesComposite = false;
+ }
+ }
+
+ removeAttribute(aAttr) {
+ if (aAttr == "freebusy") {
+ this._setFreeBusy(false);
+ }
+ return super.removeAttribute(aAttr);
+ }
+
+ setAttribute(aAttr, aVal) {
+ if (aAttr == "freebusy") {
+ this._setFreeBusy(aVal == "true");
+ }
+ return super.setAttribute(aAttr, aVal);
+ }
+
+ async getItems(aCalendar) {
+ // The minimonth automatically clears extra styles on a month change.
+ // Therefore we only need to fill the minimonth with new info.
+
+ let calendar = aCalendar || cal.view.getCompositeCalendar(window);
+ let filter =
+ calendar.ITEM_FILTER_COMPLETED_ALL |
+ calendar.ITEM_FILTER_CLASS_OCCURRENCES |
+ calendar.ITEM_FILTER_ALL_ITEMS;
+
+ // Get new info.
+ for await (let items of cal.iterate.streamValues(
+ calendar.getItems(filter, 0, this.firstDate, this.lastDate)
+ )) {
+ items.forEach(item => this.setBusyDaysForOccurrence(item, true));
+ }
+ }
+
+ updateAccessibleLabel() {
+ let label;
+ if (this.mValue) {
+ label = dateFormatter.format(this.mValue);
+ } else {
+ label = cal.l10n.getCalString("minimonthNoSelectedDate");
+ }
+ this.setAttribute("aria-label", label);
+ }
+
+ update(aValue) {
+ let changed =
+ this.mValue &&
+ aValue &&
+ (this.mValue.getFullYear() != aValue.getFullYear() ||
+ this.mValue.getMonth() != aValue.getMonth() ||
+ this.mValue.getDate() != aValue.getDate());
+
+ this.mValue = aValue;
+ if (changed) {
+ this.fireEvent("change");
+ }
+ this.showMonth(aValue);
+ if (aValue) {
+ this.setFocusedDate(aValue);
+ }
+ this.updateAccessibleLabel();
+ }
+
+ setFocusedDate(aDate, aForceFocus) {
+ let newFocused = this.getBoxForDate(aDate);
+ if (!newFocused) {
+ return;
+ }
+ if (this.mFocused) {
+ this.mFocused.setAttribute("tabindex", "-1");
+ }
+ this.mFocused = newFocused;
+ this.mFocused.setAttribute("tabindex", "0");
+ // Only actually move the focus if it is already in the calendar box.
+ if (!aForceFocus) {
+ let calbox = this.querySelector(".minimonth-calendar");
+ aForceFocus = calbox.contains(document.commandDispatcher.focusedElement);
+ }
+ if (aForceFocus) {
+ this.mFocused.focus();
+ }
+ }
+
+ focusDate(aDate) {
+ this.showMonth(aDate);
+ this.setFocusedDate(aDate);
+ }
+
+ switchMonth(aMonth) {
+ let newMonth = new Date(this.mEditorDate);
+ newMonth.setMonth(aMonth);
+ this.showMonth(newMonth);
+ }
+
+ switchYear(aYear) {
+ let newMonth = new Date(this.mEditorDate);
+ newMonth.setFullYear(aYear);
+ this.showMonth(newMonth);
+ }
+
+ selectDate(aDate, aMainDate) {
+ if (
+ !aMainDate ||
+ aDate < this._getStartDate(aMainDate) ||
+ aDate > this._getEndDate(aMainDate)
+ ) {
+ aMainDate = new Date(aDate);
+ aMainDate.setDate(1);
+ }
+ // Note that aMainDate and this.mEditorDate refer to the first day
+ // of the corresponding month.
+ let sameMonth = this._sameDay(aMainDate, this.mEditorDate);
+ let sameDate = this._sameDay(aDate, this.mValue);
+ if (!sameMonth && !sameDate) {
+ // Change month and select day.
+ this.mValue = aDate;
+ this.showMonth(aMainDate);
+ } else if (!sameMonth) {
+ // Change month only.
+ this.showMonth(aMainDate);
+ } else if (!sameDate) {
+ // Select day only.
+ let day = this.getBoxForDate(aDate);
+ if (this.mSelected) {
+ this.mSelected.removeAttribute("selected");
+ }
+ this.mSelected = day;
+ day.setAttribute("selected", "true");
+ this.mValue = aDate;
+ this.setFocusedDate(aDate);
+ }
+ }
+
+ _getStartDate(aMainDate) {
+ let date = new Date(aMainDate);
+ let firstWeekday = (7 + aMainDate.getDay() - this.weekStart) % 7;
+ date.setDate(date.getDate() - firstWeekday);
+ return date;
+ }
+
+ _getEndDate(aMainDate) {
+ let date = this._getStartDate(aMainDate);
+ let calbox = this.querySelector(".minimonth-calendar");
+ let days = (calbox.children.length - 1) * 7;
+ date.setDate(date.getDate() + days - 1);
+ return date;
+ }
+
+ _sameDay(aDate1, aDate2) {
+ if (
+ aDate1 &&
+ aDate2 &&
+ aDate1.getDate() == aDate2.getDate() &&
+ aDate1.getMonth() == aDate2.getMonth() &&
+ aDate1.getFullYear() == aDate2.getFullYear()
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ advanceMonth(aDir) {
+ let advEditorDate = new Date(this.mEditorDate); // At 1st of month.
+ let advMonth = this.mEditorDate.getMonth() + aDir;
+ advEditorDate.setMonth(advMonth);
+ this.showMonth(advEditorDate);
+ }
+
+ advanceYear(aDir) {
+ let advEditorDate = new Date(this.mEditorDate); // At 1st of month.
+ let advYear = this.mEditorDate.getFullYear() + aDir;
+ advEditorDate.setFullYear(advYear);
+ this.showMonth(advEditorDate);
+ }
+
+ moveDateByOffset(aYears, aMonths, aDays) {
+ const date = new Date(
+ this.mFocused.date.getFullYear() + aYears,
+ this.mFocused.date.getMonth() + aMonths,
+ this.mFocused.date.getDate() + aDays
+ );
+ this.focusDate(date);
+ }
+
+ focusCalendar() {
+ this.mFocused.focus();
+ }
+
+ onDayActivate(aEvent) {
+ // The associated date might change when setting this.value if month changes.
+ const date = aEvent.target.date;
+ if (this.getAttribute("readonly") != "true") {
+ this.value = date;
+ this.fireEvent("select");
+ }
+ this.setFocusedDate(date, true);
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ }
+
+ onDayMovement(event, years, months, days) {
+ this.moveDateByOffset(years, months, days);
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+ disconnectedCallback() {
+ if (this.mObservesComposite) {
+ cal.view.getCompositeCalendar(window).removeObserver(this.calICompositeObserver);
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(CalendarMinimonth, [
+ Ci.calIObserver,
+ Ci.calICompositeObserver,
+ ]);
+ customElements.define("calendar-minimonth", CalendarMinimonth);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-modebox.js b/comm/calendar/base/content/widgets/calendar-modebox.js
new file mode 100644
index 0000000000..417c790e34
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-modebox.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * A calendar-modebox directly extends to a xul:box element with extra functionality. Like a
+ * xul:hbox it has a horizontal orientation. It is designed to be displayed only:
+ * 1) in given application modes (e.g "task" mode, "calendar" mode) and
+ * 2) only in relation to the "checked" attribute of a control (e.g. a command or checkbox).
+ *
+ * - The attribute "mode" denotes a comma-separated list of all modes that the modebox should
+ * not be collapsed in, e.g. `mode="calendar,task"`.
+ * - The attribute "current" denotes the current viewing mode.
+ * - The attribute "refcontrol" points to a control, either a "command", "checkbox" or other
+ * elements that support a "checked" attribute, that is often used to denote whether a
+ * modebox should be displayed or not. If "refcontrol" is set to the id of a command you
+ * can there set the oncommand attribute like:
+ * `oncommand='document.getElementById('my-mode-pane').togglePane(event)`.
+ * In case it is a checkbox element or derived checkbox element this is done automatically
+ * by listening to the event "CheckboxChange". So if the current application mode is one of
+ * the modes listed in the "mode" attribute it is additionally verified whether the element
+ * denoted by "refcontrol" is checked or not.
+ * - The attribute "collapsedinmodes" is a comma-separated list of the modes the modebox
+ * should be collapsed in (e.g. "mail,calendar"). For example, if the user collapses a
+ * modebox when in a given mode, that mode would be added to "collapsedinmodes". This
+ * attribute is made persistent across restarts.
+ *
+ * @augments {MozXULElement}
+ */
+ class CalendarModebox extends MozXULElement {
+ static get observedAttributes() {
+ return ["current"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.mRefControl = null;
+
+ if (this.hasAttribute("refcontrol")) {
+ this.mRefControl = document.getElementById(this.getAttribute("refcontrol"));
+ if (this.mRefControl && this.mRefControl.localName == "checkbox") {
+ this.mRefControl.addEventListener("CheckboxStateChange", this, true);
+ }
+ }
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name == "current" && oldValue != newValue) {
+ let display = this.isVisibleInMode(newValue);
+ this.setVisible(display, false, true);
+ }
+ }
+
+ get currentMode() {
+ return this.getAttribute("current");
+ }
+
+ /**
+ * The event handler for various events relevant to CalendarModebox.
+ *
+ * @param {Event} event - The event.
+ */
+ handleEvent(event) {
+ if (event.type == "CheckboxStateChange") {
+ this.onCheckboxStateChange(event);
+ }
+ }
+
+ /**
+ * A "mode attribute" contains comma-separated lists of values, for example:
+ * `modewidths="200,200,200"`. Each of these values corresponds to one of the modes in
+ * the "mode" attribute: `mode="mail,calendar,task"`. This function sets a new value for
+ * a given mode in a given "mode attribute".
+ *
+ * @param {string} attributeName - A "mode attribute" in which to set a new value.
+ * @param {string} value - A new value to set.
+ * @param {string} [mode=this.currentMode] - Set the value for this mode.
+ */
+ setModeAttribute(attributeName, value, mode = this.currentMode) {
+ if (!this.hasAttribute(attributeName)) {
+ return;
+ }
+ let attributeValues = this.getAttribute(attributeName).split(",");
+ let modes = this.getAttribute("mode").split(",");
+ attributeValues[modes.indexOf(mode)] = value;
+ this.setAttribute(attributeName, attributeValues.join(","));
+ }
+
+ /**
+ * A "mode attribute" contains comma-separated lists of values, for example:
+ * `modewidths="200,200,200"`. Each of these values corresponds to one of the modes in
+ * the "mode" attribute: `mode="mail,calendar,task"`. This function returns the value
+ * for a given mode in a given "mode attribute".
+ *
+ * @param {string} attributeName - A "mode attribute" to get a value from.
+ * @param {string} [mode=this.currentMode] - Get the value for this mode.
+ * @returns {string} The value found in the mode attribute or an empty string.
+ */
+ getModeAttribute(attributeName, mode = this.currentMode) {
+ if (!this.hasAttribute(attributeName)) {
+ return "";
+ }
+ let attributeValues = this.getAttribute(attributeName).split(",");
+ let modes = this.getAttribute("mode").split(",");
+ return attributeValues[modes.indexOf(mode)];
+ }
+
+ /**
+ * Sets the visibility (collapsed state) of this modebox and (optionally) updates the
+ * `collapsedinmode` attribute and (optionally) notifies the `refcontrol`.
+ *
+ * @param {boolean} visible - Whether the modebox should become visible or not.
+ * @param {boolean} [toPushModeCollapsedAttribute=true] - Whether to push the current mode
+ * to `collapsedinmodes` attribute.
+ * @param {boolean} [toNotifyRefControl=true] - Whether to notify the `refcontrol`.
+ */
+ setVisible(visible, toPushModeCollapsedAttribute = true, toNotifyRefControl = true) {
+ let pushModeCollapsedAttribute = toPushModeCollapsedAttribute === true;
+ let notifyRefControl = toNotifyRefControl === true;
+
+ let collapsedModes = [];
+ let modeIndex = -1;
+ let collapsedInMode = false;
+
+ if (this.hasAttribute("collapsedinmodes")) {
+ collapsedModes = this.getAttribute("collapsedinmodes").split(",");
+ modeIndex = collapsedModes.indexOf(this.currentMode);
+ collapsedInMode = modeIndex > -1;
+ }
+
+ let display = visible;
+ if (display && !pushModeCollapsedAttribute) {
+ display = !collapsedInMode;
+ }
+
+ this.collapsed = !display || !this.isVisibleInMode();
+
+ if (pushModeCollapsedAttribute) {
+ if (!display) {
+ if (modeIndex == -1) {
+ collapsedModes.push(this.currentMode);
+ if (this.getAttribute("collapsedinmodes") == ",") {
+ collapsedModes.splice(0, 2);
+ }
+ }
+ } else if (modeIndex > -1) {
+ collapsedModes.splice(modeIndex, 1);
+ if (collapsedModes.join(",") == "") {
+ collapsedModes[0] = ",";
+ }
+ }
+ this.setAttribute("collapsedinmodes", collapsedModes.join(","));
+
+ Services.xulStore.persist(this, "collapsedinmodes");
+ }
+
+ if (notifyRefControl && this.hasAttribute("refcontrol")) {
+ let command = document.getElementById(this.getAttribute("refcontrol"));
+ if (command) {
+ command.setAttribute("checked", display);
+ command.disabled = !this.isVisibleInMode();
+ }
+ }
+ }
+
+ /**
+ * Return whether this modebox is visible for a given mode, according to both its
+ * `mode` and `collapsedinmodes` attributes.
+ *
+ * @param {string} [mode=this.currentMode] - Is the modebox visible for this mode?
+ * @returns {boolean} Whether this modebox is visible for the given mode.
+ */
+ isVisible(mode = this.currentMode) {
+ if (!this.isVisibleInMode(mode)) {
+ return false;
+ }
+ let collapsedModes = this.getAttribute("collapsedinmodes").split(",");
+ return !collapsedModes.includes(mode);
+ }
+
+ /**
+ * Returns whether this modebox is visible for a given mode, according to its
+ * `mode` attribute.
+ *
+ * @param {string} [mode=this.currentMode] - Is the modebox visible for this mode?
+ * @returns {boolean} Whether this modebox is visible for the given mode.
+ */
+ isVisibleInMode(mode = this.currentMode) {
+ return this.hasAttribute("mode") ? this.getAttribute("mode").split(",").includes(mode) : true;
+ }
+
+ /**
+ * Used to toggle the checked state of a command connected to this modebox, and set the
+ * visibility of this modebox accordingly.
+ *
+ * @param {Event} event - An event with a command (with a checked attribute) as its target.
+ */
+ togglePane(event) {
+ let command = event.target;
+ let newValue = command.getAttribute("checked") == "true" ? "false" : "true";
+ command.setAttribute("checked", newValue);
+ this.setVisible(newValue == "true", true, true);
+ }
+
+ /**
+ * Handles a change in a checkbox state, by making this modebox visible or not.
+ *
+ * @param {Event} event - An event with a target that has a `checked` attribute.
+ */
+ onCheckboxStateChange(event) {
+ let newValue = event.target.checked;
+ this.setVisible(newValue, true, true);
+ }
+ }
+
+ customElements.define("calendar-modebox", CalendarModebox);
+
+ /**
+ * A `calendar-modebox` but with a vertical orientation like a `vbox`. (Different Custom
+ * Elements cannot be defined using the same class, thus we need this subclass.)
+ *
+ * @augments {CalendarModebox}
+ */
+ class CalendarModevbox extends CalendarModebox {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ super.connectedCallback();
+ this.setAttribute("orient", "vertical");
+ }
+ }
+
+ customElements.define("calendar-modevbox", CalendarModevbox);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-notifications-setting.js b/comm/calendar/base/content/widgets/calendar-notifications-setting.js
new file mode 100644
index 0000000000..1f772992c7
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-notifications-setting.js
@@ -0,0 +1,259 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+
+ /**
+ * A calendar-notifications-setting provides controls to config notifications
+ * times of a calendar.
+ *
+ * @augments {MozXULElement}
+ */
+ class CalendarNotificationsSetting extends MozXULElement {
+ connectedCallback() {
+ MozXULElement.insertFTLIfNeeded("calendar/calendar-widgets.ftl");
+ }
+
+ /**
+ * @type {string} A string in the form of "PT5M PT0M" to represent the notifications times.
+ */
+ get value() {
+ return [...this._elList.children]
+ .map(row => {
+ let count = row.querySelector("input").value;
+ let unit = row.querySelector(".unit-menu").value;
+ let [relation, tag] = row.querySelector(".relation-menu").value.split("-");
+
+ tag = tag == "END" ? "END:" : "";
+ relation = relation == "before" ? "-" : "";
+ let durTag = unit == "D" ? "P" : "PT";
+ return `${tag}${relation}${durTag}${count}${unit}`;
+ })
+ .join(",");
+ }
+
+ set value(value) {
+ // An array of notifications times, each item is in the form of [5, "M",
+ // "before-start"], i.e. a triple of time, unit and relation.
+ let items = [];
+ let durations = value?.split(",") || [];
+ for (let dur of durations) {
+ dur = dur.trim();
+ if (!dur) {
+ continue;
+ }
+ let [relation, value] = dur.split(":");
+ if (!value) {
+ value = relation;
+ relation = "START";
+ }
+ if (value.startsWith("-")) {
+ relation = `before-${relation}`;
+ value = value.slice(1);
+ } else {
+ relation = `after-${relation}`;
+ }
+ let prefix = value.slice(0, 2);
+ if (prefix != "PT") {
+ prefix = value[0];
+ }
+ let unit = value.slice(-1);
+ if ((prefix == "P" && unit != "D") || (prefix == "PT" && !["M", "H"].includes(unit))) {
+ continue;
+ }
+ value = value.slice(prefix.length, -1);
+ items.push([value, unit, relation]);
+ }
+ this._render(items);
+ }
+
+ /**
+ * @type {boolean} If true, all form controls should be disabled.
+ */
+ set disabled(disabled) {
+ this._disabled = disabled;
+ this._updateDisabled();
+ }
+
+ /**
+ * Update the disabled attributes of all form controls to this._disabled.
+ */
+ _updateDisabled() {
+ for (let el of this.querySelectorAll("label, input, button, menulist")) {
+ el.disabled = this._disabled;
+ }
+ }
+
+ /**
+ * Because form controls can be dynamically added/removed, we bind events to
+ * _elButtonAdd and _elList.
+ */
+ _bindEvents() {
+ this._elButtonAdd.addEventListener("click", e => {
+ // Add a notification time row.
+ this._addNewRow(0, "M", "before-START");
+ this._emit();
+ });
+
+ this._elList.addEventListener("change", e => {
+ if (!HTMLInputElement.isInstance(e.target)) {
+ // We only care about change event of input elements.
+ return;
+ }
+ // We don't want this to interfere with the 'change' event emitted by
+ // calendar-notifications-setting itself.
+ e.stopPropagation();
+ this._updateMenuLists();
+ this._emit();
+ });
+
+ this._elList.addEventListener("command", e => {
+ let el = e.target;
+ if (el.tagName == "menuitem") {
+ this._emit();
+ } else if (el.tagName == "button") {
+ // Remove a notification time row.
+ el.closest("hbox").remove();
+ this._updateAddButton();
+ this._emit();
+ }
+ });
+ }
+
+ /**
+ * Render the layout and the add button, then bind events. This is delayed
+ * until the first `set value` call, so that l10n works correctly.
+ */
+ _renderLayout() {
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <hbox align="center">
+ <label data-l10n-id="calendar-notifications-label"></label>
+ <spacer flex="1"></spacer>
+ <button class="add-button"
+ data-l10n-id="calendar-add-notification-button"/>
+ </hbox>
+ <separator class="thin"/>
+ <vbox class="calendar-notifications-list indent"></vbox>
+ `)
+ );
+ this._elList = this.querySelector(".calendar-notifications-list");
+ this._elButtonAdd = this.querySelector("button");
+ this._bindEvents();
+ }
+
+ /**
+ * Render this_items to a list of rows.
+ *
+ * @param {Array<[number, string, string]>} items - An array of count, unit and relation.
+ */
+ _render(items) {
+ this._renderLayout();
+
+ // Render a row for each item in this._items.
+ items.forEach(([value, unit, relation]) => {
+ this._addNewRow(value, unit, relation);
+ });
+ if (items.length) {
+ this._updateMenuLists();
+ this._updateDisabled();
+ }
+ }
+
+ /**
+ * Render a notification entry to a row. Each row contains a time input, a
+ * unit menulist, a relation menulist and a remove button.
+ */
+ _addNewRow(value, unit, relation) {
+ let fragment = MozXULElement.parseXULToFragment(`
+ <hbox class="calendar-notifications-row" align="center">
+ <html:input class="size3" value="${value}" type="number" min="0"/>
+ <menulist class="unit-menu" crop="none" value="${unit}">
+ <menupopup>
+ <menuitem value="M"/>
+ <menuitem value="H"/>
+ <menuitem value="D"/>
+ </menupopup>
+ </menulist>
+ <menulist class="relation-menu" crop="none" value="${relation}">
+ <menupopup class="reminder-relation-origin-menupopup">
+ <menuitem data-id="reminderCustomOriginBeginBeforeEvent"
+ value="before-START"/>
+ <menuitem data-id="reminderCustomOriginBeginAfterEvent"
+ value="after-START"/>
+ <menuitem data-id="reminderCustomOriginEndBeforeEvent"
+ value="before-END"/>
+ <menuitem data-id="reminderCustomOriginEndAfterEvent"
+ value="after-END"/>
+ </menupopup>
+ </menulist>
+ <button class="remove-button"></button>
+ </hbox>
+ `);
+ this._elList.appendChild(fragment);
+ this._updateMenuLists();
+ this._updateAddButton();
+ }
+
+ /**
+ * To prevent a too crowded UI, hide the add button if already have 5 rows.
+ */
+ _updateAddButton() {
+ if (this._elList.childElementCount >= 5) {
+ this._elButtonAdd.hidden = true;
+ } else {
+ this._elButtonAdd.hidden = false;
+ }
+ }
+
+ /**
+ * Iterate all rows, update the plurality of menulist (unit) to the input
+ * value (time).
+ */
+ _updateMenuLists() {
+ for (let row of this._elList.children) {
+ let input = row.querySelector("input");
+ let menulist = row.querySelector(".unit-menu");
+ this._updateMenuList(input.value, menulist);
+ for (let menuItem of row.querySelectorAll(".relation-menu menuitem")) {
+ menuItem.label = cal.l10n.getString("calendar-alarms", menuItem.dataset.id);
+ }
+ }
+ }
+
+ /**
+ * Update the plurality of a menulist (unit) options to the input value (time).
+ */
+ _updateMenuList(length, menu) {
+ let getUnitEntry = unit =>
+ ({
+ M: "unitMinutes",
+ H: "unitHours",
+ D: "unitDays",
+ }[unit] || "unitMinutes");
+
+ for (let menuItem of menu.getElementsByTagName("menuitem")) {
+ menuItem.label = PluralForm.get(length, cal.l10n.getCalString(getUnitEntry(menuItem.value)))
+ .replace("#1", "")
+ .trim();
+ }
+ }
+
+ /**
+ * Emit a change event.
+ */
+ _emit() {
+ this.dispatchEvent(new CustomEvent("change", { detail: this.value }));
+ }
+ }
+
+ customElements.define("calendar-notifications-setting", CalendarNotificationsSetting);
+}
diff --git a/comm/calendar/base/content/widgets/datetimepickers.js b/comm/calendar/base/content/widgets/datetimepickers.js
new file mode 100644
index 0000000000..ae2c87caf8
--- /dev/null
+++ b/comm/calendar/base/content/widgets/datetimepickers.js
@@ -0,0 +1,1529 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements, MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ // Leave these first arguments as `undefined`, to use the OS style if
+ // intl.regional_prefs.use_os_locales is true or the app language matches the OS language.
+ // Otherwise, the app language is used.
+ let dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "short" });
+ let timeFormatter = new Services.intl.DateTimeFormat(undefined, { timeStyle: "short" });
+
+ let probeSucceeded;
+ let alphaMonths;
+ let yearIndex, monthIndex, dayIndex;
+ let ampmIndex, amRegExp, pmRegExp;
+ let parseTimeRegExp, parseShortDateRegex;
+
+ class MozTimepickerMinute extends MozXULElement {
+ static get observedAttributes() {
+ return ["label", "selected"];
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 50;
+ let deltaView = 0;
+
+ if (event.deltaMode == event.DOM_DELTA_PAGE || event.deltaMode == event.DOM_DELTA_LINE) {
+ // Line/Page scrolling is usually vertical
+ if (event.deltaY) {
+ deltaView = event.deltaY < 0 ? -1 : 1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ // The natural direction for pixel scrolling is left/right
+ this.pixelScrollDelta += event.deltaX;
+ if (this.pixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.pixelScrollDelta = 0;
+ } else if (this.pixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.pixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ this.moveMinutes(deltaView);
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ this.clickMinute = (minuteItem, minuteNumber) => {
+ this.closest("timepicker-grids").clickMinute(minuteItem, minuteNumber);
+ };
+ this.moveMinutes = number => {
+ this.closest("timepicker-grids").moveMinutes(number);
+ };
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes()) {
+ return;
+ }
+
+ const spacer = document.createXULElement("spacer");
+ spacer.setAttribute("flex", "1");
+
+ const minutebox = document.createXULElement("vbox");
+ minutebox.addEventListener("click", () => {
+ this.clickMinute(this, this.getAttribute("value"));
+ });
+
+ const box = document.createXULElement("box");
+
+ this.label = document.createXULElement("label");
+ this.label.classList.add("time-picker-minute-label");
+
+ box.appendChild(this.label);
+ minutebox.appendChild(box);
+
+ this.appendChild(spacer.cloneNode());
+ this.appendChild(minutebox);
+ this.appendChild(spacer);
+
+ this.pixelScrollDelta = 0;
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.label) {
+ return;
+ }
+
+ if (this.hasAttribute("label")) {
+ this.label.setAttribute("value", this.getAttribute("label"));
+ } else {
+ this.label.removeAttribute("value");
+ }
+
+ if (this.hasAttribute("selected")) {
+ this.label.setAttribute("selected", this.getAttribute("selected"));
+ } else {
+ this.label.removeAttribute("selected");
+ }
+ }
+ }
+
+ class MozTimepickerHour extends MozXULElement {
+ static get observedAttributes() {
+ return ["label", "selected"];
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 50;
+ let deltaView = 0;
+
+ if (event.deltaMode == event.DOM_DELTA_PAGE || event.deltaMode == event.DOM_DELTA_LINE) {
+ // Line/Page scrolling is usually vertical
+ if (event.deltaY) {
+ deltaView = event.deltaY < 0 ? -1 : 1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ // The natural direction for pixel scrolling is left/right
+ this.pixelScrollDelta += event.deltaX;
+ if (this.pixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.pixelScrollDelta = 0;
+ } else if (this.pixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.pixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ this.moveHours(deltaView);
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ this.clickHour = (hourItem, hourNumber) => {
+ this.closest("timepicker-grids").clickHour(hourItem, hourNumber);
+ };
+ this.moveHours = number => {
+ this.closest("timepicker-grids").moveHours(number);
+ };
+ this.doubleClickHour = (hourItem, hourNumber) => {
+ this.closest("timepicker-grids").doubleClickHour(hourItem, hourNumber);
+ };
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes()) {
+ return;
+ }
+
+ const spacer = document.createXULElement("spacer");
+ spacer.setAttribute("flex", "1");
+
+ const hourbox = document.createXULElement("vbox");
+ hourbox.addEventListener("click", () => {
+ this.clickHour(this, this.getAttribute("value"));
+ });
+ hourbox.addEventListener("dblclick", () => {
+ this.doubleClickHour(this, this.getAttribute("value"));
+ });
+
+ const box = document.createXULElement("box");
+
+ this.label = document.createXULElement("label");
+ this.label.classList.add("time-picker-hour-label");
+
+ box.appendChild(this.label);
+ hourbox.appendChild(box);
+ hourbox.appendChild(spacer.cloneNode());
+
+ this.appendChild(spacer.cloneNode());
+ this.appendChild(hourbox);
+ this.appendChild(spacer);
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.label) {
+ return;
+ }
+
+ if (this.hasAttribute("label")) {
+ this.label.setAttribute("value", this.getAttribute("label"));
+ } else {
+ this.label.removeAttribute("value");
+ }
+
+ if (this.hasAttribute("selected")) {
+ this.label.setAttribute("selected", this.getAttribute("selected"));
+ } else {
+ this.label.removeAttribute("selected");
+ }
+ }
+ }
+
+ /**
+ * The MozTimepickerGrids widget displays the grid of times to select, e.g. for an event.
+ * Typically it represents the popup content that let's the user select a time, in a
+ * <timepicker> widget.
+ *
+ * @augments MozXULElement
+ */
+ class MozTimepickerGrids extends MozXULElement {
+ constructor() {
+ super();
+
+ this.content = MozXULElement.parseXULToFragment(`
+ <vbox class="time-picker-grids">
+ <vbox class="time-picker-hour-grid" format12hours="false">
+ <hbox flex="1" class="timepicker-topRow-hour-class">
+ <timepicker-hour class="time-picker-hour-box-class" value="0" label="0"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="1" label="1"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="2" label="2"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="3" label="3"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="4" label="4"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="5" label="5"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="6" label="6"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="7" label="7"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="8" label="8"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="9" label="9"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="10" label="10"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="11" label="11"></timepicker-hour>
+ <hbox class="timepicker-amLabelBox-class amLabelBox" hidden="true">
+ <label></label>
+ </hbox>
+ </hbox>
+ <hbox flex="1" class="timepicker-bottomRow-hour-class">
+ <timepicker-hour class="time-picker-hour-box-class" value="12" label="12"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="13" label="13"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="14" label="14"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="15" label="15"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="16" label="16"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="17" label="17"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="18" label="18"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="19" label="19"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="20" label="20"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="21" label="21"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="22" label="22"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="23" label="23"></timepicker-hour>
+ <hbox class="pmLabelBox timepicker-pmLabelBox-class" hidden="true">
+ <label></label>
+ </hbox>
+ </hbox>
+ </vbox>
+ <vbox class="time-picker-five-minute-grid-box">
+ <vbox class="time-picker-five-minute-grid">
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-five-minute-class" value="0" label=":00" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="5" label=":05" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="10" label=":10" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="15" label=":15" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="20" label=":20" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="25" label=":25" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-five-minute-class" value="30" label=":30" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="35" label=":35" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="40" label=":40" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="45" label=":45" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="50" label=":50" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="55" label=":55" flex="1"></timepicker-minute>
+ </hbox>
+ </vbox>
+ <hbox class="time-picker-minutes-bottom">
+ <spacer flex="1"></spacer>
+ <label class="time-picker-more-control-label" value="»" onclick="clickMore()"></label>
+ </hbox>
+ </vbox>
+ <vbox class="time-picker-one-minute-grid-box" flex="1" hidden="true">
+ <vbox class="time-picker-one-minute-grid" flex="1">
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="0" label=":00" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="1" label=":01" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="2" label=":02" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="3" label=":03" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="4" label=":04" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="5" label=":05" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="6" label=":06" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="7" label=":07" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="8" label=":08" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="9" label=":09" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="10" label=":10" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="11" label=":11" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="12" label=":12" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="13" label=":13" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="14" label=":14" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="15" label=":15" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="16" label=":16" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="17" label=":17" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="18" label=":18" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="19" label=":19" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="20" label=":20" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="21" label=":21" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="22" label=":22" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="23" label=":23" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="24" label=":24" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="25" label=":25" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="26" label=":26" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="27" label=":27" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="28" label=":28" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="29" label=":29" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="30" label=":30" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="31" label=":31" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="32" label=":32" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="33" label=":33" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="34" label=":34" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="35" label=":35" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="36" label=":36" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="37" label=":37" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="38" label=":38" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="39" label=":39" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="40" label=":40" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="41" label=":41" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="42" label=":42" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="43" label=":43" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="44" label=":44" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="45" label=":45" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="46" label=":46" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="47" label=":47" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="48" label=":48" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="49" label=":49" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="50" label=":50" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="51" label=":51" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="52" label=":52" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="53" label=":53" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="54" label=":54" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="55" label=":55" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="56" label=":56" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="57" label=":57" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="58" label=":58" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="59" label=":59" flex="1"></timepicker-minute>
+ </hbox>
+ </vbox>
+ <hbox class="time-picker-minutes-bottom">
+ <spacer flex="1"></spacer>
+ <label class="time-picker-more-control-label" value="«" onclick="clickLess()"></label>
+ </hbox>
+ </vbox>
+ </vbox>
+ `);
+ }
+
+ connectedCallback() {
+ if (!this.hasChildNodes()) {
+ this.appendChild(document.importNode(this.content, true));
+ }
+
+ // set by onPopupShowing
+ this.mPicker = null;
+
+ // The currently selected time
+ this.mSelectedTime = new Date();
+ // The selected hour and selected minute items
+ this.mSelectedHourItem = null;
+ this.mSelectedMinuteItem = null;
+ // constants use to specify one and five minute view
+ this.kMINUTE_VIEW_FIVE = 5;
+ this.kMINUTE_VIEW_ONE = 1;
+ }
+
+ /**
+ * Sets new mSelectedTime.
+ *
+ * @param {string | Array} val new mSelectedTime value
+ */
+ set value(val) {
+ if (typeof val == "string") {
+ val = parseTime(val);
+ } else if (Array.isArray(val)) {
+ let [hours, minutes] = val;
+ val = new Date();
+ val.setHours(hours);
+ val.setMinutes(minutes);
+ }
+ this.mSelectedTime = val;
+ }
+
+ /**
+ * @returns {Array} An array containing mSelectedTime hours and mSelectedTime minutes
+ */
+ get value() {
+ return [this.mSelectedTime.getHours(), this.mSelectedTime.getMinutes()];
+ }
+
+ /**
+ * Set up the picker, called when the popup pops.
+ */
+ onPopupShowing() {
+ // select the hour item
+ let hours24 = this.mSelectedTime.getHours();
+ let hourItem = this.querySelector(`.time-picker-hour-box-class[value="${hours24}"]`);
+ this.selectHourItem(hourItem);
+
+ // Show the five minute view if we are an even five minutes,
+ // otherwise one minute view
+ let minutesByFive = this.calcNearestFiveMinutes(this.mSelectedTime);
+
+ if (minutesByFive == this.mSelectedTime.getMinutes()) {
+ this.clickLess();
+ } else {
+ this.clickMore();
+ }
+ }
+
+ /**
+ * Switches popup to minute view and selects the selected minute item.
+ */
+ clickMore() {
+ // switch to one minute view
+ this.switchMinuteView(this.kMINUTE_VIEW_ONE);
+
+ // select minute box corresponding to the time
+ let minutes = this.mSelectedTime.getMinutes();
+ let oneMinuteItem = this.querySelector(`.time-picker-one-minute-class[value="${minutes}"]`);
+ this.selectMinuteItem(oneMinuteItem);
+ }
+
+ /**
+ * Switches popup to five-minute view and selects the five-minute item nearest to selected
+ * minute item.
+ */
+ clickLess() {
+ // switch to five minute view
+ this.switchMinuteView(this.kMINUTE_VIEW_FIVE);
+
+ // select closest five minute box,
+ // BUT leave the selected time at what may NOT be an even five minutes
+ // So that If they click more again the proper non-even-five minute
+ // box will be selected
+ let minutesByFive = this.calcNearestFiveMinutes(this.mSelectedTime);
+ let fiveMinuteItem = this.querySelector(
+ `.time-picker-five-minute-class[value="${minutesByFive}"]`
+ );
+ this.selectMinuteItem(fiveMinuteItem);
+ }
+
+ /**
+ * Selects the hour item which was clicked.
+ *
+ * @param {Node} hourItem - Hour item which was clicked
+ * @param {number} hourNumber - Hour value of the clicked hour item
+ */
+ clickHour(hourItem, hourNumber) {
+ // select the item
+ this.selectHourItem(hourItem);
+
+ // Change the hour in the selected time.
+ this.mSelectedTime.setHours(hourNumber);
+
+ this.hasChanged = true;
+ }
+
+ /**
+ * Called when one of the hour boxes is double clicked.
+ * Sets the time to the selected hour, on the hour, and closes the popup.
+ *
+ * @param {Node} hourItem - Hour item which was clicked
+ * @param {number} hourNumber - Hour value of the clicked hour item
+ */
+ doubleClickHour(hourItem, hourNumber) {
+ // set the minutes to :00
+ this.mSelectedTime.setMinutes(0);
+
+ this.dispatchEvent(new CustomEvent("select"));
+ }
+
+ /**
+ * Changes selectedTime's minute, calls the client's onchange and closes
+ * the popup.
+ *
+ * @param {Node} minuteItem - Minute item which was clicked
+ * @param {number} minuteNumber - Minute value of the clicked minute item
+ */
+ clickMinute(minuteItem, minuteNumber) {
+ // set the minutes in the selected time
+ this.mSelectedTime.setMinutes(minuteNumber);
+ this.selectMinuteItem(minuteItem);
+ this.hasChanged = true;
+
+ this.dispatchEvent(new CustomEvent("select"));
+ }
+
+ /**
+ * Helper function to switch between "one" and "five" minute views.
+ *
+ * @param {number} view - Number representing minute view
+ */
+ switchMinuteView(view) {
+ let fiveMinuteBox = this.querySelector(".time-picker-five-minute-grid-box");
+ let oneMinuteBox = this.querySelector(".time-picker-one-minute-grid-box");
+
+ if (view == this.kMINUTE_VIEW_ONE) {
+ fiveMinuteBox.setAttribute("hidden", true);
+ oneMinuteBox.setAttribute("hidden", false);
+ } else {
+ fiveMinuteBox.setAttribute("hidden", false);
+ oneMinuteBox.setAttribute("hidden", true);
+ }
+ }
+
+ /**
+ * Selects an hour item.
+ *
+ * @param {Node} hourItem - Hour item node to be selected
+ */
+ selectHourItem(hourItem) {
+ // clear old selection, if there is one
+ if (this.mSelectedHourItem != null) {
+ this.mSelectedHourItem.removeAttribute("selected");
+ }
+ // set selected attribute, to cause the selected style to apply
+ hourItem.setAttribute("selected", "true");
+ // remember the selected item so we can deselect it
+ this.mSelectedHourItem = hourItem;
+ }
+
+ /**
+ * Selects a minute item.
+ *
+ * @param {Node} minuteItem - Minute item node to be selected
+ */
+ selectMinuteItem(minuteItem) {
+ // clear old selection, if there is one
+ if (this.mSelectedMinuteItem != null) {
+ this.mSelectedMinuteItem.removeAttribute("selected");
+ }
+ // set selected attribute, to cause the selected style to apply
+ minuteItem.setAttribute("selected", "true");
+ // remember the selected item so we can deselect it
+ this.mSelectedMinuteItem = minuteItem;
+ }
+
+ /**
+ * Moves minute by the number passed and handle rollover cases where the minutes gets
+ * greater than 59 or less than 60.
+ *
+ * @param {number} number - Moves minute by the number 'number'
+ */
+ moveMinutes(number) {
+ if (!this.mSelectedTime) {
+ return;
+ }
+
+ let idPrefix = ".time-picker-one-minute-class";
+
+ // Everything above assumes that we are showing the one-minute-grid,
+ // If not, we need to do these corrections;
+ let fiveMinuteBox = this.querySelector(".time-picker-five-minute-grid-box");
+
+ if (!fiveMinuteBox.hidden) {
+ number *= 5;
+ idPrefix = ".time-picker-five-minute-class";
+
+ // If the detailed view was shown before, then mSelectedTime.getMinutes
+ // might not be a multiple of 5.
+ this.mSelectedTime.setMinutes(this.calcNearestFiveMinutes(this.mSelectedTime));
+ }
+
+ let newMinutes = this.mSelectedTime.getMinutes() + number;
+
+ // Handle rollover cases
+ if (newMinutes < 0) {
+ newMinutes += 60;
+ }
+ if (newMinutes > 59) {
+ newMinutes -= 60;
+ }
+
+ this.mSelectedTime.setMinutes(newMinutes);
+
+ let minuteItemId = `${idPrefix}[value="${this.mSelectedTime.getMinutes()}"]`;
+ let minuteItem = this.querySelector(minuteItemId);
+
+ this.selectMinuteItem(minuteItem);
+ this.mPicker.kTextBox.value = this.mPicker.formatTime(this.mSelectedTime);
+ this.hasChanged = true;
+ }
+
+ /**
+ * Moves hours by the number passed and handle rollover cases where the hours gets greater
+ * than 23 or less than 0.
+ *
+ * @param {number} number - Moves hours by the number 'number'
+ */
+ moveHours(number) {
+ if (!this.mSelectedTime) {
+ return;
+ }
+
+ let newHours = this.mSelectedTime.getHours() + number;
+
+ // Handle rollover cases
+ if (newHours < 0) {
+ newHours += 24;
+ }
+ if (newHours > 23) {
+ newHours -= 24;
+ }
+
+ this.mSelectedTime.setHours(newHours);
+
+ let hourItemId = `.time-picker-hour-box-class[value="${this.mSelectedTime.getHours()}"]`;
+ let hourItem = this.querySelector(hourItemId);
+
+ this.selectHourItem(hourItem);
+ this.mPicker.kTextBox.value = this.mPicker.formatTime(this.mSelectedTime);
+ this.hasChanged = true;
+ }
+
+ /**
+ * Calculates the nearest even five minutes.
+ *
+ * @param {calDateTime} time - Time near to which nearest five minutes have to be found
+ */
+ calcNearestFiveMinutes(time) {
+ let minutes = time.getMinutes();
+ let minutesByFive = Math.round(minutes / 5) * 5;
+
+ if (minutesByFive > 59) {
+ minutesByFive = 55;
+ }
+ return minutesByFive;
+ }
+
+ /**
+ * Changes to 12 hours format by showing am/pm label.
+ *
+ * @param {string} amLabel - amLabelBox value
+ * @param {string} pmLabel - pmLabelBox value
+ */
+ changeTo12HoursFormat(amLabel, pmLabel) {
+ if (!this.firstElementChild) {
+ this.appendChild(document.importNode(this.content, true));
+ }
+
+ let amLabelBox = this.querySelector(".amLabelBox");
+ amLabelBox.removeAttribute("hidden");
+ amLabelBox.firstElementChild.setAttribute("value", amLabel);
+ let pmLabelBox = this.querySelector(".pmLabelBox");
+ pmLabelBox.removeAttribute("hidden");
+ pmLabelBox.firstElementChild.setAttribute("value", pmLabel);
+ this.querySelector(".time-picker-hour-box-class[value='0']").setAttribute("label", "12");
+ for (let i = 13; i < 24; i++) {
+ this.querySelector(`.time-picker-hour-box-class[value="${i}"]`).setAttribute(
+ "label",
+ i - 12
+ );
+ }
+ this.querySelector(".time-picker-hour-grid").setAttribute("format12hours", "true");
+ }
+ }
+
+ class CalendarDatePicker extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.prepend(CalendarDatePicker.fragment.cloneNode(true));
+ this._menulist = this.querySelector(".datepicker-menulist");
+ this._inputField = this._menulist._inputField;
+ this._popup = this._menulist.menupopup;
+ this._minimonth = this.querySelector("calendar-minimonth");
+
+ if (this.getAttribute("type") == "forever") {
+ this._valueIsForever = false;
+ this._foreverString = cal.l10n.getString(
+ "calendar-event-dialog",
+ "eventRecurrenceForeverLabel"
+ );
+
+ this._foreverItem = document.createXULElement("button");
+ this._foreverItem.setAttribute("label", this._foreverString);
+ this._popup.appendChild(document.createXULElement("menuseparator"));
+ this._popup.appendChild(this._foreverItem);
+
+ this._foreverItem.addEventListener("command", () => {
+ this.value = "forever";
+ this._popup.hidePopup();
+ });
+ }
+
+ this.value = this.getAttribute("value") || new Date();
+
+ // Other attributes handled in inheritedAttributes.
+ this._handleMutation = mutations => {
+ this.value = this.getAttribute("value");
+ };
+ this._attributeObserver = new MutationObserver(this._handleMutation);
+ this._attributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["value"],
+ });
+
+ this.initializeAttributeInheritance();
+
+ this.addEventListener("keydown", event => {
+ if (event.key == "Escape") {
+ this._popup.hidePopup();
+ }
+ });
+ this._menulist.addEventListener("change", event => {
+ event.stopPropagation();
+
+ let value = parseDateTime(this._inputBoxValue);
+ if (!value) {
+ this._inputBoxValue = this._minimonthValue;
+ return;
+ }
+ this._inputBoxValue = this._minimonthValue = value;
+ this._valueIsForever = false;
+
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ this._popup.addEventListener("popupshown", () => {
+ this._minimonth.focusDate(this._minimonthValue);
+ const calendar = this._minimonth.querySelector(".minimonth-calendar");
+ calendar.querySelector("td[selected]").focus();
+ });
+ this._minimonth.addEventListener("change", event => {
+ event.stopPropagation();
+ });
+ this._minimonth.addEventListener("select", () => {
+ this._inputBoxValue = this._minimonthValue;
+ this._valueIsForever = false;
+ this._popup.hidePopup();
+
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this._attributeObserver.disconnect();
+
+ if (this._menulist) {
+ this._menulist.remove();
+ this._menulist = null;
+ this._inputField = null;
+ this._popup = null;
+ this._minimonth = null;
+ this._foreverItem = null;
+ }
+ }
+
+ static get fragment() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <menulist is="menulist-editable" class="datepicker-menulist" editable="true" sizetopopup="false">
+ <menupopup ignorekeys="true" popupanchor="bottomright" popupalign="topright">
+ <calendar-minimonth tabindex="0"/>
+ </menupopup>
+ </menulist>
+ `),
+ true
+ );
+
+ Object.defineProperty(this, "fragment", { value: frag });
+ return frag;
+ }
+
+ static get inheritedAttributes() {
+ return { ".datepicker-menulist": "disabled" };
+ }
+
+ set value(val) {
+ let wasForever = this._valueIsForever;
+ if (this.getAttribute("type") == "forever" && val == "forever") {
+ this._valueIsForever = true;
+ this._inputBoxValue = val;
+ if (!wasForever) {
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ }
+ return;
+ } else if (typeof val == "string") {
+ val = parseDateTime(val);
+ }
+
+ let existingValue = this._minimonthValue;
+ this._valueIsForever = false;
+ this._inputBoxValue = this._minimonthValue = val;
+
+ if (
+ wasForever ||
+ existingValue.getFullYear() != val.getFullYear() ||
+ existingValue.getMonth() != val.getMonth() ||
+ existingValue.getDate() != val.getDate()
+ ) {
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ }
+ }
+
+ get value() {
+ if (this._valueIsForever) {
+ return "forever";
+ }
+ return this._minimonth.value;
+ }
+
+ focus() {
+ this._menulist.focus();
+ }
+
+ set _inputBoxValue(val) {
+ if (val == "forever") {
+ this._inputField.value = this._foreverString;
+ return;
+ }
+ this._inputField.value = formatDate(val);
+ }
+
+ get _inputBoxValue() {
+ return this._inputField.value;
+ }
+
+ set _minimonthValue(val) {
+ if (val == "forever") {
+ return;
+ }
+ this._minimonth.value = val;
+ }
+
+ get _minimonthValue() {
+ return this._minimonth.value;
+ }
+ }
+
+ const MenuBaseControl = MozElements.BaseControlMixin(MozElements.MozElementMixin(XULMenuElement));
+ MenuBaseControl.implementCustomInterface(CalendarDatePicker, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ class CalendarTimePicker extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.prepend(CalendarTimePicker.fragment.cloneNode(true));
+ this._menulist = this.firstElementChild;
+ this._inputField = this._menulist._inputField;
+ this._popup = this._menulist.menupopup;
+ this._grid = this._popup.firstElementChild;
+
+ this.value = this.getAttribute("value") || new Date();
+
+ // Change the grids in the timepicker-grids for 12-hours time format.
+ if (ampmIndex) {
+ // Find the locale strings for the AM/PM prefix/suffix.
+ let amTime = new Date(2000, 0, 1, 6, 12, 34);
+ let pmTime = new Date(2000, 0, 1, 18, 12, 34);
+ amTime = timeFormatter.format(amTime);
+ pmTime = timeFormatter.format(pmTime);
+ let amLabel = parseTimeRegExp.exec(amTime)[ampmIndex] || "AM";
+ let pmLabel = parseTimeRegExp.exec(pmTime)[ampmIndex] || "PM";
+
+ this._grid.changeTo12HoursFormat(amLabel, pmLabel);
+ }
+
+ // Other attributes handled in inheritedAttributes.
+ this._handleMutation = mutations => {
+ this.value = this.getAttribute("value");
+ };
+ this._attributeObserver = new MutationObserver(this._handleMutation);
+ this._attributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["value"],
+ });
+
+ this.initializeAttributeInheritance();
+
+ this._inputField.addEventListener("change", event => {
+ event.stopPropagation();
+
+ let value = parseTime(this._inputBoxValue);
+ if (!value) {
+ this._inputBoxValue = this._gridValue;
+ return;
+ }
+ this.value = value;
+ });
+ this._menulist.menupopup.addEventListener("popupshowing", () => {
+ this._grid.onPopupShowing();
+ });
+ this._menulist.menupopup.addEventListener("popuphiding", () => {
+ this.value = this._gridValue;
+ });
+ this._grid.addEventListener("select", event => {
+ event.stopPropagation();
+
+ this.value = this._gridValue;
+ this._popup.hidePopup();
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this._attributeObserver.disconnect();
+
+ if (this._menulist) {
+ this._menulist.remove();
+ this._menulist = null;
+ this._inputField = null;
+ this._popup = null;
+ this._grid = null;
+ }
+ }
+
+ static get fragment() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <menulist is="menulist-editable" class="timepicker-menulist" editable="true" sizetopopup="false">
+ <menupopup popupanchor="bottomright" popupalign="topright">
+ <timepicker-grids/>
+ </menupopup>
+ </menulist>
+ `),
+ true
+ );
+
+ Object.defineProperty(this, "fragment", { value: frag });
+ return frag;
+ }
+
+ static get inheritedAttributes() {
+ return { ".timepicker-menulist": "disabled" };
+ }
+
+ set value(val) {
+ if (typeof val == "string") {
+ val = parseTime(val);
+ } else if (Array.isArray(val)) {
+ let [hours, minutes] = val;
+ val = new Date();
+ val.setHours(hours);
+ val.setMinutes(minutes);
+ }
+ if (val.getHours() != this._hours || val.getMinutes() != this._minutes) {
+ let settingInitalValue = this._hours === undefined;
+
+ this._inputBoxValue = this._gridValue = val;
+ [this._hours, this._minutes] = this._gridValue;
+
+ if (!settingInitalValue) {
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ }
+ }
+ }
+
+ get value() {
+ return [this._hours, this._minutes];
+ }
+
+ focus() {
+ this._menulist.focus();
+ }
+
+ set _inputBoxValue(val) {
+ if (typeof val == "string") {
+ val = parseTime(val);
+ } else if (Array.isArray(val)) {
+ let [hours, minutes] = val;
+ val = new Date();
+ val.setHours(hours);
+ val.setMinutes(minutes);
+ }
+ this._inputField.value = formatTime(val);
+ }
+
+ get _inputBoxValue() {
+ return this._inputField.value;
+ }
+
+ set _gridValue(val) {
+ this._grid.value = val;
+ }
+
+ get _gridValue() {
+ return this._grid.value;
+ }
+ }
+
+ MenuBaseControl.implementCustomInterface(CalendarTimePicker, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ class CalendarDateTimePicker extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this._datepicker = document.createXULElement("datepicker");
+ this._datepicker.classList.add("datetimepicker-datepicker");
+ this._datepicker.setAttribute("anonid", "datepicker");
+ this._timepicker = document.createXULElement("timepicker");
+ this._timepicker.classList.add("datetimepicker-timepicker");
+ this._timepicker.setAttribute("anonid", "timepicker");
+ this.appendChild(this._datepicker);
+ this.appendChild(this._timepicker);
+
+ if (this.getAttribute("value")) {
+ this._datepicker.value = this.getAttribute("value");
+ this._timepicker.value = this.getAttribute("value");
+ }
+
+ this.initializeAttributeInheritance();
+
+ this._datepicker.addEventListener("change", event => {
+ event.stopPropagation();
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ this._timepicker.addEventListener("change", event => {
+ event.stopPropagation();
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ if (this._datepicker) {
+ this._datepicker.remove();
+ }
+ if (this._timepicker) {
+ this._timepicker.remove();
+ }
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".datetimepicker-datepicker": "value,disabled,disabled=datepickerdisabled",
+ ".datetimepicker-timepicker": "value,disabled,disabled=timepickerdisabled",
+ };
+ }
+
+ set value(val) {
+ this._datepicker.value = this._timepicker.value = val;
+ }
+
+ get value() {
+ let dateValue = this._datepicker.value;
+ let [hours, minutes] = this._timepicker.value;
+ dateValue.setHours(hours);
+ dateValue.setMinutes(minutes);
+ dateValue.setSeconds(0);
+ dateValue.setMilliseconds(0);
+ return dateValue;
+ }
+
+ focus() {
+ this._datepicker.focus();
+ }
+ }
+
+ initDateFormat();
+ initTimeFormat();
+ customElements.define("timepicker-minute", MozTimepickerMinute);
+ customElements.define("timepicker-hour", MozTimepickerHour);
+ customElements.define("timepicker-grids", MozTimepickerGrids);
+ customElements.whenDefined("menulist-editable").then(() => {
+ customElements.define("datepicker", CalendarDatePicker);
+ customElements.define("timepicker", CalendarTimePicker);
+ customElements.define("datetimepicker", CalendarDateTimePicker);
+ });
+
+ /**
+ * Parameter aValue may be a date or a date time. Dates are
+ * read according to locale/OS setting (d-m-y or m-d-y or ...).
+ * (see initDateFormat). Uses parseTime() for times.
+ */
+ function parseDateTime(aValue) {
+ let tempDate = null;
+ if (!probeSucceeded) {
+ return null; // avoid errors accessing uninitialized data.
+ }
+
+ let year = Number.MIN_VALUE;
+ let month = -1;
+ let day = -1;
+ let timeString = null;
+
+ if (alphaMonths == null) {
+ // SHORT NUMERIC DATE, such as 2002-03-04, 4/3/2002, or CE2002Y03M04D.
+ // Made of digits & nonDigits. (Nondigits may be unicode letters
+ // which do not match \w, esp. in CJK locales.)
+ // (.*)? binds to null if no suffix.
+ let parseNumShortDateRegex = /^\D*(\d+)\D+(\d+)\D+(\d+)(.*)?$/;
+ let dateNumbersArray = parseNumShortDateRegex.exec(aValue);
+ if (dateNumbersArray != null) {
+ year = Number(dateNumbersArray[yearIndex]);
+ month = Number(dateNumbersArray[monthIndex]) - 1; // 0-based
+ day = Number(dateNumbersArray[dayIndex]);
+ timeString = dateNumbersArray[4];
+ }
+ } else {
+ // SHORT DATE WITH ALPHABETIC MONTH, such as "dd MMM yy" or "MMMM dd, yyyy"
+ // (\d+|[^\d\W]) is digits or letters, not both together.
+ // Allows 31dec1999 (no delimiters between parts) if OS does (w2k does not).
+ // Allows Dec 31, 1999 (comma and space between parts)
+ // (Only accepts ASCII month names; JavaScript RegExp does not have an
+ // easy way to describe unicode letters short of a HUGE character range
+ // regexp derived from the Alphabetic ranges in
+ // http://www.unicode.org/Public/UNIDATA/DerivedCoreProperties.txt)
+ // (.*)? binds to null if no suffix.
+ let parseAlphShortDateRegex =
+ /^\s*(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)(.*)?$/;
+ let datePartsArray = parseAlphShortDateRegex.exec(aValue);
+ if (datePartsArray != null) {
+ year = Number(datePartsArray[yearIndex]);
+ let monthString = datePartsArray[monthIndex].toUpperCase();
+ for (let monthIdx = 0; monthIdx < alphaMonths.length; monthIdx++) {
+ if (monthString == alphaMonths[monthIdx]) {
+ month = monthIdx;
+ break;
+ }
+ }
+ day = Number(datePartsArray[dayIndex]);
+ timeString = datePartsArray[4];
+ }
+ }
+ if (year != Number.MIN_VALUE && month != -1 && day != -1) {
+ // year, month, day successfully parsed
+ if (year >= 0 && year < 100) {
+ // If 0 <= year < 100, treat as 2-digit year (like formatDate):
+ // parse year as up to 30 years in future or 69 years in past.
+ // (Covers 30-year mortgage and most working people's birthdate.)
+ // otherwise will be treated as four digit year.
+ let currentYear = new Date().getFullYear();
+ let currentCentury = currentYear - (currentYear % 100);
+ year = currentCentury + year;
+ if (year < currentYear - 69) {
+ year += 100;
+ }
+ if (year > currentYear + 30) {
+ year -= 100;
+ }
+ }
+ // if time is also present, parse it
+ let hours = 0;
+ let minutes = 0;
+ let seconds = 0;
+ if (timeString != null) {
+ let time = parseTime(timeString);
+ if (time != null) {
+ hours = time.getHours();
+ minutes = time.getMinutes();
+ seconds = time.getSeconds();
+ }
+ }
+ tempDate = new Date(year, month, day, hours, minutes, seconds, 0);
+ } // else did not match regex, not a valid date
+ return tempDate;
+ }
+
+ /**
+ * Parse a variety of time formats so that cut and paste is likely to work.
+ * separator: ':' '.' ' ' symbol none
+ * "12:34:56" "12.34.56" "12 34 56" "12h34m56s" "123456"
+ * seconds optional: "02:34" "02.34" "02 34" "02h34m" "0234"
+ * minutes optional: "12" "12" "12" "12h" "12"
+ * 1st hr digit optional:"9:34" " 9.34" "9 34" "9H34M" "934am"
+ * skip nondigit prefix " 12:34" "t12.34" " 12 34" "T12H34M" "T0234"
+ * am/pm optional "02:34 a.m.""02.34pm" "02 34 A M" "02H34M P.M." "0234pm"
+ * am/pm prefix "a.m. 02:34""pm02.34" "A M 02 34" "P.M. 02H34M" "pm0234"
+ * am/pm cyrillic "02:34\u0430.\u043c." "02 34 \u0420 \u041c"
+ * am/pm arabic "\u063502:34" (RTL 02:34a) "\u0645 02.34" (RTL 02.34 p)
+ * above/below noon "\u4e0a\u534802:34" "\u4e0b\u5348 02 34"
+ * noon before/after "\u5348\u524d02:34" "\u5348\u5f8c 02 34"
+ */
+ function parseTime(aValue) {
+ let now = new Date();
+
+ let noon = cal.l10n.getDateFmtString("noon");
+ if (aValue.toLowerCase() == noon.toLowerCase()) {
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0);
+ }
+
+ let midnight = cal.l10n.getDateFmtString("midnight");
+ if (aValue.toLowerCase() == midnight.toLowerCase()) {
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
+ }
+
+ let time = null;
+ let timePartsArray = parseTimeRegExp.exec(aValue);
+ const PRE_INDEX = 1,
+ HR_INDEX = 2,
+ MIN_INDEX = 4,
+ SEC_INDEX = 6,
+ POST_INDEX = 8;
+
+ if (timePartsArray != null) {
+ let hoursString = timePartsArray[HR_INDEX];
+ let hours = Number(hoursString);
+ if (!(hours >= 0 && hours < 24)) {
+ return null;
+ }
+
+ let minutesString = timePartsArray[MIN_INDEX];
+ let minutes = minutesString == null ? 0 : Number(minutesString);
+ if (!(minutes >= 0 && minutes < 60)) {
+ return null;
+ }
+
+ let secondsString = timePartsArray[SEC_INDEX];
+ let seconds = secondsString == null ? 0 : Number(secondsString);
+ if (!(seconds >= 0 && seconds < 60)) {
+ return null;
+ }
+
+ let ampmCode = null;
+ if (timePartsArray[PRE_INDEX] || timePartsArray[POST_INDEX]) {
+ if (ampmIndex && timePartsArray[ampmIndex]) {
+ // try current format order first
+ let ampmString = timePartsArray[ampmIndex];
+ if (amRegExp.test(ampmString)) {
+ ampmCode = "AM";
+ } else if (pmRegExp.test(ampmString)) {
+ ampmCode = "PM";
+ }
+ }
+ if (ampmCode == null) {
+ // not yet found
+ // try any format order
+ let preString = timePartsArray[PRE_INDEX];
+ let postString = timePartsArray[POST_INDEX];
+ if (
+ (preString && amRegExp.test(preString)) ||
+ (postString && amRegExp.test(postString))
+ ) {
+ ampmCode = "AM";
+ } else if (
+ (preString && pmRegExp.test(preString)) ||
+ (postString && pmRegExp.test(postString))
+ ) {
+ ampmCode = "PM";
+ } // else no match, ignore and treat as 24hour time.
+ }
+ }
+ if (ampmCode == "AM") {
+ if (hours == 12) {
+ hours = 0;
+ }
+ } else if (ampmCode == "PM") {
+ if (hours < 12) {
+ hours += 12;
+ }
+ }
+ time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, seconds, 0);
+ } // else did not match regex, not valid time
+ return time;
+ }
+
+ function initDateFormat() {
+ // probe the dateformat
+ yearIndex = -1;
+ monthIndex = -1;
+ dayIndex = -1;
+ alphaMonths = null;
+ probeSucceeded = false;
+
+ // SHORT NUMERIC DATE, such as 2002-03-04, 4/3/2002, or CE2002Y03M04D.
+ // Made of digits & nonDigits. (Nondigits may be unicode letters
+ // which do not match \w, esp. in CJK locales.)
+ parseShortDateRegex = /^\D*(\d+)\D+(\d+)\D+(\d+)\D?$/;
+ // Make sure to use UTC date and timezone here to avoid the pattern
+ // detection to fail if the probe date output would have an timezone
+ // offset due to our lack of support of historic timezone definitions.
+ let probeDate = new Date(Date.UTC(2002, 3, 6)); // month is 0-based
+ let probeString = formatDate(probeDate, cal.dtz.UTC);
+ let probeArray = parseShortDateRegex.exec(probeString);
+ if (probeArray) {
+ // Numeric month format
+ for (let i = 1; i <= 3; i++) {
+ switch (Number(probeArray[i])) {
+ case 2: // falls through
+ case 2002:
+ yearIndex = i;
+ break;
+ case 4:
+ monthIndex = i;
+ break;
+ case 5: // falls through for OS timezones western to GMT
+ case 6:
+ dayIndex = i;
+ break;
+ }
+ }
+ // All three indexes are set (not -1) at this point.
+ probeSucceeded = true;
+ } else {
+ // SHORT DATE WITH ALPHABETIC MONTH, such as "dd MMM yy" or "MMMM dd, yyyy"
+ // (\d+|[^\d\W]) is digits or letters, not both together.
+ // Allows 31dec1999 (no delimiters between parts) if OS does (w2k does not).
+ // Allows Dec 31, 1999 (comma and space between parts)
+ // (Only accepts ASCII month names; JavaScript RegExp does not have an
+ // easy way to describe unicode letters short of a HUGE character range
+ // regexp derived from the Alphabetic ranges in
+ // http://www.unicode.org/Public/UNIDATA/DerivedCoreProperties.txt)
+ parseShortDateRegex = /^\s*(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\s*$/;
+ probeArray = parseShortDateRegex.exec(probeString);
+ if (probeArray != null) {
+ for (let j = 1; j <= 3; j++) {
+ switch (Number(probeArray[j])) {
+ case 2: // falls through
+ case 2002:
+ yearIndex = j;
+ break;
+ case 5: // falls through for OS timezones western to GMT
+ case 6:
+ dayIndex = j;
+ break;
+ default:
+ monthIndex = j;
+ break;
+ }
+ }
+ if (yearIndex != -1 && dayIndex != -1 && monthIndex != -1) {
+ probeSucceeded = true;
+ // Fill alphaMonths with month names.
+ alphaMonths = new Array(12);
+ for (let monthIdx = 0; monthIdx < 12; monthIdx++) {
+ probeDate.setMonth(monthIdx);
+ probeString = formatDate(probeDate);
+ probeArray = parseShortDateRegex.exec(probeString);
+ if (probeArray) {
+ alphaMonths[monthIdx] = probeArray[monthIndex].toUpperCase();
+ } else {
+ probeSucceeded = false;
+ }
+ }
+ }
+ }
+ }
+ if (!probeSucceeded) {
+ dump("\nOperating system short date format is not recognized: " + probeString + "\n");
+ }
+ }
+
+ /**
+ * Time format in 24-hour format or 12-hour format with am/pm string.
+ * Should match formats
+ * HH:mm, H:mm, HH:mm:ss, H:mm:ss
+ * hh:mm tt, h:mm tt, hh:mm:ss tt, h:mm:ss tt
+ * tt hh:mm, tt h:mm, tt hh:mm:ss, tt h:mm:ss
+ * where
+ * HH is 24 hour digits, with leading 0. H is 24 hour digits, no leading 0.
+ * hh is 12 hour digits, with leading 0. h is 12 hour digits, no leading 0.
+ * mm and ss are is minutes and seconds digits, with leading 0.
+ * tt is localized AM or PM string.
+ * ':' may be ':' or a units marker such as 'h', 'm', or 's' in 15h12m00s
+ * or may be omitted as in 151200.
+ */
+ function initTimeFormat() {
+ // probe the Time format
+ ampmIndex = null;
+ // Digits HR sep MIN sep SEC sep
+ // Index: 2 3 4 5 6 7
+ // prettier-ignore
+ let digitsExpr = "(\\d?\\d)\\s?(\\D)?\\s?(?:(\\d\\d)\\s?(\\D)?\\s?(?:(\\d\\d)\\s?(\\D)?\\s?)?)?";
+ // digitsExpr has 6 captures, so index of first ampmExpr is 1, of last is 8.
+ let probeTimeRegExp = new RegExp("^\\s*(\\D*)\\s?" + digitsExpr + "\\s?(\\D*)\\s*$");
+ const PRE_INDEX = 1,
+ HR_INDEX = 2,
+ // eslint-disable-next-line no-unused-vars
+ MIN_INDEX = 4,
+ SEC_INDEX = 6,
+ POST_INDEX = 8;
+ let amProbeTime = new Date(2000, 0, 1, 6, 12, 34);
+ let pmProbeTime = new Date(2000, 0, 1, 18, 12, 34);
+ let amProbeString = timeFormatter.format(amProbeTime);
+ let pmProbeString = timeFormatter.format(pmProbeTime);
+ let amFormatExpr = null,
+ pmFormatExpr = null;
+ if (amProbeString != pmProbeString) {
+ let amProbeArray = probeTimeRegExp.exec(amProbeString);
+ let pmProbeArray = probeTimeRegExp.exec(pmProbeString);
+ if (amProbeArray != null && pmProbeArray != null) {
+ if (
+ amProbeArray[PRE_INDEX] &&
+ pmProbeArray[PRE_INDEX] &&
+ amProbeArray[PRE_INDEX] != pmProbeArray[PRE_INDEX]
+ ) {
+ ampmIndex = PRE_INDEX;
+ } else if (amProbeArray[POST_INDEX] && pmProbeArray[POST_INDEX]) {
+ if (amProbeArray[POST_INDEX] == pmProbeArray[POST_INDEX]) {
+ // check if need to append previous character,
+ // captured by the optional separator pattern after seconds digits,
+ // or after minutes if no seconds, or after hours if no minutes.
+ for (let k = SEC_INDEX; k >= HR_INDEX; k -= 2) {
+ let nextSepI = k + 1;
+ let nextDigitsI = k + 2;
+ if (
+ (k == SEC_INDEX || (!amProbeArray[nextDigitsI] && !pmProbeArray[nextDigitsI])) &&
+ amProbeArray[nextSepI] &&
+ pmProbeArray[nextSepI] &&
+ amProbeArray[nextSepI] != pmProbeArray[nextSepI]
+ ) {
+ amProbeArray[POST_INDEX] = amProbeArray[nextSepI] + amProbeArray[POST_INDEX];
+ pmProbeArray[POST_INDEX] = pmProbeArray[nextSepI] + pmProbeArray[POST_INDEX];
+ ampmIndex = POST_INDEX;
+ break;
+ }
+ }
+ } else {
+ ampmIndex = POST_INDEX;
+ }
+ }
+ if (ampmIndex) {
+ let makeFormatRegExp = function (string) {
+ // make expr to accept either as provided, lowercased, or uppercased
+ let regExp = string.replace(/(\W)/g, "[$1]"); // escape punctuation
+ let lowercased = string.toLowerCase();
+ if (string != lowercased) {
+ regExp += "|" + lowercased;
+ }
+ let uppercased = string.toUpperCase();
+ if (string != uppercased) {
+ regExp += "|" + uppercased;
+ }
+ return regExp;
+ };
+ amFormatExpr = makeFormatRegExp(amProbeArray[ampmIndex]);
+ pmFormatExpr = makeFormatRegExp(pmProbeArray[ampmIndex]);
+ }
+ }
+ }
+ // International formats ([roman, cyrillic]|arabic|chinese/kanji characters)
+ // covering languages of U.N. (en,fr,sp,ru,ar,zh) and G8 (en,fr,de,it,ru,ja).
+ // See examples at parseTimeOfDay.
+ let amExpr = "[Aa\u0410\u0430][. ]?[Mm\u041c\u043c][. ]?|\u0635|\u4e0a\u5348|\u5348\u524d";
+ let pmExpr = "[Pp\u0420\u0440][. ]?[Mm\u041c\u043c][. ]?|\u0645|\u4e0b\u5348|\u5348\u5f8c";
+ if (ampmIndex) {
+ amExpr = amFormatExpr + "|" + amExpr;
+ pmExpr = pmFormatExpr + "|" + pmExpr;
+ }
+ let ampmExpr = amExpr + "|" + pmExpr;
+ // Must build am/pm formats into parse time regexp so that it can
+ // match them without mistaking the initial char for an optional divider.
+ // (For example, want to be able to parse both "12:34pm" and
+ // "12H34M56Spm" for any characters H,M,S and any language's "pm".
+ // The character between the last digit and the "pm" is optional.
+ // Must recognize "pm" directly, otherwise in "12:34pm" the "S" pattern
+ // matches the "p" character so only "m" is matched as ampm suffix.)
+ //
+ // digitsExpr has 6 captures, so index of first ampmExpr is 1, of last is 8.
+ parseTimeRegExp = new RegExp(
+ "(" + ampmExpr + ")?\\s?" + digitsExpr + "(" + ampmExpr + ")?\\s*$"
+ );
+ amRegExp = new RegExp("^(?:" + amExpr + ")$");
+ pmRegExp = new RegExp("^(?:" + pmExpr + ")$");
+ }
+
+ function formatDate(aDate, aTimezone) {
+ // Usually, floating is ok here, so no need to pass aTimezone - we just need to pass
+ // it in if we need to make sure formatting happens without a timezone conversion.
+ let formatter = aTimezone
+ ? new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeZone: aTimezone.tzid,
+ })
+ : dateFormatter;
+ return formatter.format(aDate);
+ }
+
+ function formatTime(aValue) {
+ return timeFormatter.format(aValue);
+ }
+}
diff --git a/comm/calendar/base/content/widgets/mouseoverPreviews.js b/comm/calendar/base/content/widgets/mouseoverPreviews.js
new file mode 100644
index 0000000000..38e5c1e24f
--- /dev/null
+++ b/comm/calendar/base/content/widgets/mouseoverPreviews.js
@@ -0,0 +1,439 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Code which generates event and task (todo) preview tooltips/titletips
+ * when the mouse hovers over either the event list, the task list, or
+ * an event or task box in one of the grid views.
+ *
+ * (Portions of this code were previously in calendar.js and unifinder.js,
+ * some of it duplicated.)
+ */
+
+/* exported onMouseOverItem, showToolTip, getPreviewForItem,
+ getEventStatusString, getToDoStatusString */
+
+/* import-globals-from ../calendar-ui-utils.js */
+
+/**
+ * PUBLIC: This changes the mouseover preview based on the start and end dates
+ * of an occurrence of a (one-time or recurring) calEvent or calToDo.
+ * Used by all grid views.
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * PUBLIC: Displays a tooltip with details when hovering over an item in the views
+ *
+ * @param {DOMEvent} occurrenceBoxMouseEvent the triggering event
+ * @returns {boolean} true, if the tooltip is displayed
+ */
+function onMouseOverItem(occurrenceBoxMouseEvent) {
+ if ("occurrence" in occurrenceBoxMouseEvent.currentTarget) {
+ // occurrence of repeating event or todo
+ let occurrence = occurrenceBoxMouseEvent.currentTarget.occurrence;
+ const toolTip = document.getElementById("itemTooltip");
+ return showToolTip(toolTip, occurrence);
+ }
+ return false;
+}
+
+/**
+ * PUBLIC: Displays a tooltip for a given item
+ *
+ * @param {Node} aTooltip the node to hold the tooltip
+ * @param {CalIEvent|calIToDo} aItem the item to create the tooltip for
+ * @returns {boolean} true, if the tooltip is displayed
+ */
+function showToolTip(aToolTip, aItem) {
+ if (aItem) {
+ let holderBox = getPreviewForItem(aItem);
+ if (holderBox) {
+ while (aToolTip.lastChild) {
+ aToolTip.lastChild.remove();
+ }
+ aToolTip.appendChild(holderBox);
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * PUBLIC: Called when a user hovers over a todo element and the text for the
+ * mouse over is changed.
+ *
+ * @param {calIToDo} toDoItem - the item to create the preview for
+ * @param {boolean} aIsTooltip enabled if used for tooltip composition (default)
+ */
+function getPreviewForItem(aItem, aIsTooltip = true) {
+ if (aItem.isEvent()) {
+ return getPreviewForEvent(aItem, aIsTooltip);
+ } else if (aItem.isTodo()) {
+ return getPreviewForTask(aItem, aIsTooltip);
+ }
+ return null;
+}
+
+/**
+ * PUBLIC: Returns the string for status (none), Tentative, Confirmed, or
+ * Cancelled for a given event
+ *
+ * @param {calIEvent} aEvent The event
+ * @returns {string} The string for the status property of the event
+ */
+function getEventStatusString(aEvent) {
+ switch (aEvent.status) {
+ // Event status value keywords are specified in RFC2445sec4.8.1.11
+ case "TENTATIVE":
+ return cal.l10n.getCalString("statusTentative");
+ case "CONFIRMED":
+ return cal.l10n.getCalString("statusConfirmed");
+ case "CANCELLED":
+ return cal.l10n.getCalString("eventStatusCancelled");
+ default:
+ return "";
+ }
+}
+
+/**
+ * PUBLIC: Returns the string for status (none), NeedsAction, InProcess,
+ * Cancelled, orCompleted for a given ToDo
+ *
+ * @param {calIToDo} aToDo The ToDo
+ * @returns {string} The string for the status property of the event
+ */
+function getToDoStatusString(aToDo) {
+ switch (aToDo.status) {
+ // Todo status keywords are specified in RFC2445sec4.8.1.11
+ case "NEEDS-ACTION":
+ return cal.l10n.getCalString("statusNeedsAction");
+ case "IN-PROCESS":
+ return cal.l10n.getCalString("statusInProcess");
+ case "CANCELLED":
+ return cal.l10n.getCalString("todoStatusCancelled");
+ case "COMPLETED":
+ return cal.l10n.getCalString("statusCompleted");
+ default:
+ return "";
+ }
+}
+
+/**
+ * PRIVATE: Called when a user hovers over a todo element and the text for the
+ * mouse overis changed.
+ *
+ * @param {calIToDo} toDoItem - the item to create the preview for
+ * @param {boolean} aIsTooltip enabled if used for tooltip composition (default)
+ */
+function getPreviewForTask(toDoItem, aIsTooltip = true) {
+ if (toDoItem) {
+ const vbox = document.createXULElement("vbox");
+ vbox.setAttribute("class", "tooltipBox");
+ if (aIsTooltip) {
+ // tooltip appears above or below pointer, so may have as little as
+ // one half the screen height available (avoid top going off screen).
+ vbox.style.maxHeight = Math.floor(screen.height / 2);
+ } else {
+ vbox.setAttribute("flex", "1");
+ }
+ boxInitializeHeaderTable(vbox);
+
+ let hasHeader = false;
+
+ if (toDoItem.title) {
+ boxAppendLabeledText(vbox, "tooltipTitle", toDoItem.title);
+ hasHeader = true;
+ }
+
+ let location = toDoItem.getProperty("LOCATION");
+ if (location) {
+ boxAppendLabeledText(vbox, "tooltipLocation", location);
+ hasHeader = true;
+ }
+
+ // First try to get calendar name appearing in tooltip
+ if (toDoItem.calendar.name) {
+ let calendarNameString = toDoItem.calendar.name;
+ boxAppendLabeledText(vbox, "tooltipCalName", calendarNameString);
+ }
+
+ if (toDoItem.entryDate && toDoItem.entryDate.isValid) {
+ boxAppendLabeledDateTime(vbox, "tooltipStart", toDoItem.entryDate);
+ hasHeader = true;
+ }
+
+ if (toDoItem.dueDate && toDoItem.dueDate.isValid) {
+ boxAppendLabeledDateTime(vbox, "tooltipDue", toDoItem.dueDate);
+ hasHeader = true;
+ }
+
+ if (toDoItem.priority && toDoItem.priority != 0) {
+ let priorityInteger = parseInt(toDoItem.priority, 10);
+ let priorityString;
+
+ // These cut-offs should match calendar-event-dialog.js
+ if (priorityInteger >= 1 && priorityInteger <= 4) {
+ priorityString = cal.l10n.getCalString("highPriority");
+ } else if (priorityInteger == 5) {
+ priorityString = cal.l10n.getCalString("normalPriority");
+ } else {
+ priorityString = cal.l10n.getCalString("lowPriority");
+ }
+ boxAppendLabeledText(vbox, "tooltipPriority", priorityString);
+ hasHeader = true;
+ }
+
+ if (toDoItem.status && toDoItem.status != "NONE") {
+ let status = getToDoStatusString(toDoItem);
+ boxAppendLabeledText(vbox, "tooltipStatus", status);
+ hasHeader = true;
+ }
+
+ if (
+ toDoItem.status != null &&
+ toDoItem.percentComplete != 0 &&
+ toDoItem.percentComplete != 100
+ ) {
+ boxAppendLabeledText(vbox, "tooltipPercent", String(toDoItem.percentComplete) + "%");
+ hasHeader = true;
+ } else if (toDoItem.percentComplete == 100) {
+ if (toDoItem.completedDate == null) {
+ boxAppendLabeledText(vbox, "tooltipPercent", "100%");
+ } else {
+ boxAppendLabeledDateTime(vbox, "tooltipCompleted", toDoItem.completedDate);
+ }
+ hasHeader = true;
+ }
+
+ let description = toDoItem.descriptionText;
+ if (description) {
+ // display wrapped description lines like body of message below headers
+ if (hasHeader) {
+ boxAppendBodySeparator(vbox);
+ }
+ boxAppendBody(vbox, description, aIsTooltip);
+ }
+
+ return vbox;
+ }
+ return null;
+}
+
+/**
+ * PRIVATE: Called when mouse moves over a different, or when mouse moves over
+ * event in event list. The instStartDate is date of instance displayed at event
+ * box (recurring or multiday events may be displayed by more than one event box
+ * for different days), or null if should compute next instance from now.
+ *
+ * @param {calIEvent} aEvent - the item to create the preview for
+ * @param {boolean} aIsTooltip enabled if used for tooltip composition (default)
+ */
+function getPreviewForEvent(aEvent, aIsTooltip = true) {
+ let event = aEvent;
+ const vbox = document.createXULElement("vbox");
+ vbox.setAttribute("class", "tooltipBox");
+ if (aIsTooltip) {
+ // tooltip appears above or below pointer, so may have as little as
+ // one half the screen height available (avoid top going off screen).
+ vbox.maxHeight = Math.floor(screen.height / 2);
+ } else {
+ vbox.setAttribute("flex", "1");
+ }
+ boxInitializeHeaderTable(vbox);
+
+ if (event) {
+ if (event.title) {
+ boxAppendLabeledText(vbox, "tooltipTitle", aEvent.title);
+ }
+
+ let location = event.getProperty("LOCATION");
+ if (location) {
+ boxAppendLabeledText(vbox, "tooltipLocation", location);
+ }
+ if (!(event.startDate && event.endDate)) {
+ // Event may be recurrent event. If no displayed instance specified,
+ // use next instance, or previous instance if no next instance.
+ event = getCurrentNextOrPreviousRecurrence(event);
+ }
+ boxAppendLabeledDateTimeInterval(vbox, "tooltipDate", event);
+
+ // First try to get calendar name appearing in tooltip
+ if (event.calendar.name) {
+ let calendarNameString = event.calendar.name;
+ boxAppendLabeledText(vbox, "tooltipCalName", calendarNameString);
+ }
+
+ if (event.status && event.status != "NONE") {
+ let statusString = getEventStatusString(event);
+ boxAppendLabeledText(vbox, "tooltipStatus", statusString);
+ }
+
+ if (event.organizer && event.getAttendees().length > 0) {
+ let organizer = event.organizer;
+ boxAppendLabeledText(vbox, "tooltipOrganizer", organizer);
+ }
+
+ let description = event.descriptionText;
+ if (description) {
+ boxAppendBodySeparator(vbox);
+ // display wrapped description lines, like body of message below headers
+ boxAppendBody(vbox, description, aIsTooltip);
+ }
+ return vbox;
+ }
+ return null;
+}
+
+/**
+ * PRIVATE: Append a separator, a thin space between header and body.
+ *
+ * @param {Node} vbox box to which to append separator.
+ */
+function boxAppendBodySeparator(vbox) {
+ const separator = document.createXULElement("separator");
+ separator.setAttribute("class", "tooltipBodySeparator");
+ vbox.appendChild(separator);
+}
+
+/**
+ * PRIVATE: Append description to box for body text. Rendered as HTML.
+ * Indentation and line breaks are preserved.
+ *
+ * @param {Node} box - Box to which to append the body.
+ * @param {string} textString - Text of the body.
+ * @param {boolean} aIsTooltip - True for "tooltip" and false for "conflict-dialog" case.
+ */
+function boxAppendBody(box, textString, aIsTooltip) {
+ let type = aIsTooltip ? "description" : "vbox";
+ let xulDescription = document.createXULElement(type);
+ xulDescription.setAttribute("class", "tooltipBody");
+ if (!aIsTooltip) {
+ xulDescription.setAttribute("flex", "1");
+ }
+ let docFragment = cal.view.textToHtmlDocumentFragment(textString, document);
+ xulDescription.appendChild(docFragment);
+ box.appendChild(xulDescription);
+}
+
+/**
+ * PRIVATE: Use dateFormatter to format date and time,
+ * and to header table append a row containing localized Label: date.
+ *
+ * @param {Node} box The node to add the date label to
+ * @param {string} labelProperty The label
+ * @param {calIDateTime} date - The datetime object to format and add
+ */
+function boxAppendLabeledDateTime(box, labelProperty, date) {
+ date = date.getInTimezone(cal.dtz.defaultTimezone);
+ let formattedDateTime = cal.dtz.formatter.formatDateTime(date);
+ boxAppendLabeledText(box, labelProperty, formattedDateTime);
+}
+
+/**
+ * PRIVATE: Use dateFormatter to format date and time interval,
+ * and to header table append a row containing localized Label: interval.
+ *
+ * @param box contains header table.
+ * @param labelProperty name of property for localized field label.
+ * @param item the event or task
+ */
+function boxAppendLabeledDateTimeInterval(box, labelProperty, item) {
+ let dateString = cal.dtz.formatter.formatItemInterval(item);
+ boxAppendLabeledText(box, labelProperty, dateString);
+}
+
+/**
+ * PRIVATE: create empty 2-column table for header fields, and append it to box.
+ *
+ * @param {Node} box The node to create a column table for
+ */
+function boxInitializeHeaderTable(box) {
+ let table = document.createElementNS("http://www.w3.org/1999/xhtml", "table");
+ table.setAttribute("class", "tooltipHeaderTable");
+ box.appendChild(table);
+}
+
+/**
+ * PRIVATE: To headers table, append a row containing Label: value, where label
+ * is localized text for labelProperty.
+ *
+ * @param box box containing headers table
+ * @param labelProperty name of property for localized name of header
+ * @param textString value of header field.
+ */
+function boxAppendLabeledText(box, labelProperty, textString) {
+ let labelText = cal.l10n.getCalString(labelProperty);
+ let table = box.querySelector("table");
+ let row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+
+ row.appendChild(createTooltipHeaderLabel(labelText));
+ row.appendChild(createTooltipHeaderDescription(textString));
+
+ table.appendChild(row);
+}
+
+/**
+ * PRIVATE: Creates an element for field label (for header table)
+ *
+ * @param {string} text The text to display in the node
+ * @returns {Node} The node
+ */
+function createTooltipHeaderLabel(text) {
+ let labelCell = document.createElementNS("http://www.w3.org/1999/xhtml", "th");
+ labelCell.setAttribute("class", "tooltipHeaderLabel");
+ labelCell.textContent = text;
+ return labelCell;
+}
+
+/**
+ * PRIVATE: Creates an element for field value (for header table)
+ *
+ * @param {string} text The text to display in the node
+ * @returns {Node} The node
+ */
+function createTooltipHeaderDescription(text) {
+ let descriptionCell = document.createElementNS("http://www.w3.org/1999/xhtml", "td");
+ descriptionCell.setAttribute("class", "tooltipHeaderDescription");
+ descriptionCell.textContent = text;
+ return descriptionCell;
+}
+
+/**
+ * PRIVATE: If now is during an occurrence, return the occurrence. If now is
+ * before an occurrence, return the next occurrence or otherwise the previous
+ * occurrence.
+ *
+ * @param {calIEvent} calendarEvent The text to display in the node
+ * @returns {mixed} Returns a calIDateTime for the detected
+ * occurrence or calIEvent, if this is a
+ * non-recurring event
+ */
+function getCurrentNextOrPreviousRecurrence(calendarEvent) {
+ if (!calendarEvent.recurrenceInfo) {
+ return calendarEvent;
+ }
+
+ let dur = calendarEvent.duration.clone();
+ dur.isNegative = true;
+
+ // To find current event when now is during event, look for occurrence
+ // starting duration ago.
+ let probeTime = cal.dtz.now();
+ probeTime.addDuration(dur);
+
+ let occ = calendarEvent.recurrenceInfo.getNextOccurrence(probeTime);
+
+ if (!occ) {
+ let occs = calendarEvent.recurrenceInfo.getOccurrences(
+ calendarEvent.startDate,
+ probeTime,
+ 0,
+ {}
+ );
+ occ = occs[occs.length - 1];
+ }
+ return occ;
+}