diff options
Diffstat (limited to 'comm/calendar/base/content/dialogs')
41 files changed, 11104 insertions, 0 deletions
diff --git a/comm/calendar/base/content/dialogs/calendar-alarm-dialog.js b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.js new file mode 100644 index 0000000000..8f384960ba --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.js @@ -0,0 +1,484 @@ +/* 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/. */ + +/* exported onDismissAllAlarms, setupWindow, finishWindow, addWidgetFor, + * removeWidgetFor, onSelectAlarm, ensureCalendarVisible + */ + +/* global MozElements */ + +/* import-globals-from ../item-editing/calendar-item-editing.js */ + +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +window.addEventListener("load", event => { + setupWindow(); + window.arguments[0].wrappedJSObject.window_onLoad(); +}); +window.addEventListener("unload", finishWindow); +window.addEventListener("focus", onFocusWindow); +window.addEventListener("keypress", event => { + if (event.key == "Escape") { + window.close(); + } +}); + +var gShutdownDetected = false; + +/** + * Detects the "mail-unloading-messenger" notification to prevent snoozing items + * as well as closes this window when the main window is closed. Not doing so can + * cause data loss with CalStorageCalendar. + */ +var gShutdownObserver = { + observe() { + let windows = Array.from(Services.wm.getEnumerator("mail:3pane")); + if (windows.filter(win => !win.closed).length == 0) { + gShutdownDetected = true; + window.close(); + } + }, +}; + +addEventListener("DOMContentLoaded", () => { + document.getElementById("alarm-snooze-all-popup").addEventListener("snooze", event => { + snoozeAllItems(event.detail); + }); +}); + +XPCOMUtils.defineLazyGetter(this, "gReadOnlyNotification", () => { + return new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document.getElementById("readonly-notification").append(element); + }); +}); + +/** + * Helper function to get the alarm service and cache it. + * + * @returns The alarm service component + */ +function getAlarmService() { + if (!("mAlarmService" in window)) { + window.mAlarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService( + Ci.calIAlarmService + ); + } + return window.mAlarmService; +} + +/** + * Event handler for the 'snooze' event. Snoozes the given alarm by the given + * number of minutes using the alarm service. + * + * @param event The snooze event + */ +function onSnoozeAlarm(event) { + // reschedule alarm: + let duration = getDuration(event.detail); + if (aboveSnoozeLimit(duration)) { + // we prevent snoozing too far if the alarm wouldn't be displayed + return; + } + getAlarmService().snoozeAlarm(event.target.item, event.target.alarm, duration); +} + +/** + * Event handler for the 'dismiss' event. Dismisses the given alarm using the + * alarm service. + * + * @param event The snooze event + */ +function onDismissAlarm(event) { + getAlarmService().dismissAlarm(event.target.item, event.target.alarm); +} + +/** + * Called to dismiss all alarms in the alarm window. + */ +function onDismissAllAlarms() { + // removes widgets on the fly: + let alarmRichlist = document.getElementById("alarm-richlist"); + let parentItems = {}; + let widgets = []; + + // Make a copy of the child nodes as they get modified live + for (let node of alarmRichlist.children) { + // Check if the node is a valid alarm and is still part of DOM + if ( + node.parentNode && + node.item && + node.alarm && + !(node.item.parentItem.hashId in parentItems) + ) { + // We only need to acknowledge one occurrence for repeating items + parentItems[node.item.parentItem.hashId] = node.item.parentItem; + widgets.push({ item: node.item, alarm: node.alarm }); + } + } + for (let widget of widgets) { + getAlarmService().dismissAlarm(widget.item, widget.alarm); + } +} + +/** + * Event handler fired when the alarm widget's "Details..." label was clicked. + * Open the event dialog in the most recent Thunderbird window. + * + * @param event The itemdetails event. + */ +function onItemDetails(event) { + // We want this to happen in a calendar window if possible. Otherwise open + // it using our window. + let calWindow = cal.window.getCalendarWindow(); + if (calWindow) { + calWindow.modifyEventWithDialog(event.target.item, true); + } else { + modifyEventWithDialog(event.target.item, true); + } +} + +/** + * Sets up the alarm dialog, initializing the default snooze length and setting + * up the relative date update timer. + */ +var gRelativeDateUpdateTimer; +function setupWindow() { + // We want to update when we are at 0 seconds past the minute. To do so, use + // setTimeout to wait until we are there, then setInterval to execute every + // minute. Since setInterval is not totally exact, we may run into problems + // here. I hope not! + let current = new Date(); + + let timeout = (60 - current.getSeconds()) * 1000; + gRelativeDateUpdateTimer = setTimeout(() => { + updateRelativeDates(); + gRelativeDateUpdateTimer = setInterval(updateRelativeDates, 60 * 1000); + }, timeout); + + // Configure the shutdown observer. + Services.obs.addObserver(gShutdownObserver, "mail-unloading-messenger"); + + // Give focus to the alarm richlist after onload completes. See bug 103197 + setTimeout(onFocusWindow, 0); +} + +/** + * Unload function for the alarm dialog. If applicable, snooze the remaining + * alarms and clean up the relative date update timer. + */ +function finishWindow() { + Services.obs.removeObserver(gShutdownObserver, "mail-unloading-messenger"); + + if (gShutdownDetected) { + return; + } + + let alarmRichlist = document.getElementById("alarm-richlist"); + + if (alarmRichlist.children.length > 0) { + // If there are still items, the window wasn't closed using dismiss + // all/snooze all. This can happen when the closer is clicked or escape + // is pressed. Snooze all remaining items using the default snooze + // property. + let snoozePref = Services.prefs.getIntPref("calendar.alarms.defaultsnoozelength", 0); + if (snoozePref <= 0) { + snoozePref = 5; + } + + snoozeAllItems(snoozePref); + } + + // Stop updating the relative time + clearTimeout(gRelativeDateUpdateTimer); +} + +/** + * Set up the focused element. If no element is focused, then switch to the + * richlist. + */ +function onFocusWindow() { + if (!document.commandDispatcher.focusedElement) { + document.getElementById("alarm-richlist").focus(); + } +} + +/** + * Timer callback to update all relative date labels + */ +function updateRelativeDates() { + let alarmRichlist = document.getElementById("alarm-richlist"); + for (let node of alarmRichlist.children) { + if (node.item && node.alarm) { + node.updateRelativeDateLabel(); + } + } +} + +/** + * Function to snooze all alarms the given number of minutes. + * + * @param aDurationMinutes The duration in minutes + */ +function snoozeAllItems(aDurationMinutes) { + let duration = getDuration(aDurationMinutes); + if (aboveSnoozeLimit(duration)) { + // we prevent snoozing too far if the alarm wouldn't be displayed + return; + } + + let alarmRichlist = document.getElementById("alarm-richlist"); + let parentItems = {}; + + // Make a copy of the child nodes as they get modified live + for (let node of alarmRichlist.children) { + // Check if the node is a valid alarm and is still part of DOM + if ( + node.parentNode && + node.item && + node.alarm && + cal.acl.isCalendarWritable(node.item.calendar) && + cal.acl.userCanModifyItem(node.item) && + !(node.item.parentItem.hashId in parentItems) + ) { + // We only need to acknowledge one occurrence for repeating items + parentItems[node.item.parentItem.hashId] = node.item.parentItem; + getAlarmService().snoozeAlarm(node.item, node.alarm, duration); + } + } + // we need to close the widget here explicitly because the dialog will stay + // opened if there a still not snoozable alarms + document.getElementById("alarm-snooze-all-button").firstElementChild.hidePopup(); +} + +/** + * Receive a calIDuration object for a given number of minutes + * + * @param {long} aMinutes The number of minutes + * @returns {calIDuration} + */ +function getDuration(aMinutes) { + const MINUTESINWEEK = 7 * 24 * 60; + + // converting to weeks if any is required to avoid an integer overflow of duration.minutes as + // this is of type short + let weeks = Math.floor(aMinutes / MINUTESINWEEK); + aMinutes -= weeks * MINUTESINWEEK; + + let duration = cal.createDuration(); + duration.minutes = aMinutes; + duration.weeks = weeks; + duration.normalize(); + return duration; +} + +/** + * Check whether the snooze period exceeds the current limitation of the AlarmService and prompt + * the user with a message if so + * + * @param {calIDuration} aDuration The duration to snooze + * @returns {boolean} + */ +function aboveSnoozeLimit(aDuration) { + const LIMIT = Ci.calIAlarmService.MAX_SNOOZE_MONTHS; + + let currentTime = cal.dtz.now().getInTimezone(cal.dtz.UTC); + let limitTime = currentTime.clone(); + limitTime.month += LIMIT; + + let durationUntilLimit = limitTime.subtractDate(currentTime); + if (aDuration.compare(durationUntilLimit) > 0) { + let msg = PluralForm.get(LIMIT, cal.l10n.getCalString("alarmSnoozeLimitExceeded")); + cal.showError(msg.replace("#1", LIMIT), window); + return true; + } + return false; +} + +/** + * Sets up the window title, counting the number of alarms in the window. + */ +function setupTitle() { + let alarmRichlist = document.getElementById("alarm-richlist"); + let reminders = alarmRichlist.children.length; + + let title = PluralForm.get(reminders, cal.l10n.getCalString("alarmWindowTitle.label")); + document.title = title.replace("#1", reminders); +} + +/** + * Comparison function for the start date of a calendar item and + * the start date of a calendar-alarm-widget. + * + * @param aItem A calendar item for the comparison of the start date property + * @param aWidgetItem The alarm widget item for the start date comparison with the given calendar item + * @returns 1 - if the calendar item starts before the calendar-alarm-widget + * -1 - if the calendar-alarm-widget starts before the calendar item + * 0 - otherwise + */ +function widgetAlarmComptor(aItem, aWidgetItem) { + if (aItem == null || aWidgetItem == null) { + return -1; + } + + // Get the dates to compare + let aDate = aItem[cal.dtz.startDateProp(aItem)]; + let bDate = aWidgetItem[cal.dtz.startDateProp(aWidgetItem)]; + + return aDate.compare(bDate); +} + +/** + * Add an alarm widget for the passed alarm and item. + * + * @param aItem The calendar item to add a widget for. + * @param aAlarm The alarm to add a widget for. + */ +function addWidgetFor(aItem, aAlarm) { + let widget = document.createXULElement("richlistitem", { + is: "calendar-alarm-widget-richlistitem", + }); + let alarmRichlist = document.getElementById("alarm-richlist"); + + // Add widgets sorted by start date ascending + cal.data.binaryInsertNode(alarmRichlist, widget, aItem, widgetAlarmComptor, false); + + widget.item = aItem; + widget.alarm = aAlarm; + widget.addEventListener("snooze", onSnoozeAlarm); + widget.addEventListener("dismiss", onDismissAlarm); + widget.addEventListener("itemdetails", onItemDetails); + + setupTitle(); + doReadOnlyChecks(); + + if (!alarmRichlist.userSelectedWidget) { + // Always select first widget of the list. + // Since the onselect event causes scrolling, + // we don't want to process the event when adding widgets. + alarmRichlist.suppressOnSelect = true; + alarmRichlist.selectedItem = alarmRichlist.firstElementChild; + alarmRichlist.suppressOnSelect = false; + } + + window.focus(); + window.getAttention(); +} + +/** + * Remove the alarm widget for the passed alarm and item. + * + * @param aItem The calendar item to remove the alarm widget for. + * @param aAlarm The alarm to remove the widget for. + */ +function removeWidgetFor(aItem, aAlarm) { + let hashId = aItem.hashId; + let alarmRichlist = document.getElementById("alarm-richlist"); + let nodes = alarmRichlist.children; + let notfound = true; + for (let i = nodes.length - 1; notfound && i >= 0; --i) { + let widget = nodes[i]; + if ( + widget.item && + widget.item.hashId == hashId && + widget.alarm && + widget.alarm.icalString == aAlarm.icalString + ) { + if (widget.selected) { + // Advance selection if needed + widget.control.selectedItem = widget.previousElementSibling || widget.nextElementSibling; + } + + widget.removeEventListener("snooze", onSnoozeAlarm); + widget.removeEventListener("dismiss", onDismissAlarm); + widget.removeEventListener("itemdetails", onItemDetails); + + widget.remove(); + doReadOnlyChecks(); + closeIfEmpty(); + notfound = false; + } + } + + // Update the title + setupTitle(); + closeIfEmpty(); +} + +/** + * Enables/disables the 'snooze all' button and displays or removes a r/o + * notification based on the readability of the calendars of the alarms visible + * in the alarm list + */ +function doReadOnlyChecks() { + let countRO = 0; + let alarmRichlist = document.getElementById("alarm-richlist"); + for (let node of alarmRichlist.children) { + if (!cal.acl.isCalendarWritable(node.item.calendar) || !cal.acl.userCanModifyItem(node.item)) { + countRO++; + } + } + + // we disable the button if there are only alarms for not-writable items + let snoozeAllButton = document.getElementById("alarm-snooze-all-button"); + snoozeAllButton.disabled = countRO && countRO == alarmRichlist.children.length; + if (snoozeAllButton.disabled) { + let tooltip = cal.l10n.getString("calendar-alarms", "reminderDisabledSnoozeButtonTooltip"); + snoozeAllButton.setAttribute("tooltiptext", tooltip); + } else { + snoozeAllButton.removeAttribute("tooltiptext"); + } + + let notification = gReadOnlyNotification.getNotificationWithValue("calendar-readonly"); + if (countRO && !notification) { + let message = cal.l10n.getString("calendar-alarms", "reminderReadonlyNotification", [ + snoozeAllButton.label, + ]); + gReadOnlyNotification.appendNotification( + "calendar-readonly", + { + label: message, + priority: gReadOnlyNotification.PRIORITY_WARNING_MEDIUM, + }, + null + ); + } else if (notification && !countRO) { + gReadOnlyNotification.removeNotification(notification); + } +} + +/** + * Close the alarm dialog if there are no further alarm widgets + */ +function closeIfEmpty() { + let alarmRichlist = document.getElementById("alarm-richlist"); + + // we don't want to close if the alarm service is still loading, as the + // removed alarms may be immediately added again. + if (!alarmRichlist.hasChildNodes() && !getAlarmService().isLoading) { + window.close(); + } +} + +/** + * Handler function called when an alarm entry in the richlistbox is selected + * + * @param event The DOM event from the click action + */ +function onSelectAlarm(event) { + let richList = document.getElementById("alarm-richlist"); + if (richList == event.target) { + richList.ensureElementIsVisible(richList.getSelectedItem(0)); + richList.userSelectedWidget = true; + } +} + +function ensureCalendarVisible(aCalendar) { + // This function is called on the alarm dialog from calendar-item-editing.js. + // Normally, it makes sure that the calendar being edited is made visible, + // but the alarm dialog is too far away from the calendar views that it + // makes sense to force visibility for the calendar. Therefore, do nothing. +} diff --git a/comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml new file mode 100644 index 0000000000..02591f4810 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml @@ -0,0 +1,53 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/calendar-alarm-dialog.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<html + id="calendar-alarm-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="calendar-alarm-dialog" + windowtype="Calendar:AlarmWindow" + persist="screenX screenY width height" + lightweightthemes="true" + width="600" + height="300" + scrolling="false" +> + <head> + <title>&calendar.alarm.title.label;</title> + <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-alarm-widget.js"></script> + <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-alarm-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <vbox id="readonly-notification"> + <!-- notificationbox will be added here lazily. --> + </vbox> + + <richlistbox id="alarm-richlist" flex="1" onselect="onSelectAlarm(event)" /> + + <hbox id="alarm-actionbar" pack="end" align="center"> + <button id="alarm-snooze-all-button" type="menu" label="&calendar.alarm.snoozeallfor.label;"> + <menupopup is="calendar-snooze-popup" id="alarm-snooze-all-popup" ignorekeys="true" /> + </button> + <button + id="alarm-dismiss-all-button" + label="&calendar.alarm.dismissall.label;" + oncommand="onDismissAllAlarms();" + /> + </hbox> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js new file mode 100644 index 0000000000..d53569028c --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js @@ -0,0 +1,43 @@ +/* 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 getPreviewForItem */ // From mouseoverPreviews.js + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +window.addEventListener("DOMContentLoaded", onLoad); + +function onLoad() { + let dialog = document.querySelector("dialog"); + let item = window.arguments[0].item; + let vbox = getPreviewForItem(item, false); + if (vbox) { + document.getElementById("item-box").replaceWith(vbox); + } + + let descr = document.getElementById("conflicts-description"); + + // TODO These strings should move to Fluent. + // For that matter, this dialog should be reworked! + document.title = cal.l10n.getCalString("itemModifiedOnServerTitle"); + descr.textContent = cal.l10n.getCalString("itemModifiedOnServer"); + + if (window.arguments[0].mode == "modify") { + descr.textContent += cal.l10n.getCalString("modifyWillLoseData"); + dialog.getButton("accept").setAttribute("label", cal.l10n.getCalString("proceedModify")); + } else { + descr.textContent += cal.l10n.getCalString("deleteWillLoseData"); + dialog.getButton("accept").setAttribute("label", cal.l10n.getCalString("proceedDelete")); + } + + dialog.getButton("cancel").setAttribute("label", cal.l10n.getCalString("updateFromServer")); +} + +document.addEventListener("dialogaccept", () => { + window.arguments[0].overwrite = true; +}); + +document.addEventListener("dialogcancel", () => { + window.arguments[0].overwrite = false; +}); diff --git a/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml new file mode 100644 index 0000000000..868df36248 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml @@ -0,0 +1,35 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-views.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<html + id="calendar-conflicts-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Calendar:Conflicts" + persist="screenX screenY" + lightweightthemes="true" + scrolling="false" +> + <head> + <link rel="localization" href="branding/brand.ftl" /> + <script defer="defer" src="chrome://calendar/content/widgets/mouseoverPreviews.js"></script> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-conflicts-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog> + <vbox id="conflicts-vbox" flex="1"> + <vbox id="item-box" flex="1" /> + <description id="conflicts-description" style="max-width: 40em; margin-top: 1ex" /> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-creation.js b/comm/calendar/base/content/dialogs/calendar-creation.js new file mode 100644 index 0000000000..b4d2a7c2e4 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-creation.js @@ -0,0 +1,836 @@ +/* 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/. */ + +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +ChromeUtils.defineModuleGetter(this, "MsgAuthPrompt", "resource:///modules/MsgAsyncPrompter.jsm"); + +/* exported checkRequired, fillLocationPlaceholder, selectProvider, updateNoCredentials, */ + +/* import-globals-from calendar-identity-utils.js */ + +/** + * For managing dialog button handler state. Stores the current handlers so we + * can remove them with removeEventListener. Provides a way to look up the + * button handler functions to be used with a given panel. + */ +var gButtonHandlers = { + accept: null, + extra2: null, + + // Maps a panel DOM node ID to the button handlers to use for that panel. + forNodeId: { + "panel-select-calendar-type": { + accept: selectCalendarType, + }, + "panel-local-calendar-settings": { + accept: registerLocalCalendar, + extra2: () => selectPanel("panel-select-calendar-type"), + }, + "panel-network-calendar-settings": { + accept: event => { + event.preventDefault(); + event.stopPropagation(); + findCalendars(); + }, + extra2: () => selectPanel("panel-select-calendar-type"), + }, + "panel-select-calendars": { + accept: createNetworkCalendars, + extra2: () => selectPanel("panel-network-calendar-settings"), + }, + "panel-addon-calendar-settings": { + extra2: () => selectPanel("panel-select-calendar-type"), + // This 'accept' is set dynamically when the calendar type is selected. + accept: null, + }, + }, +}; + +/** @type {calICalendar | null} */ +var gLocalCalendar = null; + +/** + * A type of calendar that can be created with this dialog. + * + * @typedef {CalendarType} + * @property {string} id A unique ID for this type, e.g. "local" or + * "network" for built-in types and + * "3" or "4" for add-on types. + * @property {boolean} builtIn Whether this is a built in type. + * @property {Function} onSelected The "accept" button handler to call when + * the type is selected. + * @property {string} [label] Text to use in calendar type selection UI. + * @property {string} [panelSrc] The "src" property for the <browser> for + * this type's settings panel, typically a + * path to an html document. Only needed + * for types registered by add-ons. + * @property {Function} [onCreated] The "accept" button handler for this + * type's settings panel. Only needed for + * types registered by add-ons. + */ + +/** + * Registry of calendar types. The key should match the type's `id` property. + * Add-ons may register additional types. + * + * @type {Map<string, CalendarType>} + */ +var gCalendarTypes = new Map([ + [ + "local", + { + id: "local", + builtIn: true, + onSelected: () => { + // Create a local calendar to use, so we can share code with the calendar + // preferences dialog. + if (!gLocalCalendar) { + gLocalCalendar = cal.manager.createCalendar( + "storage", + Services.io.newURI("moz-storage-calendar://") + ); + + initMailIdentitiesRow(gLocalCalendar); + notifyOnIdentitySelection(gLocalCalendar); + } + selectPanel("panel-local-calendar-settings"); + }, + }, + ], + [ + "network", + { + id: "network", + builtIn: true, + onSelected: () => selectPanel("panel-network-calendar-settings"), + }, + ], +]); + +/** @type {CalendarType | null} */ +var gSelectedCalendarType = null; + +/** + * Register a calendar type to offer in the dialog. For add-ons to use. Add-on + * code should store the returned ID and use it for unregistering the type. + * + * @param {CalendarType} type - The type object to register. + * @returns {string} The generated ID for the type. + */ +function registerCalendarType(type) { + type.id = String(gCalendarTypes.size + 1); + type.builtIn = false; + + if (!type.onSelected) { + type.onSelected = () => selectPanel("panel-addon-calendar-settings"); + } + gCalendarTypes.set(type.id, type); + + // Add an option for this type to the "select calendar type" panel. + let radiogroup = document.getElementById("calendar-type"); + let radio = document.createXULElement("radio"); + radio.setAttribute("value", type.id); + radio.setAttribute("label", type.label); + radiogroup.appendChild(radio); + + return type.id; +} + +/** + * Unregister a calendar type. For add-ons to use. + * + * @param {string} id - The ID of the type to unregister. + */ +function unregisterCalendarType(id) { + // Don't allow unregistration of built-in types. + if (gCalendarTypes.get(id)?.builtIn) { + cal.WARN( + `calendar creation dialog: unregistering calendar type "${id}"` + + " failed because it is a built in type" + ); + return; + } + // We are using the size of gCalendarTypes to generate unique IDs for + // registered types, so don't fully remove the type. + gCalendarTypes.set(id, undefined); + + // Remove the option for this type from the "select calendar type" panel. + let radiogroup = document.getElementById("calendar-type"); + let radio = radiogroup.querySelector(`[value="${id}"]`); + if (radio) { + radiogroup.removeChild(radio); + } +} + +/** + * Tools for managing how providers are used for calendar detection. May be used + * by add-ons to modify which providers are used and which results are preferred. + */ +var gProviderUsage = { + /** + * A function that returns a list of provider types to filter out and not use + * to detect calendars, for a given location and username. The providers are + * filtered out before calendar detection. For example, the "Provider for + * Google Calendar" add-on might filter out the "caldav" provider: + * + * (providers, location, username) => { + * domain = username.split("@")[1]; + * if (providers.includes("gdata") && (domain == "googlemail.com" || domain == "gmail.com")) { + * return ["caldav"]; + * } + * return []; + * } + * + * @callback ProviderFilter + * @param {string[]} providers - Array of provider types to be used (if not filtered out). + * @param {string} location - Location to use for calendar detection. + * @param {string} username - Username to use for calendar detection. + * @returns {string[]} Array of provider types to be filtered out. + */ + + /** @type {ProviderFilter[]} */ + _preDetectFilters: [], + + /** + * A mapping from a less preferred provider type to a set of more preferred + * provider types. Used after calendar detection to default to a more + * preferred provider when there are results from more than one provider. + * + * @typedef {Map<string, Set<string>>} ProviderPreferences + */ + + /** + * @type {ProviderPreferences} + */ + _postDetectPreferences: new Map(), + + get preDetectFilters() { + return this._preDetectFilters; + }, + + get postDetectPreferences() { + return this._postDetectPreferences; + }, + + /** + * Add a new provider filter function. + * + * @param {ProviderFilter} providerFilter + */ + addPreDetectFilter(providerFilter) { + this._preDetectFilters.push(providerFilter); + }, + + /** + * Add a preference for one provider type over another provider type. + * + * @param {string} preferredType - The preferred provider type. + * @param {string} nonPreferredType - The non-preferred provider type. + */ + addPostDetectPreference(preferredType, nonPreferredType) { + let prefs = this._postDetectPreferences; + + if (this.detectPreferenceCycle(prefs, preferredType, nonPreferredType)) { + cal.WARN( + `Adding a preference for provider type "${preferredType}" over ` + + `type "${nonPreferredType}" would cause a preference cycle, ` + + `not adding this preference to prevent a cycle` + ); + } else { + let current = prefs.get(nonPreferredType); + if (current) { + current.add(preferredType); + } else { + prefs.set(nonPreferredType, new Set([preferredType])); + } + } + }, + + /** + * Check whether adding a preference for one provider type over another would + * cause a cycle in the order of preferences. We assume that the preferences + * do not contain any cycles already. + * + * @param {ProviderPreferences} prefs - The current preferences. + * @param {string} preferred - Potential preferred provider. + * @param {string} nonPreferred - Potential non-preferred provider. + * @returns {boolean} True if it would cause a cycle. + */ + detectPreferenceCycle(prefs, preferred, nonPreferred) { + let cycle = false; + + let innerDetect = preferredSet => { + if (cycle) { + // Bail out, a cycle has already been detected. + return; + } else if (preferredSet.has(nonPreferred)) { + // A cycle! We have arrived back at the nonPreferred provider type. + cycle = true; + return; + } + // Recursively check each preferred type. + for (let item of preferredSet) { + let nextPreferredSet = prefs.get(item); + if (nextPreferredSet) { + innerDetect(nextPreferredSet); + } + } + }; + + innerDetect(new Set([preferred])); + return cycle; + }, +}; + +// If both ics and caldav results exist, default to the caldav results. +gProviderUsage.addPostDetectPreference("caldav", "ics"); + +/** + * Select a specific panel in the dialog. Used to move from one panel to another. + * + * @param {string} id - The id of the panel node to select. + */ +function selectPanel(id) { + for (let element of document.getElementById("calendar-creation-dialog").children) { + element.hidden = element.id != id; + } + let panel = document.getElementById(id); + updateButton("accept", panel); + updateButton("extra2", panel); + selectNetworkStatus("none"); + checkRequired(); + + let firstInput = panel.querySelector("input"); + if (firstInput) { + firstInput.focus(); + } +} + +/** + * Set a specific network loading status for the network settings panel. + * See the CSS file for appropriate values to set. + * + * @param {string} status - The status to set. + */ +function selectNetworkStatus(status) { + for (let row of document.querySelectorAll(".network-status-row")) { + row.setAttribute("status", status); + } +} + +/** + * Update the label, accesskey, and event listener for a dialog button. + * + * @param {string} name - The dialog button name, e.g. 'accept', 'extra2'. + * @param {Element} sourceNode - The source node to take attribute values from. + */ +function updateButton(name, sourceNode) { + let dialog = document.getElementById("calendar-creation-dialog"); + let button = dialog.getButton(name); + let label = sourceNode.getAttribute("buttonlabel" + name); + let accesskey = sourceNode.getAttribute("buttonaccesskey" + name); + + let handler = gButtonHandlers.forNodeId[sourceNode.id][name]; + + if (label) { + button.setAttribute("label", label); + button.hidden = false; + } else { + button.hidden = true; + } + + button.setAttribute("accesskey", accesskey || ""); + + // 'dialogaccept', 'dialogextra2', etc. + let eventName = "dialog" + name; + + document.removeEventListener(eventName, gButtonHandlers[name]); + if (handler) { + document.addEventListener(eventName, handler); + // Store a reference to the current handler, to allow removing it later. + gButtonHandlers[name] = handler; + } +} + +/** + * Update the disabled state of the accept button by checking the values of + * required fields, based on the current panel. + */ +function checkRequired() { + let dialog = document.getElementById("calendar-creation-dialog"); + let selectedPanel = null; + for (let element of dialog.children) { + if (!element.hidden) { + selectedPanel = element; + } + } + if (!selectedPanel) { + dialog.setAttribute("buttondisabledaccept", "true"); + return; + } + + let disabled = false; + switch (selectedPanel.id) { + case "panel-local-calendar-settings": + disabled = !selectedPanel.querySelector("form").checkValidity(); + break; + case "panel-network-calendar-settings": { + let location = document.getElementById("network-location-input"); + let username = document.getElementById("network-username-input"); + + disabled = !location.value && !username.value.split("@")[1]; + break; + } + } + + if (disabled) { + dialog.setAttribute("buttondisabledaccept", "true"); + } else { + dialog.removeAttribute("buttondisabledaccept"); + } +} + +/** + * Update the placeholder text for the network location field. If the username + * is a valid email address use the domain part of the username, otherwise use + * the default placeholder. + */ +function fillLocationPlaceholder() { + let location = document.getElementById("network-location-input"); + let userval = document.getElementById("network-username-input").value; + let parts = userval.split("@"); + let domain = parts.length == 2 && parts[1] ? parts[1] : null; + + if (domain) { + location.setAttribute("placeholder", domain); + } else { + location.setAttribute("placeholder", location.getAttribute("default-placeholder")); + } +} + +/** + * Update the select network calendar panel to show or hide the provider + * selection dropdown. + * + * @param {boolean} isSingle - If true, there is just one matching provider. + */ +function setSingleProvider(isSingle) { + document.getElementById("network-selectcalendar-description-single").hidden = !isSingle; + document.getElementById("network-selectcalendar-description-multiple").hidden = isSingle; + document.getElementById("network-selectcalendar-providertype-box").hidden = isSingle; +} + +/** + * Fill the providers menulist with the given provider types. The types must + * correspond to the providers that detected calendars. + * + * @param {string[]} providerTypes - An array of provider types. + * @returns {Element} The selected menuitem. + */ +function fillProviders(providerTypes) { + let menulist = document.getElementById("network-selectcalendar-providertype-menulist"); + let popup = menulist.menupopup; + while (popup.lastChild) { + popup.removeChild(popup.lastChild); + } + + let providers = cal.provider.detection.providers; + + for (let type of providerTypes) { + let provider = providers.get(type); + let menuitem = document.createXULElement("menuitem"); + menuitem.value = type; + menuitem.setAttribute("label", provider.displayName || type); + popup.appendChild(menuitem); + } + + // Select a provider menu item based on provider preferences. + let preferredTypes = new Set(providerTypes); + + for (let [nonPreferred, preferredSet] of gProviderUsage.postDetectPreferences) { + if (preferredTypes.has(nonPreferred) && setsIntersect(preferredSet, preferredTypes)) { + preferredTypes.delete(nonPreferred); + } + } + let preferredIndex = providerTypes.findIndex(type => preferredTypes.has(type)); + menulist.selectedIndex = preferredIndex == -1 ? 0 : preferredIndex; + + return menulist.selectedItem; +} + +/** + * Return true if the intersection of two sets contains at least one item. + * + * @param {Set} setA - A set. + * @param {Set} setB - A set. + * @returns {boolean} + */ +function setsIntersect(setA, setB) { + for (let item of setA) { + if (setB.has(item)) { + return true; + } + } + return false; +} + +/** + * Select the given provider and update the calendar list to fill the + * corresponding calendars. Will use the results from the last findCalendars + * response. + * + * @param {string} type - The provider type to select. + */ +function selectProvider(type) { + let providerMap = findCalendars.lastResult; + let calendarList = document.getElementById("network-calendar-list"); + + let calendars = providerMap.get(type) || []; + renderCalendarList(calendarList, calendars); +} + +/** + * Empty a calendar list and then fill it with calendars. + * + * @param {Element} calendarList - A richlistbox element for listing calendars. + * @param {calICalendar[]} calendars - An array of calendars to display in the list. + */ +function renderCalendarList(calendarList, calendars) { + while (calendarList.hasChildNodes()) { + calendarList.lastChild.remove(); + } + let propertiesButtonLabel = calendarList.getAttribute("propertiesbuttonlabel"); + calendars.forEach((calendar, index) => { + let item = document.createXULElement("richlistitem"); + item.calendar = calendar; + + let checkbox = document.createXULElement("checkbox"); + let checkboxId = "checkbox" + index; + checkbox.id = checkboxId; + checkbox.classList.add("calendar-selected"); + item.appendChild(checkbox); + + let colorMarker = document.createElement("div"); + colorMarker.classList.add("calendar-color"); + colorMarker.style.backgroundColor = calendar.getProperty("color"); + item.appendChild(colorMarker); + + let label = document.createXULElement("label"); + label.classList.add("calendar-name"); + label.value = calendar.name; + label.control = checkboxId; + item.appendChild(label); + + let propertiesButton = document.createXULElement("button"); + propertiesButton.classList.add("calendar-edit-button"); + propertiesButton.label = propertiesButtonLabel; + propertiesButton.addEventListener("command", openCalendarPropertiesFromEvent); + item.appendChild(propertiesButton); + + if (calendar.getProperty("disabled")) { + item.disabled = true; + item.toggleAttribute("calendar-disabled", true); + checkbox.disabled = true; + propertiesButton.disabled = true; + } else { + checkbox.checked = true; + } + calendarList.appendChild(item); + }); +} + +/** + * Update dialog fields based on the value of the "no credentials" checkbox. + * + * @param {boolean} noCredentials - True, if "no credentials" is checked. + */ +function updateNoCredentials(noCredentials) { + if (noCredentials) { + document.getElementById("network-username-input").setAttribute("disabled", "true"); + document.getElementById("network-username-input").value = ""; + } else { + document.getElementById("network-username-input").removeAttribute("disabled"); + } +} + +/** + * The accept button event listener for the "select calendar type" panel. + * + * @param {Event} event + */ +function selectCalendarType(event) { + event.preventDefault(); + event.stopPropagation(); + let radiogroup = document.getElementById("calendar-type"); + let calendarType = gCalendarTypes.get(radiogroup.value); + + if (!calendarType.builtIn && calendarType !== gSelectedCalendarType) { + setUpAddonCalendarSettingsPanel(calendarType); + } + gSelectedCalendarType = calendarType; + calendarType.onSelected(); +} + +/** + * Set up the settings panel for calendar types registered by addons. + * + * @param {CalendarType} calendarType - The calendar type. + */ +function setUpAddonCalendarSettingsPanel(calendarType) { + function setUpBrowser(browser, src) { + // Allow keeping dialog background color without jumping through hoops. + browser.setAttribute("transparent", "true"); + browser.setAttribute("flex", "1"); + browser.setAttribute("type", "content"); + browser.setAttribute("src", src); + } + let panel = document.getElementById("panel-addon-calendar-settings"); + let browser = panel.lastElementChild; + + if (browser) { + setUpBrowser(browser, calendarType.panelSrc); + } else { + browser = document.createXULElement("browser"); + setUpBrowser(browser, calendarType.panelSrc); + + panel.appendChild(browser); + // The following emit is needed for the browser to work with addon content. + ExtensionParent.apiManager.emit("extension-browser-inserted", browser); + } + + // Set up the accept button handler for the panel. + gButtonHandlers.forNodeId["panel-addon-calendar-settings"].accept = calendarType.onCreated; +} + +/** + * Handle change of the email (identity) menu for local calendar creation. + * Show a notification when "none" is selected. + * + * @param {Event} event - The menu selection event. + */ +function onChangeIdentity(event) { + notifyOnIdentitySelection(gLocalCalendar); +} + +/** + * Prepare the local storage calendar with the information from the dialog. + * This can be monkeypatched to add additional values. + * + * @param {calICalendar} calendar - The calendar to prepare. + * @returns {calICalendar} The same calendar, prepared with any + * extra values. + */ +function prepareLocalCalendar(calendar) { + calendar.name = document.getElementById("local-calendar-name-input").value; + calendar.setProperty("color", document.getElementById("local-calendar-color-picker").value); + + if (!document.getElementById("local-fire-alarms-checkbox").checked) { + calendar.setProperty("suppressAlarms", true); + } + + saveMailIdentitySelection(calendar); + return calendar; +} + +/** + * The accept button event listener for the "local calendar settings" panel. + * Registers the local storage calendar and closes the dialog. + */ +function registerLocalCalendar() { + cal.manager.registerCalendar(prepareLocalCalendar(gLocalCalendar)); +} + +/** + * Start detection and find any calendars using the information from the + * network settings panel. + * + * @param {string} [password] - The password for this attempt, if any. + * @param {boolean} [savePassword] - Whether to save the password in the + * password manager. + */ +function findCalendars(password, savePassword = false) { + selectNetworkStatus("loading"); + let username = document.getElementById("network-username-input"); + let location = document.getElementById("network-location-input"); + let locationValue = location.value || username.value.split("@")[1] || ""; + + // webcal(s): doesn't work with content principal. + locationValue = locationValue.replace(/^webcal(s)?(:.*)/, "http$1$2").trim(); + cal.provider.detection + .detect( + username.value, + password, + locationValue, + savePassword, + gProviderUsage.preDetectFilters, + {} + ) + .then(onDetectionSuccess, onDetectionError.bind(null, password, locationValue)); +} + +/** + * Called when detection successfully finds calendars. Displays the UI for + * selecting calendars to subscribe to. + * + * @param {Map<string, calICalendar[]>} providerMap Map from provider type + * (e.g. "ics", "caldav") + * to an array of calendars. + */ +function onDetectionSuccess(providerMap) { + // Disable the calendars the user has already subscribed to. In the future + // we should show a string when all calendars are already subscribed. + let existing = new Set(cal.manager.getCalendars({}).map(calendar => calendar.uri.spec)); + + let calendarsMap = new Map(); + for (let [provider, calendars] of providerMap.entries()) { + let newCalendars = calendars.map(calendar => { + let newCalendar = prepareNetworkCalendar(calendar); + if (existing.has(calendar.uri.spec)) { + newCalendar.setProperty("disabled", true); + } + return newCalendar; + }); + + calendarsMap.set(provider.type, newCalendars); + } + + if (!calendarsMap.size) { + selectNetworkStatus("notfound"); + return; + } + + // Update the panel with the results from the provider map. + setSingleProvider(calendarsMap.size <= 1); + findCalendars.lastResult = calendarsMap; + + let selectedItem = fillProviders([...calendarsMap.keys()]); + selectProvider(selectedItem.value); + + // Select the panel and validate the fields. + selectPanel("panel-select-calendars"); + checkRequired(); +} + +/** + * Called when detection fails to find any calendars. Show an appropriate + * error message, or if the error is an authentication error and no password + * was entered for this attempt, prompt the user to enter a password. + * + * @param {string} [password] - The password entered, if any. + * @param {string} [location] - The location input from the dialog. + * @param {Error} error - An error object. + */ +function onDetectionError(password, location, error) { + if (error instanceof cal.provider.detection.AuthFailedError) { + if (password) { + selectNetworkStatus("authfail"); + } else { + findCalendarsWithPassword(location); + return; + } + } else if (error instanceof cal.provider.detection.CanceledError) { + selectNetworkStatus("none"); + } else { + selectNetworkStatus("notfound"); + } + cal.ERROR( + "Error during calendar detection: " + + `${error.fileName || error.filename}:${error.lineNumber}: ${error}\n${error.stack}` + ); +} + +/** + * Prompt the user for a password and attempt to find calendars with it. + * + * @param {string} location - The location input from the dialog. + */ +function findCalendarsWithPassword(location) { + let password = { value: "" }; + let savePassword = { value: 1 }; + + let okWasClicked = new MsgAuthPrompt().promptPassword2( + null, + cal.l10n.getAnyString("messenger-mapi", "mapi", "loginText", [location]), + password, + cal.l10n.getAnyString("passwordmgr", "passwordmgr", "rememberPassword"), + savePassword + ); + + if (okWasClicked) { + findCalendars(password.value, savePassword.value); + } else { + selectNetworkStatus("authfail"); + } +} + +/** + * Make preparations on the given calendar (a detected calendar). This + * function can be monkeypatched to make general preparations, e.g. for values + * from additional form fields. + * + * @param {calICalendar} calendar - The calendar to prepare. + * @returns {calICalendar} The same calendar, prepared with + * any extra values. + */ +function prepareNetworkCalendar(calendar) { + let cached = document.getElementById("network-cache-checkbox").checked; + + if (!calendar.getProperty("cache.always")) { + let cacheSupported = calendar.getProperty("cache.supported") !== false; + calendar.setProperty("cache.enabled", cacheSupported ? cached : false); + } + + return calendar; +} + +/** + * The accept button handler for the 'select network calendars' panel. + * Subscribes to all of the selected network calendars and allows the dialog to + * close. + */ +function createNetworkCalendars() { + for (let listItem of document.getElementById("network-calendar-list").children) { + if (listItem.querySelector(".calendar-selected").checked) { + cal.manager.registerCalendar(listItem.calendar); + } + } +} + +/** + * Open the calendar properties dialog for a calendar in the calendar list. + * + * @param {Event} event - The triggering event. + */ +function openCalendarPropertiesFromEvent(event) { + let listItem = event.target.closest("richlistitem"); + if (listItem) { + let calendar = listItem.calendar; + if (calendar && !calendar.getProperty("disabled")) { + cal.window.openCalendarProperties(window, { calendar, canDisable: false }); + + // Update the calendar list item. + listItem.querySelector(".calendar-name").value = calendar.name; + listItem.querySelector(".calendar-color").style.backgroundColor = + calendar.getProperty("color"); + } + } +} + +window.addEventListener("load", () => { + fillLocationPlaceholder(); + selectPanel("panel-select-calendar-type"); + if (window.arguments[0]) { + let spec = window.arguments[0].spec; + if (/^webcals?:\/\//.test(spec)) { + selectPanel("panel-network-calendar-settings"); + document.getElementById("network-location-input").value = spec; + checkRequired(); + } + } +}); diff --git a/comm/calendar/base/content/dialogs/calendar-creation.xhtml b/comm/calendar/base/content/dialogs/calendar-creation.xhtml new file mode 100644 index 0000000000..7ae66d3af9 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-creation.xhtml @@ -0,0 +1,259 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-creation.css"?> + +<!-- TODO: messenger.dtd is used for the "Next" button; some relocation is needed. --> +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendarCreation.dtd"> %dtd1; +<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > +%dtd2; +<!ENTITY % dtd3 SYSTEM "chrome://lightning/locale/lightning.dtd" > +%dtd3; +<!ENTITY % dtd4 SYSTEM "chrome://messenger/locale/messenger.dtd" > +%dtd4; ]> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="calendar-general-dialog" + lightweightthemes="true" + style="min-width: 500px; min-height: 380px" + scrolling="false" +> + <head> + <title>&wizard.title;</title> + <link rel="localization" href="toolkit/global/wizard.ftl" /> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-identity-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-creation.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog + id="calendar-creation-dialog" + style="width: 100vw; height: 100vh" + buttons="accept,cancel" + > + <!-- Panel: Select Calendar Type --> + <vbox + id="panel-select-calendar-type" + buttonlabelaccept="&nextButton.label;" + buttonaccesskeyaccept="" + > + <description>&initialpage.description;</description> + <hbox class="indent"> + <radiogroup id="calendar-type"> + <radio value="local" label="&initialpage.computer.label;" selected="true" /> + <radio value="network" label="&initialpage.network.label;" /> + </radiogroup> + </hbox> + </vbox> + + <!-- Panel: Local Calendar Settings --> + <vbox + id="panel-local-calendar-settings" + buttonlabelaccept="&buttons.create.label;" + buttonaccesskeyaccept="&buttons.create.accesskey;" + buttonlabelextra2="&buttons.back.label;" + buttonaccesskeyextra2="&buttons.back.accesskey;" + hidden="true" + > + <vbox id="no-identity-notification" class="notification-inline"> + <!-- notificationbox will be added here lazily. --> + </vbox> + <html:form> + <html:table> + <html:tr> + <html:th> + <label + value="&calendar.server.dialog.name.label;" + control="local-calendar-name-input" + /> + </html:th> + <html:td> + <html:input + id="local-calendar-name-input" + class="calendar-creation-text-input" + flex="1" + required="required" + oninput="checkRequired()" + /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + value="&calendarproperties.color.label;" + control="local-calendar-color-picker" + /> + </html:th> + <html:td> + <html:input + id="local-calendar-color-picker" + class="small-margin" + type="color" + value="#A8C2E1" + /> + </html:td> + </html:tr> + <html:tr> + <html:th> </html:th> + <html:td> + <checkbox + id="local-fire-alarms-checkbox" + label="&calendarproperties.firealarms.label;" + checked="true" + /> + </html:td> + </html:tr> + <html:tr id="calendar-email-identity-row"> + <html:th> + <label + value="&lightning.calendarproperties.email.label;" + control="email-identity-menulist" + /> + </html:th> + <html:td> + <menulist id="email-identity-menulist" oncommand="onChangeIdentity(event)"> + <menupopup id="email-identity-menupopup" /> + </menulist> + </html:td> + </html:tr> + </html:table> + </html:form> + </vbox> + + <!-- Panel: Network Calendar Settings --> + <html:table + id="panel-network-calendar-settings" + flex="1" + buttonlabelaccept="&buttons.find.label;" + buttonaccesskeyaccept="&buttons.find.accesskey;" + buttonlabelextra2="&buttons.back.label;" + buttonaccesskeyextra2="&buttons.back.accesskey;" + hidden="true" + > + <html:tr id="network-username-row"> + <html:th> + <label value="&locationpage.username.label;" control="network-username-input" /> + </html:th> + <html:td> + <html:input + id="network-username-input" + class="calendar-creation-text-input" + flex="1" + oninput="fillLocationPlaceholder(); checkRequired()" + /> + </html:td> + </html:tr> + <html:tr id="network-location-row"> + <html:th> + <label value="&location.label;" control="network-location-input" /> + </html:th> + <html:td> + <html:input + id="network-location-input" + class="calendar-creation-text-input" + flex="1" + oninput="checkRequired()" + placeholder="&location.placeholder;" + default-placeholder="&location.placeholder;" + /> + </html:td> + </html:tr> + <html:tr id="network-nocredentials-row"> + <html:th> </html:th> + <html:td> + <checkbox + id="network-nocredentials-checkbox" + label="&network.nocredentials.label;" + oncommand="updateNoCredentials(this.checked)" + /> + </html:td> + </html:tr> + <html:tr id="network-cache-row"> + <html:th> </html:th> + <html:td> + <checkbox + id="network-cache-checkbox" + label="&calendarproperties.cache3.label;" + checked="true" + /> + </html:td> + </html:tr> + <html:tr class="network-status-row" status="none"> + <html:th> + <html:img + class="network-status-image" + src="chrome://global/skin/icons/loading.png" + srcset="chrome://global/skin/icons/loading@2x.png 2x" + alt="" + /> + </html:th> + <html:td> + <description class="status-label network-loading-label" + >&network.loading.description;</description + > + <description class="status-label network-notfound-label" + >&network.notfound.description;</description + > + <description class="status-label network-authfail-label" + >&network.authfail.description;</description + > + </html:td> + </html:tr> + </html:table> + + <!-- Panel: Select Calendars --> + <vbox + id="panel-select-calendars" + flex="1" + buttonlabelaccept="&buttons.subscribe.label;" + buttonaccesskeyaccept="&buttons.subscribe.accesskey;" + buttonlabelextra2="&buttons.back.label;" + buttonaccesskeyextra2="&buttons.back.accesskey;" + hidden="true" + > + <description id="network-selectcalendar-description-single" + >&network.subscribe.single.description;</description + > + <description id="network-selectcalendar-description-multiple" hidden="true" + >&network.subscribe.multiple.description;</description + > + <hbox id="network-selectcalendar-providertype-box" align="center" hidden="true"> + <label id="network-selectcalendar-providertype-label" value="&calendartype.label;" /> + <menulist + id="network-selectcalendar-providertype-menulist" + flex="1" + onselect="selectProvider(this.selectedItem.value)" + > + <menupopup id="network-selectcalendar-providertype-menupopup" /> + </menulist> + </hbox> + <richlistbox + id="network-calendar-list" + propertiesbuttonlabel="&calendar.context.properties.label;" + /> + </vbox> + + <!-- Panel: Add-on Calendar Settings --> + <!-- Populated dynamically by add-ons that need UI for a particular calendar type. --> + <vbox + id="panel-addon-calendar-settings" + buttonlabelaccept="&buttons.create.label;" + buttonaccesskeyaccept="&buttons.create.accesskey;" + buttonlabelextra2="&buttons.back.label;" + buttonaccesskeyextra2="&buttons.back.accesskey;" + hidden="true" + /> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-dialog-utils.js b/comm/calendar/base/content/dialogs/calendar-dialog-utils.js new file mode 100644 index 0000000000..d002bcdf5c --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-dialog-utils.js @@ -0,0 +1,662 @@ +/* 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/. */ + +/* exported gInTab, gMainWindow, gTabmail, intializeTabOrWindowVariables, + * dispose, setDialogId, loadReminders, saveReminder, + * commonUpdateReminder, updateLink, + * adaptScheduleAgent, sendMailToOrganizer, + * openAttachmentFromItemSummary, + */ + +/* import-globals-from ../item-editing/calendar-item-iframe.js */ +/* import-globals-from ../calendar-ui-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", +}); + +// Variables related to whether we are in a tab or a window dialog. +var gInTab = false; +var gMainWindow = null; +var gTabmail = null; + +/** + * Initialize variables for tab vs window. + */ +function intializeTabOrWindowVariables() { + let args = window.arguments[0]; + gInTab = args.inTab; + if (gInTab) { + gTabmail = parent.document.getElementById("tabmail"); + gMainWindow = parent; + } else { + gMainWindow = parent.opener; + } +} + +/** + * Dispose of controlling operations of this event dialog. Uses + * window.arguments[0].job.dispose() + */ +function dispose() { + let args = window.arguments[0]; + if (args.job && args.job.dispose) { + args.job.dispose(); + } +} + +/** + * Sets the id of a Dialog to another value to allow different CSS styles + * to be used. + * + * @param aDialog The Dialog to be changed. + * @param aNewId The new ID as String. + */ +function setDialogId(aDialog, aNewId) { + aDialog.setAttribute("id", aNewId); + applyPersistedProperties(aDialog); +} + +/** + * Apply the persisted properties from xulstore.json on a dialog based on the current dialog id. + * This needs to be invoked after changing a dialog id while loading to apply the values for the + * new dialog id. + * + * @param aDialog The Dialog to apply the property values for + */ +function applyPersistedProperties(aDialog) { + let xulStore = Services.xulStore; + // first we need to detect which properties are persisted + let persistedProps = aDialog.getAttribute("persist") || ""; + if (persistedProps == "") { + return; + } + let propNames = persistedProps.split(" "); + let { outerWidth: width, outerHeight: height } = aDialog; + let doResize = false; + // now let's apply persisted values if applicable + for (let propName of propNames) { + if (xulStore.hasValue(aDialog.baseURI, aDialog.id, propName)) { + let propValue = xulStore.getValue(aDialog.baseURI, aDialog.id, propName); + if (propName == "width") { + width = propValue; + doResize = true; + } else if (propName == "height") { + height = propValue; + doResize = true; + } else { + aDialog.setAttribute(propName, propValue); + } + } + } + + if (doResize) { + aDialog.ownerGlobal.resizeTo(width, height); + } +} + +/** + * Create a calIAlarm from the given menuitem. The menuitem must have the + * following attributes: unit, length, origin, relation. + * + * @param {Element} aMenuitem - The menuitem to create the alarm from. + * @param {calICalendar} aCalendar - The calendar for getting the default alarm type. + * @returns The calIAlarm with information from the menuitem. + */ +function createReminderFromMenuitem(aMenuitem, aCalendar) { + let reminder = aMenuitem.reminder || new CalAlarm(); + // clone immutable reminders if necessary to set default values + let isImmutable = !reminder.isMutable; + if (isImmutable) { + reminder = reminder.clone(); + } + let offset = cal.createDuration(); + offset[aMenuitem.getAttribute("unit")] = aMenuitem.getAttribute("length"); + offset.normalize(); + offset.isNegative = aMenuitem.getAttribute("origin") == "before"; + reminder.related = + aMenuitem.getAttribute("relation") == "START" + ? Ci.calIAlarm.ALARM_RELATED_START + : Ci.calIAlarm.ALARM_RELATED_END; + reminder.offset = offset; + reminder.action = getDefaultAlarmType(aCalendar); + // make reminder immutable in case it was before + if (isImmutable) { + reminder.makeImmutable(); + } + return reminder; +} + +/** + * This function opens the needed dialogs to edit the reminder. Note however + * that calling this function from an extension is not recommended. To allow an + * extension to open the reminder dialog, set the menulist "item-alarm" to the + * custom menuitem and call updateReminder(). + * + * @param {Element} reminderList - The reminder menu element. + * @param {calIEvent | calIToDo} calendarItem - The calendar item. + * @param {number} lastAlarmSelection - Index of previously selected item in the menu. + * @param {calICalendar} calendar - The calendar to use. + * @param {calITimezone} [timezone] - Timezone to use. + */ +function editReminder( + reminderList, + calendarItem, + lastAlarmSelection, + calendar, + timezone = cal.dtz.defaultTimezone +) { + let customItem = reminderList.querySelector(".reminder-custom-menuitem"); + + let args = { + reminders: customItem.reminders, + item: calendarItem, + timezone, + calendar, + // While these are "just" callbacks, the dialog is opened modally, so aside + // from what's needed to set up the reminders, nothing else needs to be done. + onOk(reminders) { + customItem.reminders = reminders; + }, + onCancel() { + reminderList.selectedIndex = lastAlarmSelection; + }, + }; + + window.setCursor("wait"); + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-reminder.xhtml", + "_blank", + "chrome,titlebar,modal,resizable,centerscreen", + args + ); +} + +/** + * Update the reminder details from the selected alarm. This shows a string + * describing the reminder set, or nothing in case a preselected reminder was + * chosen. + * + * @param {Element} reminderDetails - The reminder details element. + * @param {Element} reminderList - The reminder menu element. + * @param {calICalendar} calendar - The calendar. + */ +function updateReminderDetails(reminderDetails, reminderList, calendar) { + // find relevant elements in the document + let reminderMultipleLabel = reminderDetails.querySelector(".reminder-multiple-alarms-label"); + let iconBox = reminderDetails.querySelector(".alarm-icons-box"); + let reminderSingleLabel = reminderDetails.querySelector(".reminder-single-alarms-label"); + + let reminders = reminderList.querySelector(".reminder-custom-menuitem").reminders || []; + + let actionValues = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"]; + let actionMap = {}; + for (let action of actionValues) { + actionMap[action] = true; + } + + // Filter out any unsupported action types. + reminders = reminders.filter(x => x.action in actionMap); + + if (reminderList.value == "custom") { + // Depending on how many alarms we have, show either the "Multiple Alarms" + // label or the single reminder label. + reminderMultipleLabel.hidden = reminders.length < 2; + reminderSingleLabel.hidden = reminders.length > 1; + + cal.alarms.addReminderImages(iconBox, reminders); + + // If there is only one reminder, display the reminder string + if (reminders.length == 1) { + reminderSingleLabel.value = reminders[0].toString(window.calendarItem); + } + } else { + reminderMultipleLabel.setAttribute("hidden", "true"); + reminderSingleLabel.setAttribute("hidden", "true"); + if (reminderList.value == "none") { + // No reminder selected means show no icons. + while (iconBox.lastChild) { + iconBox.lastChild.remove(); + } + } else { + // This is one of the predefined dropdown items. We should show a + // single icon in the icons box to tell the user what kind of alarm + // this will be. + let mockAlarm = new CalAlarm(); + mockAlarm.action = getDefaultAlarmType(calendar); + cal.alarms.addReminderImages(iconBox, [mockAlarm]); + } + } +} + +/** + * Check whether a reminder matches one of the default menu items or not. + * + * @param {calIAlarm} reminder - The reminder to match to a menu item. + * @param {Element} reminderList - The reminder menu element. + * @param {calICalendar} calendar - The current calendar, to get the default alarm type. + * @returns {boolean} True if the reminder matches a menu item, false if not. + */ +function matchCustomReminderToMenuitem(reminder, reminderList, calendar) { + let defaultAlarmType = getDefaultAlarmType(calendar); + let reminderPopup = reminderList.menupopup; + if ( + reminder.related != Ci.calIAlarm.ALARM_RELATED_ABSOLUTE && + reminder.offset && + reminder.action == defaultAlarmType + ) { + // Exactly one reminder that's not absolute, we may be able to match up + // popup items. + let relation = reminder.related == Ci.calIAlarm.ALARM_RELATED_START ? "START" : "END"; + + // If the time duration for offset is 0, means the reminder is '0 minutes before' + let origin = reminder.offset.inSeconds == 0 || reminder.offset.isNegative ? "before" : "after"; + + let unitMap = { + days: 86400, + hours: 3600, + minutes: 60, + }; + + for (let menuitem of reminderPopup.children) { + if ( + menuitem.localName == "menuitem" && + menuitem.hasAttribute("length") && + menuitem.getAttribute("origin") == origin && + menuitem.getAttribute("relation") == relation + ) { + let unitMult = unitMap[menuitem.getAttribute("unit")] || 1; + let length = menuitem.getAttribute("length") * unitMult; + + if (Math.abs(reminder.offset.inSeconds) == length) { + menuitem.reminder = reminder.clone(); + reminderList.selectedItem = menuitem; + // We've selected an item, so we are done here. + return true; + } + } + } + } + + return false; +} + +/** + * Load an item's reminders into the dialog. + * + * @param {calIAlarm[]} reminders - An array of alarms to load. + * @param {Element} reminderList - The reminders menulist element. + * @param {calICalendar} calendar - The calendar the item belongs to. + * @returns {number} Index of the selected item in reminders menu. + */ +function loadReminders(reminders, reminderList, calendar) { + // Select 'no reminder' by default. + reminderList.selectedIndex = 0; + + if (!reminders || !reminders.length) { + // No reminders selected, we are done + return reminderList.selectedIndex; + } + + if ( + reminders.length > 1 || + !matchCustomReminderToMenuitem(reminders[0], reminderList, calendar) + ) { + // If more than one alarm is selected, or we didn't find a matching item + // above, then select the "custom" item and attach the item's reminders to + // it. + reminderList.value = "custom"; + reminderList.querySelector(".reminder-custom-menuitem").reminders = reminders; + } + + // Return the selected index so it can be remembered. + return reminderList.selectedIndex; +} + +/** + * Save the selected reminder into the passed item. + * + * @param {calIEvent | calITodo} item The calendar item to save the reminder into. + * @param {calICalendar} calendar - The current calendar. + * @param {Element} reminderList - The reminder menu element. + */ +function saveReminder(item, calendar, reminderList) { + // We want to compare the old alarms with the new ones. If these are not + // the same, then clear the snooze/dismiss times + let oldAlarmMap = {}; + for (let alarm of item.getAlarms()) { + oldAlarmMap[alarm.icalString] = true; + } + + // Clear the alarms so we can add our new ones. + item.clearAlarms(); + + if (reminderList.value != "none") { + let menuitem = reminderList.selectedItem; + let reminders; + + if (menuitem.reminders) { + // Custom reminder entries carry their own reminder object with + // them. Make sure to clone in case these are the original item's + // reminders. + + // XXX do we need to clone here? + reminders = menuitem.reminders.map(x => x.clone()); + } else { + // Pre-defined entries specify the necessary information + // as attributes attached to the menuitem elements. + reminders = [createReminderFromMenuitem(menuitem, calendar)]; + } + + let alarmCaps = item.calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"]; + let alarmActions = {}; + for (let action of alarmCaps) { + alarmActions[action] = true; + } + + // Make sure only alarms are saved that work in the given calendar. + reminders.filter(x => x.action in alarmActions).forEach(item.addAlarm, item); + } + + // Compare alarms to see if something changed. + for (let alarm of item.getAlarms()) { + let ics = alarm.icalString; + if (ics in oldAlarmMap) { + // The new alarm is also in the old set, remember this + delete oldAlarmMap[ics]; + } else { + // The new alarm is not in the old set, this means the alarms + // differ and we can break out. + oldAlarmMap[ics] = true; + break; + } + } + + // If the alarms differ, clear the snooze/dismiss properties + if (Object.keys(oldAlarmMap).length > 0) { + let cmp = "X-MOZ-SNOOZE-TIME"; + + // Recurring item alarms potentially have more snooze props, remove them + // all. + let propsToDelete = []; + for (let [name] of item.properties) { + if (name.startsWith(cmp)) { + propsToDelete.push(name); + } + } + + item.alarmLastAck = null; + propsToDelete.forEach(item.deleteProperty, item); + } +} + +/** + * Get the default alarm type for the currently selected calendar. If the + * calendar supports DISPLAY alarms, this is the default. Otherwise it is the + * first alarm action the calendar supports. + * + * @param {calICalendar} calendar - The calendar to use. + * @returns {string} The default alarm type. + */ +function getDefaultAlarmType(calendar) { + let alarmCaps = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"]; + return alarmCaps.includes("DISPLAY") ? "DISPLAY" : alarmCaps[0]; +} + +/** + * Common update functions for both event dialogs. Called when a reminder has + * been selected from the menulist. + * + * @param {Element} reminderList - The reminders menu element. + * @param {calIEvent | calITodo} calendarItem - The calendar item. + * @param {number} lastAlarmSelection - Index of the previous selection in the reminders menu. + * @param {Element} reminderDetails - The reminder details element. + * @param {calITimezone} timezone - The relevant timezone. + * @param {boolean} suppressDialogs - If true, controls are updated without prompting + * for changes with the dialog + * @returns {number} Index of the item selected in the reminders menu. + */ +function commonUpdateReminder( + reminderList, + calendarItem, + lastAlarmSelection, + calendar, + reminderDetails, + timezone, + suppressDialogs +) { + // if a custom reminder has been selected, we show the appropriate + // dialog in order to allow the user to specify the details. + // the result will be placed in the 'reminder-custom-menuitem' tag. + if (reminderList.value == "custom") { + // Clear the reminder icons first, this will make sure that while the + // dialog is open the default reminder image is not shown which may + // confuse users. + let iconBox = reminderDetails.querySelector(".alarm-icons-box"); + while (iconBox.lastChild) { + iconBox.lastChild.remove(); + } + + // show the dialog. This call blocks until the dialog is closed. Don't + // pop up the dialog if aSuppressDialogs was specified or if this + // happens during initialization of the dialog + if (!suppressDialogs && reminderList.hasAttribute("last-value")) { + editReminder(reminderList, calendarItem, lastAlarmSelection, calendar, timezone); + } + + if (reminderList.value == "custom") { + // Only do this if the 'custom' item is still selected. If the edit + // reminder dialog was canceled then the previously selected + // menuitem is selected, which may not be the custom menuitem. + + // If one or no reminders were selected, we have a chance of mapping + // them to the existing elements in the dropdown. + let customItem = reminderList.selectedItem; + if (customItem.reminders.length == 0) { + // No reminder was selected + reminderList.value = "none"; + } else if (customItem.reminders.length == 1) { + // We might be able to match the custom reminder with one of the + // default menu items. + matchCustomReminderToMenuitem(customItem.reminders[0], reminderList, calendar); + } + } + } + + reminderList.setAttribute("last-value", reminderList.value); + + // possibly the selected reminder conflicts with the item. + // for example an end-relation combined with a task without duedate + // is an invalid state we need to take care of. we take the same + // approach as with recurring tasks. in case the reminder is related + // to the entry date we check the entry date automatically and disable + // the checkbox. the same goes for end related reminder and the due date. + if (calendarItem.isTodo()) { + // In general, (re-)enable the due/entry checkboxes. This will be + // changed in case the alarms are related to START/END below. + enableElementWithLock("todo-has-duedate", "reminder-lock"); + enableElementWithLock("todo-has-entrydate", "reminder-lock"); + + let menuitem = reminderList.selectedItem; + if (menuitem.value != "none") { + // In case a reminder is selected, retrieve the array of alarms from + // it, or create one from the currently selected menuitem. + let reminders = menuitem.reminders || [createReminderFromMenuitem(menuitem, calendar)]; + + // If a reminder is related to the entry date... + if (reminders.some(x => x.related == Ci.calIAlarm.ALARM_RELATED_START)) { + // ...automatically check 'has entrydate'. + if (!document.getElementById("todo-has-entrydate").checked) { + document.getElementById("todo-has-entrydate").checked = true; + + // Make sure gStartTime is properly initialized + updateEntryDate(); + } + + // Disable the checkbox to indicate that we need the entry-date. + disableElementWithLock("todo-has-entrydate", "reminder-lock"); + } + + // If a reminder is related to the due date... + if (reminders.some(x => x.related == Ci.calIAlarm.ALARM_RELATED_END)) { + // ...automatically check 'has duedate'. + if (!document.getElementById("todo-has-duedate").checked) { + document.getElementById("todo-has-duedate").checked = true; + + // Make sure gStartTime is properly initialized + updateDueDate(); + } + + // Disable the checkbox to indicate that we need the entry-date. + disableElementWithLock("todo-has-duedate", "reminder-lock"); + } + } + } + updateReminderDetails(reminderDetails, reminderList, calendar); + + // Return the current reminder drop down selection index so it can be remembered. + return reminderList.selectedIndex; +} + +/** + * Updates the related link on the dialog. Currently only used by the + * read-only summary dialog. + * + * @param {string} itemUrlString - The calendar item URL as a string. + * @param {Element} linkRow - The row containing the link. + * @param {Element} urlLink - The link element itself. + */ +function updateLink(itemUrlString, linkRow, urlLink) { + let linkCommand = document.getElementById("cmd_toggle_link"); + + if (linkCommand) { + // Disable if there is no url. + linkCommand.disabled = !itemUrlString; + } + + if ((linkCommand && linkCommand.getAttribute("checked") != "true") || !itemUrlString.length) { + // Hide if there is no url, or the menuitem was chosen so that the url + // should be hidden + linkRow.hidden = true; + } else { + let handler, uri; + try { + uri = Services.io.newURI(itemUrlString); + handler = Services.io.getProtocolHandler(uri.scheme); + } catch (e) { + // No protocol handler for the given protocol, or invalid uri + linkRow.hidden = true; + return; + } + + // Only show if its either an internal protocol handler, or its external + // and there is an external app for the scheme + handler = cal.wrapInstance(handler, Ci.nsIExternalProtocolHandler); + let show = !handler || handler.externalAppExistsForScheme(uri.scheme); + linkRow.hidden = !show; + + setTimeout(() => { + // HACK the url link doesn't crop when setting the value in onLoad + urlLink.setAttribute("value", itemUrlString); + urlLink.setAttribute("href", itemUrlString); + }, 0); + } +} + +/** + * Adapts the scheduling responsibility for caldav servers according to RfC 6638 + * based on forceEmailScheduling preference for the respective calendar + * + * @param {calIEvent|calIToDo} aItem - Item to apply the change on + */ +function adaptScheduleAgent(aItem) { + if ( + aItem.calendar && + aItem.calendar.type == "caldav" && + aItem.calendar.getProperty("capabilities.autoschedule.supported") + ) { + let identity = aItem.calendar.getProperty("imip.identity"); + let orgEmail = identity && identity.QueryInterface(Ci.nsIMsgIdentity).email; + let organizerAction = aItem.organizer && orgEmail && aItem.organizer.id == "mailto:" + orgEmail; + if (aItem.calendar.getProperty("forceEmailScheduling")) { + cal.LOG("Enforcing clientside email based scheduling."); + // for attendees, we change schedule-agent only in case of an + // organizer triggered action + if (organizerAction) { + aItem.getAttendees().forEach(aAttendee => { + // overwriting must always happen consistently for all + // attendees regarding SERVER or CLIENT but must not override + // e.g. NONE, so we only overwrite if the param is set to + // SERVER or doesn't exist + if ( + aAttendee.getProperty("SCHEDULE-AGENT") == "SERVER" || + !aAttendee.getProperty("SCHEDULE-AGENT") + ) { + aAttendee.setProperty("SCHEDULE-AGENT", "CLIENT"); + aAttendee.deleteProperty("SCHEDULE-STATUS"); + aAttendee.deleteProperty("SCHEDULE-FORCE-SEND"); + } + }); + } else if ( + aItem.organizer && + (aItem.organizer.getProperty("SCHEDULE-AGENT") == "SERVER" || + !aItem.organizer.getProperty("SCHEDULE-AGENT")) + ) { + // for organizer, we change the schedule-agent only in case of + // an attendee triggered action + aItem.organizer.setProperty("SCHEDULE-AGENT", "CLIENT"); + aItem.organizer.deleteProperty("SCHEDULE-STATUS"); + aItem.organizer.deleteProperty("SCHEDULE-FORCE-SEND"); + } + } else if (organizerAction) { + aItem.getAttendees().forEach(aAttendee => { + if (aAttendee.getProperty("SCHEDULE-AGENT") == "CLIENT") { + aAttendee.deleteProperty("SCHEDULE-AGENT"); + } + }); + } else if (aItem.organizer && aItem.organizer.getProperty("SCHEDULE-AGENT") == "CLIENT") { + aItem.organizer.deleteProperty("SCHEDULE-AGENT"); + } + } +} + +/** + * Extracts the item's organizer and opens a compose window to send the + * organizer an email. + * + * @param {calIEvent | calITodo} item - The calendar item. + */ +function sendMailToOrganizer(item) { + let organizer = item.organizer; + let email = cal.email.getAttendeeEmail(organizer, true); + let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]); + let identity = item.calendar.getProperty("imip.identity"); + cal.email.sendTo(email, emailSubject, null, identity); +} + +/** + * Opens an attachment. + * + * @param {AUTF8String} aAttachmentId The hashId of the attachment to open. + * @param {calIEvent | calITodo} item The calendar item. + */ +function openAttachmentFromItemSummary(aAttachmentId, item) { + if (!aAttachmentId) { + return; + } + let attachments = item + .getAttachments() + .filter(aAttachment => aAttachment.hashId == aAttachmentId); + + if (attachments.length && attachments[0].uri && attachments[0].uri.spec != "about:blank") { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(attachments[0].uri); + } +} diff --git a/comm/calendar/base/content/dialogs/calendar-error-prompt.js b/comm/calendar/base/content/dialogs/calendar-error-prompt.js new file mode 100644 index 0000000000..538d360a99 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-error-prompt.js @@ -0,0 +1,21 @@ +/* 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/. */ + +window.addEventListener("DOMContentLoaded", loadErrorPrompt); + +function loadErrorPrompt() { + let args = window.arguments[0].QueryInterface(Ci.nsIDialogParamBlock); + document.getElementById("general-text").value = args.GetString(0); + document.getElementById("error-code").value = args.GetString(1); + document.getElementById("error-description").value = args.GetString(2); +} +function toggleDetails() { + let options = document.getElementById("details-box"); + options.collapsed = !options.collapsed; + // Grow the window height if the details overflow. + window.resizeTo( + window.outerWidth, + document.body.scrollHeight + window.outerHeight - window.innerHeight + ); +} diff --git a/comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml b/comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml new file mode 100644 index 0000000000..8454809bd1 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml @@ -0,0 +1,59 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1; +<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > +%dtd2; ]> +<html + id="calendar-error-prompt" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&calendar.error.title;</title> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-error-prompt.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog buttons="accept" style="width: 500px"> + <html:textarea + id="general-text" + class="plain" + readonly="readonly" + rows="3" + style="resize: none" + ></html:textarea> + <hbox> + <button id="details-button" label="&calendar.error.detail;" oncommand="toggleDetails()" /> + <spacer flex="1" /> + </hbox> + <vbox id="details-box" collapsed="true" persist="collapsed"> + <hbox> + <label value="&calendar.error.code;" /> + <label id="error-code" value="" /> + </hbox> + <vbox> + <label value="&calendar.error.description;" control="error-description" /> + <html:textarea + id="error-description" + class="plain" + readonly="readonly" + rows="5" + style="resize: none" + ></html:textarea> + </vbox> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js new file mode 100644 index 0000000000..1efb94d446 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js @@ -0,0 +1,1601 @@ +/* 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 MozXULElement */ +/* import-globals-from ../calendar-ui-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", +}); + +var readOnly = false; + +// The UI elements in this dialog. Initialised in the DOMContentLoaded handler. +var attendeeList; +var dayHeaderInner; +var dayHeaderOuter; +var freebusyGrid; +var freebusyGridBackground; +var freebusyGridInner; + +// displayStartTime is midnight before the first displayed date, in the default timezone. +// displayEndTime is midnight after the last displayed date, in the default timezone. +// Initialised in the load event handler. +var displayStartTime; +var displayEndTime; +var numberDaysDisplayed; +var numberDaysDisplayedPref = Services.prefs.getIntPref("calendar.view.attendees.visibleDays", 16); +var showOnlyWholeDays = Services.prefs.getBoolPref( + "calendar.view.attendees.showOnlyWholeDays", + false +); +var dayStartHour = Services.prefs.getIntPref("calendar.view.daystarthour", 8); +var dayEndHour = Services.prefs.getIntPref("calendar.view.dayendhour", 17); + +var updateByFunction = false; // To avoid triggering eventListener on timePicker which would lead to an error when triggering. + +var previousStartTime; +var previousEndTime; +var previousTimezone; + +var displayStartHour = 0; // Display start hour. +var displayEndHour = 24; // Display end hour. +var showCompleteDay = true; // Display of the whole day. + +var defaultEventLength = Services.prefs.getIntPref("calendar.event.defaultlength", 60); + +var zoom = { + zoomInButton: null, + zoomOutButton: null, + levels: [ + { + // Total width in pixels of one day. + dayWidth: 360, + // Number of major columns a day is divided into. Each dividing line is labelled. + columnCount: 4, + // Duration of each major column. + columnDuration: cal.createDuration("PT6H"), + // The width in pixels of one column. + columnWidth: 90, + // The width in pixels of one second. + secondWidth: 360 / 24 / 3600, + // Which background grid to show. + gridClass: "threeMinorColumns", + }, + { + dayWidth: 720, + columnCount: 8, + columnDuration: cal.createDuration("PT3H"), + columnWidth: 90, + secondWidth: 720 / 24 / 3600, + gridClass: "threeMinorColumns", + }, + { + dayWidth: 1440, + columnCount: 24, + columnDuration: cal.createDuration("PT1H"), + columnWidth: 60, + secondWidth: 1440 / 24 / 3600, + gridClass: "twoMinorColumns", + }, + { + dayWidth: 2880, + columnCount: 48, + columnDuration: cal.createDuration("PT30M"), + columnWidth: 60, + secondWidth: 2880 / 24 / 3600, + gridClass: "twoMinorColumns", + }, + ], + currentLevel: null, + + init() { + this.zoomInButton = document.getElementById("zoom-in-button"); + this.zoomOutButton = document.getElementById("zoom-out-button"); + + this.zoomInButton.addEventListener("command", () => this.level++); + this.zoomOutButton.addEventListener("command", () => this.level--); + }, + get level() { + return this.currentLevel; + }, + set level(newZoomLevel) { + if (newZoomLevel < 0) { + newZoomLevel = 0; + } else if (newZoomLevel >= this.levels.length) { + newZoomLevel = this.levels.length - 1; + } + this.zoomInButton.disabled = newZoomLevel == this.levels.length - 1; + this.zoomOutButton.disabled = newZoomLevel == 0; + + if (!showCompleteDay) { + // To block to be in max dezoom in reduced display mode. + this.zoomOutButton.disabled = newZoomLevel == 1; + + if ( + (dayEndHour - dayStartHour) % this.levels[this.currentLevel - 1].columnDuration.hours != + 0 + ) { + // To avoid being in zoom level where the interface is not adapted. + this.zoomOutButton.disabled = true; + } + } + + if (newZoomLevel == this.currentLevel) { + return; + } + this.currentLevel = newZoomLevel; + displayEndTime = displayStartTime.clone(); + + emptyGrid(); + for (let attendee of attendeeList.getElementsByTagName("event-attendee")) { + attendee.clearFreeBusy(); + } + + for (let gridClass of ["twoMinorColumns", "threeMinorColumns"]) { + if (this.levels[newZoomLevel].gridClass == gridClass) { + dayHeaderInner.classList.add(gridClass); + freebusyGridInner.classList.add(gridClass); + } else { + dayHeaderInner.classList.remove(gridClass); + freebusyGridInner.classList.remove(gridClass); + } + } + fillGrid(); + eventBar.update(true); + }, + get dayWidth() { + return this.levels[this.currentLevel].dayWidth; + }, + get columnCount() { + return this.levels[this.currentLevel].columnCount; + }, + get columnDuration() { + return this.levels[this.currentLevel].columnDuration; + }, + get columnWidth() { + return this.levels[this.currentLevel].columnWidth; + }, + get secondWidth() { + return this.levels[this.currentLevel].secondWidth; + }, +}; + +var eventBar = { + dragDistance: 0, + dragStartX: null, + eventBarBottom: "event-bar-bottom", + eventBarTop: "event-bar-top", + + init() { + this.eventBarBottom = document.getElementById("event-bar-bottom"); + this.eventBarTop = document.getElementById("event-bar-top"); + + let outer = document.getElementById("outer"); + outer.addEventListener("dragstart", this); + outer.addEventListener("dragover", this); + outer.addEventListener("dragend", this); + }, + handleEvent(event) { + switch (event.type) { + case "dragstart": { + this.dragStartX = event.clientX + freebusyGrid.scrollLeft; + let img = document.createElement("img"); + img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + event.dataTransfer.setDragImage(img, 0, 0); + event.dataTransfer.effectAllowed = "move"; + break; + } + case "dragover": { + // Snap dragging movements to half of a minor column width. + this.dragDistance = + Math.round((event.clientX + freebusyGrid.scrollLeft - this.dragStartX) / 15) * 15; + + // Prevent the event from being dragged outside the grid. + if ( + this.eventBarBottom.offsetLeft + this.dragDistance >= freebusyGrid.scrollLeft && + // We take the size of the event not to exceed on the right side. + this.eventBarBottom.offsetLeft + this.eventBarBottom.offsetWidth + this.dragDistance <= + zoom.levels[zoom.currentLevel].dayWidth * numberDaysDisplayed + ) { + this.eventBarTop.style.transform = + this.eventBarBottom.style.transform = `translateX(${this.dragDistance}px)`; + } + break; + } + case "dragend": { + updateByFunction = true; + let positionFromStart = this.eventBarBottom.offsetLeft + this.dragDistance; + this.dragStartX = null; + this.eventBarTop.style.transform = this.eventBarBottom.style.transform = null; + + let { startValue, endValue } = dateTimePickerUI; + let durationEvent; + + // If the user goes into the past, the user will be able to use part of the hour before the beginning of the day. + // Ex: Start time of the day: 8am, End time of the day: 5:00 pm + // If the user moves the slot in the past but does not go to the end of the day time, they will be able to use the 7am to 8am time (except for the first shift corresponding to the minimum travel time). + // There is the same principle for the end of the day, but it will be for the hour following the end of the day. + + // If we go back in time, we will have to calculate with endValue. + if (this.dragDistance < 0) { + durationEvent = startValue.subtractDate(endValue); + + endValue = this.getDateFromPosition( + positionFromStart + this.eventBarBottom.offsetWidth, + startValue.timezone + ); + + startValue = endValue.clone(); + startValue.addDuration(durationEvent); + // If you move backwards, you have to check again. Otherwise a move to the last hour of the day will date the previous hour of the start of the day. + // We will do our tests with the calendar timezone and not the event timezone. + let startValueDefaultTimezone = startValue.getInTimezone(cal.dtz.defaultTimezone); + if (!showCompleteDay) { + if ( + !( + (startValueDefaultTimezone.hour >= displayStartHour || + (startValueDefaultTimezone.hour == displayStartHour - 1 && + startValueDefaultTimezone.minute > 0)) && + startValueDefaultTimezone.hour < displayEndHour + ) + ) { + let hoursHidden = 24 - displayEndHour + displayStartHour; + let reducDayDuration = cal.createDuration("-PT" + hoursHidden + "H"); + startValue.addDuration(reducDayDuration); + endValue.addDuration(reducDayDuration); + } + } + + if (dateTimePickerUI.allDay.checked) { + // BUG in icaljs + startValue.hour = 0; + startValue.minute = 0; + dateTimePickerUI.startValue = startValue; + endValue.hour = 0; + endValue.minute = 0; + dateTimePickerUI.endValue = endValue; + dateTimePickerUI.saveOldValues(); + endValue.day++; // For display only. + } else { + dateTimePickerUI.startValue = startValue; + dateTimePickerUI.endValue = endValue; + } + } else { + // If we go forward in time, we will have to calculate with startValue. + durationEvent = endValue.subtractDate(startValue); + + startValue = this.getDateFromPosition(positionFromStart, startValue.timezone); + endValue = startValue.clone(); + + if (dateTimePickerUI.allDay.checked) { + // BUG in icaljs + startValue.hour = 0; + startValue.minute = 0; + dateTimePickerUI.startValue = startValue; + endValue.addDuration(durationEvent); + endValue.hour = 0; + endValue.minute = 0; + dateTimePickerUI.endValue = endValue; + dateTimePickerUI.saveOldValues(); + endValue.day++; // For display only. + } else { + dateTimePickerUI.startValue = startValue; + endValue.addDuration(durationEvent); + dateTimePickerUI.endValue = endValue; + } + } + + updateChange(); + updateByFunction = false; + setLeftAndWidth(this.eventBarTop, startValue, endValue); + setLeftAndWidth(this.eventBarBottom, startValue, endValue); + + updatePreviousValues(); + updateRange(); + break; + } + } + }, + update(shouldScroll) { + let { startValueForDisplay, endValueForDisplay } = dateTimePickerUI; + if (dateTimePickerUI.allDay.checked) { + endValueForDisplay.day++; + } + setLeftAndWidth(this.eventBarTop, startValueForDisplay, endValueForDisplay); + setLeftAndWidth(this.eventBarBottom, startValueForDisplay, endValueForDisplay); + + if (shouldScroll) { + let scrollPoint = + this.eventBarBottom.offsetLeft - + (dayHeaderOuter.clientWidthDouble - this.eventBarBottom.clientWidthDouble) / 2; + if (scrollPoint < 0) { + scrollPoint = 0; + } + dayHeaderOuter.scrollTo(scrollPoint, 0); + freebusyGrid.scrollTo(scrollPoint, freebusyGrid.scrollTop); + } + }, + getDateFromPosition(posX, timezone) { + let numberOfDays = Math.floor(posX / zoom.dayWidth); + let remainingOffset = posX - numberOfDays * zoom.dayWidth; + + let duration = cal.createDuration(); + duration.inSeconds = numberOfDays * 60 * 60 * 24 + remainingOffset / zoom.secondWidth; + + let date = displayStartTime.clone(); + // In case of full display, do not keep the fact that displayStartTime is allDay. + if (showCompleteDay) { + date.isDate = false; + date.hour = 0; + date.minute = 0; + } + date = date.getInTimezone(timezone); // We reapply the time zone of the event. + date.addDuration(duration); + return date; + }, +}; + +var dateTimePickerUI = { + allDay: "all-day", + start: "event-starttime", + startZone: "timezone-starttime", + end: "event-endtime", + endZone: "timezone-endtime", + + init() { + for (let key of ["allDay", "start", "startZone", "end", "endZone"]) { + this[key] = document.getElementById(this[key]); + } + }, + addListeners() { + this.allDay.addEventListener("command", () => this.changeAllDay()); + this.start.addEventListener("change", () => eventBar.update(false)); + this.startZone.addEventListener("click", () => this.editTimezone(this.startZone)); + this.endZone.addEventListener("click", () => this.editTimezone(this.endZone)); + }, + + get startValue() { + return cal.dtz.jsDateToDateTime(this.start.value, this.startZone._zone); + }, + set startValue(value) { + // Set the zone first, because the change in time will trigger an update. + this.startZone._zone = value.timezone; + this.startZone.value = value.timezone.displayName || value.timezone.tzid; + this.start.value = cal.dtz.dateTimeToJsDate(value.getInTimezone(cal.dtz.floating)); + }, + get startValueForDisplay() { + return this.startValue.getInTimezone(cal.dtz.defaultTimezone); + }, + get endValue() { + return cal.dtz.jsDateToDateTime(this.end.value, this.endZone._zone); + }, + set endValue(value) { + // Set the zone first, because the change in time will trigger an update. + this.endZone._zone = value.timezone; + this.endZone.value = value.timezone.displayName || value.timezone.tzid; + this.end.value = cal.dtz.dateTimeToJsDate(value.getInTimezone(cal.dtz.floating)); + }, + get endValueForDisplay() { + return this.endValue.getInTimezone(cal.dtz.defaultTimezone); + }, + + changeAllDay() { + updateByFunction = true; + let allDay = this.allDay.checked; + if (allDay) { + document.getElementById("event-starttime").setAttribute("timepickerdisabled", true); + document.getElementById("event-endtime").setAttribute("timepickerdisabled", true); + } else { + document.getElementById("event-starttime").removeAttribute("timepickerdisabled"); + document.getElementById("event-endtime").removeAttribute("timepickerdisabled"); + } + + if (allDay) { + previousTimezone = this.startValue.timezone; + // Store date-times and related timezones so we can restore + // if the user unchecks the "all day" checkbox. + this.saveOldValues(); + + let { startValue, endValue } = this; + + // When events that end at 0:00 become all-day events, we need to + // subtract a day from the end date because the real end is midnight. + if (endValue.hour == 0 && endValue.minute == 0) { + let tempStartValue = startValue.clone(); + let tempEndValue = endValue.clone(); + tempStartValue.isDate = true; + tempEndValue.isDate = true; + tempStartValue.day++; + if (tempEndValue.compare(tempStartValue) >= 0) { + endValue.day--; + } + } + + // In order not to have an event on the day shifted because of the timezone applied to the event, we pass the event in the current timezone. + endValue = endValue.getInTimezone(cal.dtz.defaultTimezone); + startValue = startValue.getInTimezone(cal.dtz.defaultTimezone); + startValue.isDate = true; + endValue.isDate = true; + this.endValue = endValue; + this.startValue = startValue; + zoom.level = 0; + } else if (this.start._oldValue && this.end._oldValue) { + // Restore date-times previously stored. + + // Case of all day events that lasts several days or that has been changed to another day + if ( + this.start._oldValue.getHours() == 0 && + this.start._oldValue.getMinutes() == 0 && + this.end._oldValue.getHours() == 0 && + this.end._oldValue.getMinutes() == 0 + ) { + let saveMinutes = this.end._oldValue.getMinutes(); + this.start._oldValue.setHours( + cal.dtz.getDefaultStartDate(window.arguments[0].startTime).hour + ); + this.end._oldValue.setHours( + cal.dtz.getDefaultStartDate(window.arguments[0].startTime).hour + ); + let minutes = saveMinutes + defaultEventLength; + this.end._oldValue.setMinutes(minutes); + } + + // Restoration of the old time zone. + if (previousTimezone) { + this.startZone._zone = previousTimezone; + this.startZone.value = previousTimezone.displayName || previousTimezone.tzid; + this.endZone._zone = previousTimezone; + this.endZone.value = previousTimezone.displayName || previousTimezone.tzid; + } + + this.restoreOldValues(); + if (this.start.value.getTime() == this.end.value.getTime()) { + // If you uncheck all day event, to avoid having an event with a duration of 0 minutes. + this.end.value = new Date(this.end.value.getTime() + defaultEventLength * 60000); + } + } else { + // The checkbox has been unchecked for the first time, the event + // was an "All day" type, so we have to set default values. + let startValue = cal.dtz.getDefaultStartDate(window.initialStartDateValue); + let endValue = startValue.clone(); + endValue.minute += defaultEventLength; + this.startValue = startValue; + this.endValue = endValue; + } + updateByFunction = false; + updatePreviousValues(); + updateRange(); + }, + editTimezone(target) { + let field = target == this.startZone ? "startValue" : "endValue"; + let originalValue = this[field]; + + let args = { + calendar: window.arguments[0].calendar, + time: originalValue, + onOk: newValue => { + this[field] = newValue; + }, + }; + + // Open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-timezone.xhtml", + "_blank", + "chrome,titlebar,modal,resizable", + args + ); + }, + /** + * Store date-times and related timezones so we can restore. + * if the user unchecks the "all day" checkbox. + */ + saveOldValues() { + this.start._oldValue = new Date(this.start.value); + this.end._oldValue = new Date(this.end.value); + }, + restoreOldValues() { + this.end.value = this.end._oldValue; + this.start.value = this.start._oldValue; + }, +}; + +window.addEventListener( + "DOMContentLoaded", + () => { + attendeeList = document.getElementById("attendee-list"); + dayHeaderInner = document.getElementById("day-header-inner"); + dayHeaderOuter = document.getElementById("day-header-outer"); + freebusyGrid = document.getElementById("freebusy-grid"); + freebusyGridBackground = document.getElementById("freebusy-grid-background"); + freebusyGridInner = document.getElementById("freebusy-grid-inner"); + + if (numberDaysDisplayedPref < 5) { + Services.prefs.setIntPref("calendar.view.attendees.visibleDays", 16); + numberDaysDisplayedPref = 16; + } + numberDaysDisplayed = numberDaysDisplayedPref; + + eventBar.init(); + dateTimePickerUI.init(); + zoom.init(); + + attendeeList.addEventListener("scroll", () => { + if (freebusyGrid._mouseIsOver) { + return; + } + freebusyGrid.scrollTop = attendeeList.scrollTop; + }); + attendeeList.addEventListener("keypress", event => { + if (event.target.popupOpen) { + return; + } + let row = event.target.closest("event-attendee"); + if (event.key == "ArrowUp" && row.previousElementSibling) { + event.preventDefault(); + row.previousElementSibling.focus(); + } else if (["ArrowDown", "Enter"].includes(event.key) && row.nextElementSibling) { + event.preventDefault(); + row.nextElementSibling.focus(); + } + }); + + freebusyGrid.addEventListener("mouseover", () => { + freebusyGrid._mouseIsOver = true; + }); + freebusyGrid.addEventListener("mouseout", () => { + freebusyGrid._mouseIsOver = false; + }); + freebusyGrid.addEventListener("scroll", () => { + if (!freebusyGrid._mouseIsOver) { + return; + } + dayHeaderOuter.scrollLeft = freebusyGrid.scrollLeft; + attendeeList.scrollTop = freebusyGrid.scrollTop; + }); + }, + { once: true } +); + +window.addEventListener( + "load", + () => { + let [ + { startTime, endTime, displayTimezone, calendar, organizer, attendees: existingAttendees }, + ] = window.arguments; + + if (startTime.isDate) { + // Shift in the display because of the timezone in case of an all day event when the interface is launched. + startTime = startTime.getInTimezone(cal.dtz.defaultTimezone); + endTime = endTime.getInTimezone(cal.dtz.defaultTimezone); + } + + dateTimePickerUI.allDay.checked = startTime.isDate; + if (dateTimePickerUI.allDay.checked) { + document.getElementById("event-starttime").setAttribute("timepickerdisabled", true); + document.getElementById("event-endtime").setAttribute("timepickerdisabled", true); + } + dateTimePickerUI.startValue = startTime; + + // When events that end at 0:00 become all-day events, we need to + // subtract a day from the end date because the real end is midnight. + if (startTime.isDate && endTime.hour == 0 && endTime.minute == 0) { + let tempStartTime = startTime.clone(); + let tempEndTime = endTime.clone(); + tempStartTime.isDate = true; + tempEndTime.isDate = true; + tempStartTime.day++; + if (tempEndTime.compare(tempStartTime) >= 0) { + endTime.day--; + } + } + dateTimePickerUI.endValue = endTime; + + previousStartTime = dateTimePickerUI.startValue; + previousEndTime = dateTimePickerUI.endValue; + + if (dateTimePickerUI.allDay.checked) { + dateTimePickerUI.saveOldValues(); + } + + if (displayTimezone) { + dateTimePickerUI.startZone.parentNode.hidden = false; + dateTimePickerUI.endZone.parentNode.hidden = false; + } + + displayStartTime = cal.dtz.now(); + displayStartTime.isDate = true; + displayStartTime.icalString; // BUG in icaljs + + // Choose the days to display. We always display at least 5 days, more if + // the window is large enough. If the event is in the past, use the day of + // the event as the first day. If it's today, tomorrow, or the next day, + // use today as the first day, otherwise show two days before the event + // (and therefore also two days after it). + let difference = startTime.subtractDate(displayStartTime); + if (difference.isNegative) { + displayStartTime = startTime.clone(); + displayStartTime.isDate = true; + displayStartTime.icalString; // BUG in icaljs + } else if (difference.compare(cal.createDuration("P2D")) > 0) { + displayStartTime = startTime.clone(); + displayStartTime.isDate = true; + displayStartTime.icalString; // BUG in icaljs + displayStartTime.day -= 2; + } + displayStartTime = displayStartTime.getInTimezone(cal.dtz.defaultTimezone); + displayEndTime = displayStartTime.clone(); + + readOnly = calendar.isReadOnly; + zoom.level = 2; + layout(); + eventBar.update(true); + dateTimePickerUI.addListeners(); + addEventListener("resize", layout); + + dateTimePickerUI.start.addEventListener("change", function (event) { + if (!updateByFunction) { + updateEndDate(); + if (dateTimePickerUI.allDay.checked) { + dateTimePickerUI.saveOldValues(); + } + updateRange(); + } + }); + dateTimePickerUI.end.addEventListener("change", function (event) { + if (!updateByFunction) { + checkDate(); + dateTimePickerUI.saveOldValues(); + updateChange(); + updateRange(); + } + }); + + const attendees = Array.from(existingAttendees); + + // If there are no existing attendees, we assume that this is the first time + // others are being invited. By default, the organizer is added as an + // attendee, letting the organizer remove themselves if that isn't desired. + if (attendees.length == 0) { + if (organizer) { + attendees.push(organizer); + } else { + const organizerId = calendar.getProperty("organizerId"); + if (organizerId) { + // We explicitly don't mark this attendee as organizer, as that has + // special meaning in ical.js. This represents the organizer as a + // potential attendee of the event and can be removed by the organizer + // through the interface if they do not plan on attending. By default, + // the organizer has accepted. + const organizerAsAttendee = new CalAttendee(); + organizerAsAttendee.id = cal.email.removeMailTo(organizerId); + organizerAsAttendee.commonName = calendar.getProperty("organizerCN"); + organizerAsAttendee.role = "REQ-PARTICIPANT"; + organizerAsAttendee.participationStatus = "ACCEPTED"; + attendees.push(organizerAsAttendee); + } + } + } + + // Add all provided attendees to the attendee list. + for (let attendee of attendees) { + let attendeeElement = attendeeList.appendChild(document.createXULElement("event-attendee")); + attendeeElement.attendee = attendee; + } + + // Add a final empty row for user input. + attendeeList.appendChild(document.createXULElement("event-attendee")).focus(); + updateVerticalScrollbars(); + }, + { once: true } +); + +window.addEventListener("dialogaccept", () => { + // Build the list of attendees which have been filled in. + let attendeeElements = attendeeList.getElementsByTagName("event-attendee"); + const attendees = Array.from(attendeeElements) + .map(element => element.attendee) + .filter(attendee => !!attendee.id); + + const [{ organizer: existingOrganizer, calendar, onOk }] = window.arguments; + + // Determine the organizer of the event. If there are no attendees other than + // the organizer, we want to leave it as a personal event with no organizer. + // Only set that value if other attendees have been added. + let organizer; + + const organizerId = existingOrganizer?.id ?? calendar.getProperty("organizerId"); + if (organizerId) { + const nonOrganizerAttendees = attendees.filter(attendee => attendee.id != organizerId); + if (nonOrganizerAttendees.length != 0) { + if (existingOrganizer) { + organizer = existingOrganizer; + } else { + organizer = new CalAttendee(); + organizer.id = cal.email.removeMailTo(organizerId); + organizer.commonName = calendar.getProperty("organizerCN"); + organizer.isOrganizer = true; + } + } else { + // Since we don't set the organizer if the event is personal, don't add + // the organizer as an attendee either. + attendees.length = 0; + } + } + + let { startValue, endValue } = dateTimePickerUI; + if (dateTimePickerUI.allDay.checked) { + startValue.isDate = true; + endValue.isDate = true; + } + + onOk(attendees, organizer, startValue, endValue); +}); + +/** + * Passing the event change on dateTimePickerUI.end.addEventListener in function, to limit the creations of interface + * in case of change of day + hour (example at the time of a drag and drop), it was going to trigger the event 2 times: once for the hour and once the day + */ +function updateChange() { + if ( + previousStartTime.getInTimezone(cal.dtz.defaultTimezone).day == + dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).day && + previousStartTime.getInTimezone(cal.dtz.defaultTimezone).month == + dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).month && + previousStartTime.getInTimezone(cal.dtz.defaultTimezone).year == + dateTimePickerUI.endValue.getInTimezone(cal.dtz.defaultTimezone).year && + previousEndTime.getInTimezone(cal.dtz.defaultTimezone).day == + dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).day && + previousEndTime.getInTimezone(cal.dtz.defaultTimezone).month == + dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).month && + previousEndTime.getInTimezone(cal.dtz.defaultTimezone).year == + dateTimePickerUI.endValue.getInTimezone(cal.dtz.defaultTimezone).year + ) { + eventBar.update(false); + } else { + displayStartTime = dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone); + + displayStartTime.day -= 1; + displayStartTime.isDate = true; + + displayEndTime = displayStartTime.clone(); + + emptyGrid(); + for (let attendee of attendeeList.getElementsByTagName("event-attendee")) { + attendee.clearFreeBusy(); + } + + layout(); + eventBar.update(true); + } + previousStartTime = dateTimePickerUI.startValue; + previousEndTime = dateTimePickerUI.endValue; +} + +/** + * Handler function to be used when the Start time or End time of the event have + * changed. + * If the end date is earlier than the start date, an error is displayed and the user's modification is cancelled + */ +function checkDate() { + if (dateTimePickerUI.startValue && dateTimePickerUI.endValue) { + if (dateTimePickerUI.endValue.compare(dateTimePickerUI.startValue) > -1) { + updatePreviousValues(); + } else { + // Don't allow for negative durations. + let callback = function () { + Services.prompt.alert(null, document.title, cal.l10n.getCalString("warningEndBeforeStart")); + }; + setTimeout(callback, 1); + dateTimePickerUI.endValue = previousEndTime; + dateTimePickerUI.startValue = previousStartTime; + } + } +} + +/** + * Update the end date of the event if the user changes the start date via the timepicker. + */ +function updateEndDate() { + let duration = previousEndTime.subtractDate(previousStartTime); + + let endDatePrev = dateTimePickerUI.startValue.clone(); + endDatePrev.addDuration(duration); + + updateByFunction = true; + + dateTimePickerUI.endValue = endDatePrev; + + updateChange(); + updatePreviousValues(); + + updateByFunction = false; +} + +/** + * Updated previous values that are used to return to the previous state if the end date is before the start date + */ +function updatePreviousValues() { + previousStartTime = dateTimePickerUI.startValue; + previousEndTime = dateTimePickerUI.endValue; +} + +/** + * Lays out the window on load or resize. Fills the grid and sets the size of some elements that + * can't easily be done with a stylesheet. + */ +function layout() { + fillGrid(); + let spacer = document.getElementById("spacer"); + spacer.style.height = `${dayHeaderOuter.clientHeight + 1}px`; + freebusyGridInner.style.minHeight = freebusyGrid.clientHeight + "px"; + updateVerticalScrollbars(); +} + +/** + * Checks if the grid has a vertical scrollbar and updates the header to match. + */ +function updateVerticalScrollbars() { + if (freebusyGrid.scrollHeight > freebusyGrid.clientHeight) { + dayHeaderOuter.style.overflowY = "scroll"; + dayHeaderInner.style.overflowY = "scroll"; + } else { + dayHeaderOuter.style.overflowY = null; + dayHeaderInner.style.overflowY = null; + } +} + +/** + * Clears the grid. + */ +function emptyGrid() { + while (dayHeaderInner.lastChild) { + dayHeaderInner.lastChild.remove(); + } +} + +/** + * Ensures at least five days are represented on the grid. If the window is wide enough, more days + * are shown. + */ +function fillGrid() { + setTimeRange(); + + if (!showCompleteDay) { + displayEndTime.isDate = false; + displayEndTime.hour = dayStartHour; + displayEndTime.minute = 0; + displayStartTime.isDate = false; + displayStartTime.hour = dayStartHour; + displayStartTime.minute = 0; + } else { + // BUG in icaljs + displayEndTime.isDate = true; + displayEndTime.hour = 0; + displayEndTime.minute = 0; + displayStartTime.isDate = true; + displayStartTime.hour = 0; + displayStartTime.minute = 0; + } + + let oldEndTime = displayEndTime.clone(); + + while ( + dayHeaderInner.childElementCount < numberDaysDisplayed || + dayHeaderOuter.scrollWidth < dayHeaderOuter.clientWidth + ) { + dayHeaderInner.appendChild(document.createXULElement("calendar-day")).date = displayEndTime; + displayEndTime.addDuration(cal.createDuration("P1D")); + } + + freebusyGridInner.style.width = dayHeaderInner.childElementCount * zoom.dayWidth + "px"; + if (displayEndTime.compare(oldEndTime) > 0) { + for (let attendee of attendeeList.getElementsByTagName("event-attendee")) { + attendee.updateFreeBusy(oldEndTime, displayEndTime); + } + } +} + +/** + * Aligns element horizontally on the grid to match the time period it represents. + * + * @param {Element} element - The element to align. + * @param {calIDateTime} startTime - The start time to be represented. + * @param {calIDateTime} endTime - The end time to be represented. + */ +function setLeftAndWidth(element, startTime, endTime) { + element.style.left = getOffsetLeft(startTime) + "px"; + element.style.width = getOffsetLeft(endTime) - getOffsetLeft(startTime) + "px"; +} + +/** + * Determines the offset in pixels from the first day displayed. + * + * @param {calIDateTime} startTime - The start time to be represented. + */ +function getOffsetLeft(startTime) { + let coordinates = 0; + startTime = startTime.getInTimezone(cal.dtz.defaultTimezone); + + let difference = startTime.subtractDate(displayStartTime); + + if (displayStartTime.timezoneOffset != startTime.timezoneOffset) { + // Time changes. + let diffTimezone = cal.createDuration(); + diffTimezone.inSeconds = startTime.timezoneOffset - displayStartTime.timezoneOffset; + // We add the difference to the date difference otherwise the following calculations will be incorrect. + difference.addDuration(diffTimezone); + } + + if (!showCompleteDay) { + // Start date of the day displayed for the date of the object being processed. + let currentDateStartHour = startTime.clone(); + currentDateStartHour.hour = displayStartHour; + currentDateStartHour.minute = 0; + + let dayToDayDuration = currentDateStartHour.subtractDate(displayStartTime); + if (currentDateStartHour.timezoneOffset != displayStartTime.timezoneOffset) { + // Time changes. + let diffTimezone = cal.createDuration(); + diffTimezone.inSeconds = + currentDateStartHour.timezoneOffset - displayStartTime.timezoneOffset; + // We add the difference to the date difference otherwise the following calculations will be incorrect. + dayToDayDuration.addDuration(diffTimezone); + } + + if (startTime.hour < displayStartHour) { + // The date starts before the start time of the day, we do not take into consideration the time before the start of the day. + coordinates = (dayToDayDuration.weeks * 7 + dayToDayDuration.days) * zoom.dayWidth; + } else if (startTime.hour >= displayEndHour) { + // The event starts after the end of the day, we do not take into consideration the time before the following day. + coordinates = (dayToDayDuration.weeks * 7 + dayToDayDuration.days + 1) * zoom.dayWidth; + } else { + coordinates = + (difference.weeks * 7 + difference.days) * zoom.dayWidth + + (difference.hours * 60 * 60 + difference.minutes * 60 + difference.seconds) * + zoom.secondWidth; + } + } else { + coordinates = difference.inSeconds * zoom.secondWidth; + } + + return coordinates; +} + +/** + * Set the time range, setting the start and end hours from the prefs, or + * to 24 hrs if the event is outside the range from the prefs. + */ +function setTimeRange() { + let dateStart = dateTimePickerUI.startValue; + let dateEnd = dateTimePickerUI.endValue; + + let dateStartDefaultTimezone = dateStart.getInTimezone(cal.dtz.defaultTimezone); + let dateEndDefaultTimezone = dateEnd.getInTimezone(cal.dtz.defaultTimezone); + + if ( + showOnlyWholeDays || + dateTimePickerUI.allDay.checked || + dateStartDefaultTimezone.hour < dayStartHour || + (dateStartDefaultTimezone.hour == dayEndHour && dateStartDefaultTimezone.minute > 0) || + dateStartDefaultTimezone.hour > dayEndHour || + (dateEndDefaultTimezone.hour == dayEndHour && dateEndDefaultTimezone.minute > 0) || + dateEndDefaultTimezone.hour > dayEndHour || + dateStartDefaultTimezone.day != dateEndDefaultTimezone.day + ) { + if (!showCompleteDay) { + // We modify the levels to readapt them. + for (let i = 0; i < zoom.levels.length; i++) { + zoom.levels[i].columnCount = + zoom.levels[i].columnCount * (24 / (dayEndHour - dayStartHour)); + zoom.levels[i].dayWidth = zoom.levels[i].columnCount * zoom.levels[i].columnWidth; + } + } + displayStartHour = 0; + displayEndHour = 24; + showCompleteDay = true; + + // To reactivate the dezoom button if you were in dezoom max for a reduced display. + zoom.zoomOutButton.disabled = zoom.currentLevel == 0; + } else { + if (zoom.currentLevel == 0) { + // To avoid being in max dezoom in the reduced display mode. + zoom.currentLevel++; + } + zoom.zoomOutButton.disabled = zoom.currentLevel == 1; + + if (zoom.currentLevel == 1 && (dayEndHour - dayStartHour) % zoom.columnDuration.hours != 0) { + // To avoid being in zoom level where the interface is not adapted. + zoom.currentLevel++; + // Otherwise the class of the grid is not updated. + for (let gridClass of ["twoMinorColumns", "threeMinorColumns"]) { + if (zoom.levels[zoom.currentLevel].gridClass == gridClass) { + dayHeaderInner.classList.add(gridClass); + freebusyGridInner.classList.add(gridClass); + } else { + dayHeaderInner.classList.remove(gridClass); + freebusyGridInner.classList.remove(gridClass); + } + } + } + + if ( + (dayEndHour - dayStartHour) % zoom.levels[zoom.currentLevel - 1].columnDuration.hours != + 0 + ) { + zoom.zoomOutButton.disabled = true; + } + + if (showCompleteDay) { + // We modify the levels to readapt them. + for (let i = 0; i < zoom.levels.length; i++) { + zoom.levels[i].columnCount = + zoom.levels[i].columnCount / (24 / (dayEndHour - dayStartHour)); + zoom.levels[i].dayWidth = zoom.levels[i].columnCount * zoom.levels[i].columnWidth; + } + } + displayStartHour = dayStartHour; + displayEndHour = dayEndHour; + showCompleteDay = false; + } +} + +/** + * Function to trigger a change of display type (reduced or full). + */ +function updateRange() { + let dateStart = dateTimePickerUI.startValue; + let dateEnd = dateTimePickerUI.endValue; + + let dateStartDefaultTimezone = dateStart.getInTimezone(cal.dtz.defaultTimezone); + let dateEndDefaultTimezone = dateEnd.getInTimezone(cal.dtz.defaultTimezone); + + let durationEvent = dateEnd.subtractDate(dateStart); + + if ( + // Reduced -> Full. + (!showCompleteDay && + (dateTimePickerUI.allDay.checked || + (dateStartDefaultTimezone.hour == displayEndHour && dateStartDefaultTimezone.minute > 0) || + dateStartDefaultTimezone.hour > displayEndHour || + (dateEndDefaultTimezone.hour == displayEndHour && dateEndDefaultTimezone.minute > 0) || + dateStartDefaultTimezone.hour < dayStartHour || + dateEndDefaultTimezone.hour > displayEndHour || + dateStartDefaultTimezone.day != dateEndDefaultTimezone.day)) || + // Full -> Reduced. + (showCompleteDay && + dateStartDefaultTimezone.hour >= dayStartHour && + dateStartDefaultTimezone.hour < dayEndHour && + (dateEndDefaultTimezone.hour < dayEndHour || + (dateEndDefaultTimezone.hour == dayEndHour && dateEndDefaultTimezone.minute == 0)) && + dateStartDefaultTimezone.day == dateEndDefaultTimezone.day) || + durationEvent.days > numberDaysDisplayedPref || + (numberDaysDisplayed > numberDaysDisplayedPref && durationEvent.days < numberDaysDisplayedPref) + ) { + // We redo the grid if we change state (reduced -> full, full -> reduced or if you need to change the number of days displayed). + displayStartTime = dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone); + displayStartTime.isDate = true; + displayStartTime.day--; + + displayEndTime = displayStartTime.clone(); + + emptyGrid(); + for (let attendee of attendeeList.getElementsByTagName("event-attendee")) { + attendee.clearFreeBusy(); + } + + if (durationEvent.days > numberDaysDisplayedPref) { + numberDaysDisplayed = durationEvent.days + 2; + } else { + numberDaysDisplayed = numberDaysDisplayedPref; + } + layout(); + eventBar.update(true); + } +} + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * Represents a row on the grid for a single attendee. The element itself is the row header, and + * this class holds reference to any elements on the grid itself that represent the free/busy + * status for this row's attendee. The free/busy elements are removed automatically if this + * element is removed. + */ + class EventAttendee extends MozXULElement { + static #DEFAULT_ROLE = "REQ-PARTICIPANT"; + static #DEFAULT_USER_TYPE = "INDIVIDUAL"; + + static #roleCycle = ["REQ-PARTICIPANT", "OPT-PARTICIPANT", "NON-PARTICIPANT", "CHAIR"]; + static #userTypeCycle = ["INDIVIDUAL", "GROUP", "RESOURCE", "ROOM"]; + + #attendee = null; + #roleIcon = null; + #userTypeIcon = null; + #input = null; + + // Because these divs have no reference back to the corresponding attendee, + // we currently have to expose this in order to test that free/busy updates + // happen appropriately. + _freeBusyDiv = null; + + connectedCallback() { + // Initialize a default attendee. + this.#attendee = new CalAttendee(); + this.#attendee.role = EventAttendee.#DEFAULT_ROLE; + this.#attendee.userType = EventAttendee.#DEFAULT_USER_TYPE; + + // Set up participation role icon. Its image is a grid of icons, the + // display of which is determined by CSS rules defined in + // calendar-attendees.css based on its class and "attendeerole" attribute. + this.#roleIcon = this.appendChild(document.createElement("img")); + this.#roleIcon.classList.add("role-icon"); + this.#roleIcon.setAttribute( + "src", + "chrome://calendar/skin/shared/calendar-event-dialog-attendees.png" + ); + this.#updateRoleIcon(); + this.#roleIcon.addEventListener("click", this); + + // Set up calendar user type icon. Its image is a grid of icons, the + // display of which is determined by CSS rules defined in + // calendar-attendees.css based on its class and "usertype" attribute. + this.#userTypeIcon = this.appendChild(document.createElement("img")); + this.#userTypeIcon.classList.add("usertype-icon"); + this.#userTypeIcon.setAttribute("src", "chrome://calendar/skin/shared/attendee-icons.png"); + this.#updateUserTypeIcon(); + this.#userTypeIcon.addEventListener("click", this); + + this.#input = this.appendChild(document.createElement("input", { is: "autocomplete-input" })); + this.#input.classList.add("plain"); + this.#input.setAttribute("autocompletesearch", "addrbook ldap"); + this.#input.setAttribute("autocompletesearchparam", "{}"); + this.#input.setAttribute("forcecomplete", "true"); + this.#input.setAttribute("timeout", "200"); + this.#input.setAttribute("completedefaultindex", "true"); + this.#input.setAttribute("completeselectedindex", "true"); + this.#input.setAttribute("minresultsforpopup", "1"); + this.#input.addEventListener("change", this); + this.#input.addEventListener("keydown", this); + this.#input.addEventListener("input", this); + this.#input.addEventListener("click", this); + + this._freeBusyDiv = freebusyGridInner.appendChild(document.createElement("div")); + this._freeBusyDiv.classList.add("freebusy-row"); + } + + disconnectedCallback() { + this._freeBusyDiv.remove(); + } + + /** + * Get the attendee for this row. The attendee will be cloned to prevent + * accidental modification, which could cause the UI to fall out of sync. + * + * @returns {calIAttendee} - The attendee for this row. + */ + get attendee() { + return this.#attendee.clone(); + } + + /** + * Set the attendee for this row. + * + * @param {calIAttendee} attendee - The new attendee for this row. + */ + set attendee(attendee) { + this.#attendee = attendee.clone(); + + // Update display values of the icons and input box. + this.#updateRoleIcon(); + this.#updateUserTypeIcon(); + + // If the attendee has a name set, build a display string from their name + // and email; otherwise, we can use the email address as is. + const attendeeEmail = cal.email.removeMailTo(this.#attendee.id); + if (this.#attendee.commonName) { + this.#input.value = MailServices.headerParser + .makeMailboxObject(this.#attendee.commonName, attendeeEmail) + .toString(); + } else { + this.#input.value = attendeeEmail; + } + + this.updateFreeBusy(displayStartTime, displayEndTime); + } + + /** Removes all free/busy information from this row. */ + clearFreeBusy() { + while (this._freeBusyDiv.lastChild) { + this._freeBusyDiv.lastChild.remove(); + } + } + + /** + * Queries the free/busy service for information about this row's attendee, and displays the + * information on the grid if there is any. + * + * @param {calIDateTime} from - The start of a time period to query. + * @param {calIDateTime} to - The end of a time period to query. + */ + updateFreeBusy(from, to) { + let addresses = MailServices.headerParser.makeFromDisplayAddress(this.#input.value); + if (addresses.length === 0) { + return; + } + + let calendar = cal.email.prependMailTo(addresses[0].email); + + let pendingDiv = this._freeBusyDiv.appendChild(document.createElement("div")); + pendingDiv.classList.add("pending"); + setLeftAndWidth(pendingDiv, from, to); + + cal.freeBusyService.getFreeBusyIntervals( + calendar, + from, + to, + Ci.calIFreeBusyInterval.BUSY_ALL, + { + onResult: (operation, results) => { + for (let result of results) { + let freeBusyType = Number(result.freeBusyType); // For some reason this is a string. + if (freeBusyType == Ci.calIFreeBusyInterval.FREE) { + continue; + } + + let block = this._freeBusyDiv.appendChild(document.createElement("div")); + switch (freeBusyType) { + case Ci.calIFreeBusyInterval.BUSY_TENTATIVE: + block.classList.add("tentative"); + break; + case Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE: + block.classList.add("unavailable"); + break; + case Ci.calIFreeBusyInterval.UNKNOWN: + block.classList.add("unknown"); + break; + default: + block.classList.add("busy"); + break; + } + setLeftAndWidth(block, result.interval.start, result.interval.end); + } + if (!operation.isPending) { + this.dispatchEvent(new CustomEvent("freebusy-update-finished")); + pendingDiv.remove(); + } + }, + } + ); + this.dispatchEvent(new CustomEvent("freebusy-update-started")); + } + + focus() { + this.scrollIntoView(); + this.#input.focus(); + } + + handleEvent(event) { + if ( + // Change, e.g. due to blur. + event.type == "change" || + (event.type == "keydown" && event.key == "Enter") || + // A click on the line of the input field. + (event.type == "click" && event.target.nodeName == "input") || + // A click on an autocomplete suggestion. + (event.type == "input" && + event.inputType == "insertReplacementText" && + event.explicitOriginalTarget != event.originalTarget) + ) { + const nextElement = this.nextElementSibling; + if (this.#input.value) { + /** + * Given structured address data, build it into a collection of + * mailboxes, resolving any groups into individual mailboxes in the + * process. + * + * @param {Map<string, msgIAddressObject>} accumulatorMap - A map from + * attendee ID to the corresponding mailbox. + * @param {msgIAddressObject} address - Structured representation of + * an RFC 5322 address to resolve to one or more mailboxes. + * @returns {Map<string, msgIAddressObject>} - A map containing all + * entries from the provided map as well as any individual + * mailboxes resolved from the provided address. + */ + function resolveAddressesToMailboxes(accumulatorMap, address) { + let list = MailUtils.findListInAddressBooks(address.name); + if (list) { + // If the address was for a group, collect each mailbox from that + // group, recursively if necessary. + return list.childCards + .map(card => { + card.QueryInterface(Ci.nsIAbCard); + + return MailServices.headerParser.makeMailboxObject( + card.displayName, + card.primaryEmail + ); + }) + .reduce(resolveAddressesToMailboxes, accumulatorMap); + } + + // The address data was a single mailbox; add it to the map. + return accumulatorMap.set(address.email, address); + } + + // Take the addresses in the input and resolve them into individual + // mailboxes for attendees. + const attendeeAddresses = MailServices.headerParser.makeFromDisplayAddress( + this.#input.value + ); + // Clear input so possible later events won't try to add again. + this.#input.value = ""; + const resolvedMailboxes = attendeeAddresses.reduce( + resolveAddressesToMailboxes, + new Map() + ); + + // We want to ensure that this row and its attendee is preserved if + // the attendee is still in the list; otherwise, we may throw away + // what we already know about them (e.g., required vs. optional or + // RSVP status). + const attendeeEmail = this.#attendee.id && cal.email.removeMailTo(this.#attendee.id); + if (attendeeEmail && resolvedMailboxes.has(attendeeEmail)) { + // Update attendee name from mailbox and ensure we don't duplicate + // the row. + const mailbox = resolvedMailboxes.get(attendeeEmail); + this.#attendee.commonName = mailbox.name; + resolvedMailboxes.delete(attendeeEmail); + } else { + // The attendee for this row was not found in the revised list of + // mailboxes, so remove the row from the attendee list. + nextElement?.focus(); + this.remove(); + } + + // For any mailboxes beyond that representing the current attendee, + // add a new row immediately following this one (or its previous + // location if removed). + for (const [email, mailbox] of resolvedMailboxes) { + const newAttendee = new CalAttendee(); + newAttendee.id = cal.email.prependMailTo(email); + newAttendee.role = EventAttendee.#DEFAULT_ROLE; + newAttendee.userType = EventAttendee.#DEFAULT_USER_TYPE; + + if (mailbox.name && mailbox.name != mailbox.email) { + newAttendee.commonName = mailbox.name; + } + + const newRow = attendeeList.insertBefore( + document.createXULElement("event-attendee"), + nextElement + ); + newRow.attendee = newAttendee; + } + + // If there are no rows following, create an empty row for the next attendee. + if (!nextElement) { + attendeeList.appendChild(document.createXULElement("event-attendee")).focus(); + freebusyGrid.scrollTop = attendeeList.scrollTop; + } + } else if (this.nextElementSibling) { + // This row is now empty, but there are additional rows (and thus an + // empty row for new entries). Remove this row and focus the next. + this.nextElementSibling.focus(); + this.remove(); + } + + updateVerticalScrollbars(); + + if (this.parentNode) { + this.clearFreeBusy(); + this.updateFreeBusy(displayStartTime, displayEndTime); + } + } else if (event.type == "click") { + if (event.button != 0 || readOnly) { + return; + } + + const cycle = (values, current) => { + let nextIndex = (values.indexOf(current) + 1) % values.length; + return values[nextIndex]; + }; + + let target = event.target; + if (target == this.#roleIcon) { + this.#attendee.role = cycle(EventAttendee.#roleCycle, this.#attendee.role); + this.#updateRoleIcon(); + } else if (target == this.#userTypeIcon) { + if (!this.#attendee.isOrganizer) { + this.#attendee.userType = cycle(EventAttendee.#userTypeCycle, this.#attendee.userType); + this.#updateUserTypeIcon(); + } + } + } else if (event.type == "keydown" && event.key == "ArrowRight") { + let nextElement = this.nextElementSibling; + if (this.#input.value) { + if (!nextElement) { + attendeeList.appendChild(document.createXULElement("event-attendee")); + } + } else if (this.nextElementSibling) { + // No value but not the last row? Remove. + this.remove(); + } + } + } + + /** + * Update the tooltip and icon of the role icon node to match the current + * role for this row's attendee. + */ + #updateRoleIcon() { + const role = this.#attendee.role ?? EventAttendee.#DEFAULT_ROLE; + const roleValueToStringKeyMap = { + "REQ-PARTICIPANT": "event.attendee.role.required", + "OPT-PARTICIPANT": "event.attendee.role.optional", + "NON-PARTICIPANT": "event.attendee.role.nonparticipant", + CHAIR: "event.attendee.role.chair", + }; + + let tooltip; + if (role in roleValueToStringKeyMap) { + tooltip = cal.l10n.getString( + "calendar-event-dialog-attendees", + roleValueToStringKeyMap[role] + ); + } else { + tooltip = cal.l10n.getString( + "calendar-event-dialog-attendees", + "event.attendee.role.unknown", + [role] + ); + } + + this.#roleIcon.setAttribute("attendeerole", role); + this.#roleIcon.setAttribute("title", tooltip); + } + + /** + * Update the tooltip and icon of the user type icon node to match the + * current user type for this row's attendee. + */ + #updateUserTypeIcon() { + const userType = this.#attendee.userType ?? EventAttendee.#DEFAULT_USER_TYPE; + const userTypeValueToStringKeyMap = { + INDIVIDUAL: "event.attendee.usertype.individual", + GROUP: "event.attendee.usertype.group", + RESOURCE: "event.attendee.usertype.resource", + ROOM: "event.attendee.usertype.room", + // UNKNOWN and any unrecognized user types are handled below. + }; + + let tooltip; + if (userType in userTypeValueToStringKeyMap) { + tooltip = cal.l10n.getString( + "calendar-event-dialog-attendees", + userTypeValueToStringKeyMap[userType] + ); + } else { + tooltip = cal.l10n.getString( + "calendar-event-dialog-attendees", + "event.attendee.usertype.unknown", + [userType] + ); + } + + this.#userTypeIcon.setAttribute("usertype", userType); + this.#userTypeIcon.setAttribute("title", tooltip); + } + } + customElements.define("event-attendee", EventAttendee); + + /** + * Represents a group of columns for a single day on the grid. The element itself is the column + * header, and this class holds reference to elements on the grid that provide the background + * coloring for the day. The elements are removed automatically if this element is removed. + */ + class CalendarDay extends MozXULElement { + connectedCallback() { + let dayLabelContainer = this.appendChild(document.createXULElement("box")); + dayLabelContainer.setAttribute("pack", "center"); + + this.dayLabel = dayLabelContainer.appendChild(document.createXULElement("label")); + this.dayLabel.classList.add("day-label"); + + let columnContainer = this.appendChild(document.createXULElement("box")); + + // A half-column-wide spacer to align labels with the dividing grid lines. + columnContainer.appendChild(document.createXULElement("box")).style.width = + zoom.columnWidth / 2 + "px"; + + let column = displayEndTime.clone(); + column.isDate = false; + for (let i = 1; i < zoom.columnCount; i++) { + column.addDuration(zoom.columnDuration); + + let columnBox = columnContainer.appendChild(document.createXULElement("box")); + columnBox.style.width = zoom.columnWidth + "px"; + columnBox.setAttribute("align", "center"); + + let columnLabel = columnBox.appendChild(document.createXULElement("label")); + columnLabel.classList.add("hour-label"); + columnLabel.setAttribute("flex", "1"); + columnLabel.setAttribute("value", cal.dtz.formatter.formatTime(column)); + } + + // A half-column-wide (minus 1px) spacer to align labels with the dividing grid lines. + columnContainer.appendChild(document.createXULElement("box")).style.width = + zoom.columnWidth / 2 - 1 + "px"; + } + + disconnectedCallback() { + if (this.dayColumn) { + this.dayColumn.remove(); + } + } + + /** @returns {calIDateTime} - The day this group of columns represents. */ + get date() { + return this.mDate; + } + /** @param {calIDateTime} value - The day this group of columns represents. */ + set date(value) { + this.mDate = value.clone(); + this.dayLabel.value = cal.dtz.formatter.formatDateShort(this.mDate); + + let datePlus1 = value.clone(); + if (!showCompleteDay) { + // To avoid making a 24 hour day in reduced display. + let hoursToShow = dayEndHour - dayStartHour; + datePlus1.addDuration(cal.createDuration("PT" + hoursToShow + "H")); + } else { + datePlus1.addDuration(cal.createDuration("P1D")); + } + + let dayOffPref = [ + "calendar.week.d0sundaysoff", + "calendar.week.d1mondaysoff", + "calendar.week.d2tuesdaysoff", + "calendar.week.d3wednesdaysoff", + "calendar.week.d4thursdaysoff", + "calendar.week.d5fridaysoff", + "calendar.week.d6saturdaysoff", + ][this.mDate.weekday]; + + this.dayColumn = freebusyGridBackground.appendChild(document.createElement("div")); + this.dayColumn.classList.add("day-column"); + setLeftAndWidth(this.dayColumn, this.mDate, datePlus1); + if (Services.prefs.getBoolPref(dayOffPref)) { + this.dayColumn.classList.add("day-off"); + } + + if (dayStartHour > 0) { + let dayStart = value.clone(); + dayStart.isDate = false; + dayStart.hour = dayStartHour; + let beforeStartDiv = this.dayColumn.appendChild(document.createElement("div")); + beforeStartDiv.classList.add("time-off"); + setLeftAndWidth(beforeStartDiv, this.mDate, dayStart); + beforeStartDiv.style.left = "0"; + } + if (dayEndHour < 24) { + let dayEnd = value.clone(); + dayEnd.isDate = false; + dayEnd.hour = dayEndHour; + let afterEndDiv = this.dayColumn.appendChild(document.createElement("div")); + afterEndDiv.classList.add("time-off"); + setLeftAndWidth(afterEndDiv, dayEnd, datePlus1); + afterEndDiv.style.left = null; + afterEndDiv.style.right = "0"; + } + } + } + customElements.define("calendar-day", CalendarDay); +} diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml new file mode 100644 index 0000000000..964dd050c5 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml @@ -0,0 +1,227 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-views.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/shared/input-fields.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar.dtd"> %dtd1; +<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > +%dtd2; ]> +<html + id="calendar-event-dialog-attendees-v2" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Calendar:EventDialog:Attendees" + orient="vertical" + lightweightthemes="true" + scrolling="false" + style="min-width: 800px; min-height: 500px" +> + <head> + <title>&invite.title.label;</title> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script> + <script + defer="defer" + src="chrome://calendar/content/calendar-event-dialog-attendees.js" + ></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog defaultButton="none"> + <hbox align="center" pack="end"> + <spacer flex="1" /> + <label value="&event.freebusy.zoom;" control="zoom-menulist" /> + <toolbarbutton id="zoom-out-button" class="zoom-out-icon" /> + <toolbarbutton id="zoom-in-button" class="zoom-in-icon" /> + </hbox> + <hbox id="outer" flex="1"> + <vbox class="attendee-list-container"> + <box id="spacer"></box> + <vbox id="attendee-list" flex="1"></vbox> + </vbox> + <splitter /> + <vbox flex="1" class="attendees-grid-container"> + <box id="day-header-outer"> + <stack> + <hbox id="day-header-inner"></hbox> + <html:div id="event-bar-top" draggable="true"></html:div> + </stack> + </box> + <vbox id="freebusy-grid" flex="1"> + <stack> + <html:div id="freebusy-grid-background"></html:div> + <html:div id="freebusy-grid-inner"></html:div> + <html:div id="event-bar-bottom" draggable="true"></html:div> + </stack> + </vbox> + </vbox> + </hbox> + <hbox> + <hbox flex="1"> + <hbox flex="1"> + <table xmlns="http://www.w3.org/1999/xhtml"> + <tr> + <td> + <img + class="role-icon" + attendeerole="REQ-PARTICIPANT" + src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png" + alt="" + /> + </td> + <td>&event.attendee.role.required;</td> + </tr> + <tr> + <td> + <img + class="role-icon" + attendeerole="OPT-PARTICIPANT" + src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png" + alt="" + /> + </td> + <td>&event.attendee.role.optional;</td> + </tr> + <tr> + <td> + <img + class="role-icon" + attendeerole="NON-PARTICIPANT" + src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png" + alt="" + /> + </td> + <td>&event.attendee.role.nonparticipant;</td> + </tr> + <tr> + <td> + <img + class="role-icon" + attendeerole="CHAIR" + src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png" + alt="" + /> + </td> + <td>&event.attendee.role.chair;</td> + </tr> + </table> + </hbox> + <hbox flex="1"> + <table xmlns="http://www.w3.org/1999/xhtml"> + <tr> + <td> + <img + class="usertype-icon" + usertype="INDIVIDUAL" + src="chrome://calendar/skin/shared/attendee-icons.png" + alt="" + /> + </td> + <td>&event.attendee.usertype.individual;</td> + </tr> + <tr> + <td> + <img + class="usertype-icon" + usertype="GROUP" + src="chrome://calendar/skin/shared/attendee-icons.png" + alt="" + /> + </td> + <td>&event.attendee.usertype.group;</td> + </tr> + <tr> + <td> + <img + class="usertype-icon" + usertype="RESOURCE" + src="chrome://calendar/skin/shared/attendee-icons.png" + alt="" + /> + </td> + <td>&event.attendee.usertype.resource;</td> + </tr> + <tr> + <td> + <img + class="usertype-icon" + usertype="ROOM" + src="chrome://calendar/skin/shared/attendee-icons.png" + alt="" + /> + </td> + <td>&event.attendee.usertype.room;</td> + </tr> + </table> + </hbox> + <hbox flex="1"> + <table xmlns="http://www.w3.org/1999/xhtml"> + <tr> + <td><xul:box class="legend" status="BUSY_TENTATIVE" /></td> + <td>&event.freebusy.legend.busy_tentative;</td> + </tr> + <tr> + <td><xul:box class="legend" status="BUSY" /></td> + <td>&event.freebusy.legend.busy;</td> + </tr> + <tr> + <td><xul:box class="legend" status="BUSY_UNAVAILABLE" /></td> + <td>&event.freebusy.legend.busy_unavailable;</td> + </tr> + <tr> + <td><xul:box class="legend" status="UNKNOWN" /></td> + <td>&event.freebusy.legend.unknown;</td> + </tr> + </table> + </hbox> + </hbox> + <vbox> + <table xmlns="http://www.w3.org/1999/xhtml"> + <tr> + <td></td> + <td> + <xul:checkbox id="all-day" label="&event.alldayevent.label;" /> + </td> + </tr> + <tr> + <td> + <xul:label control="event-starttime" value="&newevent.from.label;" /> + </td> + <td> + <xul:datetimepicker id="event-starttime" /> + </td> + <td id="timezone-starttime-cell" hidden="true"> + <xul:label id="timezone-starttime" class="text-link" hyperlink="true" /> + </td> + </tr> + <tr> + <td> + <xul:label control="event-endtime" value="&newevent.to.label;" /> + </td> + <td> + <xul:datetimepicker id="event-endtime" /> + </td> + <td id="timezone-endtime-cell" hidden="true"> + <xul:label id="timezone-endtime" class="text-link" hyperlink="true" /> + </td> + </tr> + </table> + </vbox> + </hbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js new file mode 100644 index 0000000000..379e5e387e --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js @@ -0,0 +1,1237 @@ +/* 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/. */ + +var { splitRecurrenceRules } = ChromeUtils.import( + "resource:///modules/calendar/calRecurrenceUtils.jsm" +); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +var gIsReadOnly = false; +var gStartTime = null; +var gEndTime = null; +var gUntilDate = null; + +window.addEventListener("load", onLoad); + +/** + * Object wrapping the methods and properties of recurrencePreview binding. + */ +const RecurrencePreview = { + /** + * Initializes some properties and adds event listener to the #recurrencePreview node. + */ + init() { + this.node = document.getElementById("recurrencePreview"); + this.mRecurrenceInfo = null; + this.mResizeHandler = null; + this.mDateTime = null; + document.getElementById("recurrencePrevious").addEventListener("click", () => { + this.showPreviousMonth(); + }); + document.getElementById("recurrenceNext").addEventListener("click", () => { + this.showNextMonth(); + }); + document.getElementById("recurrenceToday").addEventListener("click", () => { + this.jumpToToday(); + }); + this.togglePreviousMonthButton(); + }, + /** + * Setter for mDateTime property. + * + * @param {Date} val - The date value that is to be set. + */ + set dateTime(val) { + this.mDateTime = val.clone(); + }, + /** + * Getter for mDateTime property. + */ + get dateTime() { + if (this.mDateTime == null) { + this.mDateTime = cal.dtz.now(); + } + return this.mDateTime; + }, + /** + * Updates content of #recurrencePreview node. + */ + updateContent() { + let date = cal.dtz.dateTimeToJsDate(this.dateTime); + for (let minimonth of this.node.children) { + minimonth.showMonth(date); + date.setMonth(date.getMonth() + 1); + } + }, + /** + * Updates preview of #recurrencePreview node. + */ + updatePreview(recurrenceInfo) { + let minimonth = this.node.querySelector("calendar-minimonth"); + this.node.style.minHeight = minimonth.getBoundingClientRect().height + "px"; + + this.mRecurrenceInfo = recurrenceInfo; + let start = this.dateTime.clone(); + start.day = 1; + start.hour = 0; + start.minute = 0; + start.second = 0; + let end = start.clone(); + end.month++; + + for (let minimonth of this.node.children) { + // we now have one of the minimonth controls while 'start' + // and 'end' are set to the interval this minimonth shows. + minimonth.showMonth(cal.dtz.dateTimeToJsDate(start)); + if (recurrenceInfo) { + // retrieve an array of dates that represents all occurrences + // that fall into this time interval [start,end[. + // note: the following loop assumes that this array contains + // dates that are strictly monotonically increasing. + // should getOccurrenceDates() not enforce this assumption we + // need to fall back to some different algorithm. + let dates = recurrenceInfo.getOccurrenceDates(start, end, 0); + + // now run through all days of this month and set the + // 'busy' attribute with respect to the occurrence array. + let index = 0; + let occurrence = null; + if (index < dates.length) { + occurrence = dates[index++].getInTimezone(start.timezone); + } + let current = start.clone(); + while (current.compare(end) < 0) { + let box = minimonth.getBoxForDate(current); + if (box) { + if ( + occurrence && + occurrence.day == current.day && + occurrence.month == current.month && + occurrence.year == current.year + ) { + box.setAttribute("busy", 1); + if (index < dates.length) { + occurrence = dates[index++].getInTimezone(start.timezone); + // take into account that the very next occurrence + // can happen at the same day as the previous one. + if ( + occurrence.day == current.day && + occurrence.month == current.month && + occurrence.year == current.year + ) { + continue; + } + } else { + occurrence = null; + } + } else { + box.removeAttribute("busy"); + } + } + current.day++; + } + } + start.month++; + end.month++; + } + }, + /** + * Shows the previous month in the recurrence preview. + */ + showPreviousMonth() { + let prevMinimonth = this.node.querySelector(`calendar-minimonth[active-month="true"]`); + + let activeDate = this.previousMonthDate( + prevMinimonth.getAttribute("year"), + prevMinimonth.getAttribute("month") + ); + + if (activeDate) { + this.resetDisplayOfMonths(); + this.displayCurrentMonths(activeDate); + this.togglePreviousMonthButton(); + } + }, + /** + * Shows the next month in the recurrence preview. + */ + showNextMonth() { + let prevMinimonth = this.node.querySelector(`calendar-minimonth[active-month="true"]`); + + let activeDate = this.nextMonthDate( + prevMinimonth.getAttribute("year"), + prevMinimonth.getAttribute("month") + ); + + if (activeDate) { + this.resetDisplayOfMonths(); + this.displayCurrentMonths(activeDate); + this.togglePreviousMonthButton(); + } + }, + /** + * Shows the current day's month in the recurrence preview. + */ + jumpToToday() { + let activeDate = new Date(); + this.resetDisplayOfMonths(); + this.displayCurrentMonths(activeDate); + this.togglePreviousMonthButton(); + }, + /** + * Selects the minimonth element belonging to a year and month. + */ + selectMinimonth(year, month) { + let minimonthIdentifier = `calendar-minimonth[year="${year}"][month="${month}"]`; + let selectedMinimonth = this.node.querySelector(minimonthIdentifier); + + if (selectedMinimonth) { + return selectedMinimonth; + } + + selectedMinimonth = document.createXULElement("calendar-minimonth"); + this.node.appendChild(selectedMinimonth); + + selectedMinimonth.setAttribute("readonly", "true"); + selectedMinimonth.setAttribute("month", month); + selectedMinimonth.setAttribute("year", year); + selectedMinimonth.hidden = true; + + if (this.mRecurrenceInfo) { + this.updatePreview(this.mRecurrenceInfo); + } + + return selectedMinimonth; + }, + /** + * Returns the next month's first day when given a year and month. + */ + nextMonthDate(currentYear, currentMonth) { + // If month is December, select first day of January + if (currentMonth == 11) { + return new Date(parseInt(currentYear) + 1, 0, 1); + } + return new Date(parseInt(currentYear), parseInt(currentMonth) + 1, 1); + }, + /** + * Returns the previous month's first day when given a year and month. + */ + previousMonthDate(currentYear, currentMonth) { + // If month is January, select first day of December. + if (currentMonth == 0) { + return new Date(parseInt(currentYear) - 1, 11, 1); + } + return new Date(parseInt(currentYear), parseInt(currentMonth) - 1, 1); + }, + /** + * Reset the recurrence preview months, making all hidden and none set to active. + */ + resetDisplayOfMonths() { + let calContainer = this.node; + for (let minimonth of calContainer.children) { + minimonth.hidden = true; + minimonth.setAttribute("active-month", false); + } + }, + /** + * Display the active month and the next two months in the recurrence preview. + */ + displayCurrentMonths(activeDate) { + let activeMonth = activeDate.getMonth(); + let activeYear = activeDate.getFullYear(); + + let month1Date = this.nextMonthDate(activeYear, activeMonth); + let month2Date = this.nextMonthDate(month1Date.getFullYear(), month1Date.getMonth()); + + let activeMinimonth = this.selectMinimonth(activeYear, activeMonth); + let minimonth1 = this.selectMinimonth(month1Date.getFullYear(), month1Date.getMonth()); + let minimonth2 = this.selectMinimonth(month2Date.getFullYear(), month2Date.getMonth()); + + activeMinimonth.setAttribute("active-month", true); + activeMinimonth.removeAttribute("hidden"); + minimonth1.removeAttribute("hidden"); + minimonth2.removeAttribute("hidden"); + }, + /** + * Disable previous month button when the active month is the first month of the event. + */ + togglePreviousMonthButton() { + let activeMinimonth = this.node.querySelector(`calendar-minimonth[active-month="true"]`); + + if (activeMinimonth.getAttribute("initial-month") == "true") { + document.getElementById("recurrencePrevious").setAttribute("disabled", "true"); + } else { + document.getElementById("recurrencePrevious").removeAttribute("disabled"); + } + }, +}; + +/** + * An object containing the daypicker-weekday binding functionalities. + */ +const DaypickerWeekday = { + /** + * Method intitializing DaypickerWeekday. + */ + init() { + this.weekStartOffset = Services.prefs.getIntPref("calendar.week.start", 0); + + let mainbox = document.getElementById("daypicker-weekday"); + let numChilds = mainbox.children.length; + for (let i = 0; i < numChilds; i++) { + let child = mainbox.children[i]; + let dow = i + this.weekStartOffset; + if (dow >= 7) { + dow -= 7; + } + let day = cal.l10n.getString("dateFormat", `day.${dow + 1}.Mmm`); + child.label = day; + child.calendar = mainbox; + } + }, + /** + * Getter for days property. + */ + get days() { + let mainbox = document.getElementById("daypicker-weekday"); + let numChilds = mainbox.children.length; + let days = []; + for (let i = 0; i < numChilds; i++) { + let child = mainbox.children[i]; + if (child.getAttribute("checked") == "true") { + let index = i + this.weekStartOffset; + if (index >= 7) { + index -= 7; + } + days.push(index + 1); + } + } + return days; + }, + /** + * The weekday-picker manages an array of selected days of the week and + * the 'days' property is the interface to this array. the expected argument is + * an array containing integer elements, where each element represents a selected + * day of the week, starting with SUNDAY=1. + */ + set days(val) { + let mainbox = document.getElementById("daypicker-weekday"); + for (let child of mainbox.children) { + child.removeAttribute("checked"); + } + for (let i in val) { + let index = val[i] - 1 - this.weekStartOffset; + if (index < 0) { + index += 7; + } + mainbox.children[index].setAttribute("checked", "true"); + } + }, +}; + +/** + * An object containing the daypicker-monthday binding functionalities. + */ +const DaypickerMonthday = { + /** + * Method intitializing DaypickerMonthday. + */ + init() { + let mainbox = document.querySelector(".daypicker-monthday-mainbox"); + let child = null; + for (let row of mainbox.children) { + for (child of row.children) { + child.calendar = mainbox; + } + } + let labelLastDay = cal.l10n.getString( + "calendar-event-dialog", + "eventRecurrenceMonthlyLastDayLabel" + ); + child.setAttribute("label", labelLastDay); + }, + /** + * Setter for days property. + */ + set days(val) { + let mainbox = document.querySelector(".daypicker-monthday-mainbox"); + let days = []; + for (let row of mainbox.children) { + for (let child of row.children) { + child.removeAttribute("checked"); + days.push(child); + } + } + for (let i in val) { + let lastDayOffset = val[i] == -1 ? 0 : -1; + let index = val[i] < 0 ? val[i] + days.length + lastDayOffset : val[i] - 1; + days[index].setAttribute("checked", "true"); + } + }, + /** + * Getter for days property. + */ + get days() { + let mainbox = document.querySelector(".daypicker-monthday-mainbox"); + let days = []; + for (let row of mainbox.children) { + for (let child of row.children) { + if (child.getAttribute("checked") == "true") { + days.push(Number(child.label) ? Number(child.label) : -1); + } + } + } + return days; + }, + /** + * Disables daypicker elements. + */ + disable() { + let mainbox = document.querySelector(".daypicker-monthday-mainbox"); + for (let row of mainbox.children) { + for (let child of row.children) { + child.setAttribute("disabled", "true"); + } + } + }, + /** + * Enables daypicker elements. + */ + enable() { + let mainbox = document.querySelector(".daypicker-monthday-mainbox"); + for (let row of mainbox.children) { + for (let child of row.children) { + child.removeAttribute("disabled"); + } + } + }, +}; + +/** + * Sets up the recurrence dialog from the window arguments. Takes care of filling + * the dialog controls with the recurrence information for this window. + */ +function onLoad() { + RecurrencePreview.init(); + DaypickerWeekday.init(); + DaypickerMonthday.init(); + changeWidgetsOrder(); + + let args = window.arguments[0]; + let item = args.calendarEvent; + let calendar = item.calendar; + let recinfo = args.recurrenceInfo; + + gStartTime = args.startTime; + gEndTime = args.endTime; + RecurrencePreview.dateTime = gStartTime.getInTimezone(cal.dtz.defaultTimezone); + + onChangeCalendar(calendar); + + // Set starting value for 'repeat until' rule and highlight the start date. + let repeatDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating)); + document.getElementById("repeat-until-date").value = repeatDate; + document.getElementById("repeat-until-date").extraDate = repeatDate; + + if (item.parentItem != item) { + item = item.parentItem; + } + let rule = null; + if (recinfo) { + // Split out rules and exceptions + try { + let rrules = splitRecurrenceRules(recinfo); + let rules = rrules[0]; + // Deal with the rules + if (rules.length > 0) { + // We only handle 1 rule currently + rule = cal.wrapInstance(rules[0], Ci.calIRecurrenceRule); + } + } catch (ex) { + console.error(ex); + } + } + if (!rule) { + rule = cal.createRecurrenceRule(); + rule.type = "DAILY"; + rule.interval = 1; + rule.count = -1; + + // We don't let the user set the week start day for a given rule, but we + // want to default to the user's week start so rules behave as expected + let weekStart = Services.prefs.getIntPref("calendar.week.start", 0); + rule.weekStart = weekStart; + } + initializeControls(rule); + + // Update controls + updateRecurrenceBox(); + + opener.setCursor("auto"); + self.focus(); +} + +/** + * Initialize the dialog controls according to the passed rule + * + * @param rule The recurrence rule to parse. + */ +function initializeControls(rule) { + function getOrdinalAndWeekdayOfRule(aByDayRuleComponent) { + return { + ordinal: (aByDayRuleComponent - (aByDayRuleComponent % 8)) / 8, + weekday: Math.abs(aByDayRuleComponent % 8), + }; + } + + function setControlsForByMonthDay_YearlyRule(aDate, aByMonthDay) { + if (aByMonthDay == -1) { + // The last day of the month. + document.getElementById("yearly-group").selectedIndex = 1; + document.getElementById("yearly-ordinal").value = -1; + document.getElementById("yearly-weekday").value = -1; + } else { + if (aByMonthDay < -1) { + // The UI doesn't manage negative days apart from -1 but we can + // display in the controls the day from the start of the month. + aByMonthDay += aDate.endOfMonth.day + 1; + } + document.getElementById("yearly-group").selectedIndex = 0; + document.getElementById("yearly-days").value = aByMonthDay; + } + } + + function everyWeekDay(aByDay) { + // Checks if aByDay contains only values from 1 to 7 with any order. + let mask = aByDay.reduce((value, item) => value | (1 << item), 1); + return aByDay.length == 7 && mask == Math.pow(2, 8) - 1; + } + + document.getElementById("week-start").value = rule.weekStart; + + switch (rule.type) { + case "DAILY": + document.getElementById("period-list").selectedIndex = 0; + document.getElementById("daily-days").value = rule.interval; + break; + case "WEEKLY": + document.getElementById("weekly-weeks").value = rule.interval; + document.getElementById("period-list").selectedIndex = 1; + break; + case "MONTHLY": + document.getElementById("monthly-interval").value = rule.interval; + document.getElementById("period-list").selectedIndex = 2; + break; + case "YEARLY": + document.getElementById("yearly-interval").value = rule.interval; + document.getElementById("period-list").selectedIndex = 3; + break; + default: + document.getElementById("period-list").selectedIndex = 0; + dump("unable to handle your rule type!\n"); + break; + } + + let byDayRuleComponent = rule.getComponent("BYDAY"); + let byMonthDayRuleComponent = rule.getComponent("BYMONTHDAY"); + let byMonthRuleComponent = rule.getComponent("BYMONTH"); + let kDefaultTimezone = cal.dtz.defaultTimezone; + let startDate = gStartTime.getInTimezone(kDefaultTimezone); + + // "DAILY" ruletype + // byDayRuleComponents may have been set priorily by "MONTHLY"- ruletypes + // where they have a different context- + // that's why we also query the current rule-type + if (byDayRuleComponent.length == 0 || rule.type != "DAILY") { + document.getElementById("daily-group").selectedIndex = 0; + } else { + document.getElementById("daily-group").selectedIndex = 1; + } + + // "WEEKLY" ruletype + if (byDayRuleComponent.length == 0 || rule.type != "WEEKLY") { + DaypickerWeekday.days = [startDate.weekday + 1]; + } else { + DaypickerWeekday.days = byDayRuleComponent; + } + + // "MONTHLY" ruletype + let ruleComponentsEmpty = byDayRuleComponent.length == 0 && byMonthDayRuleComponent.length == 0; + if (ruleComponentsEmpty || rule.type != "MONTHLY") { + document.getElementById("monthly-group").selectedIndex = 1; + DaypickerMonthday.days = [startDate.day]; + let day = Math.floor((startDate.day - 1) / 7) + 1; + document.getElementById("monthly-ordinal").value = day; + document.getElementById("monthly-weekday").value = startDate.weekday + 1; + } else if (everyWeekDay(byDayRuleComponent)) { + // Every day of the month. + document.getElementById("monthly-group").selectedIndex = 0; + document.getElementById("monthly-ordinal").value = 0; + document.getElementById("monthly-weekday").value = -1; + } else if (byDayRuleComponent.length > 0) { + // One of the first five days or weekdays of the month. + document.getElementById("monthly-group").selectedIndex = 0; + let ruleInfo = getOrdinalAndWeekdayOfRule(byDayRuleComponent[0]); + document.getElementById("monthly-ordinal").value = ruleInfo.ordinal; + document.getElementById("monthly-weekday").value = ruleInfo.weekday; + } else if (byMonthDayRuleComponent.length == 1 && byMonthDayRuleComponent[0] == -1) { + // The last day of the month. + document.getElementById("monthly-group").selectedIndex = 0; + document.getElementById("monthly-ordinal").value = byMonthDayRuleComponent[0]; + document.getElementById("monthly-weekday").value = byMonthDayRuleComponent[0]; + } else if (byMonthDayRuleComponent.length > 0) { + document.getElementById("monthly-group").selectedIndex = 1; + DaypickerMonthday.days = byMonthDayRuleComponent; + } + + // "YEARLY" ruletype + if (byMonthRuleComponent.length == 0 || rule.type != "YEARLY") { + document.getElementById("yearly-month-rule").value = startDate.month + 1; + document.getElementById("yearly-month-ordinal").value = startDate.month + 1; + if (byMonthDayRuleComponent.length > 0) { + setControlsForByMonthDay_YearlyRule(startDate, byMonthDayRuleComponent[0]); + } else { + document.getElementById("yearly-days").value = startDate.day; + let ordinalDay = Math.floor((startDate.day - 1) / 7) + 1; + document.getElementById("yearly-ordinal").value = ordinalDay; + document.getElementById("yearly-weekday").value = startDate.weekday + 1; + } + } else { + document.getElementById("yearly-month-rule").value = byMonthRuleComponent[0]; + document.getElementById("yearly-month-ordinal").value = byMonthRuleComponent[0]; + if (byMonthDayRuleComponent.length > 0) { + let date = startDate.clone(); + date.month = byMonthRuleComponent[0] - 1; + setControlsForByMonthDay_YearlyRule(date, byMonthDayRuleComponent[0]); + } else if (byDayRuleComponent.length > 0) { + document.getElementById("yearly-group").selectedIndex = 1; + if (everyWeekDay(byDayRuleComponent)) { + // Every day of the month. + document.getElementById("yearly-ordinal").value = 0; + document.getElementById("yearly-weekday").value = -1; + } else { + let yearlyRuleInfo = getOrdinalAndWeekdayOfRule(byDayRuleComponent[0]); + document.getElementById("yearly-ordinal").value = yearlyRuleInfo.ordinal; + document.getElementById("yearly-weekday").value = yearlyRuleInfo.weekday; + } + } else if (byMonthRuleComponent.length > 0) { + document.getElementById("yearly-group").selectedIndex = 0; + document.getElementById("yearly-days").value = startDate.day; + } + } + + /* load up the duration of the event radiogroup */ + if (rule.isByCount) { + if (rule.count == -1) { + document.getElementById("recurrence-duration").value = "forever"; + } else { + document.getElementById("recurrence-duration").value = "ntimes"; + document.getElementById("repeat-ntimes-count").value = rule.count; + } + } else { + let untilDate = rule.untilDate; + if (untilDate) { + gUntilDate = untilDate.getInTimezone(gStartTime.timezone); // calIRecurrenceRule::untilDate is always UTC or floating + // Change the until date to start date if the rule has a forbidden + // value (earlier than the start date). + if (gUntilDate.compare(gStartTime) < 0) { + gUntilDate = gStartTime.clone(); + } + let repeatDate = cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating)); + document.getElementById("recurrence-duration").value = "until"; + document.getElementById("repeat-until-date").value = repeatDate; + } else { + document.getElementById("recurrence-duration").value = "forever"; + } + } +} + +/** + * Save the recurrence information selected in the dialog back to the given + * item. + * + * @param item The item to save back to. + * @returns The saved recurrence info. + */ +function onSave(item) { + // Always return 'null' if this item is an occurrence. + if (!item || item.parentItem != item) { + return null; + } + + // This works, but if we ever support more complex recurrence, + // e.g. recurrence for Martians, then we're going to want to + // not clone and just recreate the recurrenceInfo each time. + // The reason is that the order of items (rules/dates/datesets) + // matters, so we can't always just append at the end. This + // code here always inserts a rule first, because all our + // exceptions should come afterward. + let periodNumber = Number(document.getElementById("period-list").value); + + let args = window.arguments[0]; + let recurrenceInfo = args.recurrenceInfo; + if (recurrenceInfo) { + recurrenceInfo = recurrenceInfo.clone(); + let rrules = splitRecurrenceRules(recurrenceInfo); + if (rrules[0].length > 0) { + recurrenceInfo.deleteRecurrenceItem(rrules[0][0]); + } + recurrenceInfo.item = item; + } else { + recurrenceInfo = new CalRecurrenceInfo(item); + } + + let recRule = cal.createRecurrenceRule(); + + // We don't let the user edit the start of the week for a given rule, but we + // want to preserve the value set + let weekStart = Number(document.getElementById("week-start").value); + recRule.weekStart = weekStart; + + const ALL_WEEKDAYS = [2, 3, 4, 5, 6, 7, 1]; // The sequence MO,TU,WE,TH,FR,SA,SU. + switch (periodNumber) { + case 0: { + recRule.type = "DAILY"; + let dailyGroup = document.getElementById("daily-group"); + if (dailyGroup.selectedIndex == 0) { + let ndays = Math.max(1, Number(document.getElementById("daily-days").value)); + recRule.interval = ndays; + } else { + recRule.interval = 1; + let onDays = [2, 3, 4, 5, 6]; + recRule.setComponent("BYDAY", onDays); + } + break; + } + case 1: { + recRule.type = "WEEKLY"; + let ndays = Number(document.getElementById("weekly-weeks").value); + recRule.interval = ndays; + let onDays = DaypickerWeekday.days; + if (onDays.length > 0) { + recRule.setComponent("BYDAY", onDays); + } + break; + } + case 2: { + recRule.type = "MONTHLY"; + let monthInterval = Number(document.getElementById("monthly-interval").value); + recRule.interval = monthInterval; + let monthlyGroup = document.getElementById("monthly-group"); + if (monthlyGroup.selectedIndex == 0) { + let monthlyOrdinal = Number(document.getElementById("monthly-ordinal").value); + let monthlyDOW = Number(document.getElementById("monthly-weekday").value); + if (monthlyDOW < 0) { + if (monthlyOrdinal == 0) { + // Monthly rule "Every day of the month". + recRule.setComponent("BYDAY", ALL_WEEKDAYS); + } else { + // One of the first five days or the last day of the month. + recRule.setComponent("BYMONTHDAY", [monthlyOrdinal]); + } + } else { + let sign = monthlyOrdinal < 0 ? -1 : 1; + let onDays = [(Math.abs(monthlyOrdinal) * 8 + monthlyDOW) * sign]; + recRule.setComponent("BYDAY", onDays); + } + } else { + let monthlyDays = DaypickerMonthday.days; + if (monthlyDays.length > 0) { + recRule.setComponent("BYMONTHDAY", monthlyDays); + } + } + break; + } + case 3: { + recRule.type = "YEARLY"; + let yearInterval = Number(document.getElementById("yearly-interval").value); + recRule.interval = yearInterval; + let yearlyGroup = document.getElementById("yearly-group"); + if (yearlyGroup.selectedIndex == 0) { + let yearlyByMonth = [Number(document.getElementById("yearly-month-ordinal").value)]; + recRule.setComponent("BYMONTH", yearlyByMonth); + let yearlyByDay = [Number(document.getElementById("yearly-days").value)]; + recRule.setComponent("BYMONTHDAY", yearlyByDay); + } else { + let yearlyByMonth = [Number(document.getElementById("yearly-month-rule").value)]; + recRule.setComponent("BYMONTH", yearlyByMonth); + let yearlyOrdinal = Number(document.getElementById("yearly-ordinal").value); + let yearlyDOW = Number(document.getElementById("yearly-weekday").value); + if (yearlyDOW < 0) { + if (yearlyOrdinal == 0) { + // Yearly rule "Every day of a month". + recRule.setComponent("BYDAY", ALL_WEEKDAYS); + } else { + // One of the first five days or the last of a month. + recRule.setComponent("BYMONTHDAY", [yearlyOrdinal]); + } + } else { + let sign = yearlyOrdinal < 0 ? -1 : 1; + let onDays = [(Math.abs(yearlyOrdinal) * 8 + yearlyDOW) * sign]; + recRule.setComponent("BYDAY", onDays); + } + } + break; + } + } + + // Figure out how long this event is supposed to last + switch (document.getElementById("recurrence-duration").selectedItem.value) { + case "forever": { + recRule.count = -1; + break; + } + case "ntimes": { + recRule.count = Math.max(1, document.getElementById("repeat-ntimes-count").value); + break; + } + case "until": { + let untilDate = cal.dtz.jsDateToDateTime( + document.getElementById("repeat-until-date").value, + gStartTime.timezone + ); + untilDate.isDate = gStartTime.isDate; // enforce same value type as DTSTART + if (!gStartTime.isDate) { + // correct UNTIL to exactly match start date's hour, minute, second: + untilDate.hour = gStartTime.hour; + untilDate.minute = gStartTime.minute; + untilDate.second = gStartTime.second; + } + recRule.untilDate = untilDate; + break; + } + } + + if (recRule.interval < 1) { + return null; + } + + recurrenceInfo.insertRecurrenceItemAt(recRule, 0); + return recurrenceInfo; +} + +/** + * Handler function to be called when the accept button is pressed. + */ +document.addEventListener("dialogaccept", event => { + let args = window.arguments[0]; + let item = args.calendarEvent; + args.onOk(onSave(item)); + // Don't close the dialog if a warning must be showed. + if (checkUntilDate.warning) { + event.preventDefault(); + } +}); + +/** + * Handler function to be called when the Cancel button is pressed. + */ +document.addEventListener("dialogcancel", () => { + // Don't show any warning if the dialog must be closed. + checkUntilDate.warning = false; +}); + +/** + * Handler function called when the calendar is changed (also for initial + * setup). + * + * XXX we don't change the calendar in this dialog, this function should be + * consolidated or renamed. + * + * @param calendar The calendar to use for setup. + */ +function onChangeCalendar(calendar) { + let args = window.arguments[0]; + let item = args.calendarEvent; + + // Set 'gIsReadOnly' if the calendar is read-only + gIsReadOnly = false; + if (calendar && calendar.readOnly) { + gIsReadOnly = true; + } + + // Disable or enable controls based on a set or rules + // - whether this item is a stand-alone item or an occurrence + // - whether or not this item is read-only + // - whether or not the state of the item allows recurrence rules + // - tasks without an entrydate are invalid + disableOrEnable(item); + + updateRecurrenceControls(); +} + +/** + * Disable or enable certain controls based on the given item: + * Uses the following attribute: + * + * - disable-on-occurrence + * - disable-on-readonly + * + * A task without a start time is also considered readonly. + * + * @param item The item to check. + */ +function disableOrEnable(item) { + if (item.parentItem != item) { + disableRecurrenceFields("disable-on-occurrence"); + } else if (gIsReadOnly) { + disableRecurrenceFields("disable-on-readonly"); + } else if (item.isTodo() && !gStartTime) { + disableRecurrenceFields("disable-on-readonly"); + } else { + enableRecurrenceFields("disable-on-readonly"); + } +} + +/** + * Disables all fields that have an attribute that matches the argument and is + * set to "true". + * + * @param aAttributeName The attribute to search for. + */ +function disableRecurrenceFields(aAttributeName) { + let disableElements = document.getElementsByAttribute(aAttributeName, "true"); + for (let i = 0; i < disableElements.length; i++) { + disableElements[i].setAttribute("disabled", "true"); + } +} + +/** + * Enables all fields that have an attribute that matches the argument and is + * set to "true". + * + * @param aAttributeName The attribute to search for. + */ +function enableRecurrenceFields(aAttributeName) { + let enableElements = document.getElementsByAttribute(aAttributeName, "true"); + for (let i = 0; i < enableElements.length; i++) { + enableElements[i].removeAttribute("disabled"); + } +} + +/** + * Handler function to update the period-box when an item from the period-list + * is selected. Also updates the controls on that period-box. + */ +function updateRecurrenceBox() { + let periodBox = document.getElementById("period-box"); + let periodNumber = Number(document.getElementById("period-list").value); + for (let i = 0; i < periodBox.children.length; i++) { + periodBox.children[i].hidden = i != periodNumber; + } + updateRecurrenceControls(); +} + +/** + * Updates the controls regarding ranged controls (i.e repeat forever, repeat + * until, repeat n times...) + */ +function updateRecurrenceRange() { + let args = window.arguments[0]; + let item = args.calendarEvent; + if (item.parentItem != item || gIsReadOnly) { + return; + } + + let radioRangeForever = document.getElementById("recurrence-range-forever"); + let radioRangeFor = document.getElementById("recurrence-range-for"); + let radioRangeUntil = document.getElementById("recurrence-range-until"); + let rangeTimesCount = document.getElementById("repeat-ntimes-count"); + let rangeUntilDate = document.getElementById("repeat-until-date"); + let rangeAppointmentsLabel = document.getElementById("repeat-appointments-label"); + + radioRangeForever.removeAttribute("disabled"); + radioRangeFor.removeAttribute("disabled"); + radioRangeUntil.removeAttribute("disabled"); + rangeAppointmentsLabel.removeAttribute("disabled"); + + let durationSelection = document.getElementById("recurrence-duration").selectedItem.value; + + if (durationSelection == "ntimes") { + rangeTimesCount.removeAttribute("disabled"); + } else { + rangeTimesCount.setAttribute("disabled", "true"); + } + + if (durationSelection == "until") { + rangeUntilDate.removeAttribute("disabled"); + } else { + rangeUntilDate.setAttribute("disabled", "true"); + } +} + +/** + * Updates the recurrence preview calendars using the window's item. + */ +function updatePreview() { + let args = window.arguments[0]; + let item = args.calendarEvent; + if (item.parentItem != item) { + item = item.parentItem; + } + + // TODO: We should better start the whole dialog with a newly cloned item + // and always pump changes immediately into it. This would eliminate the + // need to break the encapsulation, as we do it here. But we need the item + // to contain the startdate in order to calculate the recurrence preview. + item = item.clone(); + let kDefaultTimezone = cal.dtz.defaultTimezone; + if (item.isEvent()) { + let startDate = gStartTime.getInTimezone(kDefaultTimezone); + let endDate = gEndTime.getInTimezone(kDefaultTimezone); + if (startDate.isDate) { + endDate.day--; + } + + item.startDate = startDate; + item.endDate = endDate; + } + if (item.isTodo()) { + let entryDate = gStartTime; + if (entryDate) { + entryDate = entryDate.getInTimezone(kDefaultTimezone); + } else { + item.recurrenceInfo = null; + } + item.entryDate = entryDate; + let dueDate = gEndTime; + if (dueDate) { + dueDate = dueDate.getInTimezone(kDefaultTimezone); + } + item.dueDate = dueDate; + } + + let recInfo = onSave(item); + RecurrencePreview.updatePreview(recInfo); +} + +/** + * Checks the until date just entered in the datepicker in order to avoid + * setting a date earlier than the start date. + * Restores the previous correct date, shows a warning and prevents to close the + * dialog when the user enters a wrong until date. + */ +function checkUntilDate() { + if (!gStartTime) { + // This function shouldn't run before onLoad. + return; + } + + let untilDate = cal.dtz.jsDateToDateTime( + document.getElementById("repeat-until-date").value, + gStartTime.timezone + ); + let startDate = gStartTime.clone(); + startDate.isDate = true; + if (untilDate.compare(startDate) < 0) { + let repeatDate = cal.dtz.dateTimeToJsDate( + (gUntilDate || gStartTime).getInTimezone(cal.dtz.floating) + ); + document.getElementById("repeat-until-date").value = repeatDate; + checkUntilDate.warning = true; + let callback = function () { + // No warning when the dialog is being closed with the Cancel button. + if (!checkUntilDate.warning) { + return; + } + Services.prompt.alert( + null, + document.title, + cal.l10n.getCalString("warningUntilDateBeforeStart") + ); + checkUntilDate.warning = false; + }; + setTimeout(callback, 1); + } else { + gUntilDate = untilDate; + updateRecurrenceControls(); + } +} + +/** + * Checks the date entered for a yearly absolute rule (i.e. every 12 of January) + * in order to avoid creating a rule on an invalid date. + */ +function checkYearlyAbsoluteDate() { + if (!gStartTime) { + // This function shouldn't run before onLoad. + return; + } + + const MONTH_LENGTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let dayOfMonth = document.getElementById("yearly-days").value; + let month = document.getElementById("yearly-month-ordinal").value; + document.getElementById("yearly-days").max = MONTH_LENGTHS[month - 1]; + // Check if the day value is too high. + if (dayOfMonth > MONTH_LENGTHS[month - 1]) { + document.getElementById("yearly-days").value = MONTH_LENGTHS[month - 1]; + } else { + updateRecurrenceControls(); + } + // Check if the day value is too low. + if (dayOfMonth < 1) { + document.getElementById("yearly-days").value = 1; + } else { + updateRecurrenceControls(); + } +} + +/** + * Update all recurrence controls on the dialog. + */ +function updateRecurrenceControls() { + updateRecurrencePattern(); + updateRecurrenceRange(); + updatePreview(); + window.sizeToContent(); +} + +/** + * Disables/enables controls related to the recurrence pattern. + * the status of the controls depends on which period entry is selected + * and which form of pattern rule is selected. + */ +function updateRecurrencePattern() { + let args = window.arguments[0]; + let item = args.calendarEvent; + if (item.parentItem != item || gIsReadOnly) { + return; + } + + switch (Number(document.getElementById("period-list").value)) { + // daily + case 0: { + let dailyGroup = document.getElementById("daily-group"); + let dailyDays = document.getElementById("daily-days"); + dailyDays.removeAttribute("disabled"); + if (dailyGroup.selectedIndex == 1) { + dailyDays.setAttribute("disabled", "true"); + } + break; + } + // weekly + case 1: { + break; + } + // monthly + case 2: { + let monthlyGroup = document.getElementById("monthly-group"); + let monthlyOrdinal = document.getElementById("monthly-ordinal"); + let monthlyWeekday = document.getElementById("monthly-weekday"); + let monthlyDays = DaypickerMonthday; + monthlyOrdinal.removeAttribute("disabled"); + monthlyWeekday.removeAttribute("disabled"); + monthlyDays.enable(); + if (monthlyGroup.selectedIndex == 0) { + monthlyDays.disable(); + } else { + monthlyOrdinal.setAttribute("disabled", "true"); + monthlyWeekday.setAttribute("disabled", "true"); + } + break; + } + // yearly + case 3: { + let yearlyGroup = document.getElementById("yearly-group"); + let yearlyDays = document.getElementById("yearly-days"); + let yearlyMonthOrdinal = document.getElementById("yearly-month-ordinal"); + let yearlyPeriodOfMonthLabel = document.getElementById("yearly-period-of-month-label"); + let yearlyOrdinal = document.getElementById("yearly-ordinal"); + let yearlyWeekday = document.getElementById("yearly-weekday"); + let yearlyMonthRule = document.getElementById("yearly-month-rule"); + let yearlyPeriodOfLabel = document.getElementById("yearly-period-of-label"); + yearlyDays.removeAttribute("disabled"); + yearlyMonthOrdinal.removeAttribute("disabled"); + yearlyOrdinal.removeAttribute("disabled"); + yearlyWeekday.removeAttribute("disabled"); + yearlyMonthRule.removeAttribute("disabled"); + yearlyPeriodOfLabel.removeAttribute("disabled"); + yearlyPeriodOfMonthLabel.removeAttribute("disabled"); + if (yearlyGroup.selectedIndex == 0) { + yearlyOrdinal.setAttribute("disabled", "true"); + yearlyWeekday.setAttribute("disabled", "true"); + yearlyMonthRule.setAttribute("disabled", "true"); + yearlyPeriodOfLabel.setAttribute("disabled", "true"); + } else { + yearlyDays.setAttribute("disabled", "true"); + yearlyMonthOrdinal.setAttribute("disabled", "true"); + yearlyPeriodOfMonthLabel.setAttribute("disabled", "true"); + } + break; + } + } +} + +/** + * This function changes the order for certain elements using a locale string. + * This is needed for some locales that expect a different wording order. + * + * @param aPropKey The locale property key to get the order from + * @param aPropParams An array of ids to be passed to the locale property. + * These should be the ids of the elements to change + * the order for. + */ +function changeOrderForElements(aPropKey, aPropParams) { + let localeOrder; + let parents = {}; + + for (let key in aPropParams) { + // Save original parents so that the nodes to reorder get appended to + // the correct parent nodes. + parents[key] = document.getElementById(aPropParams[key]).parentNode; + } + + try { + localeOrder = cal.l10n.getString("calendar-event-dialog", aPropKey, aPropParams).split(" "); + } catch (ex) { + let msg = + "The key " + + aPropKey + + " in calendar-event-dialog.prop" + + "erties has incorrect number of params. Expected " + + aPropParams.length + + " params."; + console.error(msg + " " + ex); + return; + } + + // Add elements in the right order, removing them from their old parent + for (let i = 0; i < aPropParams.length; i++) { + let newEl = document.getElementById(localeOrder[i]); + if (newEl) { + parents[i].appendChild(newEl); + } else { + cal.ERROR( + "Localization error, could not find node '" + + localeOrder[i] + + "'. Please have your localizer check the string '" + + aPropKey + + "'" + ); + } + } +} + +/** + * Change locale-specific widget order for Edit Recurrence window + */ +function changeWidgetsOrder() { + changeOrderForElements("monthlyOrder", ["monthly-ordinal", "monthly-weekday"]); + changeOrderForElements("yearlyOrder", [ + "yearly-days", + "yearly-period-of-month-label", + "yearly-month-ordinal", + ]); + changeOrderForElements("yearlyOrder2", [ + "yearly-ordinal", + "yearly-weekday", + "yearly-period-of-label", + "yearly-month-rule", + ]); +} diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml new file mode 100644 index 0000000000..f762a3b3ef --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml @@ -0,0 +1,1077 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-daypicker.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE html [ <!ENTITY % dialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> +%dialogDTD; ]> +<html + id="calendar-event-dialog-recurrence" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Calendar:EventDialog:Recurrence" + persist="screenX screenY width height" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&recurrence.title.label;</title> + <link rel="localization" href="calendar/calendar-recurrence-dialog.ftl" /> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script> + <script + defer="defer" + src="chrome://calendar/content/calendar-event-dialog-recurrence.js" + ></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog> + <!-- recurrence pattern --> + <html:fieldset id="recurrence-pattern-groupbox"> + <html:legend id="recurrence-pattern-caption">&event.recurrence.pattern.label;</html:legend> + <hbox flex="1" id="recurrence-pattern-hbox"> + <vbox> + <label + value="&event.recurrence.occurs.label;" + class="recurrence-pattern-hbox-label" + disable-on-readonly="true" + disable-on-occurrence="true" + control="period-list" + /> + </vbox> + <vbox flex="1"> + <menulist + id="period-list" + oncommand="updateRecurrenceBox();" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="period-list-menupopup"> + <menuitem + id="period-list-day-menuitem" + label="&event.recurrence.day.label;" + value="0" + /> + <menuitem + id="period-list-week-menuitem" + label="&event.recurrence.week.label;" + value="1" + /> + <menuitem + id="period-list-month-menuitem" + label="&event.recurrence.month.label;" + value="2" + /> + <menuitem + id="period-list-year-menuitem" + label="&event.recurrence.year.label;" + value="3" + /> + </menupopup> + </menulist> + <html:input id="week-start" type="hidden" value="1" /> + <hbox id="period-box" oncommand="updateRecurrenceControls();"> + <!-- Daily --> + <box id="period-box-daily-box" orient="vertical" align="start"> + <radiogroup id="daily-group"> + <box id="daily-period-every-box" orient="horizontal" align="center"> + <radio + id="daily-group-every-radio" + label="&event.recurrence.pattern.every.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + selected="true" + /> + <html:input + id="daily-days" + type="number" + class="size3 input-inline" + min="1" + max="32767" + value="1" + oninput="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <label + id="daily-group-every-units-label" + value="&repeat.units.days.both;" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <spacer id="daily-group-spacer" flex="1" /> + </box> + <radio + id="daily-group-weekday-radio" + label="&event.recurrence.pattern.every.weekday.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + </radiogroup> + </box> + <!-- Weekly --> + <vbox id="period-box-weekly-box" hidden="true"> + <hbox id="weekly-period-every-box" align="center"> + <label + id="weekly-period-every-label" + value="&event.recurrence.pattern.weekly.every.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="weekly-weeks" + /> + <html:input + id="weekly-weeks" + type="number" + class="size3 input-inline" + min="1" + max="32767" + value="1" + oninput="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <label + id="weekly-period-units-label" + value="&repeat.units.weeks.both;" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + </hbox> + <separator class="thin" /> + <hbox align="center"> + <label + id="weekly-period-on-label" + value="&event.recurrence.on.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="daypicker-weekday" + /> + <hbox + id="daypicker-weekday" + flex="1" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + mode="daypicker-weekday" + /> + </hbox> + </hbox> + </vbox> + + <!-- Monthly --> + <vbox id="period-box-monthly-box" hidden="true"> + <hbox id="montly-period-every-box" align="center"> + <label + id="monthly-period-every-label" + value="&event.recurrence.pattern.monthly.every.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="monthly-interval" + /> + <html:input + id="monthly-interval" + type="number" + class="size3 input-inline" + min="1" + max="32767" + value="1" + oninput="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <label + id="monthly-period-units-label" + value="&repeat.units.months.both;" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + </hbox> + <radiogroup id="monthly-group"> + <box id="monthly-period-relative-date-box" orient="horizontal" align="center"> + <radio + id="montly-period-relative-date-radio" + selected="true" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <menulist + id="monthly-ordinal" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="montly-ordinal-menupopup"> + <menuitem + id="monthly-ordinal-every-label" + label="&event.recurrence.monthly.every.label;" + value="0" + /> + <menuitem + id="monthly-ordinal-first-label" + label="&event.recurrence.monthly.first.label;" + value="1" + /> + <menuitem + id="monthly-ordinal-second-label" + label="&event.recurrence.monthly.second.label;" + value="2" + /> + <menuitem + id="monthly-ordinal-third-label" + label="&event.recurrence.monthly.third.label;" + value="3" + /> + <menuitem + id="monthly-ordinal-fourth-label" + label="&event.recurrence.monthly.fourth.label;" + value="4" + /> + <menuitem + id="monthly-ordinal-fifth-label" + label="&event.recurrence.monthly.fifth.label;" + value="5" + /> + <menuitem + id="monthly-ordinal-last-label" + label="&event.recurrence.monthly.last.label;" + value="-1" + /> + </menupopup> + </menulist> + <menulist + id="monthly-weekday" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="monthly-weekday-menupopup"> + <menuitem + id="monthly-weekday-1" + label="&event.recurrence.pattern.monthly.week.1.label;" + value="1" + /> + <menuitem + id="monthly-weekday-2" + label="&event.recurrence.pattern.monthly.week.2.label;" + value="2" + /> + <menuitem + id="monthly-weekday-3" + label="&event.recurrence.pattern.monthly.week.3.label;" + value="3" + /> + <menuitem + id="monthly-weekday-4" + label="&event.recurrence.pattern.monthly.week.4.label;" + value="4" + /> + <menuitem + id="monthly-weekday-5" + label="&event.recurrence.pattern.monthly.week.5.label;" + value="5" + /> + <menuitem + id="monthly-weekday-6" + label="&event.recurrence.pattern.monthly.week.6.label;" + value="6" + /> + <menuitem + id="monthly-weekday-7" + label="&event.recurrence.pattern.monthly.week.7.label;" + value="7" + /> + <menuitem + id="monthly-weekday-dayofmonth" + label="&event.recurrence.repeat.dayofmonth.label;" + value="-1" + /> + </menupopup> + </menulist> + </box> + <separator class="thin" /> + <box id="monthly-period-specific-date-box" orient="horizontal" align="center"> + <radio + id="montly-period-specific-date-radio" + label="&event.recurrence.repeat.recur.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <hbox id="monthly-days" class="daypicker-monthday"> + <vbox class="daypicker-monthday-mainbox" flex="1"> + <hbox class="daypicker-row" flex="1"> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="1" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="2" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="3" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="4" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="5" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="6" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="7" + mode="monthly-days" + /> + </hbox> + <hbox class="daypicker-row" flex="1"> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="8" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="9" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="10" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="11" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="12" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="13" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="14" + mode="monthly-days" + /> + </hbox> + <hbox class="daypicker-row" flex="1"> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="15" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="16" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="17" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="18" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="19" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="20" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="21" + mode="monthly-days" + /> + </hbox> + <hbox class="daypicker-row" flex="1"> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="22" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="23" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="24" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="25" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="26" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="27" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="28" + mode="monthly-days" + /> + </hbox> + <hbox class="daypicker-row" flex="1"> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="29" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="30" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="31" + mode="monthly-days" + /> + <button + class="calendar-daypicker" + type="checkbox" + autoCheck="true" + disable-on-readonly="true" + disable-on-occurrence="true" + label="" + mode="monthly-days" + /> + </hbox> + </vbox> + </hbox> + </box> + </radiogroup> + </vbox> + + <!-- Yearly --> + <box id="period-box-yearly-box" orient="vertical" align="start" hidden="true"> + <hbox id="yearly-period-every-box" align="center"> + <label + id="yearly-period-every-label" + value="&event.recurrence.every.label;" + control="yearly-interval" + /> + <html:input + id="yearly-interval" + type="number" + class="size3 input-inline" + min="1" + max="32767" + value="1" + oninput="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <label id="yearly-period-units-label" value="&repeat.units.years.both;" /> + </hbox> + <radiogroup id="yearly-group"> + <vbox> + <hbox> + <radio + id="yearly-period-absolute-radio" + label="&event.recurrence.pattern.yearly.every.month.label;" + selected="true" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <box id="yearly-period-absolute-controls" orient="horizontal" align="center"> + <html:input + id="yearly-days" + type="number" + class="size3 input-inline" + min="1" + value="1" + oninput="checkYearlyAbsoluteDate();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <label + id="yearly-period-of-month-label" + value="&event.recurrence.pattern.yearly.of.label;" + control="yearly-month-ordinal" + /> + <menulist + id="yearly-month-ordinal" + onselect="checkYearlyAbsoluteDate()" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="yearly-month-ordinal-menupopup"> + <menuitem + id="yearly-month-ordinal-1" + label="&event.recurrence.pattern.yearly.month.1.label;" + value="1" + /> + <menuitem + id="yearly-month-ordinal-2" + label="&event.recurrence.pattern.yearly.month.2.label;" + value="2" + /> + <menuitem + id="yearly-month-ordinal-3" + label="&event.recurrence.pattern.yearly.month.3.label;" + value="3" + /> + <menuitem + id="yearly-month-ordinal-4" + label="&event.recurrence.pattern.yearly.month.4.label;" + value="4" + /> + <menuitem + id="yearly-month-ordinal-5" + label="&event.recurrence.pattern.yearly.month.5.label;" + value="5" + /> + <menuitem + id="yearly-month-ordinal-6" + label="&event.recurrence.pattern.yearly.month.6.label;" + value="6" + /> + <menuitem + id="yearly-month-ordinal-7" + label="&event.recurrence.pattern.yearly.month.7.label;" + value="7" + /> + <menuitem + id="yearly-month-ordinal-8" + label="&event.recurrence.pattern.yearly.month.8.label;" + value="8" + /> + <menuitem + id="yearly-month-ordinal-9" + label="&event.recurrence.pattern.yearly.month.9.label;" + value="9" + /> + <menuitem + id="yearly-month-ordinal-10" + label="&event.recurrence.pattern.yearly.month.10.label;" + value="10" + /> + <menuitem + id="yearly-month-ordinal-11" + label="&event.recurrence.pattern.yearly.month.11.label;" + value="11" + /> + <menuitem + id="yearly-month-ordinal-12" + label="&event.recurrence.pattern.yearly.month.12.label;" + value="12" + /> + </menupopup> + </menulist> + </box> + </hbox> + <hbox> + <vbox> + <hbox> + <radio + id="yearly-period-relative-radio" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <box + id="yearly-period-relative-controls" + orient="horizontal" + align="center" + > + <menulist + id="yearly-ordinal" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="yearly-ordinal-menupopup"> + <menuitem + id="yearly-ordinal-every" + label="&event.recurrence.yearly.every.label;" + value="0" + /> + <menuitem + id="yearly-ordinal-first" + label="&event.recurrence.yearly.first.label;" + value="1" + /> + <menuitem + id="yearly-ordinal-second" + label="&event.recurrence.yearly.second.label;" + value="2" + /> + <menuitem + id="yearly-ordinal-third" + label="&event.recurrence.yearly.third.label;" + value="3" + /> + <menuitem + id="yearly-ordinal-fourth" + label="&event.recurrence.yearly.fourth.label;" + value="4" + /> + <menuitem + id="yearly-ordinal-fifth" + label="&event.recurrence.yearly.fifth.label;" + value="5" + /> + <menuitem + id="yearly-ordinal-last" + label="&event.recurrence.yearly.last.label;" + value="-1" + /> + </menupopup> + </menulist> + <menulist + id="yearly-weekday" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="yearly-weekday-menupopup"> + <menuitem + id="yearly-weekday-1" + label="&event.recurrence.pattern.yearly.week.1.label;" + value="1" + /> + <menuitem + id="yearly-weekday-2" + label="&event.recurrence.pattern.yearly.week.2.label;" + value="2" + /> + <menuitem + id="yearly-weekday-3" + label="&event.recurrence.pattern.yearly.week.3.label;" + value="3" + /> + <menuitem + id="yearly-weekday-4" + label="&event.recurrence.pattern.yearly.week.4.label;" + value="4" + /> + <menuitem + id="yearly-weekday-5" + label="&event.recurrence.pattern.yearly.week.5.label;" + value="5" + /> + <menuitem + id="yearly-weekday-6" + label="&event.recurrence.pattern.yearly.week.6.label;" + value="6" + /> + <menuitem + id="yearly-weekday-7" + label="&event.recurrence.pattern.yearly.week.7.label;" + value="7" + /> + <menuitem + id="yearly-weekday--1" + label="&event.recurrence.pattern.yearly.day.label;" + value="-1" + /> + </menupopup> + </menulist> + </box> + </hbox> + <hbox> + <label + id="yearly-period-of-label" + class="recurrence-pattern-hbox-label" + value="&event.recurrence.of.label;" + control="yearly-month-rule" + /> + <menulist + id="yearly-month-rule" + disable-on-readonly="true" + disable-on-occurrence="true" + > + <menupopup id="yearly-month-rule-menupopup"> + <menuitem + id="yearly-month-rule-1" + label="&event.recurrence.pattern.yearly.month2.1.label;" + value="1" + /> + <menuitem + id="yearly-month-rule-2" + label="&event.recurrence.pattern.yearly.month2.2.label;" + value="2" + /> + <menuitem + id="yearly-month-rule-3" + label="&event.recurrence.pattern.yearly.month2.3.label;" + value="3" + /> + <menuitem + id="yearly-month-rule-4" + label="&event.recurrence.pattern.yearly.month2.4.label;" + value="4" + /> + <menuitem + id="yearly-month-rule-5" + label="&event.recurrence.pattern.yearly.month2.5.label;" + value="5" + /> + <menuitem + id="yearly-month-rule-6" + label="&event.recurrence.pattern.yearly.month2.6.label;" + value="6" + /> + <menuitem + id="yearly-month-rule-7" + label="&event.recurrence.pattern.yearly.month2.7.label;" + value="7" + /> + <menuitem + id="yearly-month-rule-8" + label="&event.recurrence.pattern.yearly.month2.8.label;" + value="8" + /> + <menuitem + id="yearly-month-rule-9" + label="&event.recurrence.pattern.yearly.month2.9.label;" + value="9" + /> + <menuitem + id="yearly-month-rule-10" + label="&event.recurrence.pattern.yearly.month2.10.label;" + value="10" + /> + <menuitem + id="yearly-month-rule-11" + label="&event.recurrence.pattern.yearly.month2.11.label;" + value="11" + /> + <menuitem + id="yearly-month-rule-12" + label="&event.recurrence.pattern.yearly.month2.12.label;" + value="12" + /> + </menupopup> + </menulist> + </hbox> + </vbox> + </hbox> + </vbox> + </radiogroup> + </box> + </hbox> + </vbox> + </hbox> + </html:fieldset> + + <!-- range of recurrence --> + <html:fieldset id="recurrence-range-groupbox"> + <html:legend id="recurrence-range-caption">&event.recurrence.range.label;</html:legend> + <vbox> + <radiogroup id="recurrence-duration" oncommand="updateRecurrenceControls()"> + <radio + id="recurrence-range-forever" + label="&event.recurrence.forever.label;" + value="forever" + selected="true" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <box id="recurrence-range-count-box" orient="horizontal" align="center"> + <radio + id="recurrence-range-for" + label="&event.recurrence.repeat.for.label;" + value="ntimes" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <html:input + id="repeat-ntimes-count" + type="number" + class="size3 input-inline" + min="1" + max="32767" + value="5" + oninput="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + <label + id="repeat-appointments-label" + value="&event.recurrence.appointments.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + </box> + <box id="recurrence-range-until-box" orient="horizontal" align="center"> + <radio + id="recurrence-range-until" + label="&event.repeat.until.label;" + value="until" + disable-on-readonly="true" + disable-on-occurrence="true" + control="repeat-until-date" + /> + <datepicker + id="repeat-until-date" + onchange="checkUntilDate();" + disable-on-readonly="true" + disable-on-occurrence="true" + /> + </box> + </radiogroup> + </vbox> + </html:fieldset> + + <!-- preview --> + <html:fieldset id="recurrencePreviewContainer"> + <html:legend + id="recurrencePreviewLabel" + data-l10n-id="calendar-recurrence-preview-label" + ></html:legend> + <html:div id="recurrencePreviewNavigation"> + <html:button + id="recurrencePrevious" + data-l10n-id="calendar-recurrence-previous" + ></html:button> + <html:button id="recurrenceToday" data-l10n-id="calendar-recurrence-today"></html:button> + <html:button id="recurrenceNext" data-l10n-id="calendar-recurrence-next"></html:button> + </html:div> + <html:div id="recurrencePreviewCalendars"> + <html:div id="recurrencePreview"> + <calendar-minimonth + readonly="true" + hidden="false" + active-month="true" + initial-month="true" + /> + <calendar-minimonth readonly="true" hidden="false" /> + <calendar-minimonth readonly="true" hidden="false" /> + <calendar-minimonth readonly="true" hidden="true" /> + <calendar-minimonth readonly="true" hidden="true" /> + <calendar-minimonth readonly="true" hidden="true" /> + <calendar-minimonth readonly="true" hidden="true" /> + <calendar-minimonth readonly="true" hidden="true" /> + <calendar-minimonth readonly="true" hidden="true" /> + </html:div> + </html:div> + </html:fieldset> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js new file mode 100644 index 0000000000..3ee61c0e2f --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js @@ -0,0 +1,508 @@ +/* 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/. */ + +/* exported onLoad, onReminderSelected, updateReminder, onNewReminder, onRemoveReminder */ + +/* global MozElements */ + +/* import-globals-from ../calendar-ui-utils.js */ + +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", +}); + +var allowedActionsMap = {}; +var suppressListUpdate = false; + +XPCOMUtils.defineLazyGetter(this, "gReminderNotification", () => { + return new MozElements.NotificationBox(element => { + document.getElementById("reminder-notifications").append(element); + }); +}); + +/** + * Sets up the reminder dialog. + */ +function onLoad() { + let calendar = window.arguments[0].calendar; + + // Make sure the origin menulist uses the right labels, depending on if the + // dialog is showing an event or task. + function _sn(x) { + return cal.l10n.getString("calendar-alarms", getItemBundleStringName(x)); + } + + document.getElementById("reminder-before-start-menuitem").label = _sn( + "reminderCustomOriginBeginBefore" + ); + + document.getElementById("reminder-after-start-menuitem").label = _sn( + "reminderCustomOriginBeginAfter" + ); + + document.getElementById("reminder-before-end-menuitem").label = _sn( + "reminderCustomOriginEndBefore" + ); + + document.getElementById("reminder-after-end-menuitem").label = _sn( + "reminderCustomOriginEndAfter" + ); + + // Set up the action map + let supportedActions = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"]; // TODO email support, "EMAIL" + for (let action of supportedActions) { + allowedActionsMap[action] = true; + } + + // Hide all actions that are not supported by this provider + let firstAvailableItem; + let actionNodes = document.getElementById("reminder-actions-menupopup").children; + for (let actionNode of actionNodes) { + let shouldHide = + !(actionNode.value in allowedActionsMap) || + (actionNode.hasAttribute("provider") && actionNode.getAttribute("provider") != calendar.type); + actionNode.hidden = shouldHide; + if (!firstAvailableItem && !shouldHide) { + firstAvailableItem = actionNode; + } + } + + // Correct the selected item on the supported actions list. This will be + // changed when reminders are loaded, but in case there are none we need to + // provide a sensible default. + if (firstAvailableItem) { + document.getElementById("reminder-actions-menulist").selectedItem = firstAvailableItem; + } + + loadReminders(); + opener.setCursor("auto"); +} + +/** + * Load Reminders from the window's arguments and set up dialog controls to + * their initial values. + */ +function loadReminders() { + let args = window.arguments[0]; + let listbox = document.getElementById("reminder-listbox"); + let reminders = args.reminders || args.item.getAlarms(); + + // This dialog should not be shown if the calendar doesn't support alarms at + // all, so the case of maxCount = 0 breaking this logic doesn't apply. + let maxReminders = args.calendar.getProperty("capabilities.alarms.maxCount"); + let count = Math.min(reminders.length, maxReminders || reminders.length); + for (let i = 0; i < count; i++) { + if (reminders[i].action in allowedActionsMap) { + // Set up the listitem and add it to the listbox, but only if the + // action is actually supported by the calendar. + let listitem = setupListItem(null, reminders[i].clone(), args.item); + if (listitem) { + listbox.appendChild(listitem); + } + } + } + + // Set up a default absolute date. This will be overridden if the selected + // alarm is absolute. + let absDate = document.getElementById("reminder-absolute-date"); + absDate.value = cal.dtz.dateTimeToJsDate(cal.dtz.getDefaultStartDate()); + + if (listbox.children.length) { + // We have reminders, select the first by default. For some reason, + // setting the selected index in a load handler makes the selection + // break for the set item, therefore we need a setTimeout. + setupMaxReminders(); + setTimeout(() => { + listbox.selectedIndex = 0; + }, 0); + } else { + // Make sure the fields are disabled if we have no alarms + setupRadioEnabledState(true); + } +} + +/** + * Sets up the enabled state of the reminder details controls. Used when + * switching between absolute and relative alarms to disable and enable the + * needed controls. + * + * @param aDisableAll Disable all relation controls. Used when no alarms + * are added yet. + */ +function setupRadioEnabledState(aDisableAll) { + let relationItem = document.getElementById("reminder-relation-radiogroup").selectedItem; + let relativeDisabled, absoluteDisabled; + + if (aDisableAll) { + relativeDisabled = true; + absoluteDisabled = true; + } else if (relationItem) { + // This is not a mistake, when this function is called from onselect, + // the value has not been set. + relativeDisabled = relationItem.value == "absolute"; + absoluteDisabled = relationItem.value == "relative"; + } else { + relativeDisabled = false; + absoluteDisabled = false; + } + + document.getElementById("reminder-length").disabled = relativeDisabled; + document.getElementById("reminder-unit").disabled = relativeDisabled; + document.getElementById("reminder-relation-origin").disabled = relativeDisabled; + + document.getElementById("reminder-absolute-date").setAttribute("disabled", !!absoluteDisabled); + + document.getElementById("reminder-relative-radio").disabled = aDisableAll; + document.getElementById("reminder-absolute-radio").disabled = aDisableAll; + document.getElementById("reminder-actions-menulist").disabled = aDisableAll; +} + +/** + * Sets up the max reminders notification. Shows or hides the notification + * depending on if the max reminders limit has been hit or not. + */ +function setupMaxReminders() { + let args = window.arguments[0]; + let listbox = document.getElementById("reminder-listbox"); + let maxReminders = args.calendar.getProperty("capabilities.alarms.maxCount"); + + let hitMaxReminders = maxReminders && listbox.children.length >= maxReminders; + + // If we hit the maximum number of reminders, show the error box and + // disable the new button. + document.getElementById("reminder-new-button").disabled = hitMaxReminders; + + let localeErrorString = cal.l10n.getString( + "calendar-alarms", + getItemBundleStringName("reminderErrorMaxCountReached"), + [maxReminders] + ); + let pluralErrorLabel = PluralForm.get(maxReminders, localeErrorString).replace( + "#1", + maxReminders + ); + + if (hitMaxReminders) { + let notification = gReminderNotification.appendNotification( + "reminderNotification", + { + label: pluralErrorLabel, + priority: gReminderNotification.PRIORITY_WARNING_MEDIUM, + }, + null + ); + notification.closeButton.hidden = true; + } else { + gReminderNotification.removeAllNotifications(); + } +} + +/** + * Sets up a reminder listitem for the list of reminders applied to this item. + * + * @param aListItem (optional) A reference listitem to set up. If not + * passed, a new listitem will be created. + * @param aReminder The calIAlarm to display in this listitem + * @param aItem The item the alarm is set up on. + * @returns The XUL listitem node showing the passed reminder, or + * null if no list item should be shown. + */ +function setupListItem(aListItem, aReminder, aItem) { + let src; + let l10nId; + switch (aReminder.action) { + case "DISPLAY": + src = "chrome://messenger/skin/icons/new/bell.svg"; + l10nId = "calendar-event-reminder-icon-display"; + break; + case "EMAIL": + src = "chrome://messenger/skin/icons/new/mail-sm.svg"; + l10nId = "calendar-event-reminder-icon-email"; + break; + case "AUDIO": + src = "chrome://messenger/skin/icons/new/bell-ring.svg"; + l10nId = "calendar-event-reminder-icon-audio"; + break; + default: + return null; + } + + let listitem = aListItem || document.createXULElement("richlistitem"); + + // Create a random id to be used for accessibility + let reminderId = cal.getUUID(); + let ariaLabel = "reminder-action-" + aReminder.action + " " + reminderId; + + listitem.reminder = aReminder; + listitem.setAttribute("id", reminderId); + listitem.setAttribute("align", "center"); + listitem.setAttribute("aria-labelledby", ariaLabel); + listitem.setAttribute("value", aReminder.action); + + let image = listitem.querySelector("img"); + if (!image) { + image = document.createElement("img"); + image.setAttribute("class", "reminder-icon"); + listitem.appendChild(image); + } + image.setAttribute("src", src); + // Sets alt. + document.l10n.setAttributes(image, l10nId); + image.setAttribute("value", aReminder.action); + + let label = listitem.querySelector("label"); + if (!label) { + label = document.createXULElement("label"); + listitem.appendChild(label); + } + label.setAttribute("value", aReminder.toString(aItem)); + + return listitem; +} + +/** + * Handler function to be called when a reminder is selected in the listbox. + * Sets up remaining controls to show the selected alarm. + */ +function onReminderSelected() { + let length = document.getElementById("reminder-length"); + let unit = document.getElementById("reminder-unit"); + let relationOrigin = document.getElementById("reminder-relation-origin"); + let absDate = document.getElementById("reminder-absolute-date"); + let actionType = document.getElementById("reminder-actions-menulist"); + let relationType = document.getElementById("reminder-relation-radiogroup"); + + let listbox = document.getElementById("reminder-listbox"); + let listitem = listbox.selectedItem; + + if (listitem) { + try { + suppressListUpdate = true; + let reminder = listitem.reminder; + + // Action + actionType.value = reminder.action; + + // Absolute/relative things + if (reminder.related == Ci.calIAlarm.ALARM_RELATED_ABSOLUTE) { + relationType.value = "absolute"; + + // Date + absDate.value = cal.dtz.dateTimeToJsDate( + reminder.alarmDate || cal.dtz.getDefaultStartDate() + ); + } else { + relationType.value = "relative"; + + // Unit and length + let alarmlen = Math.abs(reminder.offset.inSeconds / 60); + if (alarmlen % 1440 == 0) { + unit.value = "days"; + length.value = alarmlen / 1440; + } else if (alarmlen % 60 == 0) { + unit.value = "hours"; + length.value = alarmlen / 60; + } else { + unit.value = "minutes"; + length.value = alarmlen; + } + + // Relation + let relation = reminder.offset.isNegative ? "before" : "after"; + + // Origin + let origin; + if (reminder.related == Ci.calIAlarm.ALARM_RELATED_START) { + origin = "START"; + } else if (reminder.related == Ci.calIAlarm.ALARM_RELATED_END) { + origin = "END"; + } + + relationOrigin.value = [relation, origin].join("-"); + } + } finally { + suppressListUpdate = false; + } + } else { + // no list item is selected, disable elements + setupRadioEnabledState(true); + } +} + +/** + * Handler function to be called when an aspect of the alarm has been changed + * using the dialog controls. + * + * @param event The DOM event caused by the change. + */ +function updateReminder(event) { + if ( + suppressListUpdate || + event.target.localName == "richlistitem" || + event.target.parentNode.localName == "richlistitem" || + event.target.id == "reminder-remove-button" || + !document.commandDispatcher.focusedElement + ) { + // Do not set things if the select came from selecting or removing an + // alarm from the list, or from setting when the dialog initially loaded. + // XXX Quite fragile hack since radio/radiogroup doesn't have the + // supressOnSelect stuff. + return; + } + let listbox = document.getElementById("reminder-listbox"); + let relationItem = document.getElementById("reminder-relation-radiogroup").selectedItem; + let listitem = listbox.selectedItem; + if (!listitem || !relationItem) { + return; + } + let reminder = listitem.reminder; + let length = document.getElementById("reminder-length"); + let unit = document.getElementById("reminder-unit"); + let relationOrigin = document.getElementById("reminder-relation-origin"); + let [relation, origin] = relationOrigin.value.split("-"); + let absDate = document.getElementById("reminder-absolute-date"); + let action = document.getElementById("reminder-actions-menulist").selectedItem.value; + + // Action + reminder.action = action; + + if (relationItem.value == "relative") { + if (origin == "START") { + reminder.related = Ci.calIAlarm.ALARM_RELATED_START; + } else if (origin == "END") { + reminder.related = Ci.calIAlarm.ALARM_RELATED_END; + } + + // Set up offset, taking units and before/after into account + let offset = cal.createDuration(); + offset[unit.value] = length.value; + offset.normalize(); + offset.isNegative = relation == "before"; + reminder.offset = offset; + } else if (relationItem.value == "absolute") { + reminder.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + + if (absDate.value) { + reminder.alarmDate = cal.dtz.jsDateToDateTime(absDate.value, window.arguments[0].timezone); + } else { + reminder.alarmDate = null; + } + } + + if (!setupListItem(listitem, reminder, window.arguments[0].item)) { + // Unexpected since this would mean switching to an unsupported type. + listitem.remove(); + } +} + +/** + * Gets the locale stringname that is dependent on the item type. This function + * appends the item type, i.e |aPrefix + "Event"|. + * + * @param aPrefix The prefix to prepend to the item type + * @returns The full string name. + */ +function getItemBundleStringName(aPrefix) { + if (window.arguments[0].item.isEvent()) { + return aPrefix + "Event"; + } + return aPrefix + "Task"; +} + +/** + * Handler function to be called when the "new" button is pressed, to create a + * new reminder item. + */ +function onNewReminder() { + let itemType = window.arguments[0].item.isEvent() ? "event" : "todo"; + let listbox = document.getElementById("reminder-listbox"); + + let reminder = new CalAlarm(); + let alarmlen = Services.prefs.getIntPref("calendar.alarms." + itemType + "alarmlen", 15); + let alarmunit = Services.prefs.getStringPref( + "calendar.alarms." + itemType + "alarmunit", + "minutes" + ); + + // Default is a relative DISPLAY alarm, |alarmlen| minutes before the event. + // If DISPLAY is not supported by the provider, then pick the provider's + // first alarm type. + let offset = cal.createDuration(); + if (alarmunit == "days") { + offset.days = alarmlen; + } else if (alarmunit == "hours") { + offset.hours = alarmlen; + } else { + offset.minutes = alarmlen; + } + offset.normalize(); + offset.isNegative = true; + reminder.related = Ci.calIAlarm.ALARM_RELATED_START; + reminder.offset = offset; + if ("DISPLAY" in allowedActionsMap) { + reminder.action = "DISPLAY"; + } else { + let calendar = window.arguments[0].calendar; + let actions = calendar.getProperty("capabilities.alarms.actionValues") || []; + reminder.action = actions[0]; + } + + // Set up the listbox + let listitem = setupListItem(null, reminder, window.arguments[0].item); + if (!listitem) { + return; + } + listbox.appendChild(listitem); + listbox.selectItem(listitem); + + // Since we've added an item, its safe to always enable the button + document.getElementById("reminder-remove-button").removeAttribute("disabled"); + + // Set up the enabled state and max reminders + setupRadioEnabledState(); + setupMaxReminders(); +} + +/** + * Handler function to be called when the "remove" button is pressed to remove + * the selected reminder item and advance the selection. + */ +function onRemoveReminder() { + let listbox = document.getElementById("reminder-listbox"); + let listitem = listbox.selectedItem; + let newSelection = listitem + ? listitem.nextElementSibling || listitem.previousElementSibling + : null; + + listbox.clearSelection(); + listitem.remove(); + listbox.selectItem(newSelection); + + document.getElementById("reminder-remove-button").disabled = listbox.children.length < 1; + setupMaxReminders(); +} + +/** + * Handler function to be called when the accept button is pressed. + */ +document.addEventListener("dialogaccept", () => { + let listbox = document.getElementById("reminder-listbox"); + let reminders = Array.from(listbox.children).map(node => node.reminder); + if (window.arguments[0].onOk) { + window.arguments[0].onOk(reminders); + } +}); + +/** + * Handler function to be called when the cancel button is pressed. + */ +document.addEventListener("dialogcancel", () => { + if (window.arguments[0].onCancel) { + window.arguments[0].onCancel(); + } +}); diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml new file mode 100644 index 0000000000..64fe13ff77 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml @@ -0,0 +1,148 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE window SYSTEM "chrome://calendar/locale/dialogs/calendar-event-dialog-reminder.dtd"> + +<window + id="calendar-event-dialog-reminder" + title="&reminderdialog.title;" + windowtype="Calendar:EventDialog:Reminder" + onload="onLoad()" + persist="screenX screenY width height" + lightweightthemes="true" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <dialog> + <linkset> + <html:link rel="localization" href="calendar/calendar-event-dialog-reminder.ftl" /> + </linkset> + + <!-- Javascript includes --> + <script src="chrome://calendar/content/calendar-event-dialog-reminder.js" /> + <script src="chrome://calendar/content/calendar-ui-utils.js" /> + <script src="chrome://calendar/content/widgets/calendar-minimonth.js" /> + <script src="chrome://calendar/content/widgets/datetimepickers.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <vbox id="reminder-notifications" class="notification-inline"> + <!-- notificationbox will be added here lazily. --> + </vbox> + + <!-- Listbox with custom reminders --> + <vbox flex="1"> + <richlistbox + id="reminder-listbox" + seltype="single" + class="event-dialog-listbox" + onselect="onReminderSelected()" + flex="1" + /> + <hbox id="reminder-action-buttons-box" pack="end"> + <button + id="reminder-new-button" + label="&reminder.add.label;" + accesskey="&reminder.add.accesskey;" + oncommand="onNewReminder()" + /> + <button + id="reminder-remove-button" + label="&reminder.remove.label;" + accesskey="&reminder.remove.accesskey;" + oncommand="onRemoveReminder()" + /> + </hbox> + </vbox> + + <hbox id="reminder-details-caption" class="calendar-caption" align="center"> + <label value="&reminder.reminderDetails.label;" class="header" /> + <separator class="groove" flex="1" /> + </hbox> + <radiogroup + id="reminder-relation-radiogroup" + onselect="setupRadioEnabledState(); updateReminder(event)" + > + <hbox id="reminder-relative-box" align="start" flex="1"> + <radio + id="reminder-relative-radio" + value="relative" + aria-labelledby="reminder-length reminder-unit reminder-relation-origin" + /> + <vbox id="reminder-relative-box" flex="1"> + <hbox id="reminder-relative-length-unit-relation" align="center" flex="1"> + <html:input + id="reminder-length" + class="input-inline" + type="number" + min="0" + oninput="updateReminder(event)" + /> + <menulist id="reminder-unit" oncommand="updateReminder(event)" flex="1"> + <menupopup id="reminder-unit-menupopup"> + <menuitem + id="reminder-minutes-menuitem" + label="&alarm.units.minutes;" + value="minutes" + /> + <menuitem id="reminder-hours-menuitem" label="&alarm.units.hours;" value="hours" /> + <menuitem id="reminder-days-menuitem" label="&alarm.units.days;" value="days" /> + </menupopup> + </menulist> + </hbox> + <menulist id="reminder-relation-origin" oncommand="updateReminder(event)"> + <menupopup id="reminder-relation-origin-menupopup"> + <!-- The labels here will be set in calendar-event-dialog-reminder.js --> + <menuitem id="reminder-before-start-menuitem" value="before-START" /> + <menuitem id="reminder-after-start-menuitem" value="after-START" /> + <menuitem id="reminder-before-end-menuitem" value="before-END" /> + <menuitem id="reminder-after-end-menuitem" value="after-END" /> + </menupopup> + </menulist> + </vbox> + </hbox> + <hbox id="reminder-absolute-box" flex="1"> + <radio id="reminder-absolute-radio" control="reminder-absolute-date" value="absolute" /> + <datetimepicker id="reminder-absolute-date" /> + </hbox> + </radiogroup> + + <hbox id="reminder-actions-caption" class="calendar-caption" align="center"> + <label value="&reminder.action.label;" control="reminder-actions-menulist" class="header" /> + <separator class="groove" flex="1" /> + </hbox> + <menulist + id="reminder-actions-menulist" + oncommand="updateReminder(event)" + class="reminder-list-icon" + > + <!-- Make sure the id is formatted "reminder-action-<VALUE>", for accessibility --> + <!-- TODO provider specific --> + <menupopup id="reminder-actions-menupopup"> + <menuitem + id="reminder-action-DISPLAY" + class="reminder-list-icon menuitem-iconic" + value="DISPLAY" + label="&reminder.action.alert.label;" + /> + <menuitem + id="reminder-action-EMAIL" + class="reminder-list-icon menuitem-iconic" + value="EMAIL" + label="&reminder.action.email.label;" + /> + </menupopup> + </menulist> + </dialog> +</window> diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js new file mode 100644 index 0000000000..620932dda3 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js @@ -0,0 +1,126 @@ +/* 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 addMenuItem */ // From ../calendar-ui-utils.js + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +window.addEventListener("load", onLoad); + +/** + * Sets up the timezone dialog from the window arguments, also setting up all + * dialog controls from the window's dates. + */ +function onLoad() { + let args = window.arguments[0]; + window.time = args.time; + window.onAcceptCallback = args.onOk; + + let menulist = document.getElementById("timezone-menulist"); + let tzMenuPopup = document.getElementById("timezone-menupopup"); + + // floating and UTC (if supported) at the top: + if (args.calendar.getProperty("capabilities.timezones.floating.supported") !== false) { + addMenuItem(tzMenuPopup, cal.dtz.floating.displayName, cal.dtz.floating.tzid); + } + if (args.calendar.getProperty("capabilities.timezones.UTC.supported") !== false) { + addMenuItem(tzMenuPopup, cal.dtz.UTC.displayName, cal.dtz.UTC.tzid); + } + + let tzids = {}; + let displayNames = []; + for (let timezoneId of cal.timezoneService.timezoneIds) { + let timezone = cal.timezoneService.getTimezone(timezoneId); + if (timezone && !timezone.isFloating && !timezone.isUTC) { + let displayName = timezone.displayName; + displayNames.push(displayName); + tzids[displayName] = timezone.tzid; + } + } + // the display names need to be sorted + displayNames.sort((a, b) => a.localeCompare(b)); + for (let i = 0; i < displayNames.length; ++i) { + let displayName = displayNames[i]; + addMenuItem(tzMenuPopup, displayName, tzids[displayName]); + } + + let index = findTimezone(window.time.timezone); + if (index < 0) { + index = findTimezone(cal.dtz.defaultTimezone); + if (index < 0) { + index = 0; + } + } + + menulist = document.getElementById("timezone-menulist"); + menulist.selectedIndex = index; + + updateTimezone(); + + opener.setCursor("auto"); +} + +/** + * Find the index of the timezone menuitem corresponding to the given timezone. + * + * @param timezone The calITimezone to look for. + * @returns The index of the childnode below "timezone-menulist" + */ +function findTimezone(timezone) { + let tzid = timezone.tzid; + let menulist = document.getElementById("timezone-menulist"); + let numChilds = menulist.children[0].children.length; + for (let i = 0; i < numChilds; i++) { + let menuitem = menulist.children[0].children[i]; + if (menuitem.getAttribute("value") == tzid) { + return i; + } + } + return -1; +} + +/** + * Handler function to call when the timezone selection has changed. Updates the + * timezone-time field and the timezone-stack. + */ +function updateTimezone() { + let menulist = document.getElementById("timezone-menulist"); + let menuitem = menulist.selectedItem; + let timezone = cal.timezoneService.getTimezone(menuitem.getAttribute("value")); + + // convert the date/time to the currently selected timezone + // and display the result in the appropriate control. + // before feeding the date/time value into the control we need + // to set the timezone to 'floating' in order to avoid the + // automatic conversion back into the OS timezone. + let datetime = document.getElementById("timezone-time"); + let time = window.time.getInTimezone(timezone); + time.timezone = cal.dtz.floating; + datetime.value = cal.dtz.dateTimeToJsDate(time); + + // don't highlight any timezone in the map by default + let standardTZOffset = "none"; + if (timezone.isUTC) { + standardTZOffset = "+0000"; + } else if (!timezone.isFloating) { + let standard = timezone.icalComponent.getFirstSubcomponent("STANDARD"); + // any reason why valueAsIcalString is used instead of plain value? xxx todo: ask mickey + standardTZOffset = standard.getFirstProperty("TZOFFSETTO").valueAsIcalString; + } + + let image = document.getElementById("highlighter"); + image.setAttribute("tzid", standardTZOffset); +} + +/** + * Handler function to be called when the accept button is pressed. + */ +document.addEventListener("dialogaccept", () => { + let menulist = document.getElementById("timezone-menulist"); + let menuitem = menulist.selectedItem; + let timezoneString = menuitem.getAttribute("value"); + let timezone = cal.timezoneService.getTimezone(timezoneString); + let datetime = window.time.getInTimezone(timezone); + window.onAcceptCallback(datetime); +}); diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml new file mode 100644 index 0000000000..8ec36bfce8 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml @@ -0,0 +1,63 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-timezone-highlighter.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1; +<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > +%dtd2; +<!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > +%dtd3; ]> +<html + id="calendar-event-dialog-timezone" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Calendar:EventDialog:Timezone" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&timezone.title.label;</title> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script> + <script + defer="defer" + src="chrome://calendar/content/calendar-event-dialog-timezone.js" + ></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog> + <hbox align="center"> + <spacer flex="1" /> + <datetimepicker id="timezone-time" disabled="true" /> + </hbox> + + <menulist id="timezone-menulist" oncommand="updateTimezone()"> + <menupopup id="timezone-menupopup" style="height: 460px" /> + </menulist> + + <stack id="timezone-stack"> + <html:img src="chrome://calendar/skin/shared/timezone_map.png" alt="" /> + <html:img + id="highlighter" + src="chrome://calendar/skin/shared/timezones.png" + alt="" + class="timezone-highlight" + tzid="+0000" + /> + </stack> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml new file mode 100644 index 0000000000..8029ff3174 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml @@ -0,0 +1,584 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> +#ifdef MOZ_SUITE +<?xml-stylesheet type="text/css" href="chrome://communicator/skin/communicator.css"?> +#endif + +<!DOCTYPE window [ + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> + <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd"> + <!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd"> + <!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> + %brandDTD; + %globalDTD; + %calendarDTD; + %eventDialogDTD; +]> + +<!-- Dialog id is changed during execution to allow different Window-icons + on this dialog. document.loadOverlay() will not work on this one. --> +<window id="calendar-event-window" + title="&event.title.label;" + icon="calendar-general-dialog" + windowtype="Calendar:EventDialog" + onload="onLoadCalendarItemPanel();" + onunload="onUnloadCalendarItemPanel();" + persist="screenX screenY width height" + lightweightthemes="true" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + +<dialog> + + <!-- Javascript includes --> + <script src="chrome://calendar/content/calendar-item-panel.js"/> + <script src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script src="chrome://calendar/content/calendar-ui-utils.js"/> + <script src="chrome://messenger/content/globalOverlay.js"/> + <script src="chrome://messenger/content/toolbarIconColor.js"/> + <script src="chrome://messenger/content/customizable-toolbar.js"/> + + <stringbundle id="languageBundle" src="chrome://global/locale/languageNames.properties"/> + + <!-- Command updater --> + <commandset id="globalEditMenuItems" + commandupdater="true" + events="focus" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="selectEditMenuItems" + commandupdater="true" + events="select" + oncommandupdate="goUpdateSelectEditMenuItems()"/> + <commandset id="undoEditMenuItems" + commandupdater="true" + events="undo" + oncommandupdate="goUpdateUndoEditMenuItems()"/> + <commandset id="clipboardEditMenuItems" + commandupdater="true" + events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + + <!-- Commands --> + <commandset id="itemCommands"> + + <!-- Item menu --> + <command id="cmd_item_new_event" + oncommand="openNewEvent()"/> + <command id="cmd_item_new_task" + oncommand="openNewTask()"/> + <command id="cmd_item_new_message" + oncommand="openNewMessage()"/> + <command id="cmd_item_close" + oncommand="cancelDialog()"/> + <command id="cmd_save" + disable-on-readonly="true" + oncommand="onCommandSave()"/> + <command id="cmd_item_delete" + disable-on-readonly="true" + oncommand="onCommandDeleteItem()"/> + + <!-- Edit menu --> + <command id="cmd_undo" + disabled="true" + oncommand="goDoCommand('cmd_undo')"/> + <command id="cmd_redo" + disabled="true" + oncommand="goDoCommand('cmd_redo')"/> + <command id="cmd_cut" + disabled="true" + oncommand="goDoCommand('cmd_cut')"/> + <command id="cmd_copy" + disabled="true" + oncommand="goDoCommand('cmd_copy')"/> + <command id="cmd_paste" + disabled="true" + oncommand="goDoCommand('cmd_paste')"/> + <command id="cmd_selectAll" + disabled="true" + oncommand="goDoCommand('cmd_selectAll')"/> + + <!-- View menu --> + <command id="cmd_toolbar" + oncommand="onCommandViewToolbar('event-toolbar', + 'view-toolbars-event-menuitem')"/> + <command id="cmd_customize" + oncommand="onCommandCustomize()"/> + + <!-- status --> + <command id="cmd_status_none" + oncommand="editStatus(event.target)" + hidden="true" + value="NONE"/> + <command id="cmd_status_tentative" + oncommand="editStatus(event.target)" + value="TENTATIVE"/> + <command id="cmd_status_confirmed" + oncommand="editStatus(event.target)" + value="CONFIRMED"/> + <command id="cmd_status_cancelled" + oncommand="editStatus(event.target)" + value="CANCELLED"/> + + <!-- priority --> + <command id="cmd_priority_none" + oncommand="editPriority(event.target)" + value="0"/> + <command id="cmd_priority_low" + oncommand="editPriority(event.target)" + value="9"/> + <command id="cmd_priority_normal" + oncommand="editPriority(event.target)" + value="5"/> + <command id="cmd_priority_high" + oncommand="editPriority(event.target)" + value="1"/> + + <!-- freebusy --> + <command id="cmd_showtimeas_busy" + oncommand="editShowTimeAs(event.target)" + value="OPAQUE"/> + <command id="cmd_showtimeas_free" + oncommand="editShowTimeAs(event.target)" + value="TRANSPARENT"/> + + <!-- attendees --> + <command id="cmd_attendees" + oncommand="editAttendees();"/> + <command id="cmd_email" + oncommand="sendMailToAttendees(window.attendees);"/> + <command id="cmd_email_undecided" + oncommand="sendMailToUndecidedAttendees(window.attendees);"/> + + <!-- accept, attachments, timezone --> + <command id="cmd_accept" + disable-on-readonly="true" + oncommand="acceptDialog();"/> + <command id="cmd_attach_url" + disable-on-readonly="true" + oncommand="attachURL()"/> + <command id="cmd_attach_cloud" + disable-on-readonly="true"/> + <command id="cmd_timezone" + persist="checked" + checked="false" + oncommand="toggleTimezoneLinks()"/> + </commandset> + + <keyset id="calendar-event-dialog-keyset"> + <key id="new-message-key" + modifiers="accel" + key="&event.dialog.new.message.key2;" + command="cmd_item_new_message"/> + <key id="close-key" + modifiers="accel" + key="&event.dialog.close.key;" + command="cmd_item_close"/> + <key id="save-key" + modifiers="accel" + key="&event.dialog.save.key;" + command="cmd_save"/> + <key id="saveandclose-key" + modifiers="accel" + key="&event.dialog.saveandclose.key;" + command="cmd_accept"/> + <key id="undo-key" + modifiers="accel" + key="&event.dialog.undo.key;" + command="cmd_undo"/> + <key id="redo-key" + modifiers="accel" + key="&event.dialog.redo.key;" + command="cmd_redo"/> + <key id="cut-key" + modifiers="accel" + key="&event.dialog.cut.key;" + command="cmd_cut"/> + <key id="copy-key" + modifiers="accel" + key="&event.dialog.copy.key;" + command="cmd_copy"/> + <key id="paste-key" + modifiers="accel" + key="&event.dialog.paste.key;" + command="cmd_paste"/> + <key id="select-all-key" + modifiers="accel" + key="&event.dialog.select.all.key;" + command="cmd_selectAll"/> + </keyset> + + <menupopup id="event-dialog-toolbar-context-menu"> + <menuitem id="CustomizeDialogToolbar" + label="&event.menu.view.toolbars.customize.label;" + command="cmd_customize"/> + </menupopup> + + <!-- Toolbox contains the menubar --> + <toolbox id="event-toolbox" + class="mail-toolbox" + mode="full" + defaultmode="full" + iconsize="small" + defaulticonsize="small" + labelalign="end" + defaultlabelalign="end"> + + <!-- Menubar --> + <toolbar type="menubar"> + <menubar id="event-menubar"> + + <!-- Item menu --> + <!-- These 2 Strings are placeholders, values are set at runtime --> + <menu label="Item" + accesskey="I" + id="item-menu"> + <menupopup id="item-menupopup"> + <menu id="item-new-menu" + label="&event.menu.item.new.label;" + accesskey="&event.menu.item.new.accesskey;"> + <menupopup id="item-new-menupopup" class="menulist-menupopup"> + <menuitem id="item-new-message-menuitem" + label="&event.menu.item.new.message.label;" + accesskey="&event.menu.item.new.message.accesskey;" + key="new-message-key" + command="cmd_item_new_message" + disable-on-readonly="true"/> + <menuitem id="item-new-event-menuitem" + label="&event.menu.item.new.event.label;" + accesskey="&event.menu.item.new.event.accesskey;" + command="cmd_item_new_event" + disable-on-readonly="true"/> + <menuitem id="item-new-task-menuitem" + label="&event.menu.item.new.task.label;" + accesskey="&event.menu.item.new.task.accesskey;" + command="cmd_item_new_task" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menuseparator id="item-menuseparator1"/> + <menuitem id="item-save-menuitem" + label="&event.menu.item.save.label;" + accesskey="&event.menu.item.save.accesskey;" + key="save-key" + command="cmd_save"/> + <menuitem id="item-saveandclose-menuitem" + label="&event.menu.item.saveandclose.label;" + accesskey="&event.menu.item.saveandclose.accesskey;" + key="saveandclose-key" + command="cmd_accept"/> + <menuitem id="item-delete-menuitem" + label="&event.menu.item.delete.label;" + accesskey="&event.menu.item.delete.accesskey;" + command="cmd_item_delete" + disable-on-readonly="true"/> + <menuseparator id="item-menuseparator1"/> + <menuitem id="item-close-menuitem" + label="&event.menu.item.close.label;" + accesskey="&event.menu.item.close.accesskey;" + key="close-key" + command="cmd_item_close" + disable-on-readonly="true"/> + </menupopup> + </menu> + + <!-- Edit menu --> + <menu id="edit-menu" + label="&event.menu.edit.label;" + accesskey="&event.menu.edit.accesskey;" + collapse-on-readonly="true"> + <menupopup id="edit-menupopup"> + <menuitem id="edit-undo-menuitem" + label="&event.menu.edit.undo.label;" + accesskey="&event.menu.edit.undo.accesskey;" + key="undo-key" + command="cmd_undo"/> + <menuitem id="edit-redo-menuitem" + label="&event.menu.edit.redo.label;" + accesskey="&event.menu.edit.redo.accesskey;" + key="redo-key" + command="cmd_redo"/> + <menuseparator id="edit-menuseparator1"/> + <menuitem id="edit-cut-menuitem" + label="&event.menu.edit.cut.label;" + accesskey="&event.menu.edit.cut.accesskey;" + key="cut-key" + command="cmd_cut"/> + <menuitem id="edit-copy-menuitem" + label="&event.menu.edit.copy.label;" + accesskey="&event.menu.edit.copy.accesskey;" + key="copy-key" + command="cmd_copy"/> + <menuitem id="edit-paste-menuitem" + label="&event.menu.edit.paste.label;" + accesskey="&event.menu.edit.paste.accesskey;" + key="paste-key" + command="cmd_paste"/> + <menuseparator id="edit-menuseparator2"/> + <menuitem id="edit-selectall-menuitem" + label="&event.menu.edit.select.all.label;" + accesskey="&event.menu.edit.select.all.accesskey;" + key="select-all-key" + command="cmd_selectAll"/> + </menupopup> + </menu> + + <!-- View menu --> + <menu id="view-menu" + label="&event.menu.view.label;" + accesskey="&event.menu.view.accesskey;" + collapse-on-readonly="true"> + <menupopup id="view-menupopup"> + <menu id="view-toolbars-menu" + label="&event.menu.view.toolbars.label;" + accesskey="&event.menu.view.toolbars.accesskey;"> + <menupopup id="view-toolbars-menupopup"> + <menuitem id="view-toolbars-event-menuitem" + label="&event.menu.view.toolbars.event.label;" + accesskey="&event.menu.view.toolbars.event.accesskey;" + type="checkbox" + checked="true" + command="cmd_toolbar"/> + <menuseparator id="view-toolbars-menuseparator1"/> + <menuitem id="view-toolbars-customize-menuitem" + label="&event.menu.view.toolbars.customize.label;" + accesskey="&event.menu.view.toolbars.customize.accesskey;" + command="cmd_customize"/> + </menupopup> + </menu> + </menupopup> + </menu> + + <!-- Options menu --> + <menu id="options-menu" + label="&event.menu.options.label;" + accesskey="&event.menu.options.accesskey;"> + <menupopup id="options-menupopup"> + <menuitem id="options-attendees-menuitem" + label="&event.menu.options.attendees.label;" + accesskey="&event.menu.options.attendees.accesskey;" + command="cmd_attendees" + disable-on-readonly="true"/> + <menu id="options-attachments-menu" + label="&event.attachments.menubutton.label;" + accesskey="&event.attachments.menubutton.accesskey;"> + <menupopup id="options-attachments-menupopup"> + <menuitem id="options-attachments-url-menuitem" + label="&event.attachments.url.label;" + accesskey="&event.attachments.url.accesskey;" + command="cmd_attach_url"/> + <!-- Additional items are added here in loadCloudProviders(). --> + </menupopup> + </menu> + <menuitem id="options-timezones-menuitem" + label="&event.menu.options.timezone2.label;" + accesskey="&event.menu.options.timezone2.accesskey;" + type="checkbox" + command="cmd_timezone" + disable-on-readonly="true"/> + <menuseparator id="options-menuseparator1"/> + <menu id="options-priority-menu" + label="&event.menu.options.priority2.label;" + accesskey="&event.menu.options.priority2.accesskey;" + disable-on-readonly="true"> + <menupopup id="options-priority-menupopup"> + <menuitem id="options-priority-none-menuitem" + label="&event.menu.options.priority.notspecified.label;" + accesskey="&event.menu.options.priority.notspecified.accesskey;" + type="radio" + command="cmd_priority_none" + disable-on-readonly="true"/> + <menuitem id="options-priority-low-menuitem" + label="&event.menu.options.priority.low.label;" + accesskey="&event.menu.options.priority.low.accesskey;" + type="radio" + command="cmd_priority_low" + disable-on-readonly="true"/> + <menuitem id="options-priority-normal-label" + label="&event.menu.options.priority.normal.label;" + accesskey="&event.menu.options.priority.normal.accesskey;" + type="radio" + command="cmd_priority_normal" + disable-on-readonly="true"/> + <menuitem id="options-priority-high-label" + label="&event.menu.options.priority.high.label;" + accesskey="&event.menu.options.priority.high.accesskey;" + type="radio" + command="cmd_priority_high" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menu id="options-privacy-menu" + label="&event.menu.options.privacy.label;" + accesskey="&event.menu.options.privacy.accesskey;" + disable-on-readonly="true"> + <menupopup id="options-privacy-menupopup"> + <menuitem id="options-privacy-public-menuitem" + label="&event.menu.options.privacy.public.label;" + accesskey="&event.menu.options.privacy.public.accesskey;" + type="radio" + privacy="PUBLIC" + oncommand="editPrivacy(this, event)" + disable-on-readonly="true"/> + <menuitem id="options-privacy-confidential-menuitem" + label="&event.menu.options.privacy.confidential.label;" + accesskey="&event.menu.options.privacy.confidential.accesskey;" + type="radio" + privacy="CONFIDENTIAL" + oncommand="editPrivacy(this, event)" + disable-on-readonly="true"/> + <menuitem id="options-privacy-private-menuitem" + label="&event.menu.options.privacy.private.label;" + accesskey="&event.menu.options.privacy.private.accesskey;" + type="radio" + privacy="PRIVATE" + oncommand="editPrivacy(this, event)" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menu id="options-status-menu" + label="&newevent.status.label;" + accesskey="&newevent.status.accesskey;" + class="event-only" + disable-on-readonly="true"> + <menupopup id="options-status-menupopup"> + <menuitem id="options-status-none-menuitem" + label="&newevent.eventStatus.none.label;" + accesskey="&newevent.eventStatus.none.accesskey;" + type="radio" + command="cmd_status_none" + disable-on-readonly="true"/> + <menuitem id="options-status-tentative-menuitem" + label="&newevent.status.tentative.label;" + accesskey="&newevent.status.tentative.accesskey;" + type="radio" + command="cmd_status_tentative" + disable-on-readonly="true"/> + <menuitem id="options-status-confirmed-menuitem" + label="&newevent.status.confirmed.label;" + accesskey="&newevent.status.confirmed.accesskey;" + type="radio" + command="cmd_status_confirmed" + disable-on-readonly="true"/> + <menuitem id="options-status-canceled-menuitem" + label="&newevent.eventStatus.cancelled.label;" + accesskey="&newevent.eventStatus.cancelled.accesskey;" + type="radio" + command="cmd_status_cancelled" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menuseparator id="options-menuseparator2" class="event-only"/> + <menu id="options-freebusy-menu" + class="event-only" + label="&event.menu.options.show.time.label;" + accesskey="&event.menu.options.show.time.accesskey;" + disable-on-readonly="true"> + <menupopup id="options-freebusy-menupopup"> + <menuitem id="options-freebusy-busy-menuitem" + label="&event.menu.options.show.time.busy.label;" + accesskey="&event.menu.options.show.time.busy.accesskey;" + type="radio" + command="cmd_showtimeas_busy" + disable-on-readonly="true"/> + <menuitem id="options-freebusy-free-menuitem" + label="&event.menu.options.show.time.free.label;" + accesskey="&event.menu.options.show.time.free.accesskey;" + type="radio" + command="cmd_showtimeas_free" + disable-on-readonly="true"/> + </menupopup> + </menu> + </menupopup> + </menu> + </menubar> + </toolbar> + + <toolbarpalette id="event-toolbarpalette"> +#include ../item-editing/calendar-item-toolbar.inc.xhtml + </toolbarpalette> + + <!-- toolboxid is set here since we move the toolbar around in tabs --> + <toolbar is="customizable-toolbar" id="event-toolbar" + toolboxid="event-toolbox" + class="chromeclass-toolbar themeable-full" + customizable="true" + labelalign="end" + defaultlabelalign="end" + context="event-dialog-toolbar-context-menu" + defaultset="button-saveandclose,button-attendees,button-privacy,button-url,button-delete"/> + </toolbox> + + <!-- the calendar-item-panel-iframe iframe is inserted here dynamically in the "load" handler function --> + + <hbox id="status-bar" class="statusbar chromeclass-status" role="status"> + <hbox id="status-privacy" + class="statusbarpanel" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&event.statusbarpanel.privacy.label;"/> + <hbox id="status-privacy-public-box" privacy="PUBLIC"> + <label value="&event.menu.options.privacy.public.label;"/> + </hbox> + <hbox id="status-privacy-confidential-box" privacy="CONFIDENTIAL"> + <label value="&event.menu.options.privacy.confidential.label;"/> + </hbox> + <hbox id="status-privacy-private-box" privacy="PRIVATE"> + <label value="&event.menu.options.privacy.private.label;"/> + </hbox> + </hbox> + <hbox id="status-priority" + class="statusbarpanel" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&event.priority2.label;"/> + <html:img class="cal-statusbar-1" /> + </hbox> + <hbox id="status-status" + class="statusbarpanel" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&task.status.label;"/> + <label id="status-status-tentative-label" + value="&newevent.status.tentative.label;" + hidden="true"/> + <label id="status-status-confirmed-label" + value="&newevent.status.confirmed.label;" + hidden="true"/> + <label id="status-status-cancelled-label" + value="&newevent.eventStatus.cancelled.label;" + hidden="true"/> + </hbox> + <hbox id="status-freebusy" + class="statusbarpanel event-only" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&event.statusbarpanel.freebusy.label;"/> + <label id="status-freebusy-free-label" + value="&event.freebusy.legend.free;" + hidden="true"/> + <label id="status-freebusy-busy-label" + value="&event.freebusy.legend.busy;" + hidden="true"/> + </hbox> + </hbox> +</dialog> +</window> diff --git a/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js new file mode 100644 index 0000000000..41c85eeea9 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js @@ -0,0 +1,476 @@ +/* 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 addMenuItem, getItemsFromIcsFile, putItemsIntoCal, + sortCalendarArray */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const gModel = { + /** @type {calICalendar[]} */ + calendars: [], + + /** @type {Map(number -> calIItemBase)} */ + itemsToImport: new Map(), + + /** @type {nsIFile | null} */ + file: null, + + /** @type {Map(number -> CalendarItemSummary)} */ + itemSummaries: new Map(), +}; + +/** + * Window load event handler. + */ +async function onWindowLoad() { + // Workaround to add padding to the dialog buttons area which is in shadow dom. + // If the padding value changes here it should also change in the CSS. + let dialog = document.getElementsByTagName("dialog")[0]; + dialog.shadowRoot.querySelector(".dialog-button-box").style = "padding-inline: 10px;"; + + gModel.file = window.arguments[0]; + document.getElementById("calendar-ics-file-dialog-file-path").value = gModel.file.path; + + let calendars = cal.manager.getCalendars(); + gModel.calendars = getCalendarsThatCanImport(calendars); + if (!gModel.calendars.length) { + // No calendars to import into. Show error dialog and close the window. + cal.showError(await document.l10n.formatValue("calendar-ics-file-dialog-no-calendars"), window); + window.close(); + return; + } + + let composite = cal.view.getCompositeCalendar(window); + let defaultCalendarId = composite && composite.defaultCalendar?.id; + setUpCalendarMenu(gModel.calendars, defaultCalendarId); + cal.view.colorTracker.registerWindow(window); + + // Finish laying out and displaying the window, then come back to do the hard work. + Services.tm.dispatchToMainThread(async () => { + let startTime = Date.now(); + + getItemsFromIcsFile(gModel.file).forEach((item, index) => { + gModel.itemsToImport.set(index, item); + }); + if (gModel.itemsToImport.size == 0) { + // No items to import, close the window. An error dialog has already been + // shown by `getItemsFromIcsFile`. + window.close(); + return; + } + + // We know that if `getItemsFromIcsFile` took a long time, then `setUpItemSummaries` will also + // take a long time. Show a loading message so the user knows something is happening. + let loadingMessage = document.getElementById("calendar-ics-file-dialog-items-loading-message"); + if (Date.now() - startTime > 150) { + loadingMessage.removeAttribute("hidden"); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + + // Not much point filtering or sorting if there's only one event. + if (gModel.itemsToImport.size == 1) { + document.getElementById("calendar-ics-file-dialog-filters").collapsed = true; + } + + await setUpItemSummaries(); + + // Remove the loading message from the DOM to avoid it causing problems later. + loadingMessage.remove(); + + document.addEventListener("dialogaccept", importRemainingItems); + }); +} +window.addEventListener("load", onWindowLoad); + +/** + * Takes an array of calendars and returns a sorted array of the calendars + * that can import items. + * + * @param {calICalendar[]} calendars - An array of calendars. + * @returns {calICalendar[]} Sorted array of calendars that can import items. + */ +function getCalendarsThatCanImport(calendars) { + let calendarsThatCanImport = calendars.filter( + calendar => + !calendar.getProperty("disabled") && + !calendar.readOnly && + cal.acl.userCanAddItemsToCalendar(calendar) + ); + return sortCalendarArray(calendarsThatCanImport); +} + +/** + * Add calendars to the calendar drop down menu, and select one. + * + * @param {calICalendar[]} calendars - An array of calendars. + * @param {string | null} defaultCalendarId - ID of the default (currently selected) calendar. + */ +function setUpCalendarMenu(calendars, defaultCalendarId) { + let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu"); + for (let calendar of calendars) { + let menuitem = addMenuItem(menulist, calendar.name, calendar.name); + let cssSafeId = cal.view.formatStringForCSSRule(calendar.id); + menuitem.style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`); + menuitem.classList.add("menuitem-iconic"); + } + + let index = defaultCalendarId + ? calendars.findIndex(calendar => calendar.id == defaultCalendarId) + : 0; + + menulist.selectedIndex = index == -1 ? 0 : index; + updateCalendarMenu(); +} + +/** + * Update to reflect a change in the selected calendar. + */ +function updateCalendarMenu() { + let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu"); + menulist.style.setProperty( + "--item-color", + menulist.selectedItem.style.getPropertyValue("--item-color") + ); +} + +/** + * Display summaries of each calendar item from the file being imported. + */ +async function setUpItemSummaries() { + let items = [...gModel.itemsToImport]; + let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container"); + + // Sort the items, chronologically first, tasks without a date to the end, + // then alphabetically. + let collator = new Intl.Collator(undefined, { numeric: true }); + items.sort(([, a], [, b]) => { + let aStartDate = + a.startDate?.nativeTime || + a.entryDate?.nativeTime || + a.dueDate?.nativeTime || + Number.MAX_SAFE_INTEGER; + let bStartDate = + b.startDate?.nativeTime || + b.entryDate?.nativeTime || + b.dueDate?.nativeTime || + Number.MAX_SAFE_INTEGER; + return aStartDate - bStartDate || collator.compare(a.title, b.title); + }); + + let [eventButtonText, taskButtonText] = await document.l10n.formatValues([ + "calendar-ics-file-dialog-import-event-button-label", + "calendar-ics-file-dialog-import-task-button-label", + ]); + + items.forEach(([index, item]) => { + let itemFrame = document.createXULElement("vbox"); + itemFrame.classList.add("calendar-ics-file-dialog-item-frame"); + + let importButton = document.createXULElement("button"); + importButton.classList.add("calendar-ics-file-dialog-item-import-button"); + importButton.setAttribute("label", item.isEvent() ? eventButtonText : taskButtonText); + importButton.addEventListener("command", importSingleItem.bind(null, item, index)); + + let buttonBox = document.createXULElement("hbox"); + buttonBox.setAttribute("pack", "end"); + buttonBox.setAttribute("align", "end"); + + let summary = document.createXULElement("calendar-item-summary"); + summary.setAttribute("id", "import-item-summary-" + index); + + itemFrame.appendChild(summary); + buttonBox.appendChild(importButton); + itemFrame.appendChild(buttonBox); + + itemsContainer.appendChild(itemFrame); + summary.item = item; + + summary.updateItemDetails(); + gModel.itemSummaries.set(index, summary); + }); +} + +/** + * Filter item summaries by search string. + * + * @param {searchString} [searchString] - Terms to search for. + */ +function filterItemSummaries(searchString = "") { + let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container"); + + searchString = searchString.trim(); + // Nothing to search for. Display all item summaries. + if (!searchString) { + gModel.itemSummaries.forEach(s => { + s.closest(".calendar-ics-file-dialog-item-frame").hidden = false; + }); + + itemsContainer.scrollTo(0, 0); + return; + } + + searchString = searchString.toLowerCase().normalize(); + + // Split the search string into tokens. Quoted strings are preserved. + let searchTokens = []; + let startIndex; + while ((startIndex = searchString.indexOf('"')) != -1) { + let endIndex = searchString.indexOf('"', startIndex + 1); + if (endIndex == -1) { + endIndex = searchString.length; + } + + searchTokens.push(searchString.substring(startIndex + 1, endIndex)); + let query = searchString.substring(0, startIndex); + if (endIndex < searchString.length) { + query += searchString.substr(endIndex + 1); + } + + searchString = query.trim(); + } + + if (searchString.length != 0) { + searchTokens = searchTokens.concat(searchString.split(/\s+/)); + } + + // Check the title and description of each item for matches. + gModel.itemSummaries.forEach(s => { + let title, description; + let matches = searchTokens.every(term => { + if (title === undefined) { + title = s.item.title.toLowerCase().normalize(); + } + if (title?.includes(term)) { + return true; + } + + if (description === undefined) { + description = s.item.getProperty("description")?.toLowerCase().normalize(); + } + return description?.includes(term); + }); + s.closest(".calendar-ics-file-dialog-item-frame").hidden = !matches; + }); + + itemsContainer.scrollTo(0, 0); +} + +/** + * Sort item summaries. + * + * @param {Event} event - The oncommand event that triggered this sort. + */ +function sortItemSummaries(event) { + let [key, direction] = event.target.value.split(" "); + + let comparer; + if (key == "title") { + let collator = new Intl.Collator(undefined, { numeric: true }); + if (direction == "ascending") { + comparer = (a, b) => collator.compare(a.item.title, b.item.title); + } else { + comparer = (a, b) => collator.compare(b.item.title, a.item.title); + } + } else if (key == "start") { + if (direction == "ascending") { + comparer = (a, b) => a.item.startDate.nativeTime - b.item.startDate.nativeTime; + } else { + comparer = (a, b) => b.item.startDate.nativeTime - a.item.startDate.nativeTime; + } + } else { + // How did we get here? + throw new Error(`Unexpected sort key: ${key}`); + } + + let items = [...gModel.itemSummaries.values()].sort(comparer); + let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container"); + for (let item of items) { + itemsContainer.appendChild(item.closest(".calendar-ics-file-dialog-item-frame")); + } + itemsContainer.scrollTo(0, 0); + + for (let menuitem of document.querySelectorAll( + "#calendar-ics-file-dialog-sort-popup > menuitem" + )) { + menuitem.checked = menuitem == event.target; + } +} + +/** + * Get the currently selected calendar. + * + * @returns {calICalendar} The currently selected calendar. + */ +function getCurrentlySelectedCalendar() { + let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu"); + let calendar = gModel.calendars[menulist.selectedIndex]; + return calendar; +} + +/** + * Handler for buttons that import a single item. The arguments are bound for + * each button instance, except for the event argument. + * + * @param {calIItemBase} item - Calendar item. + * @param {number} itemIndex - Index of the calendar item in the item array. + * @param {string} filePath - Path to the file being imported. + * @param {Event} event - The button event. + */ +async function importSingleItem(item, itemIndex, event) { + let dialog = document.getElementsByTagName("dialog")[0]; + let acceptButton = dialog.getButton("accept"); + let cancelButton = dialog.getButton("cancel"); + + acceptButton.disabled = true; + cancelButton.disabled = true; + + let calendar = getCurrentlySelectedCalendar(); + + await putItemsIntoCal(calendar, [item], { + onDuplicate(item, error) { + // TODO: CalCalendarManager already shows a not-very-useful error pop-up. + // Once that is fixed, use this callback to display a proper error message. + }, + onError(item, error) { + // TODO: CalCalendarManager already shows a not-very-useful error pop-up. + // Once that is fixed, use this callback to display a proper error message. + }, + }); + + event.target.closest(".calendar-ics-file-dialog-item-frame").remove(); + gModel.itemsToImport.delete(itemIndex); + gModel.itemSummaries.delete(itemIndex); + + acceptButton.disabled = false; + if (gModel.itemsToImport.size > 0) { + // Change the cancel button label to Close, as we've done some work that + // won't be cancelled. + cancelButton.label = await document.l10n.formatValue( + "calendar-ics-file-cancel-button-close-label" + ); + cancelButton.disabled = false; + } else { + // No more items to import, remove the "Import All" option. + document.removeEventListener("dialogaccept", importRemainingItems); + + cancelButton.hidden = true; + acceptButton.label = await document.l10n.formatValue( + "calendar-ics-file-accept-button-ok-label" + ); + } +} + +/** + * "Import All" button command handler. + * + * @param {Event} event - Button command event. + */ +async function importRemainingItems(event) { + event.preventDefault(); + + let dialog = document.getElementsByTagName("dialog")[0]; + let acceptButton = dialog.getButton("accept"); + let cancelButton = dialog.getButton("cancel"); + + acceptButton.disabled = true; + cancelButton.disabled = true; + + let calendar = getCurrentlySelectedCalendar(); + let filteredSummaries = [...gModel.itemSummaries.values()].filter( + summary => !summary.closest(".calendar-ics-file-dialog-item-frame").hidden + ); + let remainingItems = filteredSummaries.map(summary => summary.item); + + let progressElement = document.getElementById("calendar-ics-file-dialog-progress"); + let duplicatesElement = document.getElementById("calendar-ics-file-dialog-duplicates-message"); + let errorsElement = document.getElementById("calendar-ics-file-dialog-errors-message"); + + let optionsPane = document.getElementById("calendar-ics-file-dialog-options-pane"); + let progressPane = document.getElementById("calendar-ics-file-dialog-progress-pane"); + let resultPane = document.getElementById("calendar-ics-file-dialog-result-pane"); + + let importListener = { + count: 0, + duplicatesCount: 0, + errorsCount: 0, + progressInterval: null, + + onStart() { + progressElement.max = remainingItems.length; + optionsPane.hidden = true; + progressPane.hidden = false; + + this.progressInterval = setInterval(() => { + progressElement.value = this.count; + }, 50); + }, + onDuplicate(item, error) { + this.duplicatesCount++; + }, + onError(item, error) { + this.errorsCount++; + }, + onProgress(count, total) { + this.count = count; + }, + async onEnd() { + progressElement.value = this.count; + clearInterval(this.progressInterval); + + document.l10n.setAttributes(duplicatesElement, "calendar-ics-file-import-duplicates", { + duplicatesCount: this.duplicatesCount, + }); + duplicatesElement.hidden = this.duplicatesCount == 0; + document.l10n.setAttributes(errorsElement, "calendar-ics-file-import-errors", { + errorsCount: this.errorsCount, + }); + errorsElement.hidden = this.errorsCount == 0; + + let [acceptButtonLabel, cancelButtonLabel] = await document.l10n.formatValues([ + { id: "calendar-ics-file-accept-button-ok-label" }, + { id: "calendar-ics-file-cancel-button-close-label" }, + ]); + + filteredSummaries.forEach(summary => { + let itemIndex = parseInt(summary.id.substring("import-item-summary-".length), 10); + gModel.itemsToImport.delete(itemIndex); + gModel.itemSummaries.delete(itemIndex); + summary.closest(".calendar-ics-file-dialog-item-frame").remove(); + }); + + document.getElementById("calendar-ics-file-dialog-search-input").value = ""; + filterItemSummaries(); + let itemsRemain = !!document.querySelector(".calendar-ics-file-dialog-item-frame"); + + // An artificial delay so the progress pane doesn't appear then immediately disappear. + setTimeout(() => { + if (itemsRemain) { + acceptButton.disabled = false; + cancelButton.label = cancelButtonLabel; + cancelButton.disabled = false; + } else { + acceptButton.label = acceptButtonLabel; + acceptButton.disabled = false; + cancelButton.hidden = true; + document.removeEventListener("dialogaccept", importRemainingItems); + } + + optionsPane.hidden = !itemsRemain; + progressPane.hidden = true; + resultPane.hidden = itemsRemain; + }, 500); + }, + }; + + putItemsIntoCal(calendar, remainingItems, importListener); +} + +/** + * These functions are called via `putItemsIntoCal` in import-export.js so + * they need to be defined in global scope but they don't need to do anything + * in this case. + */ +function startBatchTransaction() {} +function endBatchTransaction() {} diff --git a/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml new file mode 100644 index 0000000000..0f28148aca --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml @@ -0,0 +1,137 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/searchBox.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-item-summary.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-ics-file-dialog.css"?> + +<html + id="calendar-ics-file-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + style="min-width: 42em; min-height: 42em" + scrolling="false" +> + <head> + <title data-l10n-id="calendar-ics-file-window-title"></title> + <link rel="localization" href="calendar/calendar-editable-item.ftl" /> + <link rel="localization" href="calendar/calendar-ics-file-dialog.ftl" /> + <script src="chrome://messenger/content/dialogShadowDom.js"></script> + <script src="chrome://calendar/content/import-export.js"></script> + <script src="chrome://calendar/content/widgets/calendar-item-summary.js"></script> + <script src="chrome://calendar/content/calendar-dialog-utils.js"></script> + <script src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script src="chrome://calendar/content/calendar-ics-file-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog + buttons="accept,cancel" + data-l10n-id="calendar-ics-file-dialog-2" + data-l10n-attrs="buttonlabelaccept" + > + <vbox id="calendar-ics-file-dialog-options-pane" flex="1"> + <vbox id="calendar-ics-file-dialog-header"> + <description + id="calendar-ics-file-dialog-message" + data-l10n-id="calendar-ics-file-dialog-message-2" + ></description> + <description id="calendar-ics-file-dialog-file-path" crop="start"></description> + + <label + id="calendar-ics-file-dialog-calendar-menu-label" + data-l10n-id="calendar-ics-file-dialog-calendar-menu-label" + control="calendar-ics-file-dialog-calendar-menu" + ></label> + + <menulist id="calendar-ics-file-dialog-calendar-menu" oncommand="updateCalendarMenu();" /> + + <hbox id="calendar-ics-file-dialog-filters"> + <search-textbox + id="calendar-ics-file-dialog-search-input" + class="themeableSearchBox" + flex="1" + data-l10n-id="calendar-ics-file-dialog-search-input" + data-l10n-attrs="placeholder" + oncommand="filterItemSummaries(this.value);" + /> + <button + id="calendar-ics-file-dialog-sort-button" + type="menu" + oncommand="sortItemSummaries(event);" + > + <menupopup id="calendar-ics-file-dialog-sort-popup"> + <menuitem + id="calendar-ics-file-dialog-sort-start-ascending" + type="radio" + checked="true" + data-l10n-id="calendar-ics-file-dialog-sort-start-ascending" + data-l10n-attrs="label" + value="start ascending" + /> + <menuitem + id="calendar-ics-file-dialog-sort-start-descending" + type="radio" + data-l10n-id="calendar-ics-file-dialog-sort-start-descending" + data-l10n-attrs="label" + value="start descending" + /> + <menuitem + id="calendar-ics-file-dialog-sort-title-ascending" + type="radio" + data-l10n-id="calendar-ics-file-dialog-sort-title-ascending" + data-l10n-attrs="label" + value="title ascending" + /> + <menuitem + id="calendar-ics-file-dialog-sort-title-descending" + type="radio" + data-l10n-id="calendar-ics-file-dialog-sort-title-descending" + data-l10n-attrs="label" + value="title descending" + /> + </menupopup> + </button> + </hbox> + </vbox> + + <vbox id="calendar-ics-file-dialog-items-container"> + <label + id="calendar-ics-file-dialog-items-loading-message" + hidden="true" + data-l10n-id="calendar-ics-file-dialog-items-loading-message" + data-l10n-attrs="value" + /> + </vbox> + </vbox> + + <vbox id="calendar-ics-file-dialog-progress-pane" hidden="true"> + <description + id="calendar-ics-file-dialog-progress-message" + data-l10n-id="calendar-ics-file-dialog-progress-message" + ></description> + <html:progress id="calendar-ics-file-dialog-progress" value="0" /> + </vbox> + + <vbox id="calendar-ics-file-dialog-result-pane" hidden="true"> + <description + id="calendar-ics-file-dialog-result-message" + data-l10n-id="calendar-ics-file-import-complete" + ></description> + <description id="calendar-ics-file-dialog-duplicates-message"></description> + <description id="calendar-ics-file-dialog-errors-message"></description> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-identity-utils.js b/comm/calendar/base/content/dialogs/calendar-identity-utils.js new file mode 100644 index 0000000000..abd5bc8eb3 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-identity-utils.js @@ -0,0 +1,187 @@ +/* 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/. */ + +/* exported initMailIdentitiesRow, saveMailIdentitySelection, + notifyOnIdentitySelection, initForceEmailScheduling, + saveForceEmailScheduling, updateForceEmailSchedulingControl */ + +/* global MozElements, addMenuItem, gCalendar */ + +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyGetter(this, "gIdentityNotification", () => { + return new MozElements.NotificationBox(element => { + document.getElementById("no-identity-notification").append(element); + }); +}); + +/** + * Initialize the email identity row. Shared between the calendar creation + * dialog and the calendar properties dialog. + * + * @param {calICalendar} aCalendar - The calendar being created or edited. + */ +function initMailIdentitiesRow(aCalendar) { + if (!aCalendar) { + document.getElementById("calendar-email-identity-row").toggleAttribute("hidden", true); + } + + let imipIdentityDisabled = aCalendar.getProperty("imip.identity.disabled"); + document + .getElementById("calendar-email-identity-row") + .toggleAttribute("hidden", imipIdentityDisabled); + + if (imipIdentityDisabled) { + // If the imip identity is disabled, we don't have to set up the + // menulist. + return; + } + + // If there is no transport but also no organizer id, then the + // provider has not statically configured an organizer id. This is + // basically what happens when "None" is selected. + let menuPopup = document.getElementById("email-identity-menupopup"); + + // Remove all children from the email list to avoid duplicates if the list + // has already been populated during a previous step in the calendar + // creation wizard. + while (menuPopup.hasChildNodes()) { + menuPopup.lastChild.remove(); + } + + addMenuItem(menuPopup, cal.l10n.getLtnString("imipNoIdentity"), "none"); + let identities; + if (aCalendar && aCalendar.aclEntry && aCalendar.aclEntry.hasAccessControl) { + identities = aCalendar.aclEntry.getOwnerIdentities(); + } else { + identities = MailServices.accounts.allIdentities; + } + for (let identity of identities) { + addMenuItem(menuPopup, identity.identityName, identity.key); + } + let sel = aCalendar.getProperty("imip.identity"); + if (sel) { + sel = sel.QueryInterface(Ci.nsIMsgIdentity); + } + document.getElementById("email-identity-menulist").value = sel ? sel.key : "none"; +} + +/** + * Returns the selected email identity. Shared between the calendar creation + * dialog and the calendar properties dialog. + * + * @param {calICalendar} aCalendar - The calendar for the identity selection. + * @returns {string} The key of the selected nsIMsgIdentity or 'none'. + */ +function getMailIdentitySelection(aCalendar) { + let sel = "none"; + if (aCalendar) { + let imipIdentityDisabled = aCalendar.getProperty("imip.identity.disabled"); + let selItem = document.getElementById("email-identity-menulist").selectedItem; + if (!imipIdentityDisabled && selItem) { + sel = selItem.getAttribute("value"); + } + } + return sel; +} + +/** + * Persists the selected email identity. Shared between the calendar creation + * dialog and the calendar properties dialog. + * + * @param {calICalendar} aCalendar - The calendar for the identity selection. + */ +function saveMailIdentitySelection(aCalendar) { + if (aCalendar) { + let sel = getMailIdentitySelection(aCalendar); + // no imip.identity.key will default to the default account/identity, whereas + // an empty key indicates no imip; that identity will not be found + aCalendar.setProperty("imip.identity.key", sel == "none" ? "" : sel); + } +} + +/** + * Displays a warning if the user doesn't assign an email identity to a + * calendar. Shared between the calendar creation dialog and the calendar + * properties dialog. + * + * @param {calICalendar} aCalendar - The calendar for the identity selection. + */ +function notifyOnIdentitySelection(aCalendar) { + gIdentityNotification.removeAllNotifications(); + + let msg = cal.l10n.getLtnString("noIdentitySelectedNotification"); + let sel = getMailIdentitySelection(aCalendar); + + if (sel == "none") { + gIdentityNotification.appendNotification( + "noIdentitySelected", + { + label: msg, + priority: gIdentityNotification.PRIORITY_WARNING_MEDIUM, + }, + null + ); + } else { + gIdentityNotification.removeAllNotifications(); + } +} + +/** + * Initializing calendar creation wizard and properties dialog to display the + * option to enforce email scheduling for outgoing scheduling operations. + * Used in the calendar properties dialog. + */ +function initForceEmailScheduling() { + if (gCalendar && gCalendar.type == "caldav") { + let checkbox = document.getElementById("force-email-scheduling"); + let curStatus = checkbox.getAttribute("checked") == "true"; + let newStatus = gCalendar.getProperty("forceEmailScheduling") || curStatus; + if (curStatus != newStatus) { + if (newStatus) { + checkbox.setAttribute("checked", "true"); + } else { + checkbox.removeAttribute("checked"); + } + } + updateForceEmailSchedulingControl(); + } else { + document.getElementById("calendar-force-email-scheduling-row").toggleAttribute("hidden", true); + } +} + +/** + * Persisting the calendar property to enforce email scheduling. Used in the + * calendar properties dialog. + */ +function saveForceEmailScheduling() { + if (gCalendar && gCalendar.type == "caldav") { + let checkbox = document.getElementById("force-email-scheduling"); + if (checkbox && checkbox.getAttribute("disable-capability") != "true") { + let status = checkbox.getAttribute("checked") == "true"; + gCalendar.setProperty("forceEmailScheduling", status); + } + } +} + +/** + * Updates the forceEmailScheduling control based on the currently assigned + * email identity to this calendar. Used in the calendar properties dialog. + */ +function updateForceEmailSchedulingControl() { + let checkbox = document.getElementById("force-email-scheduling"); + if ( + gCalendar && + gCalendar.getProperty("capabilities.autoschedule.supported") && + getMailIdentitySelection(gCalendar) != "none" + ) { + checkbox.removeAttribute("disable-capability"); + checkbox.removeAttribute("disabled"); + } else { + checkbox.setAttribute("disable-capability", "true"); + checkbox.setAttribute("disabled", "true"); + } +} diff --git a/comm/calendar/base/content/dialogs/calendar-invitations-dialog.js b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.js new file mode 100644 index 0000000000..871ffef276 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.js @@ -0,0 +1,310 @@ +/* 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 MozXULElement, MozElements */ // From calendar-invitations-dialog.xhtml. + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +// Wrap in a block to prevent leaking to window scope. +{ + class MozCalendarInvitationsRichlistitem extends MozElements.MozRichlistitem { + constructor() { + super(); + + this.mCalendarItem = null; + this.mInitialParticipationStatus = null; + this.mParticipationStatus = null; + this.calInvitationsProps = Services.strings.createBundle( + "chrome://calendar/locale/calendar-invitations-dialog.properties" + ); + } + + getString(propName) { + return this.calInvitationsProps.GetStringFromName(propName); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "calendar-invitations-richlistitem"); + this.classList.add("calendar-invitations-richlistitem"); + + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <hbox align="start" flex="1"> + <!-- Note: The wrapper div is only here because the XUL box does not + - properly crop img elements with CSS object-fit and + - object-position. Should be removed when converting the parent + - element to HTML. --> + <html:div> + <html:img class="calendar-invitations-richlistitem-icon" + src="chrome://calendar/skin/shared/calendar-invitations-dialog-list-images.png" /> + </html:div> + <vbox flex="1"> + <label class="calendar-invitations-richlistitem-title" crop="end"/> + <label class="calendar-invitations-richlistitem-date" crop="end"/> + <label class="calendar-invitations-richlistitem-recurrence" crop="end"/> + <label class="calendar-invitations-richlistitem-location" crop="end"/> + <label class="calendar-invitations-richlistitem-organizer" crop="end"/> + <label class="calendar-invitations-richlistitem-attendee" crop="end"/> + <label class="calendar-invitations-richlistitem-spacer" value="" hidden="true"/> + </vbox> + <vbox> + <button group="${this.getAttribute("itemId")}" + type="radio" + class="calendar-invitations-richlistitem-accept-button + calendar-invitations-richlistitem-button" + label="&calendar.invitations.list.accept.button.label;" + oncommand="accept();"/> + <button group="${this.getAttribute("itemId")}" + type="radio" + class="calendar-invitations-richlistitem-decline-button + calendar-invitations-richlistitem-button" + label="&calendar.invitations.list.decline.button.label;" + oncommand="decline();"/> + </vbox> + </hbox> + `, + ["chrome://calendar/locale/calendar-invitations-dialog.dtd"] + ) + ); + } + + set calendarItem(val) { + this.setCalendarItem(val); + } + + get calendarItem() { + return this.mCalendarItem; + } + + set initialParticipationStatus(val) { + this.mInitialParticipationStatus = val; + } + + get initialParticipationStatus() { + return this.mInitialParticipationStatus; + } + + set participationStatus(val) { + this.mParticipationStatus = val; + let icon = this.querySelector(".calendar-invitations-richlistitem-icon"); + // Status attribute changes the image region in CSS. + icon.setAttribute("status", val); + document.l10n.setAttributes( + icon, + `calendar-invitation-current-participation-status-icon-${val.toLowerCase()}` + ); + } + + get participationStatus() { + return this.mParticipationStatus; + } + + setCalendarItem(item) { + this.mCalendarItem = item; + this.mInitialParticipationStatus = this.getCalendarItemParticipationStatus(item); + this.participationStatus = this.mInitialParticipationStatus; + + let titleLabel = this.querySelector(".calendar-invitations-richlistitem-title"); + titleLabel.setAttribute("value", item.title); + + let dateLabel = this.querySelector(".calendar-invitations-richlistitem-date"); + let dateString = cal.dtz.formatter.formatItemInterval(item); + if (item.startDate.isDate) { + dateString += ", " + this.getString("allday-event"); + } + dateLabel.setAttribute("value", dateString); + + let recurrenceLabel = this.querySelector(".calendar-invitations-richlistitem-recurrence"); + if (item.recurrenceInfo) { + recurrenceLabel.setAttribute("value", this.getString("recurrent-event")); + } else { + recurrenceLabel.setAttribute("hidden", "true"); + let spacer = this.querySelector(".calendar-invitations-richlistitem-spacer"); + spacer.removeAttribute("hidden"); + } + + let locationLabel = this.querySelector(".calendar-invitations-richlistitem-location"); + let locationProperty = item.getProperty("LOCATION") || this.getString("none"); + let locationString = this.calInvitationsProps.formatStringFromName("location", [ + locationProperty, + ]); + + locationLabel.setAttribute("value", locationString); + + let organizerLabel = this.querySelector(".calendar-invitations-richlistitem-organizer"); + let org = item.organizer; + let organizerProperty = ""; + if (org) { + if (org.commonName && org.commonName.length > 0) { + organizerProperty = org.commonName; + } else if (org.id) { + organizerProperty = org.id.replace(/^mailto:/i, ""); + } + } + let organizerString = this.calInvitationsProps.formatStringFromName("organizer", [ + organizerProperty, + ]); + organizerLabel.setAttribute("value", organizerString); + + let attendeeLabel = this.querySelector(".calendar-invitations-richlistitem-attendee"); + let att = cal.itip.getInvitedAttendee(item); + let attendeeProperty = ""; + if (att) { + if (att.commonName && att.commonName.length > 0) { + attendeeProperty = att.commonName; + } else if (att.id) { + attendeeProperty = att.id.replace(/^mailto:/i, ""); + } + } + let attendeeString = this.calInvitationsProps.formatStringFromName("attendee", [ + attendeeProperty, + ]); + attendeeLabel.setAttribute("value", attendeeString); + Array.from(this.querySelectorAll("button")).map(button => + button.setAttribute("group", item.hashId) + ); + } + + getCalendarItemParticipationStatus(item) { + let att = cal.itip.getInvitedAttendee(item); + return att ? att.participationStatus : null; + } + + setCalendarItemParticipationStatus(item, status) { + if (item.calendar?.supportsScheduling) { + let att = item.calendar.getSchedulingSupport().getInvitedAttendee(item); + if (att) { + let att_ = att.clone(); + att_.participationStatus = status; + + // Update attendee + item.removeAttendee(att); + item.addAttendee(att_); + return true; + } + } + return false; + } + + accept() { + this.participationStatus = "ACCEPTED"; + } + + decline() { + this.participationStatus = "DECLINED"; + } + } + customElements.define("calendar-invitations-richlistitem", MozCalendarInvitationsRichlistitem, { + extends: "richlistitem", + }); +} + +window.addEventListener("DOMContentLoaded", onLoad); +window.addEventListener("unload", onUnload); + +/** + * Sets up the invitations dialog from the window arguments, retrieves the + * invitations from the invitations manager. + */ +async function onLoad() { + let title = document.title; + let updatingBox = document.getElementById("updating-box"); + updatingBox.removeAttribute("hidden"); + opener.setCursor("auto"); + + let { invitationsManager } = window.arguments[0]; + let items = await cal.iterate.mapStream(invitationsManager.getInvitations(), chunk => { + document.title = title + " (" + chunk.length + ")"; + let updatingBox = document.getElementById("updating-box"); + updatingBox.setAttribute("hidden", "true"); + let richListBox = document.getElementById("invitations-listbox"); + for (let item of chunk) { + let newNode = document.createXULElement("richlistitem", { + is: "calendar-invitations-richlistitem", + }); + richListBox.appendChild(newNode); + newNode.calendarItem = item; + } + }); + + invitationsManager.toggleInvitationsPanel(items); + updatingBox.setAttribute("hidden", "true"); + + let richListBox = document.getElementById("invitations-listbox"); + if (richListBox.getRowCount() > 0) { + richListBox.selectedIndex = 0; + } else { + let noInvitationsBox = document.getElementById("noinvitations-box"); + noInvitationsBox.removeAttribute("hidden"); + } +} + +/** + * Cleans up the invitations dialog, cancels pending requests. + */ +async function onUnload() { + let args = window.arguments[0]; + return args.invitationsManager.cancelPendingRequests(); +} + +/** + * Handler function to be called when the accept button is pressed. + */ +document.addEventListener("dialogaccept", async () => { + let args = window.arguments[0]; + fillJobQueue(args.queue); + await args.invitationsManager.processJobQueue(args.queue); + args.finishedCallBack(); +}); + +/** + * Handler function to be called when the cancel button is pressed. + */ +document.addEventListener("dialogcancel", () => { + let args = window.arguments[0]; + args.finishedCallBack(); +}); + +/** + * Fills the job queue from the invitations-listbox's items. The job queue + * contains objects for all items that have a modified participation status. + * + * @param queue The queue to fill. + */ +function fillJobQueue(queue) { + let richListBox = document.getElementById("invitations-listbox"); + let rowCount = richListBox.getRowCount(); + for (let i = 0; i < rowCount; i++) { + let richListItem = richListBox.getItemAtIndex(i); + let newStatus = richListItem.participationStatus; + let oldStatus = richListItem.initialParticipationStatus; + if (newStatus != oldStatus) { + let actionString = "modify"; + let oldCalendarItem = richListItem.calendarItem; + let newCalendarItem = oldCalendarItem.clone(); + + // set default alarm on unresponded items that have not been declined: + if ( + !newCalendarItem.getAlarms().length && + oldStatus == "NEEDS-ACTION" && + newStatus != "DECLINED" + ) { + cal.alarms.setDefaultValues(newCalendarItem); + } + + richListItem.setCalendarItemParticipationStatus(newCalendarItem, newStatus); + let job = { + action: actionString, + oldItem: oldCalendarItem, + newItem: newCalendarItem, + }; + queue.push(job); + } + } +} diff --git a/comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml new file mode 100644 index 0000000000..c0ed60d95d --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml @@ -0,0 +1,53 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-invitations-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar-invitations-dialog.dtd"> +%dtd1; ]> + +<html + id="calendar-invitations-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="calendar-general-dialog" + lightweightthemes="true" + persist="screenX screenY" + style="min-width: 600px; min-height: 350px" + scrolling="false" +> + <head> + <title>&calendar.invitations.dialog.invitations.text;</title> + <link rel="localization" href="calendar/calendar-invitations-dialog.ftl" /> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-invitations-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog class="scrollable" buttons="accept,cancel"> + <div id="invitationContainer"> + <richlistbox id="invitations-listbox" /> + <hbox id="updating-box" align="center" pack="center" hidden="true"> + <label value="&calendar.invitations.dialog.statusmessage.updating.text;" crop="end" /> + <html:img + class="calendar-invitations-updating-icon" + src="chrome://global/skin/icons/loading.png" + alt="" + /> + </hbox> + <hbox id="noinvitations-box" align="center" pack="center" hidden="true"> + <label + value="&calendar.invitations.dialog.statusmessage.noinvitations.text;" + crop="end" + /> + </hbox> + </div> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js new file mode 100644 index 0000000000..dbc8532586 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js @@ -0,0 +1,52 @@ +/* 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 addMenuItem */ + +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +/** + * @callback onOkCallback + * @param {nsIMsgIdentity} identity - The identity the user selected. + */ + +/** + * @typdef {object} CalendarItipIdentityDialogArgs + * @property {nsIMsgIdentity[]} identities - List of identities to select from. + * @property {number} responseMode - One of the response mode constants + * from calIItipItem indicating the + * mode the user choose. + * @property {Function} onCancel - Called when the user clicks cancel. + * @property {onOkCallback} onOk - Called when the user selects an + * identity. + */ + +/** + * Populates the identity menu list with the available identities. + */ +function onLoad() { + let label = document.getElementById("identity-menu-label"); + document.l10n.setAttributes( + label, + window.arguments[0].responseMode == Ci.calIItipItem.NONE + ? "calendar-itip-identity-label-none" + : "calendar-itip-identity-label" + ); + + let identityMenu = document.getElementById("identity-menu"); + for (let identity of window.arguments[0].identities) { + let menuitem = addMenuItem(identityMenu, identity.fullAddress, identity.fullAddress); + menuitem.identity = identity; + } + + identityMenu.selectedIndex = 0; + + document.addEventListener("dialogaccept", () => { + window.arguments[0].onOk(identityMenu.selectedItem.identity); + }); + + document.addEventListener("dialogcancel", window.arguments[0].onCancel); +} + +window.addEventListener("load", onLoad); diff --git a/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml new file mode 100644 index 0000000000..fc3f380024 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-itip-identity-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE html> + +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + lightweightthemes="true" +> + <head> + <title data-l10n-id="calendar-itip-identity-dialog-title"></title> + + <link rel="localization" href="calendar/calendar-itip-identity-dialog.ftl" /> + + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-itip-identity-dialog.js"></script> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + </head> + + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog buttons="accept,cancel"> + <html:div + id="calendar-itip-identity-warning" + data-l10n-id="calendar-itip-identity-warning" + ></html:div> + + <label id="identity-menu-label" control="identity-menu" /> + + <menulist id="identity-menu" /> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-migration-dialog.js b/comm/calendar/base/content/dialogs/calendar-migration-dialog.js new file mode 100644 index 0000000000..bac1cd8fec --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-migration-dialog.js @@ -0,0 +1,113 @@ +/* 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/. */ + +window.addEventListener("DOMContentLoaded", event => { + gMigrateWizard.loadMigrators(); +}); + +var gMigrateWizard = { + /** + * Called from onload of the migrator window. Takes all of the migrators + * that were passed in via window.arguments and adds them to checklist. The + * user can then check these off to migrate the data from those sources. + */ + loadMigrators() { + let wizardPage2 = document.getElementById("wizardPage2"); + wizardPage2.addEventListener("pageshow", gMigrateWizard.migrateChecked); + + let listbox = document.getElementById("datasource-list"); + + // XXX Once we have branding for lightning, this hack can go away + let props = Services.strings.createBundle("chrome://calendar/locale/migration.properties"); + + let wizard = document.querySelector("wizard"); + let desc = document.getElementById("wizard-desc"); + // Since we don't translate "Lightning"... + wizard.title = props.formatStringFromName("migrationTitle", ["Lightning"]); + desc.textContent = props.formatStringFromName("migrationDescription", ["Lightning"]); + + console.debug("migrators: " + window.arguments.length); + for (let migrator of window.arguments[0]) { + let checkbox = document.createXULElement("checkbox"); + checkbox.setAttribute("checked", true); + checkbox.setAttribute("label", migrator.title); + checkbox.migrator = migrator; + listbox.appendChild(checkbox); + } + }, + + /** + * Called from the second page of the wizard. Finds all of the migrators + * that were checked and begins migrating their data. Also controls the + * progress dialog so the user can see what is happening. (somewhat) + */ + migrateChecked() { + let migrators = []; + + // Get all the checked migrators into an array + let listbox = document.getElementById("datasource-list"); + for (let i = listbox.children.length - 1; i >= 0; i--) { + if (listbox.children[i].getAttribute("checked")) { + migrators.push(listbox.children[i].migrator); + } + } + + // If no migrators were checked, then we're done + if (migrators.length == 0) { + window.close(); + } + + // Don't let the user get away while we're migrating + // XXX may want to wire this into the 'cancel' function once that's + // written + let wizard = document.querySelector("wizard"); + wizard.canAdvance = false; + wizard.canRewind = false; + + // We're going to need this for the progress meter's description + let props = Services.strings.createBundle("chrome://calendar/locale/migration.properties"); + let label = document.getElementById("progress-label"); + let meter = document.getElementById("migrate-progressmeter"); + + let i = 0; + // Because some of our migrators involve async code, we need this + // call-back function so we know when to start the next migrator. + function getNextMigrator() { + if (migrators[i]) { + let mig = migrators[i]; + + // Increment i to point to the next migrator + i++; + console.debug("starting migrator: " + mig.title); + label.value = props.formatStringFromName("migratingApp", [mig.title]); + meter.value = ((i - 1) / migrators.length) * 100; + mig.args.push(getNextMigrator); + + try { + mig.migrate(...mig.args); + } catch (e) { + console.debug("Failed to migrate: " + mig.title); + console.debug(e); + getNextMigrator(); + } + } else { + console.debug("migration done"); + wizard.canAdvance = true; + label.value = props.GetStringFromName("finished"); + meter.value = 100; + gMigrateWizard.setCanRewindFalse(); + } + } + + // And get the first migrator + getNextMigrator(); + }, + + /** + * Makes sure the wizard "back" button can not be pressed. + */ + setCanRewindFalse() { + document.querySelector("wizard").canRewind = false; + }, +}; diff --git a/comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml new file mode 100644 index 0000000000..115f035000 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!-- Style sheets --> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%brandDTD; +<!ENTITY % migrationDtd SYSTEM "chrome://calendar/locale/migration.dtd"> +%migrationDtd; ]> +<html + id="migration-wizard" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + branded="true" + windowtype="Calendar:MigrationWizard" + scrolling="false" +> + <head> + <title>&migration.title;</title> + <link rel="localization" href="toolkit/global/wizard.ftl" /> + <script defer="defer" src="chrome://calendar/content/import-export.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-migration-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <wizard style="width: 100vw; height: 100vh"> + <wizardpage + id="wizardPage1" + pageid="initialPage" + next="progressPage" + label="&migration.welcome;" + > + <label id="wizard-desc" control="datasource-list">&migration.list.description;</label> + <vbox id="datasource-list" flex="1" /> + </wizardpage> + + <wizardpage id="wizardPage2" pageid="progressPage" label="&migration.importing;"> + <label control="migrate-progressmeter">&migration.progress.description;</label> + <vbox flex="1"> + <html:progress id="migrate-progressmeter" value="0" max="100" /> + <label value="" flex="1" id="progress-label" /> + </vbox> + </wizardpage> + </wizard> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js new file mode 100644 index 0000000000..cb66e24ff9 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js @@ -0,0 +1,62 @@ +/* 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/. */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +window.addEventListener("DOMContentLoaded", onLoad); + +document.addEventListener("dialogaccept", () => exitOccurrenceDialog(1)); +document.addEventListener("dialogcancel", () => exitOccurrenceDialog(0)); + +function exitOccurrenceDialog(aReturnValue) { + window.arguments[0].value = aReturnValue; + window.close(); +} + +function getDString(aKey) { + return cal.l10n.getString("calendar-occurrence-prompt", aKey); +} + +function onLoad() { + let action = window.arguments[0].action || "edit"; + // the calling code prevents sending no items + let multiple = window.arguments[0].items.length == 1 ? "single" : "multiple"; + let itemType; + for (let item of window.arguments[0].items) { + let type = item.isEvent() ? "event" : "task"; + if (itemType != type) { + itemType = itemType ? "mixed" : type; + } + } + + // Set up title and type label + document.title = getDString(`windowtitle.${itemType}.${action}`); + let title = document.getElementById("title-label"); + if (multiple == "multiple") { + title.value = getDString("windowtitle.multipleitems"); + document.getElementById("isrepeating-label").value = getDString( + `header.containsrepeating.${itemType}.label` + ); + } else { + title.value = window.arguments[0].items[0].title; + document.getElementById("isrepeating-label").value = getDString( + `header.isrepeating.${itemType}.label` + ); + } + + // Set up buttons + document.getElementById("accept-buttons-box").setAttribute("action", action); + document.getElementById("accept-buttons-box").setAttribute("type", itemType); + + document.getElementById("accept-occurrence-button").label = getDString( + `buttons.${multiple}.occurrence.${action}.label` + ); + + document.getElementById("accept-allfollowing-button").label = getDString( + `buttons.${multiple}.allfollowing.${action}.label` + ); + document.getElementById("accept-parent-button").label = getDString( + `buttons.${multiple}.parent.${action}.label` + ); +} diff --git a/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml new file mode 100644 index 0000000000..7a7357681c --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-occurrence-prompt.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://calendar/locale/calendar-occurrence-prompt.dtd"> +<html + id="calendar-occurrence-prompt" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + scrolling="false" +> + <head> + <title><!-- windowtitle.${itemType}.${action} --></title> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-occurrence-prompt.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog buttons="accept,cancel"> + <vbox id="occurrence-prompt-header" pack="center"> + <label id="title-label" crop="end" /> + <label id="isrepeating-label" /> + </vbox> + + <vbox id="accept-buttons-box" flex="1" pack="center"> + <button + id="accept-occurrence-button" + default="true" + dlgtype="accept" + class="occurrence-accept-buttons" + accesskey="&buttons.occurrence.accesskey;" + oncommand="exitOccurrenceDialog(1)" + pack="start" + /> + <!-- XXXphilipp Button is hidden until all following is implemented --> + <button + id="accept-allfollowing-button" + class="occurrence-accept-buttons" + accesskey="&buttons.allfollowing.accesskey;" + oncommand="exitOccurrenceDialog(2)" + hidden="true" + pack="start" + /> + <button + id="accept-parent-button" + class="occurrence-accept-buttons" + accesskey="&buttons.parent.accesskey;" + oncommand="exitOccurrenceDialog(3)" + pack="start" + /> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-properties-dialog.js b/comm/calendar/base/content/dialogs/calendar-properties-dialog.js new file mode 100644 index 0000000000..c8533b2d51 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-properties-dialog.js @@ -0,0 +1,251 @@ +/* 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/. */ + +/* exported onLoad */ + +/* import-globals-from ../../../../mail/base/content/utilityOverlay.js */ +/* import-globals-from ../calendar-ui-utils.js */ +/* import-globals-from calendar-identity-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); + +/** + * The calendar to modify, is retrieved from window.arguments[0].calendar + */ +var gCalendar; + +window.addEventListener("DOMContentLoaded", onLoad); + +/** + * Called when the calendar properties dialog gets opened. When opening the + * window, use an object as argument with a 'calendar' property for the + * calendar in question, and a `canDisable` property for whether to offer + * disabling/enabling the calendar. + */ +function onLoad() { + /** @type {{ calendar: calICalendar, canDisable: boolean}} */ + let args = window.arguments[0]; + + gCalendar = args.calendar; // eslint-disable-line no-global-assign + + // Some servers provide colors as an 8-character hex string, which the color + // picker can't handle. Strip the alpha component. + let calColor = gCalendar.getProperty("color"); + let alphaHex = calColor?.match(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/); + if (alphaHex) { + gCalendar.setProperty("color", alphaHex[1]); + calColor = alphaHex[1]; + } + + if (args.canDisable && !gCalendar.getProperty("force-disabled")) { + document.documentElement.setAttribute("canDisable", "true"); + } else { + document.getElementById("calendar-enabled-checkbox").hidden = true; + } + + document.getElementById("calendar-name").value = gCalendar.name; + document.getElementById("calendar-color").value = calColor || "#A8C2E1"; + if (["memory", "storage"].includes(gCalendar.type)) { + document.getElementById("calendar-uri-row").hidden = true; + } else { + document.getElementById("calendar-uri").value = gCalendar.uri.spec; + } + document.getElementById("read-only").checked = gCalendar.readOnly; + + if (gCalendar.getProperty("capabilities.username.supported") === true) { + document.getElementById("calendar-username").value = gCalendar.getProperty("username"); + document.getElementById("calendar-username-row").toggleAttribute("hidden", false); + } else { + document.getElementById("calendar-username-row").toggleAttribute("hidden", true); + } + + // Set up refresh interval + initRefreshInterval(); + + // Set up the cache field + let cacheBox = document.getElementById("cache"); + let canCache = gCalendar.getProperty("cache.supported") !== false; + let alwaysCache = gCalendar.getProperty("cache.always"); + if (!canCache || alwaysCache) { + cacheBox.setAttribute("disable-capability", "true"); + cacheBox.hidden = true; + cacheBox.disabled = true; + } + cacheBox.checked = alwaysCache || (canCache && gCalendar.getProperty("cache.enabled")); + + // Set up the show alarms row and checkbox + let suppressAlarmsRow = document.getElementById("calendar-suppressAlarms-row"); + let suppressAlarms = gCalendar.getProperty("suppressAlarms"); + document.getElementById("fire-alarms").checked = !suppressAlarms; + + suppressAlarmsRow.toggleAttribute( + "hidden", + gCalendar.getProperty("capabilities.alarms.popup.supported") === false + ); + + // Set up the identity and scheduling rows. + initMailIdentitiesRow(gCalendar); + notifyOnIdentitySelection(gCalendar); + initForceEmailScheduling(); + + // Set up the disabled checkbox + let calendarDisabled = false; + if (gCalendar.getProperty("force-disabled")) { + document.getElementById("force-disabled-description").removeAttribute("hidden"); + document.getElementById("calendar-enabled-checkbox").setAttribute("disabled", "true"); + } else { + calendarDisabled = gCalendar.getProperty("disabled"); + document.getElementById("calendar-enabled-checkbox").checked = !calendarDisabled; + document.querySelector("dialog").getButton("extra1").setAttribute("hidden", "true"); + } + setupEnabledCheckbox(); + + // start focus on title, unless we are disabled + if (!calendarDisabled) { + document.getElementById("calendar-name").focus(); + } + + let notificationsSetting = document.getElementById("calendar-notifications-setting"); + notificationsSetting.value = gCalendar.getProperty("notifications.times"); +} + +/** + * Called when the dialog is accepted, to save settings. + */ +function onAcceptDialog() { + // Save calendar name + gCalendar.name = document.getElementById("calendar-name").value; + + // Save calendar color + gCalendar.setProperty("color", document.getElementById("calendar-color").value); + + // Save calendar user + if (gCalendar.getProperty("capabilities.username.supported") === true) { + gCalendar.setProperty("username", document.getElementById("calendar-username").value); + } + + // Save readonly state + gCalendar.readOnly = document.getElementById("read-only").checked; + + // Save supressAlarms + gCalendar.setProperty("suppressAlarms", !document.getElementById("fire-alarms").checked); + + // Save refresh interval + if (gCalendar.canRefresh) { + let value = document.getElementById("calendar-refreshInterval-menulist").value; + gCalendar.setProperty("refreshInterval", value); + } + + // Save cache options + let alwaysCache = gCalendar.getProperty("cache.always"); + if (!alwaysCache) { + gCalendar.setProperty("cache.enabled", document.getElementById("cache").checked); + } + + // Save identity and scheduling options. + saveMailIdentitySelection(gCalendar); + saveForceEmailScheduling(); + + if (!gCalendar.getProperty("force-disabled")) { + // Save disabled option (should do this last), remove auto-enabled + gCalendar.setProperty( + "disabled", + !document.getElementById("calendar-enabled-checkbox").checked + ); + gCalendar.deleteProperty("auto-enabled"); + } + + gCalendar.setProperty( + "notifications.times", + document.getElementById("calendar-notifications-setting").value + ); +} +// When this event fires, onAcceptDialog might not be the function defined +// above, so call it indirectly. +document.addEventListener("dialogaccept", () => onAcceptDialog()); + +/** + * Called when an identity is selected. + */ +function onChangeIdentity(aEvent) { + notifyOnIdentitySelection(gCalendar); + updateForceEmailSchedulingControl(); +} + +/** + * When the calendar is disabled, we need to disable a number of other elements + */ +function setupEnabledCheckbox() { + let isEnabled = document.getElementById("calendar-enabled-checkbox").checked; + let els = document.getElementsByAttribute("disable-with-calendar", "true"); + for (let i = 0; i < els.length; i++) { + els[i].disabled = !isEnabled || els[i].getAttribute("disable-capability") == "true"; + } +} + +/** + * Called to unsubscribe from a calendar. The button for this function is not + * shown unless the provider for the calendar is missing (i.e force-disabled) + */ +document.addEventListener("dialogextra1", () => { + cal.manager.unregisterCalendar(gCalendar); + window.close(); +}); + +function initRefreshInterval() { + function createMenuItem(minutes) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("value", minutes); + + let everyMinuteString = cal.l10n.getCalString("calendarPropertiesEveryMinute"); + let label = PluralForm.get(minutes, everyMinuteString).replace("#1", minutes); + menuitem.setAttribute("label", label); + + return menuitem; + } + + document + .getElementById("calendar-refreshInterval-row") + .toggleAttribute("hidden", !gCalendar.canRefresh); + + if (gCalendar.canRefresh) { + let refreshInterval = gCalendar.getProperty("refreshInterval"); + if (refreshInterval === null) { + refreshInterval = 30; + } + + let foundValue = false; + let separator = document.getElementById("calendar-refreshInterval-manual-separator"); + let menulist = document.getElementById("calendar-refreshInterval-menulist"); + for (let min of [1, 5, 15, 30, 60]) { + let menuitem = createMenuItem(min); + + separator.parentNode.insertBefore(menuitem, separator); + if (refreshInterval == min) { + menulist.selectedItem = menuitem; + foundValue = true; + } + } + + if (refreshInterval == 0) { + menulist.selectedItem = document.getElementById("calendar-refreshInterval-manual"); + foundValue = true; + } + + if (!foundValue) { + // Special menuitem in case the user changed the value in the config editor. + let menuitem = createMenuItem(refreshInterval); + separator.parentNode.insertBefore(menuitem, separator.nextElementSibling); + menulist.selectedItem = menuitem; + } + } +} + +/** + * Open the Preferences tab with global notifications setting. + */ +function showGlobalNotificationsPref() { + openPreferencesTab("paneCalendar", "calendarNotificationCategory"); +} diff --git a/comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml new file mode 100644 index 0000000000..cc1186eb3d --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml @@ -0,0 +1,257 @@ +<?xml version="1.0" encoding="UTf-8"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-properties-dialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1; +<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > +%dtd2; +<!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendarCreation.dtd" > +%dtd3; +<!ENTITY % dtd4 SYSTEM "chrome://lightning/locale/lightning.dtd" > +%dtd4; ]> +<html + id="calendar-properties-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="calendar-general-dialog" + windowtype="Calendar:PropertiesDialog" + persist="screenX screenY" + lightweightthemes="true" + width="600" + height="630" +> + <head> + <title>&calendar.server.dialog.title.edit;</title> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-identity-utils.js"></script> + <script + defer="defer" + src="chrome://calendar/content/widgets/calendar-notifications-setting.js" + ></script> + <script defer="defer" src="chrome://calendar/content/calendar-properties-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <!-- A streamlined version of this dialog is used in the accountSetup.xhtml file + as a native HTML dialog. Keep these dialogs in sync if a property changes. --> + <dialog + buttons="accept,cancel,extra1" + buttonlabelextra1="&calendarproperties.unsubscribe.label;" + buttonaccesskeyextra1="&calendarproperties.unsubscribe.accesskey;" + > + <description id="force-disabled-description" hidden="true" + >&calendarproperties.forceDisabled.label;</description + > + + <vbox id="no-identity-notification" class="notification-inline"> + <!-- notificationbox will be added here lazily. --> + </vbox> + <checkbox + id="calendar-enabled-checkbox" + label="&calendarproperties.enabled2.label;" + oncommand="setupEnabledCheckbox()" + /> + <html:table id="calendar-properties-table"> + <html:tr id="calendar-name-row"> + <html:th> + <label + id="calendar-name-label" + value="&calendar.server.dialog.name.label;" + disable-with-calendar="true" + control="calendar-name" + /> + </html:th> + <html:td> + <hbox flex="1" class="input-container"> + <html:input + id="calendar-name" + type="text" + class="input-inline" + disable-with-calendar="true" + aria-labelledby="calendar-name-label" + /> + </hbox> + </html:td> + </html:tr> + <html:tr id="calendar-color-row"> + <html:th> + <label + id="calendar-color-label" + value="&calendarproperties.color.label;" + disable-with-calendar="true" + control="calendar-color" + /> + </html:th> + <html:td> + <html:input + id="calendar-color" + type="color" + class="input-inline-color" + disable-with-calendar="true" + aria-labelledby="calendar-color-label" + /> + </html:td> + </html:tr> + <html:tr id="calendar-username-row"> + <html:th> + <label + id="calendar-username-label" + value="&locationpage.username.label;" + disable-with-calendar="true" + control="calendar-username" + /> + </html:th> + <html:td> + <hbox flex="1" class="input-container"> + <html:input + id="calendar-username" + type="text" + class="input-inline" + disable-with-calendar="true" + aria-labelledby="calendar-username-label" + /> + </hbox> + </html:td> + </html:tr> + <html:tr id="calendar-uri-row"> + <html:th> + <label + id="calendar-uri-label" + value="&calendarproperties.location.label;" + disable-with-calendar="true" + control="calendar-uri" + /> + </html:th> + <html:td> + <hbox flex="1" class="input-container"> + <html:input + id="calendar-uri" + type="url" + class="input-inline" + readonly="readonly" + disable-with-calendar="true" + aria-labelledby="calendar-uri-label" + /> + </hbox> + </html:td> + </html:tr> + <html:tr id="calendar-refreshInterval-row"> + <html:th> + <label + value="&calendarproperties.refreshInterval.label;" + disable-with-calendar="true" + control="calendar-refreshInterval-textbox" + /> + </html:th> + <html:td> + <menulist + id="calendar-refreshInterval-menulist" + disable-with-calendar="true" + label="&calendarproperties.refreshInterval.label;" + > + <menupopup id="calendar-refreshInterval-menupopup"> + <!-- This will be filled programmatically to reduce the number of needed strings --> + <menuseparator id="calendar-refreshInterval-manual-separator" /> + <menuitem + id="calendar-refreshInterval-manual" + value="0" + label="&calendarproperties.refreshInterval.manual.label;" + /> + </menupopup> + </menulist> + </html:td> + </html:tr> + <html:tr id="calendar-readOnly-row"> + <html:th></html:th> + <html:td> + <checkbox + id="read-only" + label="&calendarproperties.readonly.label;" + disable-with-calendar="true" + /> + </html:td> + </html:tr> + <html:tr id="calendar-suppressAlarms-row"> + <html:th></html:th> + <html:td> + <checkbox + id="fire-alarms" + label="&calendarproperties.firealarms.label;" + disable-with-calendar="true" + /> + </html:td> + </html:tr> + <html:tr id="calendar-cache-row"> + <html:th></html:th> + <html:td> + <checkbox + id="cache" + label="&calendarproperties.cache3.label;" + disable-with-calendar="true" + /> + </html:td> + </html:tr> + <html:tr id="calendar-email-identity-row"> + <html:th> + <label + value="&lightning.calendarproperties.email.label;" + control="email-identity-menulist" + disable-with-calendar="true" + /> + </html:th> + <html:td> + <menulist + id="email-identity-menulist" + disable-with-calendar="true" + oncommand="onChangeIdentity(event)" + > + <menupopup id="email-identity-menupopup" /> + </menulist> + </html:td> + </html:tr> + <html:tr id="calendar-force-email-scheduling-row"> + <html:th></html:th> + <html:td> + <checkbox + id="force-email-scheduling" + label="&lightning.calendarproperties.forceEmailScheduling.label;" + disable-with-calendar="true" + tooltiptext="&lightning.calendarproperties.forceEmailScheduling.tooltiptext2;" + /> + </html:td> + </html:tr> + </html:table> + + <separator /> + <vbox id="calendar-notifications"> + <label + id="calendar-notifications-title" + value="&lightning.calendarproperties.notifications.label;" + disable-with-calendar="true" + /> + <calendar-notifications-setting + id="calendar-notifications-setting" + disable-with-calendar="true" + /> + <hbox id="global-notifications-row"> + <button + label="&lightning.calendarproperties.globalNotifications.label;" + oncommand="showGlobalNotificationsPref();" + ></button> + </hbox> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js new file mode 100644 index 0000000000..0f39cecbf1 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js @@ -0,0 +1,58 @@ +/* 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/. */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +window.addEventListener("DOMContentLoaded", onLoad); +function onLoad() { + let extension = window.arguments[0].extension; + document.getElementById("provider-name-label").value = extension.name; + + let calendarList = document.getElementById("calendar-list"); + + for (let calendar of cal.manager.getCalendars()) { + if (calendar.providerID != extension.id) { + continue; + } + + let item = document.createXULElement("richlistitem"); + item.setAttribute("calendar-id", calendar.id); + + let checkbox = document.createXULElement("checkbox"); + checkbox.classList.add("calendar-selected"); + item.appendChild(checkbox); + + let colorMarker = document.createElement("div"); + colorMarker.classList.add("calendar-color"); + item.appendChild(colorMarker); + colorMarker.style.backgroundColor = calendar.getProperty("color"); + + let label = document.createXULElement("label"); + label.classList.add("calendar-name"); + label.value = calendar.name; + item.appendChild(label); + + calendarList.appendChild(item); + } +} + +document.addEventListener("dialogaccept", () => { + // Tell our caller that the extension should be uninstalled. + let args = window.arguments[0]; + args.shouldUninstall = true; + + let calendarList = document.getElementById("calendar-list"); + + // Unsubscribe from all selected calendars + for (let item of calendarList.children) { + if (item.querySelector(".calendar-selected").checked) { + cal.manager.unregisterCalendar(cal.manager.getCalendarById(item.getAttribute("calendar-id"))); + } + } +}); + +document.addEventListener("dialogcancel", () => { + let args = window.arguments[0]; + args.shouldUninstall = false; +}); diff --git a/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml new file mode 100644 index 0000000000..3d254aafcc --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-providerUninstall-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/calendar-widgets.css"?> + +<!DOCTYPE html SYSTEM "chrome://calendar/locale/provider-uninstall.dtd"> +<html + id="calendar-provider-uninstall-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Calendar:ProviderUninstall" + width="480" + height="320" + style="min-width: 480px" + scrolling="false" +> + <head> + <title>&providerUninstall.title;</title> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script + defer="defer" + src="chrome://calendar/content/calendar-providerUninstall-dialog.js" + ></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog + buttonlabelaccept="&providerUninstall.accept.label;" + buttonaccesskeyaccept="&providerUninstall.accept.accesskey;" + > + <description id="pre-name-description">&providerUninstall.preName.label;</description> + <label id="provider-name-label" /> + <description id="post-name-description">&providerUninstall.postName.label;</description> + <description id="reinstall-note-description" + >&providerUninstall.reinstallNote.label;</description + > + + <richlistbox id="calendar-list" flex="1" /> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-summary-dialog.js b/comm/calendar/base/content/dialogs/calendar-summary-dialog.js new file mode 100644 index 0000000000..0f70375814 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-summary-dialog.js @@ -0,0 +1,381 @@ +/* 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/. */ + +/* exported reply */ + +/* global MozElements */ + +/* import-globals-from calendar-dialog-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +ChromeUtils.defineESModuleGetters(this, { + SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "gStatusNotification", () => { + return new MozElements.NotificationBox(async element => { + let box = document.getElementById("status-notifications"); + // Fix window size after the notification animation is done. + box.addEventListener( + "transitionend", + () => { + window.sizeToContent(); + }, + { once: true } + ); + box.append(element); + }); +}); + +window.addEventListener("load", onLoad); +window.addEventListener("unload", onUnload); + +/** + * Sets up the summary dialog, setting all needed fields on the dialog from the + * item received in the window arguments. + */ +async function onLoad() { + let args = window.arguments[0]; + let item = args.calendarEvent; + item = item.clone(); // use an own copy of the passed item + window.calendarItem = item; + window.isInvitation = args.isInvitation; + let dialog = document.querySelector("dialog"); + + document.title = item.title; + + // set the dialog-id to enable the right CSS to be used. + if (item.isEvent()) { + setDialogId(dialog, "calendar-event-summary-dialog"); + } else if (item.isTodo()) { + setDialogId(dialog, "calendar-task-summary-dialog"); + } + + // Start setting up the item summary custom element. + let itemSummary = document.getElementById("calendar-item-summary"); + itemSummary.item = item; + + window.readOnly = itemSummary.readOnly; + let calendar = itemSummary.calendar; + + if (!window.readOnly) { + let attendee = cal.itip.getInvitedAttendee(item, calendar); + if (attendee) { + // if this is an unresponded invitation, preset our default alarm values: + if (!item.getAlarms().length && attendee.participationStatus == "NEEDS-ACTION") { + cal.alarms.setDefaultValues(item); + } + + window.attendee = attendee.clone(); + // Since we don't have API to update an attendee in place, remove + // and add again. Also, this is needed if the attendee doesn't exist + // (i.e REPLY on a mailing list) + item.removeAttendee(attendee); + item.addAttendee(window.attendee); + + window.responseMode = "USER"; + } + } + + // Finish setting up the item summary custom element. + itemSummary.updateItemDetails(); + + updateToolbar(); + updateDialogButtons(item); + + if (typeof window.ToolbarIconColor !== "undefined") { + window.ToolbarIconColor.init(); + } + + await document.l10n.translateRoots(); + window.sizeToContent(); + window.focus(); + opener.setCursor("auto"); +} + +function onUnload() { + if (typeof window.ToolbarIconColor !== "undefined") { + window.ToolbarIconColor.uninit(); + } +} + +/** + * Updates the user's participation status (PARTSTAT from see RFC5545), and + * send a notification if requested. Then close the dialog. + * + * @param {string} aResponseMode - a literal of one of the response modes defined + * in calIItipItem (like 'NONE') + * @param {string} aPartStat - participation status; a PARTSTAT value + */ +function reply(aResponseMode, aPartStat) { + // Set participation status. + if (window.attendee) { + let aclEntry = window.calendarItem.calendar.aclEntry; + if (aclEntry) { + let userAddresses = aclEntry.getUserAddresses(); + if ( + userAddresses.length > 0 && + !cal.email.attendeeMatchesAddresses(window.attendee, userAddresses) + ) { + window.attendee.setProperty("SENT-BY", "mailto:" + userAddresses[0]); + } + } + window.attendee.participationStatus = aPartStat; + updateToolbar(); + } + + // Send notification and close window. + saveAndClose(aResponseMode); +} + +/** + * Stores the event in the calendar, sends a notification if requested and + * closes the dialog. + * + * @param {string} aResponseMode - a literal of one of the response modes defined + * in calIItipItem (like 'NONE') + */ +function saveAndClose(aResponseMode) { + window.responseMode = aResponseMode; + document.querySelector("dialog").acceptDialog(); +} + +function updateToolbar() { + if (window.readOnly || window.isInvitation !== true) { + document.getElementById("summary-toolbox").hidden = true; + return; + } + + let replyButtons = document.getElementsByAttribute("type", "menu-button"); + for (let element of replyButtons) { + element.removeAttribute("hidden"); + if (window.attendee) { + // we disable the control which represents the current partstat + let status = window.attendee.participationStatus || "NEEDS-ACTION"; + if (element.getAttribute("value") == status) { + element.setAttribute("disabled", "true"); + } else { + element.removeAttribute("disabled"); + } + } + } + + if (window.attendee) { + // we display a notification about the users partstat + let partStat = window.attendee.participationStatus || "NEEDS-ACTION"; + let type = window.calendarItem.isEvent() ? "event" : "task"; + + let msgStr = { + ACCEPTED: type + "Accepted", + COMPLETED: "taskCompleted", + DECLINED: type + "Declined", + DELEGATED: type + "Delegated", + TENTATIVE: type + "Tentative", + }; + // this needs to be noted differently to get accepted the '-' in the key + msgStr["NEEDS-ACTION"] = type + "NeedsAction"; + msgStr["IN-PROGRESS"] = "taskInProgress"; + + let msg = cal.l10n.getString("calendar-event-dialog", msgStr[partStat]); + + gStatusNotification.appendNotification( + "statusNotification", + { + label: msg, + priority: gStatusNotification.PRIORITY_INFO_MEDIUM, + }, + null + ); + } else { + gStatusNotification.removeAllNotifications(); + } +} + +/** + * Copy the text content of the given link node to the clipboard. + * + * @param {string} labelNode - The label node inside an html:a element. + */ +function locationCopyLink(labelNode) { + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); + clipboard.copyString(labelNode.parentNode.getAttribute("href")); +} + +/** + * This configures the dialog buttons depending on the writable status + * of the item and whether it recurs or not: + * 1) The calendar is read-only - The buttons stay hidden. + * 2) The item is an invitation - The buttons stay hidden. + * 3) The item is recurring - Show an edit menu with occurrence options. + * 4) Otherwise - Show the single edit button. + * + * @param {calIItemBase} item + */ +function updateDialogButtons(item) { + let editButton = document.getElementById("calendar-summary-dialog-edit-button"); + let isRecurring = item.parentItem !== item; + if (window.readOnly === true) { + // This enables pressing the "enter" key to close the dialog. + editButton.focus(); + } else if (window.isInvitation === true) { + document.addEventListener("dialogaccept", onInvitationDialogAccept); + } else if (isRecurring) { + // Show the edit button menu for repeating events. + let menuButton = document.getElementById("calendar-summary-dialog-edit-menu-button"); + menuButton.hidden = false; + + // Pressing the "enter" key will display the occurrence menu. + document.getElementById("calendar-summary-dialog-edit-menu-button").focus(); + document.addEventListener("dialogaccept", evt => { + evt.preventDefault(); + }); + } else { + // Show the single edit button for non-repeating events. + document.addEventListener("dialogaccept", () => { + useEditDialog(item); + }); + editButton.hidden = false; + } + // Show the custom dialog footer when the event is editable. + if (window.readOnly !== true && window.isInvitation !== true) { + let footer = document.getElementById("calendar-summary-dialog-custom-button-footer"); + footer.hidden = false; + } +} + +/** + * Saves any changed information to the item. + */ +function onInvitationDialogAccept() { + // let's make sure we have a response mode defined + let resp = window.responseMode || "USER"; + let respMode = { responseMode: Ci.calIItipItem[resp] }; + + let args = window.arguments[0]; + let oldItem = args.calendarEvent; + let newItem = window.calendarItem; + let calendar = newItem.calendar; + saveReminder(newItem, calendar, document.querySelector(".item-alarm")); + adaptScheduleAgent(newItem); + args.onOk(newItem, calendar, oldItem, null, respMode); + window.calendarItem = newItem; +} + +/** + * Invokes the editing dialog for the current item occurrence. + */ +function onEditThisOccurrence() { + useEditDialog(window.calendarItem); +} + +/** + * Invokes the editing dialog for all occurrences of the current item. + */ +function onEditAllOccurrences() { + useEditDialog(window.calendarItem.parentItem); +} + +/** + * Switch to the "modify" mode dialog so the user can make changes to the event. + * + * @param {calIItemBase} item + */ +function useEditDialog(item) { + window.addEventListener("unload", () => { + window.opener.modifyEventWithDialog(item, false); + }); + window.close(); +} + +/** + * Initializes the context menu used for the attendees area. + * + * @param {Event} event + */ +function onAttendeeContextMenu(event) { + let copyMenu = document.getElementById("attendee-popup-copy-menu"); + let item = window.arguments[0].calendarEvent; + + let attId = + event.target.getAttribute("attendeeid") || event.target.parentNode.getAttribute("attendeeid"); + let attendee = item.getAttendees().find(att => att.id == attId); + + if (!attendee) { + copyMenu.hidden = true; + return; + } + + let id = attendee.toString(); + let idMenuItem = document.getElementById("attendee-popup-copy-menu-id"); + idMenuItem.setAttribute("label", id); + idMenuItem.hidden = false; + + let name = attendee.commonName; + let nameMenuItem = document.getElementById("attendee-popup-copy-menu-common-name"); + if (name && name != id) { + nameMenuItem.setAttribute("label", name); + nameMenuItem.hidden = false; + } else { + nameMenuItem.hidden = true; + } + + copyMenu.hidden = false; +} + +/** + * Initializes the context menu used for the event description area in the + * event summary. + * + * @param {Event} event + */ +function openDescriptionContextMenu(event) { + const popup = document.getElementById("description-popup"); + const link = event.target.closest("a") ? event.target.closest("a").getAttribute("href") : null; + const linkText = event.target.closest("a") ? event.target.closest("a").text : null; + const copyLinkTextMenuItem = document.getElementById("description-context-menu-copy-link-text"); + const copyLinkLocationMenuItem = document.getElementById( + "description-context-menu-copy-link-location" + ); + const selectionCollapsed = SelectionUtils.getSelectionDetails(window).docSelectionIsCollapsed; + + // Hide copy command if there is no text selected. + popup.querySelector('[command="cmd_copy"]').hidden = selectionCollapsed; + + copyLinkLocationMenuItem.hidden = !link; + copyLinkTextMenuItem.hidden = !link; + popup.querySelector("#calendar-summary-description-context-menuseparator").hidden = + selectionCollapsed && !link; + copyLinkTextMenuItem.setAttribute("text", linkText); + + popup.openPopupAtScreen(event.screenX, event.screenY, true, event); + event.preventDefault(); +} + +/** + * Copies the link text in a calender event description + * @param {Event} event + */ +async function copyLinkTextToClipboard(event) { + return navigator.clipboard.writeText(event.target.getAttribute("text")); +} + +/** + * Copies the label value of a menuitem to the clipboard. + */ +async function copyLabelToClipboard(event) { + return navigator.clipboard.writeText(event.target.getAttribute("label")); +} + +/** + * Brings up the compose window to send an e-mail to all attendees. + */ +function sendMailToAttendees() { + let item = window.arguments[0].calendarEvent; + let toList = cal.email.createRecipientList(item.getAttendees()); + let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]); + let identity = item.calendar.getProperty("imip.identity"); + cal.email.sendTo(toList, emailSubject, null, identity); +} diff --git a/comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml new file mode 100644 index 0000000000..d3bfe394fc --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml @@ -0,0 +1,232 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-item-summary.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-summary-dialog.css"?> + +<!DOCTYPE html [ <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd"> +<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" > +<!ENTITY % dialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > +%globalDTD; %calendarDTD; %dialogDTD; %brandDTD; ]> +<html + id="calendar-summary-dialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="calendar-general-dialog" + windowtype="Calendar:EventSummaryDialog" + lightweightthemes="true" + persist="screenX screenY" + scrolling="false" +> + <head> + <title><!-- item title --></title> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="calendar/calendar-summary-dialog.ftl" /> + <link rel="localization" href="calendar/calendar-editable-item.ftl" /> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script> + <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-item-summary.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-summary-dialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog buttons=","> + <toolbox + id="summary-toolbox" + class="mail-toolbox" + mode="full" + defaultmode="full" + iconsize="small" + defaulticonsize="small" + labelalign="end" + defaultlabelalign="end" + > + <toolbar + id="summary-toolbar" + toolboxid="summary-toolbox" + class="chromeclass-toolbar themeable-full" + customizable="false" + labelalign="end" + defaultlabelalign="end" + > + <toolbarbutton + id="saveandcloseButton" + tooltiptext="&summary.dialog.saveclose.tooltiptext;" + label="&summary.dialog.saveclose.label;" + oncommand="saveAndClose('NONE');" + class="cal-event-toolbarbutton toolbarbutton-1 saveandcloseButton" + /> + <toolbarbutton + is="toolbarbutton-menu-button" + id="acceptButton" + type="menu" + tooltiptext="&summary.dialog.accept.tooltiptext;" + label="&summary.dialog.accept.label;" + oncommand="reply('AUTO', 'ACCEPTED');" + class="cal-event-toolbarbutton toolbarbutton-1 replyButton" + > + <menupopup id="acceptDropdown"> + <menuitem + id="acceptButton_Send" + tooltiptext="&summary.dialog.send.tooltiptext;" + label="&summary.dialog.send.label;" + oncommand="reply('AUTO', 'ACCEPTED'); event.stopPropagation();" + /> + <menuitem + id="acceptButton_DontSend" + tooltiptext="&summary.dialog.dontsend.tooltiptext;" + label="&summary.dialog.dontsend.label;" + oncommand="reply('NONE', 'ACCEPTED'); event.stopPropagation();" + /> + </menupopup> + </toolbarbutton> + <toolbarbutton + is="toolbarbutton-menu-button" + id="tentativeButton" + type="menu" + tooltiptext="&summary.dialog.tentative.tooltiptext;" + label="&summary.dialog.tentative.label;" + oncommand="reply('AUTO', 'TENTATIVE');" + class="cal-event-toolbarbutton toolbarbutton-1 replyButton" + > + <menupopup id="tentativeDropdown"> + <menuitem + id="tenatativeButton_Send" + tooltiptext="&summary.dialog.send.tooltiptext;" + label="&summary.dialog.send.label;" + oncommand="reply('AUTO', 'TENTATIVE'); event.stopPropagation();" + /> + <menuitem + id="tenativeButton_DontSend" + tooltiptext="&summary.dialog.dontsend.tooltiptext;" + label="&summary.dialog.dontsend.label;" + oncommand="reply('NONE', 'TENTATIVE'); event.stopPropagation();" + /> + </menupopup> + </toolbarbutton> + <toolbarbutton + is="toolbarbutton-menu-button" + id="declineButton" + type="menu" + tooltiptext="&summary.dialog.decline.tooltiptext;" + label="&summary.dialog.decline.label;" + oncommand="reply('AUTO', 'DECLINED');" + class="cal-event-toolbarbutton toolbarbutton-1 replyButton" + > + <menupopup id="declineDropdown"> + <menuitem + id="declineButton_Send" + tooltiptext="&summary.dialog.send.tooltiptext;" + label="&summary.dialog.send.label;" + oncommand="reply('AUTO', 'DECLINED'); event.stopPropagation();" + /> + <menuitem + id="declineButton_DontSend" + tooltiptext="&summary.dialog.dontsend.tooltiptext;" + label="&summary.dialog.dontsend.label;" + oncommand="reply('NONE', 'DECLINED'); event.stopPropagation();" + /> + </menupopup> + </toolbarbutton> + </toolbar> + </toolbox> + + <vbox id="status-notifications"> + <!-- notificationbox will be added here lazily. --> + </vbox> + <calendar-item-summary id="calendar-item-summary" flex="1" /> + + <!-- LOCATION LINK CONTEXT MENU --> + <menupopup id="location-link-context-menu"> + <menuitem + id="location-link-context-menu-copy" + label="&calendar.copylink.label;" + accesskey="&calendar.copylink.accesskey;" + oncommand="locationCopyLink(this.parentNode.triggerNode)" + /> + </menupopup> + <!-- ATTENDEES CONTEXT MENU --> + <menupopup id="attendee-popup"> + <menu id="attendee-popup-copy-menu" data-l10n-id="text-action-copy"> + <menupopup> + <menuitem + id="attendee-popup-copy-menu-common-name" + oncommand="copyLabelToClipboard(event)" + /> + <menuitem id="attendee-popup-copy-menu-id" oncommand="copyLabelToClipboard(event)" /> + </menupopup> + </menu> + <menuitem + id="attendee-popup-sendemail" + label="&event.email.attendees.label;" + accesskey="&event.email.attendees.accesskey;" + oncommand="sendMailToAttendees()" + /> + </menupopup> + <menupopup id="description-popup" onpopupshowing="goUpdateGlobalEditMenuItems(true);"> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy" /> + <menuitem + id="description-context-menu-copy-link-location" + label="&calendar.copylink.label;" + accesskey="&calendar.copylink.accesskey;" + oncommand="goDoCommand('cmd_copyLink')" + /> + <menuitem + id="description-context-menu-copy-link-text" + data-l10n-id="description-context-menu-copy-link-text" + oncommand="copyLinkTextToClipboard(event)" + /> + <menuseparator id="calendar-summary-description-context-menuseparator" /> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll" /> + </menupopup> + <hbox id="calendar-summary-dialog-custom-button-footer" hidden="true"> + <spacer class="button-spacer" flex="1" /> + + <button + id="calendar-summary-dialog-edit-button" + default="true" + dlgtype="accept" + hidden="true" + data-l10n-id="calendar-summary-dialog-edit-button" + /> + + <button + id="calendar-summary-dialog-edit-menu-button" + type="menu" + hidden="true" + data-l10n-id="calendar-summary-dialog-edit-menu-button" + > + <menupopup id="edit-button-context-menu"> + <menuitem + id="edit-button-context-menu-this-occurrence" + data-l10n-id="edit-button-context-menu-this-occurrence" + oncommand="onEditThisOccurrence()" + /> + <menuitem + id="edit-button-context-menu-all-occurrences" + data-l10n-id="edit-button-context-menu-all-occurrences" + oncommand="onEditAllOccurrences()" + /> + </menupopup> + </button> + </hbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js new file mode 100644 index 0000000000..f226951fd1 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js @@ -0,0 +1,26 @@ +/* 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/. */ + +window.addEventListener("DOMContentLoaded", onLoad, { once: true }); +function onLoad() { + let { calendarName, originalURI, targetURI } = window.arguments[0]; + + document.l10n.setAttributes( + document.getElementById("calendar-uri-redirect-description"), + "calendar-uri-redirect-description", + { calendarName } + ); + + document.getElementById("originalURI").textContent = originalURI; + document.getElementById("targetURI").textContent = targetURI; + window.sizeToContent(); +} + +document.addEventListener("dialogaccept", () => { + window.arguments[0].returnValue = true; +}); + +document.addEventListener("dialogcancel", () => { + window.arguments[0].returnValue = false; +}); diff --git a/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml new file mode 100644 index 0000000000..5ee74ae88e --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml @@ -0,0 +1,46 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<html + id="calendar-uri-redirect-dialog" + xmlns="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + width="600" + scrolling="false" +> + <head> + <title data-l10n-id="calendar-uri-redirect-window-title"></title> + <link rel="localization" href="calendar/calendar-uri-redirect-dialog.ftl" /> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-uri-redirect-dialog.js"></script> + </head> + <body> + <xul:dialog buttons="accept,cancel"> + <p + id="calendar-uri-redirect-description" + data-l10n-id="calendar-uri-redirect-description" + data-l10n-args='{"calendarName": ""}' + ></p> + + <p> + <span data-l10n-id="calendar-uri-redirect-original-uri-label"></span> + <br /> + <span id="originalURI"></span> + </p> + + <p> + <span data-l10n-id="calendar-uri-redirect-target-uri-label"></span> + <br /> + <span id="targetURI"></span> + </p> + </xul:dialog> + </body> +</html> diff --git a/comm/calendar/base/content/dialogs/chooseCalendarDialog.js b/comm/calendar/base/content/dialogs/chooseCalendarDialog.js new file mode 100644 index 0000000000..47532df8ea --- /dev/null +++ b/comm/calendar/base/content/dialogs/chooseCalendarDialog.js @@ -0,0 +1,89 @@ +/* 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/. */ + +/* exported loadCalendars */ + +/* import-globals-from ../calendar-ui-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function loadCalendars() { + const calendarManager = Cc["@mozilla.org/calendar/manager;1"].getService(Ci.calICalendarManager); + let listbox = document.getElementById("calendar-list"); + let composite = cal.view.getCompositeCalendar(window.opener); + let selectedIndex = 0; + let calendars; + + if (window.arguments[0].calendars) { + calendars = window.arguments[0].calendars; + } else { + calendars = calendarManager.getCalendars(); + } + calendars = sortCalendarArray(calendars); + + for (let i = 0; i < calendars.length; i++) { + let calendar = calendars[i]; + let listItem = document.createXULElement("richlistitem"); + + let colorCell = document.createXULElement("box"); + try { + colorCell.style.backgroundColor = calendar.getProperty("color") || "#a8c2e1"; + } catch (e) {} + listItem.appendChild(colorCell); + + let nameCell = document.createXULElement("label"); + nameCell.setAttribute("value", calendar.name); + nameCell.setAttribute("flex", "1"); + listItem.appendChild(nameCell); + + listItem.calendar = calendar; + listbox.appendChild(listItem); + + // Select the default calendar of the opening calendar window. + if (calendar.id == composite.defaultCalendar.id) { + selectedIndex = i; + } + } + document.getElementById("prompt").textContent = window.arguments[0].promptText; + if (window.arguments[0].promptNotify) { + document.getElementById("promptNotify").textContent = window.arguments[0].promptNotify; + } + + // this button is the default action + let dialog = document.querySelector("dialog"); + let accept = dialog.getButton("accept"); + if (window.arguments[0].labelOk) { + accept.setAttribute("label", window.arguments[0].labelOk); + accept.removeAttribute("hidden"); + } + + let extra1 = dialog.getButton("extra1"); + if (window.arguments[0].labelExtra1) { + extra1.setAttribute("label", window.arguments[0].labelExtra1); + extra1.removeAttribute("hidden"); + } else { + extra1.setAttribute("hidden", "true"); + } + + if (calendars.length) { + listbox.ensureIndexIsVisible(selectedIndex); + listbox.timedSelect(listbox.getItemAtIndex(selectedIndex), 0); + } else { + // If there are no calendars, then disable the accept button + accept.setAttribute("disabled", "true"); + } + + window.sizeToContent(); +} + +document.addEventListener("dialogaccept", () => { + let listbox = document.getElementById("calendar-list"); + window.arguments[0].onOk(listbox.selectedItem.calendar); +}); + +document.addEventListener("dialogextra1", () => { + let listbox = document.getElementById("calendar-list"); + window.arguments[0].onExtra1(listbox.selectedItem.calendar); + window.close(); +}); diff --git a/comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml b/comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml new file mode 100644 index 0000000000..6f6932d966 --- /dev/null +++ b/comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/chooseCalendarDialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!-- DTD File with all strings specific to the file --> +<!DOCTYPE window SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<window + id="chooseCalendar" + title="&calendar.select.dialog.title;" + windowtype="Calendar:CalendarPicker" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="setTimeout(loadCalendars, 0);" + lightweightthemes="true" + persist="screenX screenY height width" +> + <dialog buttons="accept,cancel"> + <script src="chrome://calendar/content/calendar-ui-utils.js" /> + <script src="chrome://calendar/content/chooseCalendarDialog.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <vbox id="dialog-box" flex="1"> + <label id="prompt" control="calendar-list" /> + <richlistbox id="calendar-list" flex="1" seltype="single" /> + <description id="promptNotify" /> + </vbox> + </dialog> +</window> diff --git a/comm/calendar/base/content/dialogs/publishDialog.js b/comm/calendar/base/content/dialogs/publishDialog.js new file mode 100644 index 0000000000..3488220301 --- /dev/null +++ b/comm/calendar/base/content/dialogs/publishDialog.js @@ -0,0 +1,68 @@ +/* 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/. */ + +var gOnOkFunction; // function to be called when user clicks OK +var gPublishObject; + +window.addEventListener("DOMContentLoaded", loadCalendarPublishDialog); + +/** + * Called when the dialog is loaded. + */ +function loadCalendarPublishDialog() { + let args = window.arguments[0]; + + gOnOkFunction = args.onOk; + + if (args.publishObject) { + gPublishObject = args.publishObject; + if ( + args.publishObject.remotePath && + /^(https?|webcals?):\/\//.test(args.publishObject.remotePath) + ) { + document.getElementById("publish-remotePath-textbox").value = args.publishObject.remotePath; + } + } else { + gPublishObject = {}; + } + + checkURLField(); + + let firstFocus = document.getElementById("publish-remotePath-textbox"); + firstFocus.focus(); +} + +/** + * Called when the OK button is clicked. + */ +function onOKCommand(event) { + gPublishObject.remotePath = document + .getElementById("publish-remotePath-textbox") + .value.replace(/^webcal/, "http"); + + // call caller's on OK function + gOnOkFunction(gPublishObject, progressDialog); + let dialog = document.querySelector("dialog"); + dialog.getButton("accept").setAttribute("label", dialog.getAttribute("buttonlabelaccept2")); + event.preventDefault(); +} +document.addEventListener("dialogaccept", onOKCommand, { once: true }); + +function checkURLField() { + document.querySelector("dialog").getButton("accept").disabled = !document.getElementById( + "publish-remotePath-textbox" + ).validity.valid; +} + +var progressDialog = { + onStartUpload() { + document.getElementById("publish-progressmeter").setAttribute("value", "0"); + document.querySelector("dialog").getButton("cancel").hidden = true; + }, + + onStopUpload(percentage) { + document.getElementById("publish-progressmeter").setAttribute("value", percentage); + }, +}; +progressDialog.wrappedJSObject = progressDialog; diff --git a/comm/calendar/base/content/dialogs/publishDialog.xhtml b/comm/calendar/base/content/dialogs/publishDialog.xhtml new file mode 100644 index 0000000000..ec6ceef7d4 --- /dev/null +++ b/comm/calendar/base/content/dialogs/publishDialog.xhtml @@ -0,0 +1,51 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/publishDialog.css"?> + +<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1; +<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd"> +%dtd2; ]> +<html + id="calendar-publishwindow" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Calendar:PublishDialog" + persist="screenX screenY" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&calendar.publish.dialog.title;</title> + <link rel="localization" href="branding/brand.ftl" /> + <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script> + <script defer="defer" src="chrome://calendar/content/publishDialog.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog + buttons="accept,cancel" + buttonlabelaccept="&calendar.publish.publish.button;" + buttonlabelaccept2="&calendar.publish.close.button;" + > + <html:div> + <html:label for="publish-remotePath-textbox">&calendar.publish.url.label;</html:label> + <html:input + id="publish-remotePath-textbox" + type="url" + pattern="(https?|webcals?)://.*" + size="64" + required="required" + placeholder="https://www.example.com/webdav/calendar.ics" + oninput="checkURLField()" + /> + </html:div> + <html:progress id="publish-progressmeter" value="0" max="100"></html:progress> + </dialog> + </html:body> +</html> |