diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/base/content/widgets | |
parent | Initial commit. (diff) | |
download | thunderbird-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 '')
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; +} |