diff options
Diffstat (limited to '')
118 files changed, 49492 insertions, 0 deletions
diff --git a/comm/calendar/base/content/calendar-base-view.js b/comm/calendar/base/content/calendar-base-view.js new file mode 100644 index 0000000000..317770b984 --- /dev/null +++ b/comm/calendar/base/content/calendar-base-view.js @@ -0,0 +1,647 @@ +/* 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 cal, calendarNavigationBar, CalendarFilteredViewMixin, calFilterProperties, currentView, + gCurrentMode, MozElements, MozXULElement, Services, toggleOrientation */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + + /** + * Calendar observer for calendar view elements. Used in CalendarBaseView class. + * + * @implements {calIObserver} + * @implements {calICompositeObserver} + * @implements {calIAlarmServiceObserver} + */ + class CalendarViewObserver { + /** + * Constructor for CalendarViewObserver. + * + * @param {CalendarBaseView} calendarView - A calendar view. + */ + constructor(calendarView) { + this.calView = calendarView.calICalendarView; + } + + QueryInterface = ChromeUtils.generateQI(["calIAlarmServiceObserver"]); + + // calIAlarmServiceObserver + + onAlarm(alarmItem) { + this.calView.flashAlarm(alarmItem, false); + } + + onNotification(item) {} + + onRemoveAlarmsByItem(item) { + // Stop the flashing for the item. + this.calView.flashAlarm(item, true); + } + + onRemoveAlarmsByCalendar(calendar) { + // Stop the flashing for all items of this calendar. + for (const key in this.calView.mFlashingEvents) { + const item = this.calView.mFlashingEvents[key]; + if (item.calendar.id == calendar.id) { + this.calView.flashAlarm(item, true); + } + } + } + + onAlarmsLoaded(calendar) {} + + // End calIAlarmServiceObserver + } + + /** + * Abstract base class for calendar view elements (day, week, multiweek, month). + * + * @implements {calICalendarView} + * @abstract + */ + class CalendarBaseView extends CalendarFilteredViewMixin(MozXULElement) { + /** + * Whether the view has been initialized. + * + * @type {boolean} + */ + #isInitialized = false; + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + + // For some unknown reason, `console.createInstance` isn't available when + // `ensureInitialized` runs. + this.mLog = console.createInstance({ + prefix: `calendar.baseview (${this.constructor.name})`, + maxLogLevel: "Warn", + maxLogLevelPref: "calendar.baseview.loglevel", + }); + + this.mSelectedItems = []; + } + + ensureInitialized() { + if (this.#isInitialized) { + return; + } + this.#isInitialized = true; + + this.calICalendarView = this.getCustomInterfaceCallback(Ci.calICalendarView); + + this.addEventListener("move", event => { + this.moveView(event.detail); + }); + + this.addEventListener("keypress", event => { + switch (event.key) { + case "PageUp": + this.moveView(-1); + break; + case "PageDown": + this.moveView(1); + break; + } + }); + + this.addEventListener("wheel", event => { + const pixelThreshold = 150; + + if (event.shiftKey && Services.prefs.getBoolPref("calendar.view.mousescroll", true)) { + let deltaView = 0; + if (event.deltaMode == event.DOM_DELTA_LINE) { + if (event.deltaY != 0) { + deltaView = event.deltaY < 0 ? -1 : 1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.mPixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.moveView(deltaView); + } + event.preventDefault(); + } + }); + + this.addEventListener("MozRotateGesture", event => { + // Threshold for the minimum and maximum angle we should accept + // rotation for. 90 degrees minimum is most logical, but 45 degrees + // allows you to rotate with one hand. + const MIN_ROTATE_ANGLE = 45; + const MAX_ROTATE_ANGLE = 180; + + const absval = Math.abs(event.delta); + if (this.supportsRotation && absval >= MIN_ROTATE_ANGLE && absval < MAX_ROTATE_ANGLE) { + toggleOrientation(); + event.preventDefault(); + } + }); + + this.addEventListener("MozMagnifyGestureStart", event => { + this.mMagnifyAmount = 0; + }); + + this.addEventListener("MozMagnifyGestureUpdate", event => { + // Threshold as to how much magnification causes the zoom to happen. + const THRESHOLD = 30; + + if (this.supportsZoom) { + this.mMagnifyAmount += event.delta; + + if (this.mMagnifyAmount > THRESHOLD) { + this.zoomOut(); + this.mMagnifyAmount = 0; + } else if (this.mMagnifyAmount < -THRESHOLD) { + this.zoomIn(); + this.mMagnifyAmount = 0; + } + event.preventDefault(); + } + }); + + this.addEventListener("MozSwipeGesture", event => { + if ( + (event.direction == SimpleGestureEvent.DIRECTION_UP && !this.rotated) || + (event.direction == SimpleGestureEvent.DIRECTION_LEFT && this.rotated) + ) { + this.moveView(-1); + } else if ( + (event.direction == SimpleGestureEvent.DIRECTION_DOWN && !this.rotated) || + (event.direction == SimpleGestureEvent.DIRECTION_RIGHT && this.rotated) + ) { + this.moveView(1); + } + }); + + this.mRangeStartDate = null; + this.mRangeEndDate = null; + + this.mWorkdaysOnly = false; + + this.mController = null; + + this.mStartDate = null; + this.mEndDate = null; + + this.mTasksInView = false; + this.mShowCompleted = false; + + this.mDisplayDaysOff = true; + this.mDaysOffArray = [0, 6]; + + this.mTimezone = null; + this.mFlashingEvents = {}; + + this.mDropShadowsLength = null; + + this.mShadowOffset = null; + this.mDropShadows = null; + + this.mMagnifyAmount = 0; + this.mPixelScrollDelta = 0; + + this.mViewStart = null; + this.mViewEnd = null; + + this.mToggleStatus = 0; + + this.mToggleStatusFlag = { + WorkdaysOnly: 1, + TasksInView: 2, + ShowCompleted: 4, + }; + + this.mTimezoneObserver = { + observe: () => { + this.timezone = cal.dtz.defaultTimezone; + this.refreshView(); + + this.updateTimeIndicatorPosition(); + }, + }; + + this.mPrefObserver = { + calView: this.calICalendarView, + + observe(subj, topic, pref) { + this.calView.handlePreference(subj, topic, pref); + }, + }; + + this.mObserver = new CalendarViewObserver(this); + + const isChecked = id => document.getElementById(id).getAttribute("checked") == "true"; + + this.workdaysOnly = isChecked("calendar_toggle_workdays_only_command"); + this.tasksInView = isChecked("calendar_toggle_tasks_in_view_command"); + this.rotated = isChecked("calendar_toggle_orientation_command"); + this.showCompleted = isChecked("calendar_toggle_show_completed_in_view_command"); + + this.mTimezone = cal.dtz.defaultTimezone; + const alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService( + Ci.calIAlarmService + ); + + alarmService.addObserver(this.mObserver); + + this.setAttribute("type", this.type); + + window.addEventListener("viewresize", event => { + if (gCurrentMode == "calendar" && this.isVisible()) { + this.onResize(); + } + }); + + // Add a preference observer to monitor changes. + Services.prefs.addObserver("calendar.", this.mPrefObserver); + Services.obs.addObserver(this.mTimezoneObserver, "defaultTimezoneChanged"); + + this.updateDaysOffPrefs(); + this.updateTimeIndicatorPosition(); + + // Remove observers on window unload. + window.addEventListener( + "unload", + () => { + alarmService.removeObserver(this.mObserver); + + Services.prefs.removeObserver("calendar.", this.mPrefObserver); + Services.obs.removeObserver(this.mTimezoneObserver, "defaultTimezoneChanged"); + }, + { once: true } + ); + } + + /** + * Handle resizing by adjusting the view to the new size. + * + * @param {calICalendarView} [calViewElem] - A calendar view element. + */ + onResize() { + // Child classes should provide the implementation. + throw new Error(this.constructor.name + ".onResize not implemented"); + } + + /** + * Whether the view has been initialized. + * + * @returns {boolean} - True if the view has been initialized, otherwise + * false. + */ + get isInitialized() { + return this.#isInitialized; + } + + get type() { + const typelist = this.id.split("-"); + return typelist[0]; + } + + set rotated(rotated) { + this.setAttribute("orient", rotated ? "horizontal" : "vertical"); + this.toggleAttribute("rotated", rotated); + } + + get rotated() { + return this.getAttribute("orient") == "horizontal"; + } + + get supportsRotation() { + return false; + } + + set displayDaysOff(displayDaysOff) { + this.mDisplayDaysOff = displayDaysOff; + } + + get displayDaysOff() { + return this.mDisplayDaysOff; + } + + set controller(controller) { + this.mController = controller; + } + + get controller() { + return this.mController; + } + + set daysOffArray(daysOffArray) { + this.mDaysOffArray = daysOffArray; + } + + get daysOffArray() { + return this.mDaysOffArray; + } + + set tasksInView(tasksInView) { + this.mTasksInView = tasksInView; + this.updateItemType(); + } + + get tasksInView() { + return this.mTasksInView; + } + + set showCompleted(showCompleted) { + this.mShowCompleted = showCompleted; + this.updateItemType(); + } + + get showCompleted() { + return this.mShowCompleted; + } + + set timezone(timezone) { + this.mTimezone = timezone; + } + + get timezone() { + return this.mTimezone; + } + + set workdaysOnly(workdaysOnly) { + this.mWorkdaysOnly = workdaysOnly; + } + + get workdaysOnly() { + return this.mWorkdaysOnly; + } + + get supportsWorkdaysOnly() { + return true; + } + + get supportsZoom() { + return false; + } + + get selectionObserver() { + return this.mSelectionObserver; + } + + get startDay() { + return this.startDate; + } + + get endDay() { + return this.endDate; + } + + get supportDisjointDates() { + return false; + } + + get hasDisjointDates() { + return false; + } + + set rangeStartDate(startDate) { + this.mRangeStartDate = startDate; + } + + get rangeStartDate() { + return this.mRangeStartDate; + } + + set rangeEndDate(endDate) { + this.mRangeEndDate = endDate; + } + + get rangeEndDate() { + return this.mRangeEndDate; + } + + get observerID() { + return "base-view-observer"; + } + + // The end date that should be used for getItems and similar queries. + get queryEndDate() { + if (!this.endDate) { + return null; + } + const end = this.endDate.clone(); + end.day += 1; + end.isDate = true; + return end; + } + + /** + * Return a date object representing the current day. + * + * @returns {calIDateTime} A date object. + */ + today() { + const date = cal.dtz.jsDateToDateTime(new Date()).getInTimezone(this.mTimezone); + date.isDate = true; + return date; + } + + /** + * Return whether this view is currently active and visible in the UI. + * + * @returns {boolean} + */ + isVisible() { + return this == currentView(); + } + + /** + * Set the view's item type based on the `tasksInView` and `showCompleted` properties. + */ + updateItemType() { + if (!this.mTasksInView) { + this.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + return; + } + + let type = Ci.calICalendar.ITEM_FILTER_TYPE_ALL; + type |= this.mShowCompleted + ? Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL + : Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + this.itemType = type; + } + + // CalendarFilteredViewMixin implementation (clearItems and removeItemsFromCalendar + // are implemented in subclasses). + + addItems(items) { + for (let item of items) { + this.doAddItem(item); + } + } + + removeItems(items) { + for (let item of items) { + this.doRemoveItem(item); + } + } + + // End of CalendarFilteredViewMixin implementation. + + /** + * Create and fire an event. + * + * @param {string} eventName - Name of the event. + * @param {object} eventDetail - The details to add to the event. + */ + fireEvent(eventName, eventDetail) { + this.dispatchEvent( + new CustomEvent(eventName, { bubbles: true, cancelable: false, detail: eventDetail }) + ); + } + + /** + * A preference handler typically called by a preferences observer when a preference + * changes. Handles common preferences while other preferences are handled in subclasses. + * + * @param {object} subject - A subject, a prefs object. + * @param {string} topic - A topic. + * @param {string} preference - A preference that has changed. + */ + handleCommonPreference(subject, topic, preference) { + switch (preference) { + case "calendar.week.d0sundaysoff": + case "calendar.week.d1mondaysoff": + case "calendar.week.d2tuesdaysoff": + case "calendar.week.d3wednesdaysoff": + case "calendar.week.d4thursdaysoff": + case "calendar.week.d5fridaysoff": + case "calendar.week.d6saturdaysoff": + this.updateDaysOffPrefs(); + break; + case "calendar.alarms.indicator.show": + case "calendar.date.format": + case "calendar.view.showLocation": + // Break here to ensure the view is refreshed. + break; + default: + return; + } + this.refreshView(); + } + + /** + * Check preferences and update which days are days off. + */ + updateDaysOffPrefs() { + const prefix = "calendar.week."; + const daysOffPrefs = [ + [0, "d0sundaysoff", "true"], + [1, "d1mondaysoff", "false"], + [2, "d2tuesdaysoff", "false"], + [3, "d3wednesdaysoff", "false"], + [4, "d4thursdaysoff", "false"], + [5, "d5fridaysoff", "false"], + [6, "d6saturdaysoff", "true"], + ]; + const filterDaysOff = ([number, name, defaultValue]) => + Services.prefs.getBoolPref(prefix + name, defaultValue); + + this.daysOffArray = daysOffPrefs.filter(filterDaysOff).map(pref => pref[0]); + } + + /** + * Adjust the position of this view's indicator of the current time, if any. + */ + updateTimeIndicatorPosition() {} + + /** + * Refresh the view. + */ + refreshView() { + if (!this.startDay || !this.endDay) { + // Don't refresh if we're not initialized. + return; + } + this.goToDay(this.selectedDay); + } + + handlePreference(subject, topic, pref) { + // Do nothing by default. + } + + flashAlarm(alarmItem, stop) { + // Do nothing by default. + } + + // calICalendarView Methods + + /** + * @note This is overridden in each of the built-in calendar views. + * It's only left here in case some extension is relying on it. + */ + goToDay(date) { + this.showDate(date); + } + + getRangeDescription() { + return cal.dtz.formatter.formatInterval(this.rangeStartDate, this.rangeEndDate); + } + + removeDropShadows() { + this.querySelectorAll("[dropbox='true']").forEach(dbox => { + dbox.setAttribute("dropbox", "false"); + }); + } + + setDateRange(startDate, endDate) { + calendarNavigationBar.setDateRange(startDate, endDate); + } + + getSelectedItems() { + return this.mSelectedItems; + } + + setSelectedItems(items) { + this.mSelectedItems = items.concat([]); + return this.mSelectedItems; + } + + getDateList() { + const start = this.startDate.clone(); + const dateList = []; + while (start.compare(this.endDate) <= 0) { + dateList.push(start); + start.day++; + } + return dateList; + } + + zoomIn(level) {} + + zoomOut(level) {} + + zoomReset() {} + + // End calICalendarView Methods + } + + XPCOMUtils.defineLazyPreferenceGetter( + CalendarBaseView.prototype, + "weekStartOffset", + "calendar.week.start", + 0 + ); + + MozXULElement.implementCustomInterface(CalendarBaseView, [Ci.calICalendarView]); + + MozElements.CalendarBaseView = CalendarBaseView; +} diff --git a/comm/calendar/base/content/calendar-chrome-startup.js b/comm/calendar/base/content/calendar-chrome-startup.js new file mode 100644 index 0000000000..8a902923f9 --- /dev/null +++ b/comm/calendar/base/content/calendar-chrome-startup.js @@ -0,0 +1,438 @@ +/* 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 calendarOnToolbarsPopupShowing, customizeMailToolbarForTabType, + * initViewCalendarPaneMenu, loadCalendarComponent, + */ + +/* globals loadCalendarManager, injectCalendarCommandController, getViewBox, + observeViewDaySelect, getViewBox, calendarController, calendarUpdateNewItemsCommand, + TodayPane, setUpInvitationsManager, changeMode, + prepareCalendarUnifinder, taskViewOnLoad, taskEdit, tearDownInvitationsManager, + unloadCalendarManager, removeCalendarCommandController, finishCalendarUnifinder, + PanelUI, changeMenuForTask, setupDeleteMenuitem, getMinimonth, currentView, + refreshEventTree, gCurrentMode, InitMessageMenu, onViewToolbarsPopupShowing, + onCommandCustomize, CustomizeMailToolbar */ + +var { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); +var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { calendarDeactivator } = ChromeUtils.import( + "resource:///modules/calendar/calCalendarDeactivator.jsm" +); + +ChromeUtils.defineModuleGetter(this, "CalMetronome", "resource:///modules/CalMetronome.jsm"); + +/** + * Does calendar initialization steps for a given chrome window. Called at + * startup as the application window is loaded, before tabs are restored. + */ +async function loadCalendarComponent() { + if (loadCalendarComponent.hasBeenCalled) { + cal.ERROR("loadCalendarComponent was called more than once for a single window"); + return; + } + loadCalendarComponent.hasBeenCalled = true; + + if (cal.manager.wrappedJSObject.mCache) { + cal.ASSERT( + [...Services.wm.getEnumerator("mail:3pane")].length > 1, + "Calendar manager initialised calendars before loadCalendarComponent ran on the first " + + "3pane window. This should not happen." + ); + } + + await uninstallLightningAddon(); + + // load locale specific default values for preferences + setLocaleDefaultPreferences(); + + // Move around toolbarbuttons and whatever is needed in the UI. + migrateCalendarUI(); + + // Load the Calendar Manager + await loadCalendarManager(); + + CalMetronome.on("day", doMidnightUpdate); + CalMetronome.on("minute", updateTimeIndicatorPosition); + + // Set up the command controller from calendar-command-controller.js + injectCalendarCommandController(); + + // Set up calendar deactivation for this window. + calendarDeactivator.registerWindow(window); + + // Set up item and day selection listeners + getViewBox().addEventListener("dayselect", observeViewDaySelect); + getViewBox().addEventListener("itemselect", calendarController.onSelectionChanged, true); + + // Start alarm service + Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService).startup(); + document.getElementById("calsidebar_splitter").addEventListener("command", () => { + window.dispatchEvent(new CustomEvent("viewresize")); + }); + document.getElementById("calendar-view-splitter").addEventListener("command", () => { + window.dispatchEvent(new CustomEvent("viewresize")); + }); + window.addEventListener("resize", event => { + if (event.target == window) { + window.dispatchEvent(new CustomEvent("viewresize")); + } + }); + + // Set calendar color CSS on this window + cal.view.colorTracker.registerWindow(window); + + /* Ensure the new items commands state can be setup properly even when no + * calendar support refreshes (i.e. the "onLoad" notification) or when none + * are active. In specific cases such as for file-based ICS calendars can + * happen, the initial "onLoad" will already have been triggered at this + * point (see bug 714431 comment 29). We thus unconditionally invoke + * calendarUpdateNewItemsCommand until somebody writes code that enables the + * checking of the calendar readiness (getProperty("ready") ?). + */ + calendarUpdateNewItemsCommand(); + + // Prepare the Today Pane, and if it is ready, display it. + await TodayPane.onLoad(); + + // Add an unload function to the window so we don't leak any listeners. + window.addEventListener("unload", unloadCalendarComponent); + + setUpInvitationsManager(); + + let filter = document.getElementById("task-tree-filtergroup"); + filter.value = filter.value || "all"; + + // Set up mode-switching menu items and mode[v]box elements for the initial mode. + // At this point no tabs have been restored, so the only reason we wouldn't be + // in "mail" mode is if a content tab has opened to display the account set-up. + let tabmail = document.getElementById("tabmail"); + if (tabmail.currentTabInfo.mode.name == "contentTab") { + changeMode("special"); + } else { + changeMode("mail"); + } + + updateTodayPaneButton(); + + prepareCalendarUnifinder(); + + taskViewOnLoad(); + taskEdit.onLoad(); + + document.getElementById("calSidebar").style.width = `${document + .getElementById("calSidebar") + .getAttribute("width")}px`; + + Services.obs.notifyObservers(window, "calendar-startup-done"); +} + +/** + * Does unload steps for a given calendar chrome window. + */ +function unloadCalendarComponent() { + tearDownInvitationsManager(); + + // Unload the calendar manager + unloadCalendarManager(); + + // Remove the command controller + removeCalendarCommandController(); + + finishCalendarUnifinder(); + + taskEdit.onUnload(); + + CalMetronome.off("minute", updateTimeIndicatorPosition); + CalMetronome.off("day", doMidnightUpdate); +} + +/** + * Uninstall the Lightning calendar addon, now that calendar is in Thunderbird. + */ +async function uninstallLightningAddon() { + try { + let addon = await AddonManager.getAddonByID("{e2fda1a4-762b-4020-b5ad-a41df1933103}"); + if (addon) { + await addon.uninstall(); + } + } catch (err) { + console.error("Error while attempting to uninstall Lightning addon:", err); + } +} +/** + * Migrate calendar UI. This function is called at each startup and can be used + * to change UI items that require js code intervention + */ +function migrateCalendarUI() { + const UI_VERSION = 3; + let currentUIVersion = Services.prefs.getIntPref("calendar.ui.version", 0); + if (currentUIVersion >= UI_VERSION) { + return; + } + + try { + if (currentUIVersion < 2) { + // If the user has customized the event/task window dialog toolbar, + // we copy that custom set of toolbar items to the event/task tab + // toolbar and add the app menu button and a spring for alignment. + let xulStore = Services.xulStore; + let uri = "chrome://calendar/content/calendar-event-dialog.xhtml"; + + if (xulStore.hasValue(uri, "event-toolbar", "currentset")) { + let windowSet = xulStore.getValue(uri, "event-toolbar", "currentset"); + let items = ""; + if (!windowSet.includes("spring")) { + items = "spring"; + } + let previousSet = windowSet == "__empty" ? "" : windowSet + ","; + let tabSet = previousSet + items; + let tabBar = document.getElementById("event-tab-toolbar"); + + tabBar.currentSet = tabSet; + // For some reason we also have to do the following, + // presumably because the toolbar has already been + // loaded into the DOM so the toolbar's currentset + // attribute does not yet match the new currentSet. + tabBar.setAttribute("currentset", tabSet); + } + } + if (currentUIVersion < 3) { + // Rename toolbar button id "button-save" to + // "button-saveandclose" in customized toolbars + let xulStore = Services.xulStore; + let windowUri = "chrome://calendar/content/calendar-event-dialog.xhtml"; + let tabUri = "chrome://messenger/content/messenger.xhtml"; + + if (xulStore.hasValue(windowUri, "event-toolbar", "currentset")) { + let windowSet = xulStore.getValue(windowUri, "event-toolbar", "currentset"); + let newSet = windowSet.replace("button-save", "button-saveandclose"); + xulStore.setValue(windowUri, "event-toolbar", "currentset", newSet); + } + if (xulStore.hasValue(tabUri, "event-tab-toolbar", "currentset")) { + let tabSet = xulStore.getValue(tabUri, "event-tab-toolbar", "currentset"); + let newSet = tabSet.replace("button-save", "button-saveandclose"); + xulStore.setValue(tabUri, "event-tab-toolbar", "currentset", newSet); + + let tabBar = document.getElementById("event-tab-toolbar"); + tabBar.currentSet = newSet; + tabBar.setAttribute("currentset", newSet); + } + } + Services.prefs.setIntPref("calendar.ui.version", UI_VERSION); + } catch (e) { + cal.ERROR("Error upgrading UI from " + currentUIVersion + " to " + UI_VERSION + ": " + e); + } +} + +function setLocaleDefaultPreferences() { + function setDefaultLocaleValue(aName) { + // Shift encoded days from 1=Monday ... 7=Sunday to 0=Sunday ... 6=Saturday + let startDefault = calendarInfo.firstDayOfWeek % 7; + + if (aName == "calendar.categories.names" && defaultBranch.getStringPref(aName) == "") { + cal.category.setupDefaultCategories(); + } else if (aName == "calendar.week.start" && defaultBranch.getIntPref(aName) != startDefault) { + defaultBranch.setIntPref(aName, startDefault); + } else if (aName.startsWith("calendar.week.d")) { + let dayNumber = parseInt(aName[15], 10); + if (dayNumber == 0) { + dayNumber = 7; + } + defaultBranch.setBoolPref(aName, calendarInfo.weekend.includes(dayNumber)); + } + } + + cal.LOG("Start loading of locale dependent preference default values..."); + + let defaultBranch = Services.prefs.getDefaultBranch(""); + let calendarInfo = cal.l10n.calendarInfo(); + + let prefDefaults = [ + "calendar.week.start", + "calendar.week.d0sundaysoff", + "calendar.week.d1mondaysoff", + "calendar.week.d2tuesdaysoff", + "calendar.week.d3wednesdaysoff", + "calendar.week.d4thursdaysoff", + "calendar.week.d5fridaysoff", + "calendar.week.d6saturdaysoff", + "calendar.categories.names", + ]; + for (let prefDefault of prefDefaults) { + setDefaultLocaleValue(prefDefault); + } + + cal.LOG("Loading of locale sensitive preference default values completed."); +} + +/** + * Called at midnight to tell us to redraw date-specific widgets. + */ +function doMidnightUpdate() { + try { + getMinimonth().refreshDisplay(); + + // Refresh the current view and just allow the refresh for the others + // views when will be displayed. + let currView = currentView(); + currView.goToDay(); + let views = ["day-view", "week-view", "multiweek-view", "month-view"]; + for (let view of views) { + if (view != currView.id) { + document.getElementById(view).mToggleStatus = -1; + } + } + + if (!TodayPane.showsToday()) { + TodayPane.setDay(cal.dtz.now()); + } + + // Update the unifinder. + refreshEventTree(); + + // Update today's date on todaypane button. + updateTodayPaneButtonDate(); + } catch (exc) { + cal.ASSERT(false, exc); + } +} + +/** + * Update the position of the current view's indicator of the current time, if + * any. + */ +function updateTimeIndicatorPosition() { + const view = currentView(); + if (!view?.isInitialized) { + // Ensure that we don't attempt to update a view that isn't ready. Calendar + // chrome is always loaded at startup, but the view isn't initialized until + // the user switches to the calendar tab. + return; + } + + view.updateTimeIndicatorPosition(); +} + +/** + * Updates button structure to enable images on both sides of the label. + */ +function updateTodayPaneButton() { + let todaypane = document.getElementById("calendar-status-todaypane-button"); + + let iconStack = document.createXULElement("stack"); + iconStack.setAttribute("pack", "center"); + iconStack.setAttribute("align", "end"); + + let iconBegin = document.createElement("img"); + iconBegin.setAttribute("alt", ""); + iconBegin.setAttribute("src", "chrome://messenger/skin/icons/new/calendar-empty.svg"); + iconBegin.classList.add("toolbarbutton-icon-begin"); + + let iconLabel = document.createXULElement("label"); + iconLabel.classList.add("toolbarbutton-day-text"); + + let dayNumber = cal.l10n.getDateFmtString(`day.${cal.dtz.now().day}.number`); + iconLabel.textContent = dayNumber; + + iconStack.appendChild(iconBegin); + iconStack.appendChild(iconLabel); + + let iconEnd = document.createElement("img"); + iconEnd.setAttribute("alt", ""); + iconEnd.setAttribute("src", "chrome://messenger/skin/icons/new/nav-up-sm.svg"); + iconEnd.classList.add("toolbarbutton-icon-end"); + + let oldImage = todaypane.querySelector(".toolbarbutton-icon"); + todaypane.replaceChild(iconStack, oldImage); + todaypane.appendChild(iconEnd); + + let calSidebar = document.getElementById("calSidebar"); + todaypane.setAttribute("checked", !calSidebar.collapsed); +} + +/** + * Updates the date number in the calendar icon of the todaypane button. + */ +function updateTodayPaneButtonDate() { + let todaypane = document.getElementById("calendar-status-todaypane-button"); + + let dayNumber = cal.l10n.getDateFmtString(`day.${cal.dtz.now().day}.number`); + todaypane.querySelector(".toolbarbutton-day-text").textContent = dayNumber; +} + +/** + * Get the toolbox id for the current tab type. + * + * @returns {string} A toolbox id. + */ +function getToolboxIdForCurrentTabType() { + // A mapping from calendar tab types to toolbox ids. + const calendarToolboxIds = { + calendar: null, + tasks: null, + calendarEvent: "event-toolbox", + calendarTask: "event-toolbox", + }; + let tabmail = document.getElementById("tabmail"); + if (!tabmail) { + return "mail-toolbox"; // Standalone message window. + } + let tabType = tabmail.currentTabInfo.mode.type; + + return calendarToolboxIds[tabType] || null; +} + +/** + * Modify the contents of the "Toolbars" context menu for the current + * tab type. Menu items are inserted before (appear above) aInsertPoint. + * + * @param {MouseEvent} aEvent - The popupshowing event + * @param {nsIDOMXULElement} aInsertPoint - (optional) menuitem node + */ +function calendarOnToolbarsPopupShowing(aEvent, aInsertPoint) { + if (onViewToolbarsPopupShowing.length < 3) { + // SeaMonkey + onViewToolbarsPopupShowing(aEvent); + return; + } + + let toolboxes = ["navigation-toolbox"]; + let toolboxId = getToolboxIdForCurrentTabType(); + + if (toolboxId) { + toolboxes.push(toolboxId); + } + + onViewToolbarsPopupShowing(aEvent, toolboxes, aInsertPoint); +} + +/** + * Open the customize dialog for the toolbar for the current tab type. + */ +function customizeMailToolbarForTabType() { + let toolboxId = getToolboxIdForCurrentTabType(); + if (!toolboxId) { + return; + } + if (toolboxId == "event-toolbox") { + onCommandCustomize(); + } else { + CustomizeMailToolbar(toolboxId, "CustomizeMailToolbar"); + } +} + +/** + * Initialize the calendar sidebar menu state. + */ +function initViewCalendarPaneMenu() { + let calSidebar = document.getElementById("calSidebar"); + + document.getElementById("calViewCalendarPane").setAttribute("checked", !calSidebar.collapsed); + + if (document.getElementById("appmenu_calViewCalendarPane")) { + document.getElementById("appmenu_calViewCalendarPane").checked = !calSidebar.collapsed; + } +} diff --git a/comm/calendar/base/content/calendar-clipboard.js b/comm/calendar/base/content/calendar-clipboard.js new file mode 100644 index 0000000000..d3a755d167 --- /dev/null +++ b/comm/calendar/base/content/calendar-clipboard.js @@ -0,0 +1,306 @@ +/* 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 getSelectedCalendar, getSelectedItems, promptOccurrenceModification, + calendarViewController, currentView, startBatchTransaction, doTransaction, + endBatchTransaction */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/* exported cutToClipboard, pasteFromClipboard */ + +/** + * Test if a writable calendar is selected, and if the clipboard has items that + * can be pasted into Calendar. The data must be of type "text/calendar" or + * "text/plain". + * + * @returns If true, pasting is currently possible. + */ +function canPaste() { + if (Services.prefs.getBoolPref("calendar.paste.intoSelectedCalendar", false)) { + let selectedCal = getSelectedCalendar(); + if ( + !selectedCal || + !cal.acl.isCalendarWritable(selectedCal) || + !cal.acl.userCanAddItemsToCalendar(selectedCal) + ) { + return false; + } + } else { + let calendars = cal.manager + .getCalendars() + .filter(cal.acl.isCalendarWritable) + .filter(cal.acl.userCanAddItemsToCalendar); + if (!calendars.length) { + return false; + } + } + + const flavors = ["text/calendar", "text/plain"]; + return Services.clipboard.hasDataMatchingFlavors(flavors, Ci.nsIClipboard.kGlobalClipboard); +} + +/** + * Copy the ics data of the current view's selected events to the clipboard and + * deletes the events on success + * + * @param aCalendarItemArray (optional) an array of items to cut. If not + * passed, the current view's selected items will + * be used. + */ +function cutToClipboard(aCalendarItemArray = null) { + copyToClipboard(aCalendarItemArray, true); +} + +/** + * Copy the ics data of the items in calendarItemArray to the clipboard. Fills + * both text/unicode and text/calendar mime types. + * + * @param aCalendarItemArray (optional) an array of items to copy. If not + * passed, the current view's selected items will + * be used. + * @param aCutMode (optional) set to true, if this is a cut operation + */ +function copyToClipboard(aCalendarItemArray = null, aCutMode = false) { + let calendarItemArray = aCalendarItemArray || getSelectedItems(); + if (!calendarItemArray.length) { + cal.LOG("[calendar-clipboard] No items selected."); + return; + } + if (aCutMode) { + let items = calendarItemArray.filter( + aItem => + cal.acl.userCanModifyItem(aItem) || + (aItem.calendar && cal.acl.userCanDeleteItemsFromCalendar(aItem.calendar)) + ); + if (items.length < calendarItemArray.length) { + cal.LOG("[calendar-clipboard] No privilege to delete some or all selected items."); + return; + } + calendarItemArray = items; + } + let [targetItems, , response] = promptOccurrenceModification( + calendarItemArray, + true, + aCutMode ? "cut" : "copy" + ); + if (!response) { + // The user canceled the dialog, bail out + return; + } + + let icsSerializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + icsSerializer.addItems(targetItems); + let icsString = icsSerializer.serializeToString(); + + let clipboard = Services.clipboard; + let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable); + + if (trans && clipboard) { + // Register supported data flavors + trans.init(null); + trans.addDataFlavor("text/calendar"); + trans.addDataFlavor("text/plain"); + + // Create the data objects + let icsWrapper = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + icsWrapper.data = icsString; + + // Add data objects to transferable + // Both Outlook 2000 client and Lotus Organizer use text/unicode + // when pasting iCalendar data. + trans.setTransferData("text/calendar", icsWrapper); + trans.setTransferData("text/plain", icsWrapper); + + clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard); + if (aCutMode) { + // check for MODIFICATION_PARENT + let useParent = response == 3; + calendarViewController.deleteOccurrences(targetItems, useParent, true); + } + } +} + +/** + * Reads ics data from the clipboard, parses it into items and inserts the items + * into the currently selected calendar. + */ +function pasteFromClipboard() { + if (!canPaste()) { + return; + } + + let clipboard = Services.clipboard; + let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable); + + if (!trans || !clipboard) { + return; + } + + // Register the wanted data flavors (highest fidelity first!) + trans.init(null); + trans.addDataFlavor("text/calendar"); + trans.addDataFlavor("text/plain"); + + // Get transferable from clipboard + clipboard.getData(trans, Ci.nsIClipboard.kGlobalClipboard); + + // Ask transferable for the best flavor. + let flavor = {}; + let data = {}; + trans.getAnyTransferData(flavor, data); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + switch (flavor.value) { + case "text/calendar": + case "text/plain": { + let icsParser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + try { + icsParser.parseString(data); + } catch (e) { + // Ignore parser errors from the clipboard data, if it fails + // there will just be 0 items. + } + + let items = icsParser.getItems(); + if (items.length == 0) { + return; + } + + // If there are multiple items on the clipboard, the earliest + // should be set to the selected day and the rest adjusted. + let earliestDate = null; + for (let item of items) { + let date = null; + if (item.startDate) { + date = item.startDate.clone(); + } else if (item.entryDate) { + date = item.entryDate.clone(); + } else if (item.dueDate) { + date = item.dueDate.clone(); + } + + if (!date) { + continue; + } + + if (!earliestDate || date.compare(earliestDate) < 0) { + earliestDate = date; + } + } + let firstDate = currentView().selectedDay; + + let offset = null; + if (earliestDate) { + // Timezones and DT/DST time may differ between the earliest item + // and the selected day. Determine the offset between the + // earliestDate in local time and the selected day in whole days. + earliestDate = earliestDate.getInTimezone(cal.dtz.defaultTimezone); + earliestDate.isDate = true; + offset = firstDate.subtractDate(earliestDate); + let deltaDST = firstDate.timezoneOffset - earliestDate.timezoneOffset; + offset.inSeconds += deltaDST; + } + + // we only will need to ask whether to send notifications, if there + // are attendees at all + let withAttendees = items.filter(aItem => aItem.getAttendees().length > 0); + + let notify = Ci.calIItipItem.USER; + let destCal = null; + if (Services.prefs.getBoolPref("calendar.paste.intoSelectedCalendar", false)) { + destCal = getSelectedCalendar(); + } else { + let pasteText = "paste"; + if (withAttendees.length) { + if (withAttendees.every(item => item.isEvent())) { + pasteText += "Event"; + } else if (withAttendees.every(item => item.isTodo())) { + pasteText += "Task"; + } else { + pasteText += "Item"; + } + if (withAttendees.length > 1) { + pasteText += "s"; + } + } + let validPasteText = pasteText != "paste" && !pasteText.endsWith("Item"); + pasteText += items.length == withAttendees.length ? "Only" : "Also"; + + let calendars = cal.manager + .getCalendars() + .filter(cal.acl.isCalendarWritable) + .filter(cal.acl.userCanAddItemsToCalendar) + .filter(aCal => { + let status = aCal.getProperty("currentStatus"); + return Components.isSuccessCode(status); + }); + if (calendars.length > 1) { + let args = {}; + args.calendars = calendars; + args.promptText = cal.l10n.getCalString("pastePrompt"); + + if (validPasteText) { + pasteText = cal.l10n.getCalString(pasteText); + let note = cal.l10n.getCalString("pasteNotifyAbout", [pasteText]); + args.promptNotify = note; + + args.labelExtra1 = cal.l10n.getCalString("pasteDontNotifyLabel"); + args.onExtra1 = aCal => { + destCal = aCal; + notify = Ci.calIItipItem.NONE; + }; + args.labelOk = cal.l10n.getCalString("pasteAndNotifyLabel"); + args.onOk = aCal => { + destCal = aCal; + notify = Ci.calIItipItem.AUTO; + }; + } else { + args.onOk = aCal => { + destCal = aCal; + }; + } + + window.openDialog( + "chrome://calendar/content/chooseCalendarDialog.xhtml", + "_blank", + "chrome,titlebar,modal,resizable", + args + ); + } else if (calendars.length == 1) { + destCal = calendars[0]; + } + } + if (!destCal) { + return; + } + + startBatchTransaction(); + for (let item of items) { + // TODO: replace the UUID only it it already exists in the + // calendar to avoid to break invitation scenarios where remote + // parties rely on the UUID. + let newItem = item.clone(); + // Set new UID to allow multiple paste actions of the same + // clipboard content. + newItem.id = cal.getUUID(); + if (offset) { + cal.item.shiftOffset(newItem, offset); + } + + let extResp = { responseMode: Ci.calIItipItem.NONE }; + if (item.getAttendees().length > 0) { + extResp.responseMode = notify; + } + + doTransaction("add", newItem, destCal, null, null, extResp); + } + endBatchTransaction(); + break; + } + default: + break; + } +} diff --git a/comm/calendar/base/content/calendar-command-controller.js b/comm/calendar/base/content/calendar-command-controller.js new file mode 100644 index 0000000000..605b2e9a58 --- /dev/null +++ b/comm/calendar/base/content/calendar-command-controller.js @@ -0,0 +1,869 @@ +/* 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 goUpdateCommand, currentView, TodayPane, createEventWithDialog, + getSelectedCalendar, editSelectedEvents, viewSelectedEvents, + modifyTaskFromContext, deleteSelectedEvents, setupAttendanceMenu, + createTodoWithDialog, deleteToDoCommand, promptDeleteCalendar, + toImport, loadEventsFromFile, exportEntireCalendar, saveEventsToFile, + publishEntireCalendar, publishCalendarData, toggleUnifinder, toggleOrientation + toggleWorkdaysOnly, switchCalendarView, getTaskTree, selectAllEvents, + gCurrentMode, getSelectedTasks, canPaste, goSetMenuValue, canUndo, canRedo, + cutToClipboard, copyToClipboard, pasteFromClipboard, undo, redo, + PrintUtils */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var CalendarDeleteCommandEnabled = false; +var CalendarNewEventsCommandEnabled = false; +var CalendarNewTasksCommandEnabled = false; + +/** + * Command controller to execute calendar specific commands + * + * @see nsICommandController + */ +var calendarController = { + commands: new Set([ + // Common commands + "calendar_new_event_command", + "calendar_new_event_context_command", + "calendar_new_event_todaypane_command", + "calendar_modify_event_command", + "calendar_view_event_command", + "calendar_delete_event_command", + + "calendar_modify_focused_item_command", + "calendar_delete_focused_item_command", + + "calendar_new_todo_command", + "calendar_new_todo_context_command", + "calendar_new_todo_todaypane_command", + "calendar_toggle_tasks_in_view_command", + "calendar_modify_todo_command", + "calendar_modify_todo_todaypane_command", + "calendar_delete_todo_command", + + "calendar_new_calendar_command", + "calendar_edit_calendar_command", + "calendar_delete_calendar_command", + + "calendar_import_command", + "calendar_export_command", + "calendar_export_selection_command", + + "calendar_publish_selected_calendar_command", + "calendar_publish_calendar_command", + "calendar_publish_selected_events_command", + + "calendar_view_next_command", + "calendar_view_prev_command", + + "calendar_toggle_orientation_command", + "calendar_toggle_workdays_only_command", + + "calendar_day-view_command", + "calendar_week-view_command", + "calendar_multiweek-view_command", + "calendar_month-view_command", + + "calendar_task_filter_command", + "calendar_reload_remote_calendars", + "calendar_show_unifinder_command", + "calendar_toggle_completed_command", + "calendar_percentComplete-0_command", + "calendar_percentComplete-25_command", + "calendar_percentComplete-50_command", + "calendar_percentComplete-75_command", + "calendar_percentComplete-100_command", + "calendar_priority-0_command", + "calendar_priority-9_command", + "calendar_priority-5_command", + "calendar_priority-1_command", + "calendar_general-priority_command", + "calendar_general-progress_command", + "calendar_general-postpone_command", + "calendar_postpone-1hour_command", + "calendar_postpone-1day_command", + "calendar_postpone-1week_command", + "calendar_task_category_command", + + "calendar_attendance_command", + + // for events/tasks in a tab + "cmd_save", + "cmd_accept", + + // Pseudo commands + "calendar_in_foreground", + "calendar_in_background", + "calendar_mode_calendar", + "calendar_mode_task", + + "cmd_selectAll", + ]), + + updateCommands() { + this.commands.forEach(goUpdateCommand); + }, + + supportsCommand(aCommand) { + if (this.commands.has(aCommand)) { + return true; + } + return false; + }, + + /* eslint-disable complexity */ + isCommandEnabled(aCommand) { + switch (aCommand) { + case "calendar_new_event_command": + case "calendar_new_event_context_command": + case "calendar_new_event_todaypane_command": + return CalendarNewEventsCommandEnabled; + case "calendar_modify_focused_item_command": + return this.item_selected && canEditSelectedItems(); + case "calendar_modify_event_command": + return this.item_selected && canEditSelectedItems(); + case "calendar_view_event_command": + return this.item_selected; + case "calendar_delete_focused_item_command": + return CalendarDeleteCommandEnabled && this.selected_items_writable; + case "calendar_delete_event_command": + return CalendarDeleteCommandEnabled && this.selected_items_writable; + case "calendar_new_todo_command": + case "calendar_new_todo_context_command": + case "calendar_new_todo_todaypane_command": + case "calendar_toggle_tasks_in_view_command": + return CalendarNewTasksCommandEnabled; + case "calendar_modify_todo_command": + case "calendar_modify_todo_todaypane_command": + return this.todo_items_selected; + // This code is temporarily commented out due to + // bug 469684 Unifinder-todo: raising of the context menu fires blur-event + // this.todo_tasktree_focused; + case "calendar_edit_calendar_command": + return this.isCalendarInForeground(); + case "calendar_task_filter_command": + return true; + case "calendar_delete_todo_command": + if (!CalendarDeleteCommandEnabled) { + return false; + } + // falls through otherwise + case "calendar_toggle_completed_command": + case "calendar_percentComplete-0_command": + case "calendar_percentComplete-25_command": + case "calendar_percentComplete-50_command": + case "calendar_percentComplete-75_command": + case "calendar_percentComplete-100_command": + case "calendar_priority-0_command": + case "calendar_priority-9_command": + case "calendar_priority-5_command": + case "calendar_priority-1_command": + case "calendar_task_category_command": + case "calendar_general-progress_command": + case "calendar_general-priority_command": + case "calendar_general-postpone_command": + case "calendar_postpone-1hour_command": + case "calendar_postpone-1day_command": + case "calendar_postpone-1week_command": + return ( + ((this.isCalendarInForeground() || this.todo_tasktree_focused) && + this.writable && + this.todo_items_selected && + this.todo_items_writable) || + document.getElementById("tabmail").currentTabInfo.mode.type == "calendarTask" + ); + case "calendar_delete_calendar_command": + return this.isCalendarInForeground() && !this.last_calendar; + case "calendar_import_command": + return this.writable; + case "calendar_export_selection_command": + return this.item_selected; + case "calendar_toggle_orientation_command": + return this.isInMode("calendar") && currentView().supportsRotation; + case "calendar_toggle_workdays_only_command": + return this.isInMode("calendar") && currentView().supportsWorkdaysOnly; + case "calendar_publish_selected_events_command": + return this.item_selected; + + case "calendar_reload_remote_calendars": + return this.has_enabled_reloadable_calendars && !this.offline; + case "calendar_attendance_command": { + let attendSel = false; + if (this.todo_tasktree_focused) { + attendSel = + this.writable && + this.todo_items_invitation && + this.todo_items_selected && + this.todo_items_writable; + } else { + attendSel = + this.item_selected && this.selected_events_invitation && this.selected_items_writable; + } + + // Small hack, we want to hide instead of disable. + document.getElementById("calendar_attendance_command").setAttribute("hidden", !attendSel); + return attendSel; + } + + // The following commands all just need the calendar in foreground, + // make sure you take care when changing things here. + case "calendar_view_next_command": + case "calendar_view_prev_command": + case "calendar_in_foreground": + return this.isCalendarInForeground(); + case "calendar_in_background": + return !this.isCalendarInForeground(); + + // The following commands need calendar mode, be careful when + // changing things. + case "calendar_day-view_command": + case "calendar_week-view_command": + case "calendar_multiweek-view_command": + case "calendar_month-view_command": + case "calendar_show_unifinder_command": + case "calendar_mode_calendar": + return this.isInMode("calendar"); + + case "calendar_mode_task": + return this.isInMode("task"); + + case "cmd_selectAll": + return this.todo_tasktree_focused || this.isInMode("calendar"); + + // for events/tasks in a tab + case "cmd_save": + // falls through + case "cmd_accept": { + let tabType = document.getElementById("tabmail").currentTabInfo.mode.type; + return tabType == "calendarTask" || tabType == "calendarEvent"; + } + + default: + if (this.commands.has(aCommand)) { + // All other commands we support should be enabled by default + return true; + } + } + return false; + }, + /* eslint-enable complexity */ + + doCommand(aCommand) { + switch (aCommand) { + // Common Commands + case "calendar_new_event_command": + createEventWithDialog( + getSelectedCalendar(), + cal.dtz.getDefaultStartDate(currentView().selectedDay) + ); + break; + case "calendar_new_event_context_command": { + let newStart = currentView().selectedDateTime; + if (!newStart) { + newStart = cal.dtz.getDefaultStartDate(currentView().selectedDay); + } + createEventWithDialog(getSelectedCalendar(), newStart, null, null, null, newStart.isDate); + break; + } + case "calendar_new_event_todaypane_command": + createEventWithDialog(getSelectedCalendar(), cal.dtz.getDefaultStartDate(TodayPane.start)); + break; + case "calendar_modify_event_command": + editSelectedEvents(); + break; + case "calendar_view_event_command": + viewSelectedEvents(); + break; + case "calendar_modify_focused_item_command": { + let focusedElement = document.commandDispatcher.focusedElement; + if (focusedElement == TodayPane.agenda) { + TodayPane.agenda.editSelectedItem(); + } else if (focusedElement && focusedElement.className == "calendar-task-tree") { + modifyTaskFromContext(); + } else if (this.isInMode("calendar")) { + editSelectedEvents(); + } + break; + } + case "calendar_delete_event_command": + deleteSelectedEvents(); + break; + case "calendar_delete_focused_item_command": { + let focusedElement = document.commandDispatcher.focusedElement; + if (focusedElement == TodayPane.agenda) { + TodayPane.agenda.deleteSelectedItem(false); + } else if (focusedElement && focusedElement.className == "calendar-task-tree") { + deleteToDoCommand(false); + } else if (this.isInMode("calendar")) { + deleteSelectedEvents(); + } + break; + } + case "calendar_new_todo_command": + createTodoWithDialog( + getSelectedCalendar(), + null, + null, + null, + cal.dtz.getDefaultStartDate(currentView().selectedDay) + ); + break; + case "calendar_new_todo_context_command": { + let initialDate = currentView().selectedDateTime; + if (!initialDate || initialDate.isDate) { + initialDate = cal.dtz.getDefaultStartDate(currentView().selectedDay); + } + createTodoWithDialog(getSelectedCalendar(), null, null, null, initialDate); + break; + } + case "calendar_new_todo_todaypane_command": + createTodoWithDialog( + getSelectedCalendar(), + null, + null, + null, + cal.dtz.getDefaultStartDate(TodayPane.start) + ); + break; + case "calendar_delete_todo_command": + deleteToDoCommand(); + break; + case "calendar_modify_todo_command": + modifyTaskFromContext(cal.dtz.getDefaultStartDate(currentView().selectedDay)); + break; + case "calendar_modify_todo_todaypane_command": + modifyTaskFromContext(cal.dtz.getDefaultStartDate(TodayPane.start)); + break; + + case "calendar_new_calendar_command": + cal.window.openCalendarWizard(window); + break; + case "calendar_edit_calendar_command": + cal.window.openCalendarProperties(window, { calendar: getSelectedCalendar() }); + break; + case "calendar_delete_calendar_command": + promptDeleteCalendar(getSelectedCalendar()); + break; + + case "calendar_import_command": + if (Services.prefs.getBoolPref("mail.import.in_new_tab")) { + toImport("calendar"); + } else { + loadEventsFromFile(); + } + break; + case "calendar_export_command": + exportEntireCalendar(); + break; + case "calendar_export_selection_command": + saveEventsToFile(currentView().getSelectedItems()); + break; + + case "calendar_publish_selected_calendar_command": + publishEntireCalendar(getSelectedCalendar()); + break; + case "calendar_publish_calendar_command": + publishEntireCalendar(); + break; + case "calendar_publish_selected_events_command": + publishCalendarData(); + break; + + case "calendar_reload_remote_calendars": + cal.view.getCompositeCalendar(window).refresh(); + break; + case "calendar_show_unifinder_command": + toggleUnifinder(); + break; + case "calendar_view_next_command": + currentView().moveView(1); + break; + case "calendar_view_prev_command": + currentView().moveView(-1); + break; + case "calendar_toggle_orientation_command": + toggleOrientation(); + break; + case "calendar_toggle_workdays_only_command": + toggleWorkdaysOnly(); + break; + + case "calendar_day-view_command": + switchCalendarView("day", true); + break; + case "calendar_week-view_command": + switchCalendarView("week", true); + break; + case "calendar_multiweek-view_command": + switchCalendarView("multiweek", true); + break; + case "calendar_month-view_command": + switchCalendarView("month", true); + break; + case "calendar_attendance_command": + // This command is actually handled inline, since it takes a value + break; + + case "cmd_selectAll": + if (this.todo_tasktree_focused) { + getTaskTree().selectAll(); + } else if (this.isInMode("calendar")) { + selectAllEvents(); + } + break; + } + }, + + onEvent(aEvent) {}, + + isCalendarInForeground() { + return gCurrentMode && gCurrentMode != "mail"; + }, + + isInMode(mode) { + switch (mode) { + case "mail": + return !this.isCalendarInForeground(); + case "calendar": + return gCurrentMode && gCurrentMode == "calendar"; + case "task": + return gCurrentMode && gCurrentMode == "task"; + } + return false; + }, + + onSelectionChanged(aEvent) { + let selectedItems = aEvent.detail; + + calendarUpdateDeleteCommand(selectedItems); + calendarController.item_selected = selectedItems && selectedItems.length > 0; + + let selLength = selectedItems === undefined ? 0 : selectedItems.length; + let selected_events_readonly = 0; + let selected_events_requires_network = 0; + let selected_events_invitation = 0; + + if (selLength > 0) { + for (let item of selectedItems) { + if (item.calendar.readOnly) { + selected_events_readonly++; + } + if ( + item.calendar.getProperty("requiresNetwork") && + !item.calendar.getProperty("cache.enabled") && + !item.calendar.getProperty("cache.always") + ) { + selected_events_requires_network++; + } + + if (cal.itip.isInvitation(item)) { + selected_events_invitation++; + } else if (item.organizer) { + // If we are the organizer and there are attendees, then + // this is likely also an invitation. + let calOrgId = item.calendar.getProperty("organizerId"); + if (item.organizer.id == calOrgId && item.getAttendees().length) { + selected_events_invitation++; + } + } + } + } + + calendarController.selected_events_readonly = selected_events_readonly == selLength; + + calendarController.selected_events_requires_network = + selected_events_requires_network == selLength; + calendarController.selected_events_invitation = selected_events_invitation == selLength; + + calendarController.updateCommands(); + calendarController2.updateCommands(); + document.commandDispatcher.updateCommands("mail-toolbar"); + }, + + /** + * Condition Helpers + */ + + // These attributes will be set up manually. + item_selected: false, + selected_events_readonly: false, + selected_events_requires_network: false, + selected_events_invitation: false, + + /** + * Returns a boolean indicating if its possible to write items to any + * calendar. + */ + get writable() { + return cal.manager.getCalendars().some(cal.acl.isCalendarWritable); + }, + + /** + * Returns a boolean indicating if the application is currently in offline + * mode. + */ + get offline() { + return Services.io.offline; + }, + + /** + * Returns a boolean indicating whether there is at least one enabled + * calendar that can be reloaded. Note: ICS calendars can have a network URL + * or a file URL, but both are reloadable. + */ + get has_enabled_reloadable_calendars() { + return cal.manager + .getCalendars() + .some( + calendar => + !calendar.getProperty("disabled") && + (calendar.type == "ics" || calendar.getProperty("requiresNetwork") !== false) + ); + }, + + /** + * Returns a boolean indicating that there is only one calendar left. + */ + get last_calendar() { + return cal.manager.calendarCount < 2; + }, + + /** + * Returns a boolean indicating that at least one of the items selected + * in the current view has a writable calendar. + */ + get selected_items_writable() { + return ( + this.writable && + this.item_selected && + !this.selected_events_readonly && + (!this.offline || !this.selected_events_requires_network) + ); + }, + + /** + * Returns a boolean indicating that tasks are selected. + */ + get todo_items_selected() { + let selectedTasks = getSelectedTasks(); + return selectedTasks.length > 0; + }, + + get todo_items_invitation() { + let selectedTasks = getSelectedTasks(); + let selected_tasks_invitation = 0; + + for (let item of selectedTasks) { + if (cal.itip.isInvitation(item)) { + selected_tasks_invitation++; + } else if (item.organizer) { + // If we are the organizer and there are attendees, then + // this is likely also an invitation. + let calOrgId = item.calendar.getProperty("organizerId"); + if (item.organizer.id == calOrgId && item.getAttendees().length) { + selected_tasks_invitation++; + } + } + } + + return selectedTasks.length == selected_tasks_invitation; + }, + + /** + * Returns a boolean indicating that at least one task in the selection is + * on a calendar that is writable. + */ + get todo_items_writable() { + let selectedTasks = getSelectedTasks(); + for (let task of selectedTasks) { + if (cal.acl.isCalendarWritable(task.calendar)) { + return true; + } + } + return false; + }, +}; + +/** + * XXX This is a temporary hack so we can release 1.0b2. This will soon be + * superseded by a new command controller architecture. + */ +var calendarController2 = { + commands: new Set([ + "cmd_cut", + "cmd_copy", + "cmd_paste", + "cmd_undo", + "cmd_redo", + "cmd_print", + "button_print", + "button_delete", + "cmd_delete", + "cmd_properties", + "cmd_goForward", + "cmd_goBack", + "cmd_fullZoomReduce", + "cmd_fullZoomEnlarge", + "cmd_fullZoomReset", + "cmd_showQuickFilterBar", + ]), + + // These functions can use the same from the calendar controller for now. + updateCommands: calendarController.updateCommands, + supportsCommand: calendarController.supportsCommand, + onEvent: calendarController.onEvent, + + isCommandEnabled(aCommand) { + switch (aCommand) { + // Thunderbird Commands + case "cmd_cut": + return calendarController.selected_items_writable; + case "cmd_copy": + return calendarController.item_selected; + case "cmd_paste": + return canPaste(); + case "cmd_undo": + goSetMenuValue(aCommand, "valueDefault"); + return canUndo(); + case "cmd_redo": + goSetMenuValue(aCommand, "valueDefault"); + return canRedo(); + case "button_delete": + case "cmd_delete": + return calendarController.isCommandEnabled("calendar_delete_focused_item_command"); + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + return calendarController.isInMode("calendar") && currentView().supportsZoom; + case "cmd_properties": + return false; + case "cmd_showQuickFilterBar": + return calendarController.isInMode("task"); + default: + return true; + } + }, + + doCommand(aCommand) { + if (!this.isCommandEnabled(aCommand)) { + // doCommand is triggered for cmd_cut even if the command is disabled + // so we bail out here + return; + } + switch (aCommand) { + case "cmd_cut": + cutToClipboard(); + break; + case "cmd_copy": + copyToClipboard(); + break; + case "cmd_paste": + pasteFromClipboard(); + break; + case "cmd_undo": + undo(); + break; + case "cmd_redo": + redo(); + break; + case "button_print": + case "cmd_print": + printCalendar(); + break; + // Thunderbird commands + case "cmd_goForward": + currentView().moveView(1); + break; + case "cmd_goBack": + currentView().moveView(-1); + break; + case "cmd_fullZoomReduce": + currentView().zoomIn(); + break; + case "cmd_fullZoomEnlarge": + currentView().zoomOut(); + break; + case "cmd_fullZoomReset": + currentView().zoomReset(); + break; + case "cmd_showQuickFilterBar": + document.getElementById("task-text-filter-field").select(); + break; + + case "button_delete": + case "cmd_delete": + calendarController.doCommand("calendar_delete_focused_item_command"); + break; + } + }, +}; + +/** + * Inserts the command controller into the document. Make sure that it is + * inserted before the conflicting Thunderbird command controller. + */ +function injectCalendarCommandController() { + // This is the third-highest priority controller. It's preceded by + // DefaultController and tabmail.tabController, and followed by + // calendarController, then whatever Gecko adds. + top.controllers.insertControllerAt(2, calendarController); + document.commandDispatcher.updateCommands("calendar_commands"); +} + +/** + * Remove the calendar command controller from the document. + */ +function removeCalendarCommandController() { + top.controllers.removeController(calendarController); +} + +/** + * Handler function to set up the item context menu, depending on the given + * items. Changes the delete menuitem to fit the passed items. + * + * @param {DOMEvent} aEvent The DOM popupshowing event that is + * triggered by opening the context menu + * @param {Array.<calIItemBase>} aItems An array of items (usually the selected + * items) to adapt the context menu for + * @returns {boolean} True, to show the popup menu. + */ +function setupContextItemType(aEvent, aItems) { + function adaptModificationMenuItem(aMenuItemId, aItemType) { + let menuItem = document.getElementById(aMenuItemId); + if (menuItem) { + menuItem.setAttribute("label", cal.l10n.getCalString(`delete${aItemType}Label`)); + menuItem.setAttribute("accesskey", cal.l10n.getCalString(`delete${aItemType}Accesskey`)); + } + } + if (aItems.some(item => item.isEvent()) && aItems.some(item => item.isTodo())) { + aEvent.target.setAttribute("type", "mixed"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Item"); + } else if (aItems.length && aItems[0].isEvent()) { + aEvent.target.setAttribute("type", "event"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Event"); + } else if (aItems.length && aItems[0].isTodo()) { + aEvent.target.setAttribute("type", "todo"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Task"); + } else { + aEvent.target.removeAttribute("type"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Item"); + } + + let menu = document.getElementById("calendar-item-context-menu-attendance-menu"); + setupAttendanceMenu(menu, aItems); + return true; +} + +/** + * Tests whether the items currently selected can be edited in the event dialog. + * Invitations are not considered editable here. + */ +function canEditSelectedItems() { + let items = currentView().getSelectedItems(); + return items.every(item => { + let calendar = item.calendar; + return ( + cal.acl.isCalendarWritable(calendar) && + cal.acl.userCanModifyItem(item) && + calendar.supportsScheduling && + !calendar.getSchedulingSupport().isInvitation(item) + ); + }); +} + +/** + * Returns the selected items, based on which mode we are currently in and what task tree is focused. + */ +function getSelectedItems() { + if (calendarController.todo_tasktree_focused) { + return getSelectedTasks(); + } + + return currentView().getSelectedItems(); +} + +/** + * Deletes the selected items, based on which mode we are currently in and what task tree is focused + */ +function deleteSelectedItems() { + if (calendarController.todo_tasktree_focused) { + deleteToDoCommand(); + } else if (calendarController.isInMode("calendar")) { + deleteSelectedEvents(); + } +} + +/** + * Checks if any calendar allows new events and tasks to be added, otherwise + * disables the creation buttons. + */ +function calendarUpdateNewItemsCommand() { + // Re-calculate command status. + let calendars = cal.manager + .getCalendars() + .filter(cal.acl.isCalendarWritable) + .filter(cal.acl.userCanAddItemsToCalendar); + + CalendarNewEventsCommandEnabled = calendars.some(cal.item.isEventCalendar); + CalendarNewTasksCommandEnabled = calendars.some(cal.item.isTaskCalendar); + + [ + "calendar_new_event_command", + "calendar_new_event_context_command", + "calendar_new_event_todaypane_command", + "calendar_new_todo_command", + "calendar_new_todo_context_command", + "calendar_new_todo_todaypane_command", + "calendar_toggle_tasks_in_view_command", + ].forEach(goUpdateCommand); + + document.getElementById("sidePanelNewEvent").disabled = !CalendarNewEventsCommandEnabled; + document.getElementById("sidePanelNewTask").disabled = !CalendarNewTasksCommandEnabled; +} + +function calendarUpdateDeleteCommand(selectedItems) { + let oldValue = CalendarDeleteCommandEnabled; + CalendarDeleteCommandEnabled = selectedItems.length > 0; + + /* we must disable "delete" when at least one item cannot be deleted */ + for (let item of selectedItems) { + if (!cal.acl.userCanDeleteItemsFromCalendar(item.calendar)) { + CalendarDeleteCommandEnabled = false; + break; + } + } + + if (CalendarDeleteCommandEnabled != oldValue) { + [ + "calendar_delete_event_command", + "calendar_delete_todo_command", + "calendar_delete_focused_item_command", + "button_delete", + "cmd_delete", + ].forEach(goUpdateCommand); + } +} + +/** + * Loads the printing template into a hidden browser then starts the printing + * process for that browser. + */ +async function printCalendar() { + // Ensure the printing of this file will be detected by calPrintUtils.jsm. + cal.print.ensureInitialized(); + + await PrintUtils.loadPrintBrowser("chrome://calendar/content/printing-template.html"); + PrintUtils.startPrintWindow(PrintUtils.printBrowser.browsingContext, {}); +} +/** + * Toggle the visibility of the calendars list. + * + * @param {Event} event - The click DOMEvent. + */ +function toggleVisibilityCalendarsList(event) { + document.getElementById("calendar-list-inner-pane").togglePane(event); +} diff --git a/comm/calendar/base/content/calendar-commands.inc.xhtml b/comm/calendar/base/content/calendar-commands.inc.xhtml new file mode 100644 index 0000000000..78bce756ca --- /dev/null +++ b/comm/calendar/base/content/calendar-commands.inc.xhtml @@ -0,0 +1,101 @@ +# 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/. + +<commandset id="calendar_commands" + commandupdater="true" + events="calendar_commands" + oncommandupdate="calendarController.updateCommands()"> + <command id="agenda_delete_event_command" oncommand="TodayPane.agenda.deleteSelectedItem();"/> + <command id="agenda_edit_event_command" oncommand="TodayPane.agenda.editSelectedItem();"/> + <command id="switch2calendar" + oncommand="document.getElementById('tabmail').openTab('calendar')"/> + <command id="switch2task" + oncommand="document.getElementById('tabmail').openTab('tasks')"/> + <command id="new_calendar_tab" + oncommand="document.getElementById('tabmail').openTab('calendar')"/> + <command id="new_task_tab" + oncommand="document.getElementById('tabmail').openTab('tasks')"/> + <command id="calendar_go_to_today_command" + observes="calendar_mode_calendar" + oncommand="document.getElementById('tabmail').openTab('calendar'); goToDate(cal.dtz.now())"/> + <command id="calendar_new_calendar_command" oncommand="goDoCommand('calendar_new_calendar_command')"/> + <command id="calendar_delete_calendar_command" oncommand="goDoCommand('calendar_delete_calendar_command')"/> + <command id="calendar_edit_calendar_command" oncommand="goDoCommand('calendar_edit_calendar_command')"/> + + <command id="calendar_new_event_command" oncommand="goDoCommand('calendar_new_event_command')"/> + <command id="calendar_new_event_context_command" oncommand="goDoCommand('calendar_new_event_context_command')"/> + <command id="calendar_new_event_todaypane_command" oncommand="goDoCommand('calendar_new_event_todaypane_command')"/> + <command id="calendar_modify_event_command" oncommand="goDoCommand('calendar_modify_event_command')"/> + <command id="calendar_view_event_command" oncommand="goDoCommand('calendar_view_event_command')"/> + <command id="calendar_delete_event_command" oncommand="goDoCommand('calendar_delete_event_command')"/> + + <command id="calendar_new_todo_command" oncommand="goDoCommand('calendar_new_todo_command')"/> + <command id="calendar_new_todo_context_command" oncommand="goDoCommand('calendar_new_todo_context_command')"/> + <command id="calendar_new_todo_todaypane_command" oncommand="goDoCommand('calendar_new_todo_todaypane_command')"/> + <command id="calendar_modify_todo_command" oncommand="goDoCommand('calendar_modify_todo_command')"/> + <command id="calendar_modify_todo_todaypane_command" oncommand="goDoCommand('calendar_modify_todo_todaypane_command')"/> + <command id="calendar_delete_todo_command" oncommand="goDoCommand('calendar_delete_todo_command')"/> + + <command id="calendar_modify_focused_item_command" oncommand="goDoCommand('calendar_modify_focused_item_command')"/> + <command id="calendar_delete_focused_item_command" oncommand="goDoCommand('calendar_delete_focused_item_command')"/> + + <command id="calendar_import_command" oncommand="goDoCommand('calendar_import_command')"/> + <command id="calendar_export_command" oncommand="goDoCommand('calendar_export_command')"/> + <command id="calendar_export_selection_command" oncommand="goDoCommand('calendar_export_selection_command')"/> + + <command id="calendar_publish_selected_calendar_command" oncommand="goDoCommand('calendar_publish_selected_calendar_command')"/> + <command id="calendar_publish_calendar_command" oncommand="goDoCommand('calendar_publish_calendar_command')"/> + <command id="calendar_publish_selected_events_command" oncommand="goDoCommand('calendar_publish_selected_events_command')"/> + + <command id="calendar_reload_remote_calendars" oncommand="goDoCommand('calendar_reload_remote_calendars')"/> + + <command id="calendar_show_unifinder_command" oncommand="goDoCommand('calendar_show_unifinder_command')"/> + <!-- The dash instead of the underscore is intended. the 'xxx-view' part should be the id of the view in the deck --> + <command id="calendar_day-view_command" oncommand="goDoCommand('calendar_day-view_command')"/> + <command id="calendar_week-view_command" oncommand="goDoCommand('calendar_week-view_command')"/> + <command id="calendar_multiweek-view_command" oncommand="goDoCommand('calendar_multiweek-view_command')"/> + <command id="calendar_month-view_command" oncommand="goDoCommand('calendar_month-view_command')"/> + <command id="calendar_task_category_command"/> + <command id="calendar_toggle_completed_command" oncommand="toggleCompleted(event)"/> + <command id="calendar_percentComplete-0_command" oncommand="contextChangeTaskProgress(0)"/> + <command id="calendar_percentComplete-25_command" oncommand="contextChangeTaskProgress(25)"/> + <command id="calendar_percentComplete-50_command" oncommand="contextChangeTaskProgress(50)"/> + <command id="calendar_percentComplete-75_command" oncommand="contextChangeTaskProgress(75)"/> + <command id="calendar_percentComplete-100_command" oncommand="contextChangeTaskProgress(100)"/> + <command id="calendar_priority-0_command" oncommand="contextChangeTaskPriority(0)"/> + <command id="calendar_priority-9_command" oncommand="contextChangeTaskPriority(9)"/> + <command id="calendar_priority-5_command" oncommand="contextChangeTaskPriority(5)"/> + <command id="calendar_priority-1_command" oncommand="contextChangeTaskPriority(1)"/> + <command id="calendar_general-priority_command" oncommand="goDoCommand('calendar_general-priority_command')"/> + <command id="calendar_general-progress_command" oncommand="goDoCommand('calendar_general-progress_command')"/> + <command id="calendar_general-postpone_command"/> + <command id="calendar_postpone-1hour_command" oncommand="contextPostponeTask('PT1H')"/> + <command id="calendar_postpone-1day_command" oncommand="contextPostponeTask('P1D')"/> + <command id="calendar_postpone-1week_command" oncommand="contextPostponeTask('P1W')"/> + <command id="calendar_toggle_orientation_command" persist="checked" oncommand="goDoCommand('calendar_toggle_orientation_command')"/> + <command id="calendar_toggle_workdays_only_command" persist="checked" oncommand="goDoCommand('calendar_toggle_workdays_only_command')"/> + <command id="calendar_toggle_tasks_in_view_command" persist="checked" oncommand="toggleTasksInView()"/> + <command id="calendar_toggle_show_completed_in_view_command" persist="checked" oncommand="toggleShowCompletedInView()"/> + <command id="calendar_toggle_calendarsidebar_command" oncommand="togglePaneSplitter('calsidebar_splitter')"/> + <command id="calendar_toggle_minimonthpane_command" oncommand="document.getElementById('minimonth-pane').togglePane(event)"/> + <command id="calendar_toggle_calendarlist_command" oncommand="document.getElementById('calendar-list-pane').togglePane(event)"/> + <command id="calendar_task_filter_command" oncommand="taskViewUpdate(event.target.getAttribute('value'))"/> + <command id="calendar_toggle_filter_command" oncommand="document.getElementById('task-filter-pane').togglePane(event)"/> + <command id="calendar_view_next_command" oncommand="goDoCommand('calendar_view_next_command')"/> + <command id="calendar_view_today_command" oncommand="currentView().moveView()"/> + <command id="calendar_view_prev_command" oncommand="goDoCommand('calendar_view_prev_command')"/> + + <!-- this is a pseudo-command that is disabled when in calendar mode --> + <command id="calendar_in_foreground"/> + <!-- this is a pseudo-command that is disabled when not in calendar mode --> + <command id="calendar_in_background"/> + + <!-- These commands are enabled when in calendar or task mode, respectively --> + <command id="calendar_mode_calendar"/> + <command id="calendar_mode_task"/> + + <command id="calendar_attendance_command"/> + + <command id="calendar_toggle_todaypane_command" oncommand="TodayPane.toggleVisibility(event)"/> +</commandset> diff --git a/comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml b/comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml new file mode 100644 index 0000000000..f408dcca84 --- /dev/null +++ b/comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml @@ -0,0 +1,949 @@ +# 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/. + +<popupset id="calendar-popupset"> + <!-- Tooltips --> + <tooltip id="eventTreeTooltip" + onpopupshowing="return showToolTip(this, unifinderTreeView.getItemFromEvent(event))" + noautohide="true"/> + + <tooltip id="taskTreeTooltip" + onpopupshowing="return showToolTip(this, getTaskTree().getTaskFromEvent(event))" + noautohide="true"/> + + <tooltip id="itemTooltip" + noautohide="true"/> + + <menupopup id="agenda-menupopup"> + <menuitem label="&calendar.context.modifyorviewitem.label;" + accesskey="&calendar.context.modifyorviewitem.accesskey;" + command="agenda_edit_event_command"/> + <menu id="agenda-context-menu-convert-menu" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.calendar;"> + <menupopup id="agenda-context-menu-convert-menupopup"> + <menuitem id="agenda-context-menu-convert-message-menuitem" + label="&calendar.context.convertmenu.message.label;" + accesskey="&calendar.context.convertmenu.message.accesskey;" + oncommand="calendarMailButtonDNDObserver.onDropItems([TodayPane.agenda.selectedItem])"/> + <menuitem id="agenda-context-menu-convert-task-menuitem" + class="event-only" + label="&calendar.context.convertmenu.task.label;" + accesskey="&calendar.context.convertmenu.task.accesskey;" + oncommand="calendarTaskButtonDNDObserver.onDropItems([TodayPane.agenda.selectedItem])"/> + </menupopup> + </menu> + <menuseparator id="calendar-today-pane-menuseparator-before-delete"/> + <menuitem label="&calendar.context.deleteevent.label;" + accesskey="&calendar.context.deleteevent.accesskey;" + key="calendar-delete-item-key" + command="agenda_delete_event_command"/> + <menu id="calendar-today-pane-menu-attendance-menu" + label="&calendar.context.attendance.menu.label;" + accesskey="&calendar.context.attendance.menu.accesskey;" + oncommand="setContextPartstat(event.target, [TodayPane.agenda.selectedItem])" + observes="calendar_attendance_command"> + <menupopup id="agenda-context-menu-attendance-menupopup"> + <label id="agenda-context-attendance-thisoccurrence-label" + class="calendar-context-heading-label" + scope="this-occurrence" + value="&calendar.context.attendance.occurrence.label;"/> + <menu id="agenda-context-menu-attendance-accepted-menu" + label="&calendar.context.attendance.occ.accepted.label;" + accesskey="&calendar.context.attendance.occ.accepted.accesskey;" + value="ACCEPTED" + name="agenda-context-attendance" + scope="this-occurrence"> + <menupopup id="agenda-context-menu-occurrence-accepted-menupopup"> + <menuitem id="agenda-context-menu-attend-accept-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="ACCEPTED" + respmode="AUTO"/> + <menuitem id="agenda-context-menu-attend-accept-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="ACCEPTED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="agenda-context-menu-attendance-tentative-menu" + label="&calendar.context.attendance.occ.tentative.label;" + accesskey="&calendar.context.attendance.occ.tentative.accesskey;" + value="TENTATIVE" + name="agenda-context-attendance" + scope="this-occurrence"> + <menupopup id="agenda-context-menu-occurrence-tentative-menupopup"> + <menuitem id="agenda-context-menu-attend-tentative-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="TENTATIVE" + respmode="AUTO"/> + <menuitem id="agenda-context-menu-attend-tentative-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="TENTATIVE" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="agenda-context-menu-attendance-declined-menu" + label="&calendar.context.attendance.occ.declined.label;" + accesskey="&calendar.context.attendance.occ.declined.accesskey;" + value="DECLINED" + name="agenda-context-attendance" + scope="this-occurrence"> + <menupopup id="agenda-context-menu-occurrence-tentative-menupopup"> + <menuitem id="agenda-context-menu-attend-declined-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="DECLINED" + respmode="AUTO"/> + <menuitem id="agenda-context-menu-attend-declined-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="DECLINED" + respmode="NONE"/> + </menupopup> + </menu> + <menuitem id="agenda-context-menu-attendance-delegated-menu" + label="&calendar.context.attendance.occ.delegated.label;" + name="agenda-context-attendance" + scope="this-occurrence" + value="DELEGATED"/> + <menuitem id="agenda-context-menu-attendance-needsaction-menu" + label="&calendar.context.attendance.occ.needsaction.label;" + name="agenda-context-attendance" + scope="this-occurrence" + value="NEEDS-ACTION"/> + <label id="agenda-context-attendance-alloccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.all2.label;"/> + <menu id="agenda-context-menu-attendance-accepted-all-menu" + label="&calendar.context.attendance.all.accepted.label;" + accesskey="&calendar.context.attendance.all.accepted.accesskey;" + value="ACCEPTED" + name="agenda-context-attendance-all" + scope="all-occurrences"> + <menupopup id="agenda-context-menu-alloccurrences-accept-menupopup"> + <menuitem id="agenda-context-menu-attend-accept-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="ACCEPTED" + respmode="AUTO"/> + <menuitem id="agenda-context-menu-attend-accept-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="ACCEPTED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="agenda-context-menu-attendance-tentative-all-menu" + label="&calendar.context.attendance.all.tentative.label;" + accesskey="&calendar.context.attendance.all.tentative.accesskey;" + value="TENTATIVE" + name="agenda-context-attendance-all" + scope="all-occurrences"> + <menupopup id="agenda-context-menu-alloccurrences-tentative-menupopup"> + <menuitem id="agenda-context-menu-attend-tentative-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="TENTATIVE" + respmode="AUTO"/> + <menuitem id="agenda-context-menu-attend-tentative-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="TENTATIVE" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="agenda-context-menu-attendance-decline-all-menu" + label="&calendar.context.attendance.all.declined.label;" + accesskey="&calendar.context.attendance.all.declined.accesskey;" + value="DECLINED" + name="agenda-context-attendance-all" + scope="all-occurrences"> + <menupopup id="agenda-context-menu-alloccurrences-decline-menupopup"> + <menuitem id="agenda-context-menu-attend-declined-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="DECLINED" + respmode="AUTO"/> + <menuitem id="agenda-context-menu-attend-declined-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="DECLINED" + respmode="NONE"/> + </menupopup> + </menu> + <menuitem id="agenda-context-menu-attendance-delegated-all-menu" + label="&calendar.context.attendance.all.delegated.label;" + name="agenda-context-attendance-delegated-all" + scope="all-occurrences" + value="DELEGATED"/> + <menuitem id="agenda-context-menu-attendance-needsaction-all-menu" + label="&calendar.context.attendance.all.needsaction.label;" + name="agenda-context-attendance-needaction-all" + scope="all-occurrences" + value="NEEDS-ACTION"/> + </menupopup> + </menu> + </menupopup> + + <!-- CALENDAR LIST CONTEXT MENU --> + <menupopup id="list-calendars-context-menu"> + <menuitem id="list-calendars-context-togglevisible" + class="needs-calendar" + accesskeyshow="&calendar.context.showcalendar.accesskey;" + accesskeyhide="&calendar.context.hidecalendar.accesskey;" + oncommand="toggleCalendarVisible(document.getElementById('list-calendars-context-menu').contextCalendar);"/> + <menuitem id="list-calendars-context-showonly" + class="needs-calendar" + accesskey="&calendar.context.showonly.accesskey;" + oncommand="showOnlyCalendar(document.getElementById('list-calendars-context-menu').contextCalendar);"/> + <menuitem id="list-calendars-context-showall" + label="&calendar.context.showall.label;" + accesskey="&calendar.context.showall.accesskey;" + oncommand="showAllCalendars();"/> + <menuseparator id="list-calendars-context-showops-menuseparator"/> + <menuitem id="list-calendars-context-new" + label="&calendar.context.newserver.label;" + accesskey="&calendar.context.newserver.accesskey;" + command="calendar_new_calendar_command"/> + <menuitem id="list-calendars-context-delete" + class="needs-calendar" + labeldelete="&calendar.context.deleteserver2.label;" + labelremove="&calendar.context.removeserver.label;" + labelunsubscribe="&calendar.context.unsubscribeserver.label;" + accesskeydelete="&calendar.context.deleteserver2.accesskey;" + accesskeyremove="&calendar.context.removeserver.accesskey;" + accesskeyunsubscribe="&calendar.context.unsubscribeserver.accesskey;" + command="calendar_delete_calendar_command"/> + <menuseparator id="list-calendars-context-itemops-menuseparator" + class="needs-calendar"/> + <menuitem id="list-calendars-context-export" + class="needs-calendar" + label="&calendar.context.export.label;" + accesskey="&calendar.context.export.accesskey;" + oncommand="exportEntireCalendar(document.getElementById('list-calendars-context-menu').contextCalendar);"/> + <menuitem id="list-calendars-context-publish" + class="needs-calendar" + label="&calendar.context.publish.label;" + accesskey="&calendar.context.publish.accesskey;" + command="calendar_publish_selected_calendar_command"/> + <menuseparator id="list-calendars-context-export-menuseparator" + class="needs-calendar"/> + <menuitem id="list-calendar-context-reload" + class="needs-calendar" + data-l10n-id="list-calendar-context-reload-menuitem" + oncommand="document.getElementById('list-calendars-context-menu').contextCalendar.refresh();"/> + <menuseparator id="list-calendars-context-reload-menuseparator" + class="needs-calendar"/> + <menuitem id="list-calendars-context-edit" + class="needs-calendar" + label="&calendar.context.properties.label;" + accesskey="&calendar.context.properties.accesskey;" + command="calendar_edit_calendar_command"/> + </menupopup> + + <!-- CALENDAR ITEM CONTEXT MENU --> + <menupopup id="calendar-item-context-menu" + onpopupshowing="return setupContextItemType(event, currentView().getSelectedItems());"> + <menuitem id="calendar-item-context-menu-view-menuitem" + label="&calendar.context.modifyorviewitem.label;" + accesskey="&calendar.context.modifyorviewitem.accesskey;" + command="calendar_view_event_command"/> + <menuitem id="calendar-item-context-menu-modify-menuitem" + data-l10n-id="calendar-item-context-menu-modify-menuitem" + command="calendar_modify_event_command" + disabled="true" /> + <menuitem id="calendar-item-context-menu-newevent-menutitem" + label="&calendar.context.newevent.label;" + accesskey="&calendar.context.newevent.accesskey;" + key="calendar-new-event-key" + command="calendar_new_event_context_command"/> + <menuitem id="calendar-item-context-menu-newtodo-menuitem" + label="&calendar.context.newtodo.label;" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_context_command"/> + <menuseparator id="calendar-item-context-menuseparator-adddeletemodify"/> + <menuitem id="calendar-item-context-menu-cut-menuitem" + label="&calendar.context.cutevent.label;" + accesskey="&calendar.context.cutevent.accesskey;" + key="key_cut" + command="cmd_cut"/> + <menuitem id="calendar-item-context-menu-copy-menuitem" + label="&calendar.context.copyevent.label;" + accesskey="&calendar.context.copyevent.accesskey;" + key="key_copy" + command="cmd_copy"/> + <menuitem id="calendar-item-context-menu-paste-menuitem" + label="&calendar.context.pasteevent.label;" + accesskey="&calendar.context.pasteevent.accesskey;" + key="key_paste" + command="cmd_paste"/> + <menuseparator id="calendar-item-context-separator-cutcopypaste"/> + <menu id="calendar-item-context-menu-convert-menu" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.calendar;"> + <menupopup id="calendar-item-context-menu-convert-menupopup"> + <menuitem id="calendar-view-context-menu-convert-message-menuitem" + label="&calendar.context.convertmenu.message.label;" + accesskey="&calendar.context.convertmenu.message.accesskey;" + oncommand="calendarMailButtonDNDObserver.onDropItems(currentView().getSelectedItems())"/> + <menuitem id="calendar-item-context-menu-convert-event-menuitem" + class="todo-only" + label="&calendar.context.convertmenu.event.label;" + accesskey="&calendar.context.convertmenu.event.accesskey;" + oncommand="calendarCalendarButtonDNDObserver.onDropItems(currentView().getSelectedItems())"/> + <menuitem id="calendar-item-context-menu-convert-task-menuitem" + class="event-only" + label="&calendar.context.convertmenu.task.label;" + accesskey="&calendar.context.convertmenu.task.accesskey;" + oncommand="calendarTaskButtonDNDObserver.onDropItems(currentView().getSelectedItems())"/> + </menupopup> + </menu> + <menuseparator id="calendar-menuseparator-before-delete"/> + <!-- the label and accesskey of the following menuitem is set during runtime, + and depends on whether the item is a task or an event --> + <menuitem id="calendar-item-context-menu-delete-menuitem" + key="calendar-delete-item-key" + command="calendar_delete_event_command"/> + <menu id="calendar-item-context-menu-attendance-menu" + label="&calendar.context.attendance.menu.label;" + accesskey="&calendar.context.attendance.menu.accesskey;" + oncommand="setContextPartstat(event.target, currentView().getSelectedItems())" + observes="calendar_attendance_command"> + <menupopup id="calendar-item-context-menu-attendance-menupopup"> + <label id="calendar-item-context-attendance-thisoccurrence-label" + class="calendar-context-heading-label" + scope="this-occurrence" + value="&calendar.context.attendance.occurrence.label;"/> + <menu id="calendar-item-context-menu-attendance-accepted-menu" + label="&calendar.context.attendance.occ.accepted.label;" + accesskey="&calendar.context.attendance.occ.accepted.accesskey;" + value="ACCEPTED" + name="calendar-item-context-attendance" + scope="this-occurrence"> + <menupopup id="calendar-item-context-menu-occurrence-accepted-menupopup"> + <menuitem id="calendar-item-context-menu-attend-accept-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="ACCEPTED" + respmode="AUTO"/> + <menuitem id="calendar-item-context-menu-attend-accept-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="ACCEPTED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="calendar-item-context-menu-attendance-tentative-menu" + label="&calendar.context.attendance.occ.tentative.label;" + accesskey="&calendar.context.attendance.occ.tentative.accesskey;" + value="TENTATIVE" + name="calendar-item-context-attendance" + scope="this-occurrence"> + <menupopup id="calendar-item-context-menu-occurrence-tentative-menupopup"> + <menuitem id="calendar-item-context-menu-attend-tentative-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="TENTATIVE" + respmode="AUTO"/> + <menuitem id="calendar-item-context-menu-attend-tentative-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="TENTATIVE" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="calendar-item-context-menu-attendance-declined-menu" + label="&calendar.context.attendance.occ.declined.label;" + accesskey="&calendar.context.attendance.occ.declined.accesskey;" + value="DECLINED" + name="calendar-item-context-attendance" + scope="this-occurrence"> + <menupopup id="calendar-item-context-menu-occurrence-tentative-menupopup"> + <menuitem id="calendar-item-context-menu-attend-declined-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="DECLINED" + respmode="AUTO"/> + <menuitem id="calendar-item-context-menu-attend-declined-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="DECLINED" + respmode="NONE"/> + </menupopup> + </menu> + <menuitem id="calendar-item-context-menu-attendance-delegated-menu" + label="&calendar.context.attendance.occ.delegated.label;" + name="calendar-item-context-attendance" + scope="this-occurrence" + value="DELEGATED"/> + <menuitem id="calendar-item-context-menu-attendance-needsaction-menu" + label="&calendar.context.attendance.occ.needsaction.label;" + name="calendar-item-context-attendance" + scope="this-occurrence" + value="NEEDS-ACTION"/> + <label id="calendar-item-context-attendance-alloccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.all2.label;"/> + <menu id="calendar-item-context-menu-attendance-accepted-all-menu" + label="&calendar.context.attendance.all.accepted.label;" + accesskey="&calendar.context.attendance.all.accepted.accesskey;" + value="ACCEPTED" + name="calendar-item-context-attendance-all" + scope="all-occurrences"> + <menupopup id="calendar-item-context-menu-alloccurrences-accept-menupopup"> + <menuitem id="calendar-item-context-menu-attend-accept-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="ACCEPTED" + respmode="AUTO"/> + <menuitem id="calendar-item-context-menu-attend-accept-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="ACCEPTED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="calendar-item-context-menu-attendance-tentative-all-menu" + label="&calendar.context.attendance.all.tentative.label;" + accesskey="&calendar.context.attendance.all.tentative.accesskey;" + value="TENTATIVE" + name="calendar-item-context-attendance-all" + scope="all-occurrences"> + <menupopup id="calendar-item-context-menu-alloccurrences-tentative-menupopup"> + <menuitem id="calendar-item-context-menu-attend-tentative-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="TENTATIVE" + respmode="AUTO"/> + <menuitem id="calendar-item-context-menu-attend-tentative-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="TENTATIVE" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="calendar-item-context-menu-attendance-decline-all-menu" + label="&calendar.context.attendance.all.declined.label;" + accesskey="&calendar.context.attendance.all.declined.accesskey;" + value="DECLINED" + name="calendar-item-context-attendance-all" + scope="all-occurrences"> + <menupopup id="calendar-item-context-menu-alloccurrences-decline-menupopup"> + <menuitem id="calendar-item-context-menu-attend-declined-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="DECLINED" + respmode="AUTO"/> + <menuitem id="calendar-item-context-menu-attend-declined-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="DECLINED" + respmode="NONE"/> + </menupopup> + </menu> + <menuitem id="calendar-item-context-menu-attendance-delegated-all-menu" + label="&calendar.context.attendance.all.delegated.label;" + name="calendar-item-context-attendance-delegated-all" + scope="all-occurrences" + value="DELEGATED"/> + <menuitem id="calendar-item-context-menu-attendance-needsaction-all-menu" + label="&calendar.context.attendance.all.needsaction.label;" + name="calendar-item-context-attendance-needaction-all" + scope="all-occurrences" + value="NEEDS-ACTION"/> + </menupopup> + </menu> + </menupopup> + + <!-- CALENDAR VIEW CONTEXT MENU --> + <menupopup id="calendar-view-context-menu"> + <menuitem id="calendar-view-context-menu-newevent" + label="&calendar.context.newevent.label;" + command="calendar_new_event_context_command" + accesskey="&calendar.context.newevent.accesskey;" + key="calendar-new-event-key"/> + <menuitem id="calendar-view-context-menu-newtodo" + label="&calendar.context.newtodo.label;" + command="calendar_new_todo_context_command" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key"/> + <!-- These labels are set dynamically, based on the current view --> + <menuitem id="calendar-view-context-menu-previous" + command="calendar_view_prev_command"/> + <menuitem id="calendar-view-context-menu-next" + command="calendar_view_next_command"/> + <menuseparator id="calendar-item-context-separator-cutcopypaste"/> + <!-- Cut and copy doesn't make sense in the views, but only showing paste + makes it look like something is missing. Disable by default. --> + <menuitem id="calendar-view-context-menu-cut-menuitem" + label="&calendar.context.cutevent.label;" + accesskey="&calendar.context.cutevent.accesskey;" + key="key_cut" + disabled="true"/> + <menuitem id="calendar-view-context-menu-copy-menuitem" + label="&calendar.context.copyevent.label;" + accesskey="&calendar.context.copyevent.accesskey;" + key="key_copy" + disabled="true"/> + <menuitem id="calendar-view-context-menu-paste-menuitem" + label="&calendar.context.pasteevent.label;" + accesskey="&calendar.context.pasteevent.accesskey;" + key="key_paste" + command="cmd_paste"/> + </menupopup> + + <!-- TASK ITEM CONTEXT MENU --> + <menupopup id="taskitem-context-menu" + onpopupshowing="changeContextMenuForTask(event);" + onpopuphiding="handleTaskContextMenuStateChange(event);"> + <menuitem id="task-context-menu-modify" + label="&calendar.context.modifyorviewtask.label;" + accesskey="&calendar.context.modifyorviewtask.accesskey;" + command="calendar_modify_todo_command"/> + <menuitem id="task-context-menu-modify-todaypane" + label="&calendar.context.modifyorviewtask.label;" + accesskey="&calendar.context.modifyorviewtask.accesskey;" + command="calendar_modify_todo_todaypane_command"/> + <menuitem id="task-context-menu-new" + label="&calendar.context.newtodo.label;" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_command"/> + <menuitem id="task-context-menu-new-todaypane" + label="&calendar.context.newtodo.label;" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_todaypane_command"/> + <menuseparator id="task-context-menuseparator-cutcopypaste"/> + <menuitem id="task-context-menu-cut-menuitem" + label="&calendar.context.cutevent.label;" + accesskey="&calendar.context.cutevent.accesskey;" + key="key_cut" + command="cmd_cut"/> + <menuitem id="task-context-menu-copy-menuitem" + label="&calendar.context.copyevent.label;" + accesskey="&calendar.context.copyevent.accesskey;" + key="key_copy" + command="cmd_copy"/> + <menuitem id="task-context-menu-paste-menuitem" + label="&calendar.context.pasteevent.label;" + accesskey="&calendar.context.pasteevent.accesskey;" + key="key_paste" + command="cmd_paste"/> + <menuseparator id="calendar-menuseparator-beforemarkcompleted"/> + <menuitem id="calendar-context-markcompleted" + type="checkbox" + autocheck="false" + label="&calendar.context.markcompleted.label;" + accesskey="&calendar.context.markcompleted.accesskey;" + command="calendar_toggle_completed_command"/> + <menu id="task-context-menu-progress" + label="&calendar.context.progress.label;" + accesskey="&calendar.context.progress.accesskey;" + command="calendar_general-progress_command"> + <menupopup is="calendar-task-progress-menupopup"/> + </menu> + <menu id="task-context-menu-priority" + label="&calendar.context.priority.label;" + accesskey="&calendar.context.priority.accesskey;" + command="calendar_general-priority_command"> + <menupopup is="calendar-task-priority-menupopup"/> + </menu> + <menu id="task-context-menu-postpone" + label="&calendar.context.postpone.label;" + accesskey="&calendar.context.postpone.accesskey;" + command="calendar_general-postpone_command"> + <menupopup id="task-context-postpone-menupopup"> + <menuitem id="task-context-postpone-1hour" + label="&calendar.context.postpone.1hour.label;" + accesskey="&calendar.context.postpone.1hour.accesskey;" + command="calendar_postpone-1hour_command"/> + <menuitem id="task-context-postpone-1day" + label="&calendar.context.postpone.1day.label;" + accesskey="&calendar.context.postpone.1day.accesskey;" + command="calendar_postpone-1day_command"/> + <menuitem id="task-context-postpone-1week" + label="&calendar.context.postpone.1week.label;" + accesskey="&calendar.context.postpone.1week.accesskey;" + command="calendar_postpone-1week_command"/> + </menupopup> + </menu> + <menu id="calendar-context-calendar-menu" + label="&calendar.calendar.label;" + accesskey="&calendar.calendar.accesskey;"> + <menupopup id="calendar-context-calendar-menupopup" + onpopupshowing="addCalendarNames(event);"/> + </menu> + <menuseparator id="task-context-menu-separator-conversion"/> + <menu id="task-context-menu-convert" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.calendar;"> + <menupopup id="task-context-convert-menupopup"> + <menuitem id="calendar-context-converttomessage" + label="&calendar.context.convertmenu.message.label;" + accesskey="&calendar.context.convertmenu.message.accesskey;" + oncommand="tasksToMail()"/> + <menuitem id="calendar-context-converttoevent" + label="&calendar.context.convertmenu.event.label;" + accesskey="&calendar.context.convertmenu.event.accesskey;" + oncommand="tasksToEvents()"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="task-context-menu-delete" + label="&calendar.context.deletetask.label;" + accesskey="&calendar.context.deletetask.accesskey;" + command="calendar_delete_todo_command"/> + <menu id="task-context-menu-attendance-menu" + label="&calendar.context.attendance.menu.label;" + accesskey="&calendar.context.attendance.menu.accesskey;" + oncommand="setContextPartstat(event.target, getSelectedTasks())" + observes="calendar_attendance_command"> + <menupopup id="task-context-menu-attendance-menupopup"> + <label id="task-context-attendance-thisoccurrence-label" + class="calendar-context-heading-label" + scope="this-occurrence" + value="&calendar.context.attendance.occurrence.label;"/> + <menu id="task-context-menu-attendance-accepted-menu" + label="&calendar.context.attendance.occ.accepted.label;" + accesskey="&calendar.context.attendance.occ.accepted.accesskey;" + value="ACCEPTED" + name="task-context-attendance" + scope="this-occurrence"> + <menupopup id="task-context-menu-occurrence-accepted-menupopup"> + <menuitem id="task-context-menu-attend-accept-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="ACCEPTED" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-accept-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="ACCEPTED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-tentative-menu" + label="&calendar.context.attendance.occ.tentative.label;" + accesskey="&calendar.context.attendance.occ.tentative.accesskey;" + value="TENTATIVE" + name="task-context-attendance" + scope="this-occurrence"> + <menupopup id="task-context-menu-occurrence-tentative-menupopup"> + <menuitem id="task-context-menu-attend-tentative-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="TENTATIVE" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-tentative-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="TENTATIVE" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-declined-menu" + label="&calendar.context.attendance.occ.declined.label;" + accesskey="&calendar.context.attendance.occ.declined.accesskey;" + value="DECLINED" + name="task-context-attendance" + scope="this-occurrence"> + <menupopup id="task-context-menu-occurrence-tentative-menupopup"> + <menuitem id="task-context-menu-attend-declined-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="DECLINED" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-declined-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="DECLINED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-inprogress-menu" + label="&calendar.context.attendance.occ.inprogress.label;" + accesskey="&calendar.context.attendance.occ.inprogress.accesskey;" + value="IN-PROGRESS" + name="task-context-attendance" + scope="this-occurrence"> + <menupopup id="task-context-menu-occurrence-inprogress-menupopup"> + <menuitem id="task-context-menu-attend-inprogress-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="IN-PROGRESS" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-inprogress-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="IN-PROGRESS" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-completed-menu" + label="&calendar.context.attendance.occ.completed.label;" + accesskey="&calendar.context.attendance.occ.completed.accesskey;" + value="COMPLETED" + name="task-context-attendance" + scope="this-occurrence"> + <menupopup id="task-context-menu-occurrence-completed-menupopup"> + <menuitem id="task-context-menu-attend-completed-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="COMPLETED" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-completed-dontsend-menuitem" + scope="this-occurrence" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="COMPLETED" + respmode="NONE"/> + </menupopup> + </menu> + <menuitem id="task-context-menu-attendance-delegated-menu" + label="&calendar.context.attendance.occ.delegated.label;" + name="task-context-attendance" + scope="this-occurrence" + value="DELEGATED"/> + <menuitem id="task-context-menu-attendance-needsaction-menu" + label="&calendar.context.attendance.occ.needsaction.label;" + name="task-context-attendance" + scope="this-occurrence" + value="NEEDS-ACTION"/> + <label id="task-context-attendance-alloccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.all2.label;"/> + <menu id="task-context-menu-attendance-accepted-all-menu" + label="&calendar.context.attendance.all.accepted.label;" + accesskey="&calendar.context.attendance.all.accepted.accesskey;" + value="ACCEPTED" + name="task-context-attendance-all" + scope="all-occurrences"> + <menupopup id="task-context-menu-alloccurrences-accept-menupopup"> + <menuitem id="task-context-menu-attend-accept-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="ACCEPTED" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-accept-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="ACCEPTED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-tentative-all-menu" + label="&calendar.context.attendance.all.tentative.label;" + accesskey="&calendar.context.attendance.all.tentative.accesskey;" + value="TENTATIVE" + name="task-context-attendance-all" + scope="all-occurrences"> + <menupopup id="task-context-menu-alloccurrences-tentative-menupopup"> + <menuitem id="task-context-menu-attend-tentative-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="TENTATIVE" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-tentative-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="TENTATIVE" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-decline-all-menu" + label="&calendar.context.attendance.all.declined.label;" + accesskey="&calendar.context.attendance.all.declined.accesskey;" + value="DECLINED" + name="task-context-attendance-all" + scope="all-occurrences"> + <menupopup id="task-context-menu-alloccurrences-decline-menupopup"> + <menuitem id="task-context-menu-attend-declined-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="DECLINED" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-declined-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="DECLINED" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-inprogress-all-menu" + label="&calendar.context.attendance.all.inprogress.label;" + accesskey="&calendar.context.attendance.all.inprogress.accesskey;" + value="IN-PROGRESS" + name="task-context-attendance-all" + scope="all-occurrences"> + <menupopup id="task-context-menu-alloccurrences-inprogress-menupopup"> + <menuitem id="task-context-menu-attend-inprogress-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="IN-PROGRESS" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-inprogress-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="IN-PROGRESS" + respmode="NONE"/> + </menupopup> + </menu> + <menu id="task-context-menu-attendance-completed-all-menu" + label="&calendar.context.attendance.all.completed.label;" + accesskey="&calendar.context.attendance.all.completed.accesskey;" + value="COMPLETED" + name="task-context-attendance-all" + scope="all-occurrences"> + <menupopup id="task-context-menu-alloccurrences-completed-menupopup"> + <menuitem id="task-context-menu-attend-completed-all-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.send.label;" + accesskey="&calendar.context.attendance.send.accesskey;" + respvalue="COMPLETED" + respmode="AUTO"/> + <menuitem id="task-context-menu-attend-completed-all-dontsend-menuitem" + scope="all-occurrences" + label="&calendar.context.attendance.dontsend.label;" + accesskey="&calendar.context.attendance.dontsend.accesskey;" + respvalue="COMPLETED" + respmode="NONE"/> + </menupopup> + </menu> + <menuitem id="task-context-menu-attendance-delegated-all-menu" + label="&calendar.context.attendance.all.delegated.label;" + name="task-context-attendance-delegated-all" + scope="all-occurrences" + value="DELEGATED"/> + <menuitem id="task-context-menu-attendance-needsaction-all-menu" + label="&calendar.context.attendance.all.delegated.label;" + name="task-context-attendance-needaction-all" + scope="all-occurrences" + value="NEEDS-ACTION"/> + </menupopup> + </menu> + <menuseparator id="task-context-menu-separator-filter"/> + <menu id="task-context-menu-filter-todaypane" + label="&calendar.tasks.view.filtertasks.label;" + accesskey="&calendar.tasks.view.filtertasks.accesskey;"> + <menupopup id="task-context-menu-filter-todaypane-popup" + oncommand="TodayPane.updateCalendarToDoUnifinder(event.target.getAttribute('value'))"> + <menuitem id="task-context-menu-filter-todaypane-current" + name="filtergrouptodaypane" + value="throughcurrent" + type="radio" + label="&calendar.task.filter.current.label;" + accesskey="&calendar.task.filter.current.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-today" + name="filtergrouptodaypane" + value="throughtoday" + type="radio" + label="&calendar.task.filter.today.label;" + accesskey="&calendar.task.filter.today.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-next7days" + name="filtergrouptodaypane" + value="throughsevendays" + type="radio" + label="&calendar.task.filter.next7days.label;" + accesskey="&calendar.task.filter.next7days.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-notstarted" + name="filtergrouptodaypane" + value="notstarted" + type="radio" + label="&calendar.task.filter.notstarted.label;" + accesskey="&calendar.task.filter.notstarted.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-overdue" + name="filtergrouptodaypane" + value="overdue" + type="radio" + label="&calendar.task.filter.overdue.label;" + accesskey="&calendar.task.filter.overdue.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-completed" + name="filtergrouptodaypane" + type="radio" + value="completed" + label="&calendar.task.filter.completed.label;" + accesskey="&calendar.task.filter.completed.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-open" + name="filtergrouptodaypane" + type="radio" + value="open" + label="&calendar.task.filter.open.label;" + accesskey="&calendar.task.filter.open.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-all" + name="filtergrouptodaypane" + value="all" + type="radio" + label="&calendar.task.filter.all.label;" + accesskey="&calendar.task.filter.all.accesskey;"/> + </menupopup> + </menu> + </menupopup> + + <!-- TASKVIEW LINK CONTEXT MENU --> + <menupopup id="taskview-link-context-menu"> + <menuitem id="taskview-link-context-menu-copy" + label="&calendar.copylink.label;" + accesskey="&calendar.copylink.accesskey;" + oncommand="taskViewCopyLink(this.parentNode.triggerNode)"/> + </menupopup> + + <!-- CALENDAR EVENT DIALOG (IN TAB) TOOLBAR CONTEXT MENU --> + <menupopup id="event-dialog-toolbar-context-menu" + onpopupshowing="calendarOnToolbarsPopupShowing(event);"> + <menuseparator id="customizeEventToolbarMenuSeparator"/> + <menuitem id="CustomizeDialogToolbar" + label="&event.menu.view.toolbars.customize.label;" + command="cmd_customize"/> + </menupopup> +</popupset> diff --git a/comm/calendar/base/content/calendar-day-label.js b/comm/calendar/base/content/calendar-day-label.js new file mode 100644 index 0000000000..e0d205bc5d --- /dev/null +++ b/comm/calendar/base/content/calendar-day-label.js @@ -0,0 +1,128 @@ +/* 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, getSummarizedStyleValues */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + class MozCalendarDayLabel extends MozXULElement { + static get observedAttributes() { + return ["selected", "relation"]; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + this.textContent = ""; + this.setAttribute("flex", "1"); + this.setAttribute("pack", "center"); + + this.longWeekdayName = document.createXULElement("label"); + this.longWeekdayName.classList.add("calendar-day-label-name"); + + this.shortWeekdayName = document.createXULElement("label"); + this.shortWeekdayName.classList.add("calendar-day-label-name"); + this.shortWeekdayName.setAttribute("hidden", "true"); + + this.appendChild(this.longWeekdayName); + this.appendChild(this.shortWeekdayName); + + this.mWeekday = -1; + + this.longWeekdayPixels = 0; + + this.mDate = null; + + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.longWeekdayName || !this.shortWeekdayName) { + return; + } + + if (this.hasAttribute("selected")) { + this.longWeekdayName.setAttribute("selected", this.getAttribute("selected")); + this.shortWeekdayName.setAttribute("selected", this.getAttribute("selected")); + } else { + this.longWeekdayName.removeAttribute("selected"); + this.shortWeekdayName.removeAttribute("selected"); + } + + if (this.hasAttribute("relation")) { + this.longWeekdayName.setAttribute("relation", this.getAttribute("relation")); + this.shortWeekdayName.setAttribute("relation", this.getAttribute("relation")); + } else { + this.longWeekdayName.removeAttribute("relation"); + this.shortWeekdayName.removeAttribute("relation"); + } + } + + set weekDay(val) { + this.mWeekday = val % 7; + this.longWeekdayName.value = cal.dtz.formatter.dayName(val); + this.shortWeekdayName.value = cal.dtz.formatter.shortDayName(val); + } + + get weekDay() { + return this.mWeekday; + } + + set date(val) { + this.mDate = val; + let dateFormatter = cal.dtz.formatter; + let label = cal.l10n.getCalString("dayHeaderLabel", [ + dateFormatter.shortDayName(val.weekday), + dateFormatter.formatDateWithoutYear(val), + ]); + this.shortWeekdayName.setAttribute("value", label); + label = cal.l10n.getCalString("dayHeaderLabel", [ + dateFormatter.dayName(val.weekday), + dateFormatter.formatDateWithoutYear(val), + ]); + this.longWeekdayName.setAttribute("value", label); + } + + get date() { + return this.mDate; + } + + set shortWeekNames(val) { + // cache before change, in case we are switching to short + this.getLongWeekdayPixels(); + this.longWeekdayName.hidden = val; + this.shortWeekdayName.hidden = !val; + } + + getLongWeekdayPixels() { + // Only do this if the long weekdays are visible and we haven't already cached. + let longNameWidth = this.longWeekdayName.getBoundingClientRect().width; + + if (longNameWidth == 0) { + // weekdaypixels have not yet been laid out + return 0; + } + + this.longWeekdayPixels = + longNameWidth + + getSummarizedStyleValues(this.longWeekdayName, ["margin-left", "margin-right"]); + this.longWeekdayPixels += getSummarizedStyleValues(this, [ + "border-left-width", + "padding-left", + "padding-right", + ]); + + return this.longWeekdayPixels; + } + } + + customElements.define("calendar-day-label", MozCalendarDayLabel); +} diff --git a/comm/calendar/base/content/calendar-dnd-listener.js b/comm/calendar/base/content/calendar-dnd-listener.js new file mode 100644 index 0000000000..994d305c95 --- /dev/null +++ b/comm/calendar/base/content/calendar-dnd-listener.js @@ -0,0 +1,922 @@ +/* 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 getSelectedCalendar, MODE_RDONLY, startBatchTransaction, doTransaction, + endBatchTransaction, createEventWithDialog, createTodoWithDialog */ + +/* exported invokeEventDragSession, + * calendarMailButtonDNDObserver, calendarCalendarButtonDNDObserver, + * calendarTaskButtonDNDObserver + */ + +var calendarViewDNDObserver; +var calendarMailButtonDNDObserver; +var calendarCalendarButtonDNDObserver; +var calendarTaskButtonDNDObserver; + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); + var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + + XPCOMUtils.defineLazyModuleGetters(this, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", + }); + + var itemConversion = { + /** + * Converts an email message to a calendar item. + * + * @param {calIItemBase} item - The target calIItemBase. + * @param {nsIMsgDBHdr} message - The nsIMsgDBHdr to convert from. + */ + async calendarItemFromMessage(item, message) { + let folder = message.folder; + let msgUri = folder.getUriForMsg(message); + + item.calendar = getSelectedCalendar(); + item.title = message.mime2DecodedSubject; + item.setProperty("URL", `mid:${message.messageId}`); + + cal.dtz.setDefaultStartEndHour(item); + cal.alarms.setDefaultValues(item); + + let content = ""; + await new Promise((resolve, reject) => { + let streamListener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + onDataAvailable(request, inputStream, offset, count) { + let text = folder.getMsgTextFromStream( + inputStream, + message.charset, + count, // bytesToRead + 32768, // maxOutputLen + false, // compressQuotes + true, // stripHTMLTags + {} // out contentType + ); + // If we ever got text, we're good. Ignore further chunks. + content ||= text; + }, + onStartRequest(request) {}, + onStopRequest(request, statusCode) { + if (!Components.isSuccessCode(statusCode)) { + reject(new Error(statusCode)); + } + resolve(); + }, + }; + MailServices.messageServiceFromURI(msgUri).streamMessage( + msgUri, + streamListener, + null, + null, + false, + "", + false + ); + }); + item.setProperty("DESCRIPTION", content); + }, + + /** + * Copy base item properties from aItem to aTarget. This includes properties + * like title, location, description, priority, transparency, attendees, + * categories, calendar, recurrence and possibly more. + * + * @param {object} aItem - The item to copy from. + * @param {object} aTarget - The item to copy to. + */ + copyItemBase(aItem, aTarget) { + const copyProps = ["SUMMARY", "LOCATION", "DESCRIPTION", "URL", "CLASS", "PRIORITY"]; + + for (let prop of copyProps) { + aTarget.setProperty(prop, aItem.getProperty(prop)); + } + + // Attendees + let attendees = aItem.getAttendees(); + for (let attendee of attendees) { + aTarget.addAttendee(attendee.clone()); + } + + // Categories + let categories = aItem.getCategories(); + aTarget.setCategories(categories); + + // Organizer + aTarget.organizer = aItem.organizer ? aItem.organizer.clone() : null; + + // Calendar + aTarget.calendar = getSelectedCalendar(); + + // Recurrence + if (aItem.recurrenceInfo) { + aTarget.recurrenceInfo = aItem.recurrenceInfo.clone(); + aTarget.recurrenceInfo.item = aTarget; + } + }, + + /** + * Creates a task from the passed event. This function copies the base item + * and a few event specific properties (dates, alarms, ...). + * + * @param {object} aEvent - The event to copy from. + * @returns {object} The resulting task. + */ + taskFromEvent(aEvent) { + let item = new CalTodo(); + + this.copyItemBase(aEvent, item); + + // Dates and alarms + if (!aEvent.startDate.isDate && !aEvent.endDate.isDate) { + // Dates + item.entryDate = aEvent.startDate.clone(); + item.dueDate = aEvent.endDate.clone(); + + // Alarms + for (let alarm of aEvent.getAlarms()) { + item.addAlarm(alarm.clone()); + } + item.alarmLastAck = aEvent.alarmLastAck ? aEvent.alarmLastAck.clone() : null; + } + + // Map Status values + let statusMap = { + TENTATIVE: "NEEDS-ACTION", + CONFIRMED: "IN-PROCESS", + CANCELLED: "CANCELLED", + }; + if (aEvent.getProperty("STATUS") in statusMap) { + item.setProperty("STATUS", statusMap[aEvent.getProperty("STATUS")]); + } + return item; + }, + + /** + * Creates an event from the passed task. This function copies the base item + * and a few task specific properties (dates, alarms, ...). If the task has + * no due date, the default event length is used. + * + * @param {object} aTask - The task to copy from. + * @returns {object} The resulting event. + */ + eventFromTask(aTask) { + let item = new CalEvent(); + + this.copyItemBase(aTask, item); + + // Dates and alarms + item.startDate = aTask.entryDate; + if (!item.startDate) { + if (aTask.dueDate) { + item.startDate = aTask.dueDate.clone(); + item.startDate.minute -= Services.prefs.getIntPref("calendar.event.defaultlength", 60); + } else { + item.startDate = cal.dtz.getDefaultStartDate(); + } + } + + item.endDate = aTask.dueDate; + if (!item.endDate) { + // Make the event be the default event length if no due date was + // specified. + item.endDate = item.startDate.clone(); + item.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60); + } + + // Alarms + for (let alarm of aTask.getAlarms()) { + item.addAlarm(alarm.clone()); + } + item.alarmLastAck = aTask.alarmLastAck ? aTask.alarmLastAck.clone() : null; + + // Map Status values + let statusMap = { + "NEEDS-ACTION": "TENTATIVE", + COMPLETED: "CONFIRMED", + "IN-PROCESS": "CONFIRMED", + CANCELLED: "CANCELLED", + }; + if (aTask.getProperty("STATUS") in statusMap) { + item.setProperty("STATUS", statusMap[aTask.getProperty("STATUS")]); + } + return item; + }, + }; + + /** + * CalDNDTransferHandler provides a base class for handling drag and drop data + * transfers based on detected mime types. Actual processing of the dropped + * data is left up to CalDNDListener however children of this class mostly + * do some preprocessing first. + * + * The main methods here are the handleDataTransferItem() and handleString() + * methods that initiate transfer from a DataTransferItem or string + * respectively. Whether the data is passed as a DataTransferItem or string + * mostly depends on whether dropped from an external application or + * internally. + * + * @abstract + */ + class CalDNDTransferHandler { + /** + * List of mime types this class handles (Overridden by child class). + * + * @type {string[]} + */ + mimeTypes = []; + + /** + * @param {CalDNDListener} listener - The listener that received the + * original drop event. Most CalDNDTransferHandlers will invoke a method on + * this class once data has been processed. + */ + constructor(listener) { + this.listener = listener; + } + + /** + * Returns true if the handler is able to process any of the given mime types. + * + * @param {string|string[]} mime - The mime type to handle. + * + * @returns {boolean} + */ + willTransfer(mime) { + return Array.isArray(mime) + ? this.mimeTypes.find(type => mime.includes(type)) + : this.mimeTypes.includes(mime); + } + + /** + * Selects the most appropriate type from a list to use with mozGetDataAt(). + * + * @param {string[]} types + * + * @returns {string?} + */ + getMozType(types) { + return types.find(type => this.mimeTypes.includes(type)); + } + + /** + * Overridden by child classes that handle DataTransferItems. By default, no + * processing is done. + * + * @param {DataTransferItem} item + */ + async handleDataTransferItem(item) {} + + /** + * Overridden by child classes that handle string data. By default, no + * processing is done. + * + * @param {string} data + */ + async handleString() {} + } + + /** + * CalDNDMozMessageTransferHandler handles messages dropped from the + * message pane. + */ + class CalDNDMozMessageTransferHandler extends CalDNDTransferHandler { + mimeTypes = ["text/x-moz-message"]; + + /** + * Treats the provided data as a message uri. Invokes the listener's + * onMessageDrop() method with the corresponding message header. + * + * @param {string} data + */ + async handleString(data) { + let messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger); + this.listener.onDropMessage(messenger.msgHdrFromURI(data)); + } + } + + /** + * CalDNDAddressTransferHandler handles address book data internally dropped. + */ + class CalDNDAddressTransferHandler extends CalDNDTransferHandler { + mimeTypes = ["text/x-moz-address"]; + + /** + * Invokes the listener's onDropAddress() method. + * + * @param {string} data + */ + async handleString(data) { + this.listener.onDropAddress(data); + } + } + + /** + * CalDNDDefaultTransferHandler serves as a "catch all" and should be included + * last in the list of handlers. + */ + class CalDNDDefaultTransferHandler extends CalDNDTransferHandler { + willTransfer() { + return true; + } + + /** + * If the dropped item is a file, it is treated as an event attachment, + * otherwise it is ignored. + * + * @param {DataTransferItem} item + */ + async handleDataTransferItem(item) { + if (item.kind == "file") { + let path = item.getAsFile().mozFullPath; + if (path) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + + let uri = Services.io.newFileURI(file); + this.listener.onDropURL(uri); + } + } + } + } + + /** + * CalDNDDirectTransferHandler provides a base class for CalDNDTransferHandlers + * that directly extract the contents of a DataTransferItem for processing. + * + * @abstract + */ + class CalDNDDirectTransferHandler extends CalDNDTransferHandler { + /** + * Extracts the raw string data from a DataTransferItem before passing to + * handleString(). + * + * @param {DataTransferItem} item + */ + async handleDataTransferItem(item) { + if (item.kind == "string") { + let txt = await new Promise(resolve => item.getAsString(resolve)); + await this.handleString(txt); + } else if (item.kind == "file") { + let txt = await item.getAsFile().text(); + await this.handleString(txt); + } + } + } + + /** + * CalDNDICSTransferHandler handles internal or external data in ICS format. + */ + class CalDNDICSTransferHandler extends CalDNDDirectTransferHandler { + mimeTypes = ["text/calendar", "application/x-extension-ics"]; + + /** + * Parses the provided data as an ICS string before invoking the listener's + * onDropItems() method. + * + * @param {string} data + */ + async handleString(data) { + if (AppConstants.platform == "macosx") { + // Mac likes to convert all \r to \n, we need to reverse this. + data = data.replace(/\n\n/g, "\r\n"); + } + + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(data); + this.listener.onDropItems(parser.getItems().concat(parser.getParentlessItems())); + } + } + + /** + * CalDNDURLTransferHandler handles urls (dropped internally or externally). + */ + class CalDNDURLTransferHandler extends CalDNDDirectTransferHandler { + mimeTypes = ["text/uri-list", "text/x-moz-url"]; + + _icsFilename = /filename=.*\.ics/; + + /** + * Treats the provided data as a url. If we determine it is a url to an + * ICS file, we delegate to the "text/calendar" handler. The listener's + * onDropURL method is invoked otherwise. + * + * @param {string} data + */ + async handleString(data) { + data = data.split("\n")[0]; + if (!data) { + return; + } + + let uri = Services.io.newURI(data); + + // Below we attempt to detect ics files dropped from the message pane's + // attachment list. These will appear as uris rather than file blobs so we + // check the "filename" query parameter for a .ics extension. + if (this._icsFilename.test(uri.query)) { + let url = uri.mutate().setUsername("").setUserPass("").finalize().spec; + + let resp = await fetch(new Request(url, { method: "GET" })); + let txt = await resp.text(); + await this.listener.getHandler("text/calendar").handleString(txt); + } else { + this.listener.onDropURL(uri); + } + } + } + + /** + * CalDNDPlainTextTransferHandler handles text/plain transfers coming mainly + * from internally dropped text. + */ + class CalDNDPlainTextTransferHandler extends CalDNDDirectTransferHandler { + mimeTypes = ["text/plain"]; + + _keyWords = ["VEVENT", "VTODO", "VCALENDAR"]; + + _isICS(data) { + return this._keyWords.some(kwrd => data.includes(kwrd)); + } + + /** + * Treats the data provided as an uri to an .ics file and attempts to parse + * its contents. If we detect calendar data however, we delegate to the + * "text/calendar" handler. + * + * @param {string} data + */ + async handleString(data) { + if (this._isICS(data)) { + this.listener.getHandler("text/calendar").handleString(data); + return; + } + + let droppedUrl = data.split("\n")[0]; + if (!droppedUrl) { + return; + } + + let url = Services.io.newURI(droppedUrl); + + let localFileInstance = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + localFileInstance.initWithPath(url.pathQueryRef); + + let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + inputStream.init(localFileInstance, MODE_RDONLY, parseInt("0444", 8), {}); + + try { + let importer = Cc["@mozilla.org/calendar/import;1?type=ics"].getService(Ci.calIImporter); + let items = importer.importFromStream(inputStream); + this.onDropItems(items); + } finally { + inputStream.close(); + } + } + } + + /** + * This is the base class for calendar drag and drop listeners. + */ + class CalDNDListener { + /** + * Limits the number of items to process from a drop operation. In the + * future, this could be removed in favour of better UI for bulk operations. + * + * @type {number} + */ + maxItemsTransferred = 8; + + /** + * A list of CalDNDTransferHandlers for all of the supported mime types. + * The order of this list is important as it dictates which types will be + * selected first. + * + * @type {CalDNDTransferHandler[]} + */ + mimeHandlers = [ + new CalDNDICSTransferHandler(this), + new CalDNDMozMessageTransferHandler(this), + new CalDNDAddressTransferHandler(this), + new CalDNDURLTransferHandler(this), + new CalDNDPlainTextTransferHandler(this), + new CalDNDDefaultTransferHandler(this), + ]; + + /** + * Provides the most suitable handler for the type or one of the types of a + * list. + * + * @param {string|string[]} mime + * + * @returns {CalDNDTransferHandler} + */ + getHandler(mime) { + return this.mimeHandlers.find(handler => handler.willTransfer(mime)); + } + + /** + * Prevents the browser's default behaviour when an item is dragged over the + * drop target. + * + * @param {Event} event + */ + onDragOver(event) { + event.preventDefault(); + } + + /** + * Handles calendar event items. + * + * @param {calIItemBase[]} items + */ + onDropItems() {} + + /** + * Handles mail messages. + * + * @param {nsIMsgHdr} msgHdr + */ + onDropMessage() {} + + /** + * Handles address book data. + */ + onDropAddress() {} + + /** + * Handles the drop event. The items property of DataTransfer can be + * interpreted differently depending on whether the drop is coming from an + * internal or external source (really its up to whatever is sending the + * data to decide what the transfer entails). + * + * Mozilla seems to treat it as alternative formats for the data being + * sent while external/other applications may only have one data transfer + * item per single thing dropped. The item's interface seems to have + * more accurate mime types than the ones of mozTypesAt() so working with + * those are preferable however not always possible. + * + * This method tries to determine which of the APIs is more appropriate for + * processing the drop. It does that by checking for a source node or a + * difference between length of DataTransfer.items and DataTransfer + * .mozItemCount. + * + * Note: While testing, it was noticed that dragging text from an external + * application shows up erroneously as a file in DataTransfer.items. This is + * dealt with too. + * + * @param {Event} event + */ + async onDrop(event) { + let { dataTransfer } = event; + + // No mozSourceNode means it's an external drop, however if the drop is + // coming from Firefox then we can expect the same behaviour as done + // internally. Generally there may be more DataTransferItems than + // mozItemCount indicates. + let isInternal = + dataTransfer.mozSourceNode || dataTransfer.items.length != dataTransfer.mozItemCount; + + // For the strange case of copied text having the "file" kind, the files + // property will have a length of zero. + let actualFiles = Array.from(dataTransfer.items).filter(i => i.kind == "file").length; + let isExternalText = actualFiles != dataTransfer.files.length; + + if (isInternal || isExternalText) { + await this.onInternalDrop(dataTransfer); + } else { + await this.onExternalDrop(dataTransfer); + } + } + + /** + * This method is intended for use when the drop event originates internally. + * + * @param {DataTransfer} dataTransfer + */ + async onInternalDrop(dataTransfer) { + for (let i = 0; i < dataTransfer.mozItemCount; i++) { + if (i == this.maxItemsTransferred) { + break; + } + + let types = Array.from(dataTransfer.mozTypesAt(i)); + let handler = this.getHandler(types); + let data = dataTransfer.mozGetDataAt(handler.getMozType(types), i); + + if (typeof data == "string") { + await handler.handleString(data); + } + } + } + + /** + * This method is intended for use when the drop event originates externally. + * + * @param {DataTransfer} dataTransfer + */ + async onExternalDrop(dataTransfer) { + let i = 0; + for (let item of dataTransfer.items) { + if (i == this.maxItemsTransferred) { + break; + } + + let handler = this.getHandler(item.type); + await handler.handleDataTransferItem(item, i, dataTransfer); + i++; + } + } + } + + /** + * Drag'n'drop handler for the calendar views. + */ + class CalViewDNDObserver extends CalDNDListener { + wrappedJSObject = this; + + /** + * Gets called in case we're dropping an array of items on one of the + * calendar views. In this case we just try to add these items to the + * currently selected calendar. + * + * @param {calIItemBase[]} items + */ + onDropItems(items) { + let destCal = getSelectedCalendar(); + startBatchTransaction(); + // we fall back explicitly to the popup to ask whether to send a + // notification to participants if required + let extResp = { responseMode: Ci.calIItipItem.USER }; + try { + for (let item of items) { + doTransaction("add", item, destCal, null, null, extResp); + } + } finally { + endBatchTransaction(); + } + } + } + + /** + * Drag'n'drop handler for the 'mail mode'-button. This handler is derived + * from the base handler and just implements specific actions. + */ + class CalMailButtonDNDObserver extends CalDNDListener { + wrappedJSObject = this; + + /** + * Gets called in case we're dropping an array of items on the + * 'mail mode'-button. + * + * @param {calIItemBase[]} items + */ + onDropItems(items) { + if (items && items.length > 0) { + let item = items[0]; + let identity = item.calendar.getProperty("imip.identity"); + let parties = item.getAttendees(); + if (item.organizer) { + parties.push(item.organizer); + } + if (identity) { + // if no identity is defined, the composer will fall back to + // whatever seems suitable - in this case we don't try to remove + // the sender from the recipient list + identity = identity.QueryInterface(Ci.nsIMsgIdentity); + parties = parties.filter(aParty => { + return identity.email != cal.email.getAttendeeEmail(aParty, false); + }); + } + let recipients = cal.email.createRecipientList(parties); + cal.email.sendTo(recipients, item.title, item.getProperty("DESCRIPTION"), identity); + } + } + } + + /** + * Drag'n'drop handler for the 'open calendar tab'-button. This handler is + * derived from the base handler and just implements specific actions. + */ + class CalCalendarButtonObserver extends CalDNDListener { + wrappedJSObject = this; + + /** + * Gets called in case we're dropping an array of items + * on the 'open calendar tab'-button. + * + * @param {calIItemBase[]} items + */ + onDropItems(items) { + for (let item of items) { + let newItem = item; + if (item.isTodo()) { + newItem = itemConversion.eventFromTask(item); + } + createEventWithDialog(null, null, null, null, newItem); + } + } + + /** + * Gets called in case we're dropping a message on the 'open calendar tab'- + * button. In this case we create a new event from the mail. We open the + * default event dialog and just use the subject of the message as the event + * title. + * + * @param {nsIMsgHdr} msgHdr + */ + async onDropMessage(msgHdr) { + let newItem = new CalEvent(); + await itemConversion.calendarItemFromMessage(newItem, msgHdr); + createEventWithDialog(null, null, null, null, newItem); + } + + /** + * Gets called in case we're dropping a uri on the 'open calendar tab'- + * button. + * + * @param {nsIURI} uri + */ + onDropURL(uri) { + let newItem = new CalEvent(); + newItem.calendar = getSelectedCalendar(); + cal.dtz.setDefaultStartEndHour(newItem); + cal.alarms.setDefaultValues(newItem); + let attachment = new CalAttachment(); + attachment.uri = uri; + newItem.addAttachment(attachment); + createEventWithDialog(null, null, null, null, newItem); + } + + /** + * Gets called in case we're dropping addresses on the 'open calendar tab' + * -button. + * + * @param {string} addresses + */ + onDropAddress(addresses) { + let parsedInput = MailServices.headerParser.makeFromDisplayAddress(addresses); + let attendee = new CalAttendee(); + attendee.id = ""; + attendee.rsvp = "TRUE"; + attendee.role = "REQ-PARTICIPANT"; + attendee.participationStatus = "NEEDS-ACTION"; + let attendees = parsedInput + .filter(address => address.name.length > 0) + .map((address, index) => { + // Convert address to attendee. + if (index > 0) { + attendee = attendee.clone(); + } + attendee.id = cal.email.prependMailTo(address.email); + let commonName = null; + if (address.name.length > 0) { + // We remove any double quotes within CN due to bug 1209399. + let name = address.name.replace(/(?:(?:[\\]")|(?:"))/g, ""); + if (address.email != name) { + commonName = name; + } + } + attendee.commonName = commonName; + return attendee; + }); + let newItem = new CalEvent(); + newItem.calendar = getSelectedCalendar(); + cal.dtz.setDefaultStartEndHour(newItem); + cal.alarms.setDefaultValues(newItem); + for (let attendee of attendees) { + newItem.addAttendee(attendee); + } + createEventWithDialog(null, null, null, null, newItem); + } + } + + /** + * Drag'n'drop handler for the 'open tasks tab'-button. This handler is + * derived from the base handler and just implements specific actions. + */ + class CalTaskButtonObserver extends CalDNDListener { + wrappedJSObject = this; + + /** + * Gets called in case we're dropping an array of items on the + * 'open tasks tab'-button. + * + * @param {object} items - An array of items to handle. + */ + onDropItems(items) { + for (let item of items) { + let newItem = item; + if (item.isEvent()) { + newItem = itemConversion.taskFromEvent(item); + } + createTodoWithDialog(null, null, null, newItem); + } + } + + /** + * Gets called in case we're dropping a message on the 'open tasks tab' + * -button. + * + * @param {nsIMsgHdr} msgHdr + */ + async onDropMessage(msgHdr) { + let todo = new CalTodo(); + await itemConversion.calendarItemFromMessage(todo, msgHdr); + createTodoWithDialog(null, null, null, todo); + } + + /** + * Gets called in case we're dropping a uri on the 'open tasks tab'-button. + * + * @param {nsIURI} uri + */ + onDropURL(uri) { + let todo = new CalTodo(); + todo.calendar = getSelectedCalendar(); + cal.dtz.setDefaultStartEndHour(todo); + cal.alarms.setDefaultValues(todo); + let attachment = new CalAttachment(); + attachment.uri = uri; + todo.addAttachment(attachment); + createTodoWithDialog(null, null, null, todo); + } + } + + calendarViewDNDObserver = new CalViewDNDObserver(); + calendarMailButtonDNDObserver = new CalMailButtonDNDObserver(); + calendarCalendarButtonDNDObserver = new CalCalendarButtonObserver(); + calendarTaskButtonDNDObserver = new CalTaskButtonObserver(); +} + +/** + * Invoke a drag session for the passed item. The passed box will be used as a + * source. + * + * @param {object} aItem - The item to drag. + * @param {object} aXULBox - The XUL box to invoke the drag session from. + */ +function invokeEventDragSession(aItem, aXULBox) { + let transfer = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable); + transfer.init(null); + transfer.addDataFlavor("text/calendar"); + + let flavourProvider = { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + item: aItem, + getFlavorData(aInTransferable, aInFlavor, aOutData) { + if ( + aInFlavor == "application/vnd.x-moz-cal-event" || + aInFlavor == "application/vnd.x-moz-cal-task" + ) { + aOutData.value = aItem; + } else { + cal.ASSERT(false, "error:" + aInFlavor); + } + }, + }; + + if (aItem.isEvent()) { + transfer.addDataFlavor("application/vnd.x-moz-cal-event"); + transfer.setTransferData("application/vnd.x-moz-cal-event", flavourProvider); + } else if (aItem.isTodo()) { + transfer.addDataFlavor("application/vnd.x-moz-cal-task"); + transfer.setTransferData("application/vnd.x-moz-cal-task", flavourProvider); + } + + // Also set some normal data-types, in case we drag into another app + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems([aItem]); + + let supportsString = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + supportsString.data = serializer.serializeToString(); + transfer.setTransferData("text/calendar", supportsString); + transfer.setTransferData("text/plain", supportsString); + + let action = Ci.nsIDragService.DRAGDROP_ACTION_MOVE; + let mutArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + mutArray.appendElement(transfer); + aXULBox.sourceObject = aItem; + try { + cal.dragService.invokeDragSession(aXULBox, null, null, null, mutArray, action); + } catch (e) { + if (e.result != Cr.NS_ERROR_FAILURE) { + // Pressing Escape on some platforms results in NS_ERROR_FAILURE + // being thrown. Catch this exception, but throw anything else. + throw e; + } + } +} diff --git a/comm/calendar/base/content/calendar-editable-item.js b/comm/calendar/base/content/calendar-editable-item.js new file mode 100644 index 0000000000..e45570c995 --- /dev/null +++ b/comm/calendar/base/content/calendar-editable-item.js @@ -0,0 +1,464 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements, MozXULElement, onMouseOverItem, invokeEventDragSession */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + /** + * The MozCalendarEditableItem widget is used as a full day event item in the + * Day and Week views of the calendar. It displays the event name, alarm icon + * and the category type color. It gets displayed in the header container of + * the respective view of the calendar. + * + * @augments MozXULElement + */ + class MozCalendarEditableItem extends MozXULElement { + static get inheritedAttributes() { + return { + ".alarm-icons-box": "flashing", + }; + } + constructor() { + super(); + + this.mOccurrence = null; + + this.mSelected = false; + + this.mCalendarView = null; + + this.addEventListener( + "contextmenu", + event => { + // If the middle/right button was used for click just select the item. + if (!this.selected) { + this.select(event); + } + }, + true + ); + + this.addEventListener("click", event => { + if (event.button != 0 || this.mEditing) { + return; + } + + // If the left button was used and the item is already selected + // and there are no multiple items selected start + // the 'single click edit' timeout. Otherwise select the item too. + // Also, check if the calendar is readOnly or we are offline. + + if ( + this.selected && + !(event.ctrlKey || event.metaKey) && + cal.acl.isCalendarWritable(this.mOccurrence.calendar) && + !cal.itip.isInvitation(this.mOccurrence) + ) { + if (this.editingTimer) { + clearTimeout(this.editingTimer); + } + this.editingTimer = setTimeout(() => this.startEditing(), 350); + } else { + this.select(event); + if (!this.closest("richlistitem")) { + event.stopPropagation(); + } + } + }); + + this.addEventListener("dblclick", event => { + if (event.button != 0) { + return; + } + + event.stopPropagation(); + + // Stop 'single click edit' timeout (if started). + if (this.editingTimer) { + clearTimeout(this.editingTimer); + this.editingTimer = null; + } + + if (this.calendarView && this.calendarView.controller) { + let item = event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence; + if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) { + this.calendarView.controller.modifyOccurrence(item); + return; + } + this.calendarView.controller.viewOccurrence(item); + } + }); + + this.addEventListener("mouseover", event => { + if (this.calendarView && this.calendarView.controller) { + event.stopPropagation(); + onMouseOverItem(event); + } + }); + + // We have two event listeners for dragstart. This event listener is for the bubbling phase. + this.addEventListener("dragstart", event => { + if (document.monthDragEvent?.localName == "calendar-event-box") { + return; + } + let item = this.occurrence; + let isInvitation = + item.calendar instanceof Ci.calISchedulingSupport && item.calendar.isInvitation(item); + if ( + !cal.acl.isCalendarWritable(item.calendar) || + !cal.acl.userCanModifyItem(item) || + isInvitation + ) { + return; + } + if (!this.selected) { + this.select(event); + } + invokeEventDragSession(item, this); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + this.appendChild( + MozXULElement.parseXULToFragment(` + <html:div class="calendar-item-flex"> + <html:img class="item-type-icon" alt="" /> + <html:div class="event-name-label"></html:div> + <html:input class="plain event-name-input" + hidden="hidden" + placeholder='${cal.l10n.getCalString("newEvent")}'/> + <html:div class="alarm-icons-box"></html:div> + <html:img class="item-classification-icon" /> + <html:img class="item-recurrence-icon" /> + </html:div> + <html:div class="location-desc"></html:div> + <html:div class="calendar-category-box"></html:div> + `) + ); + + this.classList.add("calendar-color-box", "calendar-item-container"); + + // We have two event listeners for dragstart. This event listener is for the capturing phase + // where we are setting up the document.monthDragEvent which will be used in the event listener + // in the bubbling phase. + this.addEventListener( + "dragstart", + event => { + document.monthDragEvent = this; + }, + true + ); + + this.style.pointerEvents = "auto"; + this.setAttribute("tooltip", "itemTooltip"); + this.setAttribute("tabindex", "-1"); + this.addEventNameTextboxListener(); + this.initializeAttributeInheritance(); + } + + set parentBox(val) { + this.mParentBox = val; + } + + get parentBox() { + return this.mParentBox; + } + + set selected(val) { + if (val && !this.mSelected) { + this.mSelected = true; + this.setAttribute("selected", "true"); + this.focus(); + } else if (!val && this.mSelected) { + this.mSelected = false; + this.removeAttribute("selected"); + this.blur(); + } + } + + get selected() { + return this.mSelected; + } + + set calendarView(val) { + this.mCalendarView = val; + } + + get calendarView() { + return this.mCalendarView; + } + + set occurrence(val) { + this.mOccurrence = val; + this.setEditableLabel(); + this.setLocationLabel(); + this.setCSSClasses(); + } + + get occurrence() { + return this.mOccurrence; + } + + get eventNameLabel() { + return this.querySelector(".event-name-label"); + } + + get eventNameTextbox() { + return this.querySelector(".event-name-input"); + } + + addEventNameTextboxListener() { + let stopPropagationIfEditing = event => { + if (this.mEditing) { + event.stopPropagation(); + } + }; + // While editing, single click positions cursor, so don't propagate. + this.eventNameTextbox.onclick = stopPropagationIfEditing; + // While editing, double click selects words, so don't propagate. + this.eventNameTextbox.ondblclick = stopPropagationIfEditing; + // While editing, don't propagate mousedown/up (selects calEvent). + this.eventNameTextbox.onmousedown = stopPropagationIfEditing; + this.eventNameTextbox.onmouseup = stopPropagationIfEditing; + this.eventNameTextbox.onblur = () => { + this.stopEditing(true); + }; + this.eventNameTextbox.onkeypress = event => { + if (event.key == "Enter") { + // Save on enter. + this.stopEditing(true); + } else if (event.key == "Escape") { + // Abort on escape. + this.stopEditing(false); + } + }; + } + + setEditableLabel() { + let label = this.eventNameLabel; + let item = this.mOccurrence; + label.textContent = item.title + ? item.title.replace(/\n/g, " ") + : cal.l10n.getCalString("eventUntitled"); + } + + setLocationLabel() { + let locationLabel = this.querySelector(".location-desc"); + let location = this.mOccurrence.getProperty("LOCATION"); + let showLocation = Services.prefs.getBoolPref("calendar.view.showLocation", false); + + locationLabel.textContent = showLocation && location ? location : ""; + locationLabel.hidden = !showLocation || !location; + } + + setCSSClasses() { + let item = this.mOccurrence; + let cssSafeId = cal.view.formatStringForCSSRule(item.calendar.id); + this.style.setProperty("--item-backcolor", `var(--calendar-${cssSafeId}-backcolor)`); + this.style.setProperty("--item-forecolor", `var(--calendar-${cssSafeId}-forecolor)`); + let categoriesBox = this.querySelector(".calendar-category-box"); + + let categoriesArray = item.getCategories().map(cal.view.formatStringForCSSRule); + // Find the first category with a colour. + let firstCategory = categoriesArray.find( + category => Services.prefs.getStringPref("calendar.category.color." + category, "") != "" + ); + if (firstCategory) { + categoriesBox.hidden = false; + categoriesBox.style.backgroundColor = `var(--category-${firstCategory}-color)`; + } else { + categoriesBox.hidden = true; + } + + // Add alarm icons as needed. + let alarms = item.getAlarms(); + if (alarms.length && Services.prefs.getBoolPref("calendar.alarms.indicator.show", true)) { + let iconsBox = this.querySelector(".alarm-icons-box"); + // Set suppressed status on the icons box. + iconsBox.toggleAttribute("suppressed", item.calendar.getProperty("suppressAlarms")); + + cal.alarms.addReminderImages(iconsBox, alarms); + } + + // Item classification / privacy. + let classificationIcon = this.querySelector(".item-classification-icon"); + if (classificationIcon) { + switch (item.privacy) { + case "PRIVATE": + classificationIcon.setAttribute( + "src", + "chrome://calendar/skin/shared/icons/private.svg" + ); + // Set the alt attribute. + document.l10n.setAttributes( + classificationIcon, + "calendar-editable-item-privacy-icon-private" + ); + break; + case "CONFIDENTIAL": + classificationIcon.setAttribute( + "src", + "chrome://calendar/skin/shared/icons/confidential.svg" + ); + // Set the alt attribute. + document.l10n.setAttributes( + classificationIcon, + "calendar-editable-item-privacy-icon-confidential" + ); + break; + default: + classificationIcon.removeAttribute("src"); + classificationIcon.removeAttribute("data-l10n-id"); + classificationIcon.setAttribute("alt", ""); + break; + } + } + + let recurrenceIcon = this.querySelector(".item-recurrence-icon"); + if (item.parentItem != item && item.parentItem.recurrenceInfo) { + if (item.parentItem.recurrenceInfo.getExceptionFor(item.recurrenceId)) { + recurrenceIcon.setAttribute( + "src", + "chrome://messenger/skin/icons/new/recurrence-exception.svg" + ); + document.l10n.setAttributes( + recurrenceIcon, + "calendar-editable-item-recurrence-exception" + ); + } else { + recurrenceIcon.setAttribute("src", "chrome://messenger/skin/icons/new/recurrence.svg"); + document.l10n.setAttributes(recurrenceIcon, "calendar-editable-item-recurrence"); + } + recurrenceIcon.hidden = false; + } else { + recurrenceIcon.removeAttribute("src"); + recurrenceIcon.removeAttribute("data-l10n-id"); + recurrenceIcon.setAttribute("alt", ""); + recurrenceIcon.hidden = true; + } + + // Event type specific properties. + if (item.isEvent() && item.startDate.isDate) { + this.setAttribute("allday", "true"); + } + if (item.isTodo()) { + let icon = this.querySelector(".item-type-icon"); + if (cal.item.getProgressAtom(item) === "completed") { + icon.setAttribute("src", "chrome://calendar/skin/shared/todo-complete.svg"); + document.l10n.setAttributes(icon, "calendar-editable-item-todo-icon-completed-task"); + } else { + icon.setAttribute("src", "chrome://calendar/skin/shared/todo.svg"); + document.l10n.setAttributes(icon, "calendar-editable-item-todo-icon-task"); + } + } + + if (this.calendarView && item.hashId in this.calendarView.mFlashingEvents) { + this.setAttribute("flashing", "true"); + } + + if (alarms.length) { + this.setAttribute("alarm", "true"); + } + + // Priority. + if (item.priority > 0 && item.priority < 5) { + this.setAttribute("priority", "high"); + } else if (item.priority > 5 && item.priority < 10) { + this.setAttribute("priority", "low"); + } + + // Status attribute. + if (item.status) { + this.setAttribute("status", item.status.toUpperCase()); + } + + // Item class. + if (item.hasProperty("CLASS")) { + this.setAttribute("itemclass", item.getProperty("CLASS")); + } + + // Calendar name. + this.setAttribute("calendar", item.calendar.name.toLowerCase()); + + // Invitation. + if (cal.itip.isInvitation(item)) { + this.setAttribute( + "invitation-status", + cal.itip.getInvitedAttendee(item).participationStatus + ); + } + } + + startEditing() { + this.editingTimer = null; + this.mOriginalTextLabel = this.mOccurrence.title; + + this.eventNameLabel.hidden = true; + + this.mEditing = true; + + this.eventNameTextbox.value = this.mOriginalTextLabel; + this.eventNameTextbox.hidden = false; + this.eventNameTextbox.focus(); + } + + get isEditing() { + return this.mEditing || false; + } + + select(event) { + if (!this.calendarView) { + return; + } + let items = this.calendarView.mSelectedItems.slice(); + if (event.ctrlKey || event.metaKey) { + if (this.selected) { + let pos = items.indexOf(this.mOccurrence); + items.splice(pos, 1); + } else { + items.push(this.mOccurrence); + } + } else { + items = [this.mOccurrence]; + } + this.calendarView.setSelectedItems(items); + } + + stopEditing(saveChanges) { + if (!this.mEditing) { + return; + } + + this.mEditing = false; + + if (saveChanges && this.eventNameTextbox.value != this.mOriginalTextLabel) { + this.calendarView.controller.modifyOccurrence( + this.mOccurrence, + null, + null, + this.eventNameTextbox.value || cal.l10n.getCalString("eventUntitled") + ); + + // Note that as soon as we do the modifyItem, this element ceases to exist, + // so don't bother trying to modify anything further here! ('this' exists, + // because it's being kept alive, but our child content etc. is all gone). + return; + } + + this.eventNameTextbox.hidden = true; + this.eventNameLabel.hidden = false; + } + } + + MozElements.MozCalendarEditableItem = MozCalendarEditableItem; + + customElements.define("calendar-editable-item", MozCalendarEditableItem); +} diff --git a/comm/calendar/base/content/calendar-extract.js b/comm/calendar/base/content/calendar-extract.js new file mode 100644 index 0000000000..de021cb042 --- /dev/null +++ b/comm/calendar/base/content/calendar-extract.js @@ -0,0 +1,266 @@ +/* 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 getMessagePaneBrowser, addMenuItem, getSelectedCalendar + createEventWithDialog*/ + +var { Extractor } = ChromeUtils.import("resource:///modules/calendar/calExtract.jsm"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "extractService", () => { + const { CalExtractParserService } = ChromeUtils.import( + "resource:///modules/calendar/extract/CalExtractParserService.jsm" + ); + return new CalExtractParserService(); +}); + +var calendarExtract = { + onShowLocaleMenu(target) { + let localeList = document.getElementById(target.id); + let langs = []; + let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"] + .getService(Ci.nsIXULChromeRegistry) + .QueryInterface(Ci.nsIToolkitChromeRegistry); + let langRegex = /^(([^-]+)-*(.*))$/; + + for (let locale of chrome.getLocalesForPackage("calendar")) { + let localeParts = langRegex.exec(locale); + let langName = localeParts[2]; + + try { + langName = cal.l10n.getAnyString("global", "languageNames", langName); + } catch (ex) { + // If no language name is found that is ok, keep the technical term + } + + let label = cal.l10n.getCalString("extractUsing", [langName]); + if (localeParts[3] != "") { + label = cal.l10n.getCalString("extractUsingRegion", [langName, localeParts[3]]); + } + + langs.push([label, localeParts[1]]); + } + + // sort + let pref = "calendar.patterns.last.used.languages"; + let lastUsedLangs = Services.prefs.getStringPref(pref, ""); + + langs.sort((a, b) => { + let idx_a = lastUsedLangs.indexOf(a[1]); + let idx_b = lastUsedLangs.indexOf(b[1]); + + if (idx_a == -1 && idx_b == -1) { + return a[0].localeCompare(b[0]); + } else if (idx_a != -1 && idx_b != -1) { + return idx_a - idx_b; + } else if (idx_a == -1) { + return 1; + } + return -1; + }); + while (localeList.lastChild) { + localeList.lastChild.remove(); + } + + for (let lang of langs) { + addMenuItem(localeList, lang[0], lang[1], null); + } + }, + + extractWithLocale(event, isEvent) { + event.stopPropagation(); + let locale = event.target.value; + this.extractFromEmail(null, isEvent, true, locale); + }, + + async extractFromEmail(message, isEvent, fixedLang, fixedLocale) { + let folder = message.folder; + let title = message.mime2DecodedSubject; + + let content = ""; + await new Promise((resolve, reject) => { + let listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + onDataAvailable(request, inputStream, offset, count) { + let text = folder.getMsgTextFromStream( + inputStream, + message.charset, + count, // bytesToRead + 32768, // maxOutputLen + false, // compressQuotes + true, // stripHTMLTags + {} // out contentType + ); + // If we ever got text, we're good. Ignore further chunks. + content ||= text; + }, + onStartRequest(request) {}, + onStopRequest(request, statusCode) { + if (!Components.isSuccessCode(statusCode)) { + reject(new Error(statusCode)); + } + resolve(); + }, + }; + let uri = message.folder.getUriForMsg(message); + MailServices.messageServiceFromURI(uri).streamMessage(uri, listener, null, null, false, ""); + }); + + cal.LOG("[calExtract] Original email content: \n" + title + "\r\n" + content); + let date = new Date(message.date / 1000); + let time = new Date().getTime(); + + let item = isEvent ? new CalEvent() : new CalTodo(); + item.title = message.mime2DecodedSubject; + item.calendar = getSelectedCalendar(); + item.setProperty("DESCRIPTION", content); + item.setProperty("URL", `mid:${message.messageId}`); + cal.dtz.setDefaultStartEndHour(item); + cal.alarms.setDefaultValues(item); + let tabmail = document.getElementById("tabmail"); + let messagePaneBrowser = + tabmail?.currentTabInfo.chromeBrowser.contentWindow.visibleMessagePaneBrowser?.() || + tabmail?.currentAboutMessage?.getMessagePaneBrowser() || + document.getElementById("messageBrowser")?.contentWindow?.getMessagePaneBrowser(); + let sel = messagePaneBrowser?.contentWindow?.getSelection(); + // Check if there's an iframe with a selection (e.g. Thunderbird Conversations) + if (sel && sel.type !== "Range") { + try { + sel = messagePaneBrowser?.contentDocument + .querySelector("iframe") + .contentDocument.getSelection(); + } catch (ex) { + // If Thunderbird Conversations is not installed that is fine, + // we will just have an empty or null selection. + } + } + + let guessed; + let endGuess; + let extractor; + let collected = []; + let useService = Services.prefs.getBoolPref("calendar.extract.service.enabled"); + if (useService) { + let result = extractService.extract(content, { now: date }); + if (!result) { + useService = false; + } else { + guessed = result.startTime; + endGuess = result.endTime; + } + } + + if (!useService) { + let locale = Services.locale.requestedLocale; + let dayStart = Services.prefs.getIntPref("calendar.view.daystarthour", 6); + if (fixedLang) { + extractor = new Extractor(fixedLocale, dayStart); + } else { + extractor = new Extractor(locale, dayStart, false); + } + collected = extractor.extract(title, content, date, sel); + } + + // if we only have email date then use default start and end + if (!useService && collected.length <= 1) { + cal.LOG("[calExtract] Date and time information was not found in email/selection."); + createEventWithDialog(null, null, null, null, item); + } else { + if (!useService) { + guessed = extractor.guessStart(!isEvent); + endGuess = extractor.guessEnd(guessed, !isEvent); + } + let allDay = (guessed.hour == null || guessed.minute == null) && isEvent; + + if (isEvent) { + if (guessed.year != null) { + item.startDate.year = guessed.year; + } + if (guessed.month != null) { + item.startDate.month = guessed.month - 1; + } + if (guessed.day != null) { + item.startDate.day = guessed.day; + } + if (guessed.hour != null) { + item.startDate.hour = guessed.hour; + } + if (guessed.minute != null) { + item.startDate.minute = guessed.minute; + } + + item.endDate = item.startDate.clone(); + item.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60); + + if (endGuess.year != null) { + item.endDate.year = endGuess.year; + } + if (endGuess.month != null) { + item.endDate.month = endGuess.month - 1; + } + if (endGuess.day != null) { + item.endDate.day = endGuess.day; + if (allDay) { + item.endDate.day++; + } + } + if (endGuess.hour != null) { + item.endDate.hour = endGuess.hour; + } + if (endGuess.minute != null) { + item.endDate.minute = endGuess.minute; + } + } else { + let dtz = cal.dtz.defaultTimezone; + let dueDate = new Date(); + // set default + dueDate.setHours(0); + dueDate.setMinutes(0); + dueDate.setSeconds(0); + + if (endGuess.year != null) { + dueDate.setYear(endGuess.year); + } + if (endGuess.month != null) { + dueDate.setMonth(endGuess.month - 1); + } + if (endGuess.day != null) { + dueDate.setDate(endGuess.day); + } + if (endGuess.hour != null) { + dueDate.setHours(endGuess.hour); + } + if (endGuess.minute != null) { + dueDate.setMinutes(endGuess.minute); + } + + cal.item.setItemProperty(item, "entryDate", cal.dtz.jsDateToDateTime(date, dtz)); + if (endGuess.year != null) { + cal.item.setItemProperty(item, "dueDate", cal.dtz.jsDateToDateTime(dueDate, dtz)); + } + } + + // if time not guessed set allday for events + if (allDay) { + createEventWithDialog(null, null, null, null, item, true); + } else { + createEventWithDialog(null, null, null, null, item); + } + } + + let timeSpent = new Date().getTime() - time; + cal.LOG( + "[calExtract] Total time spent for conversion (including loading of dictionaries): " + + timeSpent + + "ms" + ); + }, +}; diff --git a/comm/calendar/base/content/calendar-invitation-display.js b/comm/calendar/base/content/calendar-invitation-display.js new file mode 100644 index 0000000000..ed560d02ed --- /dev/null +++ b/comm/calendar/base/content/calendar-invitation-display.js @@ -0,0 +1,169 @@ +/* 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 gMessageListeners, calImipBar */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + /** + * CalInvitationDisplay is the controller responsible for the display of the + * invitation panel when an email contains an embedded invitation. + */ + const CalInvitationDisplay = { + /** + * The itipItem currently displayed. + * + * @type {calIItipItem} + */ + currentItipItem: null, + + /** + * The XUL element that wraps the invitation. + * + * @type {XULElement} + */ + container: null, + + /** + * The node the invitation details are rendered into. + * + * @type {HTMLElement} + */ + display: null, + + /** + * The <browser> element that displays the message body. This is hidden + * when the invitation details are displayed. + */ + body: null, + + /** + * Creates a new instance and sets up listeners. + */ + init() { + this.container = document.getElementById("calendarInvitationDisplayContainer"); + this.display = document.getElementById("calendarInvitationDisplay"); + this.body = document.getElementById("messagepane"); + + window.addEventListener("onItipItemCreation", this); + window.addEventListener("onItipItemActionFinished", this); + window.addEventListener("messagepane-unloaded", this); + document.getElementById("msgHeaderView").addEventListener("message-header-pane-hidden", this); + gMessageListeners.push(this); + }, + + /** + * Renders the panel with invitation details when "onItipItemCreation" is + * received. + * + * @param {Event} evt + */ + handleEvent(evt) { + switch (evt.type) { + case "DOMContentLoaded": + this.init(); + break; + case "onItipItemCreation": + case "onItipItemActionFinished": + this.show(evt.detail); + break; + case "messagepane-unloaded": + case "message-header-pane-hidden": + this.hide(); + break; + case "calendar-invitation-panel-action": + if (evt.detail.type == "update") { + calImipBar.executeAction(); + } else { + calImipBar.executeAction(evt.detail.type.toUpperCase()); + } + break; + default: + break; + } + }, + + /** + * Hide the invitation display each time a new message to display is + * detected. If the message contains an invitation it will be displayed + * in the "onItipItemCreation" handler. + */ + onStartHeaders() { + this.hide(); + }, + + /** + * Called by messageHeaderSink. + */ + onEndHeaders() {}, + + /** + * Displays the invitation display with the data from the provided + * calIItipItem. + * + * @param {calIItipItem} itipItem + */ + async show(itipItem) { + this.currentItipItem = itipItem; + this.display.replaceChildren(); + + let [, rc, actionFunc, foundItems] = await new Promise(resolve => + cal.itip.processItipItem(itipItem, (targetItipItem, rc, actionFunc, foundItems) => + resolve([targetItipItem, rc, actionFunc, foundItems]) + ) + ); + + if (this.currentItipItem != itipItem || !Components.isSuccessCode(rc)) { + return; + } + + let [item] = itipItem.getItemList(); + let [foundItem] = foundItems; + let panel = document.createElement("calendar-invitation-panel"); + panel.addEventListener("calendar-invitation-panel-action", this); + + let method = actionFunc ? actionFunc.method : itipItem.receivedMethod; + switch (method) { + case "REQUEST:UPDATE": + panel.mode = panel.constructor.MODE_UPDATE_MAJOR; + break; + case "REQUEST:UPDATE-MINOR": + panel.mode = panel.constructor.MODE_UPDATE_MINOR; + break; + case "REQUEST": + panel.mode = foundItem + ? panel.constructor.MODE_ALREADY_PROCESSED + : panel.constructor.MODE_NEW; + break; + case "CANCEL": + panel.mode = foundItem + ? panel.constructor.MODE_CANCELLED + : panel.constructor.MODE_CANCELLED_NOT_FOUND; + break; + default: + panel.mode = panel.mode = panel.constructor.MODE_NEW; + break; + } + panel.foundItem = foundItem; + panel.item = item; + this.display.appendChild(panel); + this.body.hidden = true; + this.container.hidden = false; + }, + + /** + * Removes the invitation display from view, resetting any changes made + * to the container and message pane. + */ + hide() { + this.container.hidden = true; + this.display.replaceChildren(); + this.body.hidden = false; + }, + }; + + window.addEventListener("DOMContentLoaded", CalInvitationDisplay, { once: true }); +} diff --git a/comm/calendar/base/content/calendar-invitations-manager.js b/comm/calendar/base/content/calendar-invitations-manager.js new file mode 100644 index 0000000000..9a758c4c49 --- /dev/null +++ b/comm/calendar/base/content/calendar-invitations-manager.js @@ -0,0 +1,385 @@ +/* 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"); + +var { CalReadableStreamFactory } = ChromeUtils.import( + "resource:///modules/CalReadableStreamFactory.jsm" +); + +/* exported openInvitationsDialog, setUpInvitationsManager, + * tearDownInvitationsManager + */ + +var gInvitationsManager = null; + +/** + * Return a cached instance of the invitations manager + * + * @returns {InvitationsManager} The invitations manager instance. + */ +function getInvitationsManager() { + if (!gInvitationsManager) { + gInvitationsManager = new InvitationsManager(); + } + return gInvitationsManager; +} + +// Listeners, observers, set up, tear down, opening dialog, etc. This code kept +// separate from the InvitationsManager class itself for separation of concerns. + +// == invitations link +const FIRST_DELAY_STARTUP = 100; +const FIRST_DELAY_RESCHEDULE = 100; +const FIRST_DELAY_REGISTER = 10000; +const FIRST_DELAY_UNREGISTER = 0; + +var gInvitationsCalendarManagerObserver = { + mStoredThis: this, + QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver"]), + + onCalendarRegistered(aCalendar) { + this.mStoredThis.rescheduleInvitationsUpdate(FIRST_DELAY_REGISTER); + }, + + onCalendarUnregistering(aCalendar) { + this.mStoredThis.rescheduleInvitationsUpdate(FIRST_DELAY_UNREGISTER); + }, + + onCalendarDeleting(aCalendar) {}, +}; + +function scheduleInvitationsUpdate(firstDelay) { + getInvitationsManager().scheduleInvitationsUpdate(firstDelay); +} + +function rescheduleInvitationsUpdate(firstDelay) { + getInvitationsManager().cancelInvitationsUpdate(); + scheduleInvitationsUpdate(firstDelay); +} + +function openInvitationsDialog() { + getInvitationsManager().cancelInvitationsUpdate(); + getInvitationsManager().openInvitationsDialog(); +} + +function setUpInvitationsManager() { + scheduleInvitationsUpdate(FIRST_DELAY_STARTUP); + cal.manager.addObserver(gInvitationsCalendarManagerObserver); +} + +function tearDownInvitationsManager() { + cal.manager.removeObserver(gInvitationsCalendarManagerObserver); +} + +/** + * The invitations manager class constructor + * + * XXX do we really need this to be an instance? + * + * @class + */ +function InvitationsManager() { + this.mItemList = []; + this.mStartDate = null; + this.mTimer = null; + + window.addEventListener("unload", () => { + // Unload handlers get removed automatically + this.cancelInvitationsUpdate(); + }); +} + +InvitationsManager.prototype = { + mItemList: null, + mStartDate: null, + mTimer: null, + mPendingRequests: null, + + /** + * Schedule an update for the invitations manager asynchronously. + * + * @param firstDelay The timeout before the operation should start. + */ + scheduleInvitationsUpdate(firstDelay) { + this.cancelInvitationsUpdate(); + + this.mTimer = setTimeout(async () => { + if (Services.prefs.getBoolPref("calendar.invitations.autorefresh.enabled", true)) { + this.mTimer = setInterval( + async () => this._doInvitationsUpdate(), + Services.prefs.getIntPref("calendar.invitations.autorefresh.timeout", 3) * 60000 + ); + } + await this._doInvitationsUpdate(); + }, firstDelay); + }, + + async _doInvitationsUpdate() { + let items; + try { + items = await cal.iterate.streamToArray(this.getInvitations()); + } catch (e) { + cal.ERROR(e); + } + this.toggleInvitationsPanel(items); + }, + + /** + * Toggles the display of the invitations panel in the status bar depending + * on the number of invitation items found. + * + * @param {calIItemBase[]?} items - The invitations found, if empty or not + * provided, the panel will not be displayed. + */ + toggleInvitationsPanel(items) { + let invitationsBox = document.getElementById("calendar-invitations-panel"); + if (items) { + let count = items.length; + let value = cal.l10n.getLtnString("invitationsLink.label", [count]); + document.getElementById("calendar-invitations-label").value = value; + if (count) { + invitationsBox.removeAttribute("hidden"); + return; + } + } + + invitationsBox.setAttribute("hidden", "true"); + }, + + /** + * Cancel pending any pending invitations update. + */ + cancelInvitationsUpdate() { + clearTimeout(this.mTimer); + }, + + /** + * Cancel any pending queries for invitations. + */ + async cancelPendingRequests() { + return this.mPendingRequests && this.mPendingRequests.cancel(); + }, + + /** + * Retrieve invitations from all calendars. Notify all passed + * operation listeners. + * + * @returns {ReadableStream<calIItemBase>} + */ + getInvitations() { + this.updateStartDate(); + this.deleteAllItems(); + + let streams = []; + for (let calendar of cal.manager.getCalendars()) { + if (!cal.acl.isCalendarWritable(calendar) || calendar.getProperty("disabled")) { + continue; + } + + // temporary hack unless calCachedCalendar supports REQUEST_NEEDS_ACTION filter: + calendar = calendar.getProperty("cache.uncachedCalendar"); + if (!calendar) { + continue; + } + + let endDate = this.mStartDate.clone(); + endDate.year += 1; + streams.push( + calendar.getItems( + Ci.calICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION | + Ci.calICalendar.ITEM_FILTER_TYPE_ALL | + // we need to retrieve by occurrence to properly filter exceptions, + // should be fixed with bug 416975 + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES, + 0, + this.mStartDate, + endDate /* we currently cannot pass null here, because of bug 416975 */ + ) + ); + } + + let self = this; + let mHandledItems = {}; + return CalReadableStreamFactory.createReadableStream({ + async start(controller) { + await self.cancelPendingRequests(); + + self.mPendingRequests = cal.iterate.streamValues( + CalReadableStreamFactory.createCombinedReadableStream(streams) + ); + + for await (let items of self.mPendingRequests) { + for (let item of items) { + // we need to retrieve by occurrence to properly filter exceptions, + // should be fixed with bug 416975 + item = item.parentItem; + let hid = item.hashId; + if (!mHandledItems[hid]) { + mHandledItems[hid] = true; + self.addItem(item); + } + } + } + + self.mItemList.sort((a, b) => { + return a.startDate.compare(b.startDate); + }); + + controller.enqueue(self.mItemList.slice()); + controller.close(); + }, + close() { + self.mPendingRequests = null; + }, + }); + }, + + /** + * Open the invitations dialog, non-modal. + * + * XXX Passing these listeners in instead of keeping them in the window + * sounds fishy to me. Maybe there is a more encapsulated solution. + */ + openInvitationsDialog() { + let args = {}; + args.queue = []; + args.finishedCallBack = () => this.scheduleInvitationsUpdate(FIRST_DELAY_RESCHEDULE); + args.invitationsManager = this; + // the dialog will reset this to auto when it is done loading + window.setCursor("wait"); + // open the dialog + window.openDialog( + "chrome://calendar/content/calendar-invitations-dialog.xhtml", + "_blank", + "chrome,titlebar,resizable", + args + ); + }, + + /** + * Process the passed job queue. A job is an object that consists of an + * action, a newItem and and oldItem. This processor only takes "modify" + * operations into account. + * + * @param queue The array of objects to process. + */ + async processJobQueue(queue) { + // TODO: undo/redo + for (let i = 0; i < queue.length; i++) { + let job = queue[i]; + let oldItem = job.oldItem; + let newItem = job.newItem; + switch (job.action) { + case "modify": + let item = await newItem.calendar.modifyItem(newItem, oldItem); + cal.itip.checkAndSend(Ci.calIOperationListener.MODIFY, item, oldItem); + this.deleteItem(item); + this.addItem(item); + break; + default: + break; + } + } + }, + + /** + * Checks if the internal item list contains the given item + * XXXdbo Please document these correctly. + * + * @param item The item to look for. + * @returns A boolean value indicating if the item was found. + */ + hasItem(item) { + let hid = item.hashId; + return this.mItemList.some(item_ => hid == item_.hashId); + }, + + /** + * Adds an item to the internal item list. + * XXXdbo Please document these correctly. + * + * @param item The item to add. + */ + addItem(item) { + let recInfo = item.recurrenceInfo; + if (recInfo && !cal.itip.isOpenInvitation(item)) { + // scan exceptions: + let ids = recInfo.getExceptionIds(); + for (let id of ids) { + let ex = recInfo.getExceptionFor(id); + if (ex && this.validateItem(ex) && !this.hasItem(ex)) { + this.mItemList.push(ex); + } + } + } else if (this.validateItem(item) && !this.hasItem(item)) { + this.mItemList.push(item); + } + }, + + /** + * Removes an item from the internal item list + * XXXdbo Please document these correctly. + * + * @param item The item to remove. + */ + deleteItem(item) { + let id = item.id; + this.mItemList.filter(item_ => id != item_.id); + }, + + /** + * Remove all items from the internal item list + * XXXdbo Please document these correctly. + */ + deleteAllItems() { + this.mItemList = []; + }, + + /** + * Helper function to create a start date to search from. This date is the + * current time with hour/minute/second set to zero. + * + * @returns Potential start date. + */ + getStartDate() { + let date = cal.dtz.now(); + date.second = 0; + date.minute = 0; + date.hour = 0; + return date; + }, + + /** + * Updates the start date for the invitations manager to the date returned + * from this.getStartDate(), unless the previously existing start date is + * the same or after what getStartDate() returned. + */ + updateStartDate() { + if (this.mStartDate) { + let startDate = this.getStartDate(); + if (startDate.compare(this.mStartDate) > 0) { + this.mStartDate = startDate; + } + } else { + this.mStartDate = this.getStartDate(); + } + }, + + /** + * Checks if the item is valid for the invitation manager. Checks if the + * item is in the range of the invitation manager and if the item is a valid + * invitation. + * + * @param item The item to check + * @returns A boolean indicating if the item is a valid invitation. + */ + validateItem(item) { + if (item.calendar instanceof Ci.calISchedulingSupport && !item.calendar.isInvitation(item)) { + return false; // exclude if organizer has invited himself + } + let start = item[cal.dtz.startDateProp(item)] || item[cal.dtz.endDateProp(item)]; + return cal.itip.isOpenInvitation(item) && start.compare(this.mStartDate) >= 0; + }, +}; diff --git a/comm/calendar/base/content/calendar-keys.inc.xhtml b/comm/calendar/base/content/calendar-keys.inc.xhtml new file mode 100644 index 0000000000..572a62d88e --- /dev/null +++ b/comm/calendar/base/content/calendar-keys.inc.xhtml @@ -0,0 +1,15 @@ +# 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/. + +<keyset id="calendar-keys"> + <key id="todaypanekey" command="calendar_toggle_todaypane_command" keycode="VK_F11"/> + <key id="calendar-new-event-key" key="&lightning.keys.event.new;" modifiers="accel" command="calendar_new_event_command"/> + <key id="calendar-new-todo-key" key="&lightning.keys.todo.new;" modifiers="accel" command="calendar_new_todo_command"/> + <key id="calendar-go-to-today-key" keycode="VK_END" + command="calendar_go_to_today_command" modifiers="alt"/> + <key id="calendar-delete-item-key" keycode="VK_DELETE" + command="calendar_delete_event_command"/> + <key id="calendar-delete-todo-key" keycode="VK_DELETE" + command="calendar_delete_todo_command"/> +</keyset> diff --git a/comm/calendar/base/content/calendar-management.js b/comm/calendar/base/content/calendar-management.js new file mode 100644 index 0000000000..351c44b184 --- /dev/null +++ b/comm/calendar/base/content/calendar-management.js @@ -0,0 +1,721 @@ +/* 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 sortCalendarArray, gDataMigrator, calendarUpdateNewItemsCommand, currentView */ + +/* exported promptDeleteCalendar, loadCalendarManager, unloadCalendarManager, + * calendarListTooltipShowing, calendarListSetupContextMenu, + * ensureCalendarVisible, toggleCalendarVisible, showAllCalendars, + * showOnlyCalendar, calendarOfflineManager, openLocalCalendar, + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Get this window's currently selected calendar. + * + * @returns The currently selected calendar. + */ +function getSelectedCalendar() { + return cal.view.getCompositeCalendar(window).defaultCalendar; +} + +/** + * Deletes the passed calendar, prompting the user if he really wants to do + * this. If there is only one calendar left, no calendar is removed and the user + * is not prompted. + * + * @param aCalendar The calendar to delete. + */ +function promptDeleteCalendar(aCalendar) { + let calendars = cal.manager.getCalendars(); + if (calendars.length <= 1) { + // If this is the last calendar, don't delete it. + return; + } + + let modes = new Set(aCalendar.getProperty("capabilities.removeModes") || ["unsubscribe"]); + let title = cal.l10n.getCalString("removeCalendarTitle"); + + let textKey, b0text, b2text; + let removeFlags = 0; + let promptFlags = + Ci.nsIPromptService.BUTTON_POS_0 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + + Ci.nsIPromptService.BUTTON_POS_1 * Ci.nsIPromptService.BUTTON_TITLE_CANCEL; + + if (modes.has("delete") && !modes.has("unsubscribe")) { + textKey = "removeCalendarMessageDelete"; + promptFlags += Ci.nsIPromptService.BUTTON_DELAY_ENABLE; + b0text = cal.l10n.getCalString("removeCalendarButtonDelete"); + } else if (modes.has("delete")) { + textKey = "removeCalendarMessageDeleteOrUnsubscribe"; + promptFlags += Ci.nsIPromptService.BUTTON_POS_2 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING; + b0text = cal.l10n.getCalString("removeCalendarButtonUnsubscribe"); + b2text = cal.l10n.getCalString("removeCalendarButtonDelete"); + } else if (modes.has("unsubscribe")) { + textKey = "removeCalendarMessageUnsubscribe"; + removeFlags |= Ci.calICalendarManager.REMOVE_NO_DELETE; + b0text = cal.l10n.getCalString("removeCalendarButtonUnsubscribe"); + } else { + return; + } + + let text = cal.l10n.getCalString(textKey, [aCalendar.name]); + let res = Services.prompt.confirmEx( + window, + title, + text, + promptFlags, + b0text, + null, + b2text, + null, + {} + ); + + if (res != 1) { + // Not canceled + if (textKey == "removeCalendarMessageDeleteOrUnsubscribe" && res == 0) { + // Both unsubscribing and deleting is possible, but unsubscribing was + // requested. Make sure no delete is executed. + removeFlags |= Ci.calICalendarManager.REMOVE_NO_DELETE; + } + + cal.manager.removeCalendar(aCalendar, removeFlags); + } +} + +/** + * Call to refresh the status image of a calendar item when the + * calendar-readfailed or calendar-readonly attributes are added or removed. + * + * @param {MozRichlistitem} item - The calendar item to update. + */ +function updateCalendarStatusIndicators(item) { + let calendarName = item.querySelector(".calendar-name").textContent; + let image = item.querySelector("img.calendar-readstatus"); + if (item.hasAttribute("calendar-readfailed")) { + image.setAttribute("src", "chrome://messenger/skin/icons/new/compact/warning.svg"); + let tooltip = cal.l10n.getCalString("tooltipCalendarDisabled", [calendarName]); + image.setAttribute("title", tooltip); + } else if (item.hasAttribute("calendar-readonly")) { + image.setAttribute("src", "chrome://messenger/skin/icons/new/compact/lock.svg"); + let tooltip = cal.l10n.getCalString("tooltipCalendarReadOnly", [calendarName]); + image.setAttribute("title", tooltip); + } else { + image.removeAttribute("src"); + image.removeAttribute("title"); + } +} + +/** + * Called to initialize the calendar manager for a window. + */ +async function loadCalendarManager() { + let calendarList = document.getElementById("calendar-list"); + + // Set up the composite calendar in the calendar list widget. + let compositeCalendar = cal.view.getCompositeCalendar(window); + + // Initialize our composite observer + compositeCalendar.addObserver(compositeObserver); + + // Create the home calendar if no calendar exists. + let calendars = cal.manager.getCalendars(); + if (calendars.length) { + // migration code to make sure calendars, which do not support caching have cache enabled + // required to further clean up on top of bug 1182264 + for (let calendar of calendars) { + if ( + calendar.getProperty("cache.supported") === false && + calendar.getProperty("cache.enabled") === true + ) { + calendar.deleteProperty("cache.enabled"); + } + } + } else { + initHomeCalendar(); + } + + for (let calendar of sortCalendarArray(cal.manager.getCalendars())) { + addCalendarItem(calendar); + } + + function addCalendarItem(calendar) { + let item = document + .getElementById("calendar-list-item") + .content.firstElementChild.cloneNode(true); + let forceDisabled = calendar.getProperty("force-disabled"); + item.id = `calendar-listitem-${calendar.id}`; + item.searchLabel = calendar.name; + item.setAttribute("aria-label", calendar.name); + item.setAttribute("calendar-id", calendar.id); + item.toggleAttribute("calendar-disabled", calendar.getProperty("disabled")); + item.toggleAttribute( + "calendar-readfailed", + !Components.isSuccessCode(calendar.getProperty("currentStatus")) || forceDisabled + ); + item.toggleAttribute("calendar-readonly", calendar.readOnly); + item.toggleAttribute("calendar-muted", calendar.getProperty("suppressAlarms")); + document.l10n.setAttributes( + item.querySelector(".calendar-mute-status"), + "calendar-no-reminders-tooltip", + { calendarName: calendar.name } + ); + document.l10n.setAttributes( + item.querySelector(".calendar-more-button"), + "calendar-list-item-context-button", + { calendarName: calendar.name } + ); + + let cssSafeId = cal.view.formatStringForCSSRule(calendar.id); + let colorMarker = item.querySelector(".calendar-color"); + if (calendar.getProperty("disabled")) { + colorMarker.style.backgroundColor = "transparent"; + colorMarker.style.border = `2px solid var(--calendar-${cssSafeId}-backcolor)`; + } else { + colorMarker.style.backgroundColor = `var(--calendar-${cssSafeId}-backcolor)`; + } + + let label = item.querySelector(".calendar-name"); + label.textContent = calendar.name; + + updateCalendarStatusIndicators(item); + + let enable = item.querySelector(".calendar-enable-button"); + document.l10n.setAttributes(enable, "calendar-enable-button"); + + enable.hidden = forceDisabled || !calendar.getProperty("disabled"); + + let displayedCheckbox = item.querySelector(".calendar-displayed"); + displayedCheckbox.checked = calendar.getProperty("calendar-main-in-composite"); + displayedCheckbox.hidden = calendar.getProperty("disabled"); + let stringName = cal.view.getCompositeCalendar(window).getCalendarById(calendar.id) + ? "hideCalendar" + : "showCalendar"; + displayedCheckbox.setAttribute("title", cal.l10n.getCalString(stringName, [calendar.name])); + + calendarList.appendChild(item); + if (calendar.getProperty("calendar-main-default")) { + // The list needs to handle the addition of the row before we can select it. + setTimeout(() => { + calendarList.selectedIndex = calendarList.rows.indexOf(item); + }); + } + } + + function saveSortOrder() { + let order = [...calendarList.children].map(i => i.getAttribute("calendar-id")); + Services.prefs.setStringPref("calendar.list.sortOrder", order.join(" ")); + try { + Services.prefs.savePrefFile(null); + } catch (ex) { + cal.ERROR(ex); + } + } + + calendarList.addEventListener("click", event => { + if (event.target.matches(".calendar-enable-button")) { + let calendar = cal.manager.getCalendarById( + event.target.closest("li").getAttribute("calendar-id") + ); + calendar.setProperty("disabled", false); + calendarList.focus(); + return; + } + + if (!event.target.matches(".calendar-displayed")) { + return; + } + + let item = event.target.closest("li"); + let calendarId = item.getAttribute("calendar-id"); + let calendar = cal.manager.getCalendarById(calendarId); + + if (event.target.checked) { + compositeCalendar.addCalendar(calendar); + } else { + compositeCalendar.removeCalendar(calendar); + } + + let stringName = event.target.checked ? "hideCalendar" : "showCalendar"; + event.target.setAttribute("title", cal.l10n.getCalString(stringName, [calendar.name])); + + calendarList.focus(); + }); + calendarList.addEventListener("dblclick", event => { + if ( + event.target.matches(".calendar-displayed") || + event.target.matches(".calendar-enable-button") + ) { + return; + } + + let item = event.target.closest("li"); + if (!item) { + // Click on an empty part of the richlistbox. + cal.window.openCalendarWizard(window); + return; + } + + let calendarId = item.getAttribute("calendar-id"); + let calendar = cal.manager.getCalendarById(calendarId); + cal.window.openCalendarProperties(window, { calendar }); + }); + calendarList.addEventListener("ordered", event => { + saveSortOrder(); + calendarList.selectedIndex = calendarList.rows.indexOf(event.detail); + }); + calendarList.addEventListener("keypress", event => { + let item = calendarList.rows[calendarList.selectedIndex]; + let calendarId = item.getAttribute("calendar-id"); + let calendar = cal.manager.getCalendarById(calendarId); + + switch (event.key) { + case "Delete": + promptDeleteCalendar(calendar); + break; + case " ": { + if (item.querySelector(".calendar-displayed").checked) { + compositeCalendar.removeCalendar(calendar); + } else { + compositeCalendar.addCalendar(calendar); + } + let stringName = item.querySelector(".calendar-displayed").checked + ? "hideCalendar" + : "showCalendar"; + item + .querySelector(".calendar-displayed") + .setAttribute("title", cal.l10n.getCalString(stringName, [calendar.name])); + break; + } + } + }); + calendarList.addEventListener("select", event => { + let item = calendarList.rows[calendarList.selectedIndex]; + let calendarId = item.getAttribute("calendar-id"); + let calendar = cal.manager.getCalendarById(calendarId); + + compositeCalendar.defaultCalendar = calendar; + }); + + calendarList._calendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + + onStartBatch() {}, + onEndBatch() {}, + onLoad() {}, + onAddItem(item) {}, + onModifyItem(newItem, oldItem) {}, + onDeleteItem(deletedItem) {}, + onError(calendar, errNo, message) {}, + + onPropertyChanged(calendar, name, value, oldValue) { + let item = calendarList.getElementsByAttribute("calendar-id", calendar.id)[0]; + if (!item) { + return; + } + + switch (name) { + case "disabled": + item.toggleAttribute("calendar-disabled", value); + item.querySelector(".calendar-displayed").hidden = value; + // Update the "ENABLE" button. + let enableButton = item.querySelector(".calendar-enable-button"); + enableButton.hidden = !value; + // Update the color preview. + let cssSafeId = cal.view.formatStringForCSSRule(calendar.id); + let colorMarker = item.querySelector(".calendar-color"); + colorMarker.style.backgroundColor = value + ? "transparent" + : `var(--calendar-${cssSafeId}-backcolor)`; + colorMarker.style.border = value + ? `2px solid var(--calendar-${cssSafeId}-backcolor)` + : "none"; + break; + case "calendar-main-default": + if (value) { + calendarList.selectedIndex = calendarList.rows.indexOf(item); + } + break; + case "calendar-main-in-composite": + item.querySelector(".calendar-displayed").checked = value; + break; + case "name": + item.searchLabel = calendar.name; + item.querySelector(".calendar-name").textContent = value; + break; + case "currentStatus": + case "force-disabled": + item.toggleAttribute( + "calendar-readfailed", + name == "currentStatus" ? !Components.isSuccessCode(value) : value + ); + updateCalendarStatusIndicators(item); + break; + case "readOnly": + item.toggleAttribute("calendar-readonly", value); + updateCalendarStatusIndicators(item); + break; + case "suppressAlarms": + item.toggleAttribute("calendar-muted", value); + break; + } + }, + + onPropertyDeleting(calendar, name) { + // Since the old value is not used directly in onPropertyChanged, but + // should not be the same as the value, set it to a different value. + this.onPropertyChanged(calendar, name, null, null); + }, + }; + cal.manager.addCalendarObserver(calendarList._calendarObserver); + + calendarList._calendarManagerObserver = { + QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver"]), + + onCalendarRegistered(calendar) { + addCalendarItem(calendar); + saveSortOrder(); + }, + onCalendarUnregistering(calendar) { + let item = calendarList.getElementsByAttribute("calendar-id", calendar.id)[0]; + item.remove(); + saveSortOrder(); + }, + onCalendarDeleting(calendar) {}, + }; + cal.manager.addObserver(calendarList._calendarManagerObserver); +} + +/** + * Creates the initial "Home" calendar if no calendar exists. + */ +function initHomeCalendar() { + let composite = cal.view.getCompositeCalendar(window); + let url = Services.io.newURI("moz-storage-calendar://"); + let homeCalendar = cal.manager.createCalendar("storage", url); + homeCalendar.name = cal.l10n.getCalString("homeCalendarName"); + homeCalendar.setProperty("disabled", true); + + cal.manager.registerCalendar(homeCalendar); + Services.prefs.setStringPref("calendar.list.sortOrder", homeCalendar.id); + composite.addCalendar(homeCalendar); + + // Wrapping this in a try/catch block, as if any of the migration code + // fails, the app may not load. + if (Services.prefs.getBoolPref("calendar.migrator.enabled", true)) { + try { + gDataMigrator.checkAndMigrate(); + } catch (e) { + console.error("Migrator error: " + e); + } + } + + return homeCalendar; +} + +/** + * Called to clean up the calendar manager for a window. + */ +function unloadCalendarManager() { + let compositeCalendar = cal.view.getCompositeCalendar(window); + compositeCalendar.setStatusObserver(null, null); + compositeCalendar.removeObserver(compositeObserver); + + let calendarList = document.getElementById("calendar-list"); + cal.manager.removeCalendarObserver(calendarList._calendarObserver); + cal.manager.removeObserver(calendarList._calendarManagerObserver); +} + +/** + * A handler called to set up the context menu on the calendar list. + * + * @param {Event} event - The click DOMEvent. + */ + +function calendarListSetupContextMenu(event) { + let calendar; + let composite = cal.view.getCompositeCalendar(window); + + if (event.target.matches(".calendar-displayed")) { + return; + } + + let item = event.target.closest("li"); + if (item) { + let calendarList = document.getElementById("calendar-list"); + calendarList.selectedIndex = calendarList.rows.indexOf(item); + let calendarId = item.getAttribute("calendar-id"); + calendar = cal.manager.getCalendarById(calendarId); + } + + document.getElementById("list-calendars-context-menu").contextCalendar = calendar; + + for (let elem of document.querySelectorAll("#list-calendars-context-menu .needs-calendar")) { + elem.hidden = !calendar; + } + if (calendar) { + let stringName = composite.getCalendarById(calendar.id) ? "hideCalendar" : "showCalendar"; + document.getElementById("list-calendars-context-togglevisible").label = cal.l10n.getCalString( + stringName, + [calendar.name] + ); + let accessKey = document + .getElementById("list-calendars-context-togglevisible") + .getAttribute(composite.getCalendarById(calendar.id) ? "accesskeyhide" : "accesskeyshow"); + document.getElementById("list-calendars-context-togglevisible").accessKey = accessKey; + document.getElementById("list-calendars-context-showonly").label = cal.l10n.getCalString( + "showOnlyCalendar", + [calendar.name] + ); + setupDeleteMenuitem("list-calendars-context-delete", calendar); + document.getElementById("list-calendar-context-reload").hidden = !calendar.canRefresh; + document.getElementById("list-calendars-context-reload-menuseparator").hidden = + !calendar.canRefresh; + } +} + +/** + * Trigger the opening of the calendar list item context menu. + * + * @param {Event} event - The click DOMEvent. + */ +function openCalendarListItemContext(event) { + calendarListSetupContextMenu(event); + let popUpCalListMenu = document.getElementById("list-calendars-context-menu"); + if (event.type == "contextmenu" && event.button == 2) { + // This is a right-click. Open where it happened. + popUpCalListMenu.openPopupAtScreen(event.screenX, event.screenY, true); + return; + } + popUpCalListMenu.openPopup(event.target, "after_start", 0, 0, true); +} + +/** + * Changes the "delete calendar" menuitem to have the right label based on the + * removeModes. The menuitem must have the attributes "labelremove", + * "labeldelete" and "labelunsubscribe". + * + * @param aDeleteId The id of the menuitem to delete the calendar + */ +function setupDeleteMenuitem(aDeleteId, aCalendar) { + let calendar = aCalendar === undefined ? getSelectedCalendar() : aCalendar; + let modes = new Set( + calendar ? calendar.getProperty("capabilities.removeModes") || ["unsubscribe"] : [] + ); + + let type = "remove"; + if (modes.has("delete") && !modes.has("unsubscribe")) { + type = "delete"; + } else if (modes.has("unsubscribe") && !modes.has("delete")) { + type = "unsubscribe"; + } + + let deleteItem = document.getElementById(aDeleteId); + // Dynamically set labelremove, labeldelete, labelunsubscribe + deleteItem.label = deleteItem.getAttribute("label" + type); + // Dynamically set accesskeyremove, accesskeydelete, accesskeyunsubscribe + deleteItem.accessKey = deleteItem.getAttribute("accesskey" + type); +} + +/** + * Makes sure the passed calendar is visible to the user + * + * @param aCalendar The calendar to make visible. + */ +function ensureCalendarVisible(aCalendar) { + // We use the main window's calendar list to ensure that the calendar is visible. + // If the main window has been closed this function may still be called, + // like when an event/task window is still open and the user clicks 'save', + // thus we have the extra checks. + let calendarList = document.getElementById("calendar-list"); + if (calendarList) { + let compositeCalendar = cal.view.getCompositeCalendar(window); + compositeCalendar.addCalendar(aCalendar); + } +} + +/** + * Hides the specified calendar if it is visible, or shows it if it is hidden. + * + * @param aCalendar The calendar to show or hide + */ +function toggleCalendarVisible(aCalendar) { + let composite = cal.view.getCompositeCalendar(window); + if (composite.getCalendarById(aCalendar.id)) { + composite.removeCalendar(aCalendar); + } else { + composite.addCalendar(aCalendar); + } +} + +/** + * Shows all hidden calendars. + */ +function showAllCalendars() { + let composite = cal.view.getCompositeCalendar(window); + let cals = cal.manager.getCalendars(); + + composite.startBatch(); + for (let calendar of cals) { + if (!composite.getCalendarById(calendar.id)) { + composite.addCalendar(calendar); + } + } + composite.endBatch(); +} + +/** + * Shows only the specified calendar, and hides all others. + * + * @param aCalendar The calendar to show as the only visible calendar + */ +function showOnlyCalendar(aCalendar) { + let composite = cal.view.getCompositeCalendar(window); + let cals = composite.getCalendars() || []; + + composite.startBatch(); + for (let calendar of cals) { + if (calendar.id != aCalendar.id) { + composite.removeCalendar(calendar); + } + } + composite.addCalendar(aCalendar); + composite.endBatch(); +} + +var compositeObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver", "calICompositeObserver"]), + + onStartBatch() {}, + onEndBatch() {}, + + onLoad() { + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + onAddItem() {}, + onModifyItem() {}, + onDeleteItem() {}, + onError() {}, + + onPropertyChanged(calendar, name, value, oldValue) { + if (name == "disabled" || name == "readOnly") { + // Update commands when a calendar has been enabled or disabled. + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + } + }, + + onPropertyDeleting() {}, + + onCalendarAdded(aCalendar) { + // Update the calendar commands for number of remote calendars and for + // more than one calendar. + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + onCalendarRemoved(aCalendar) { + // Update commands to disallow deleting the last calendar and only + // allowing reload remote calendars when there are remote calendars. + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + onDefaultCalendarChanged(aNewCalendar) { + // A new default calendar may mean that the new calendar has different + // ACLs. Make sure the commands are updated. + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + }, +}; + +/** + * Shows the filepicker and creates a new calendar with a local file using the ICS + * provider. + */ +function openLocalCalendar() { + let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + picker.init(window, cal.l10n.getCalString("Open"), Ci.nsIFilePicker.modeOpen); + let wildmat = "*.ics"; + let description = cal.l10n.getCalString("filterIcs", [wildmat]); + picker.appendFilter(description, wildmat); + picker.appendFilters(Ci.nsIFilePicker.filterAll); + + picker.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !picker.file) { + return; + } + + let calendars = cal.manager.getCalendars(); + let calendar = calendars.find(x => x.uri.equals(picker.fileURL)); + if (!calendar) { + calendar = cal.manager.createCalendar("ics", picker.fileURL); + + // Strip ".ics" from filename for use as calendar name. + let prettyName = picker.fileURL.spec.match(/([^/:]+)\.ics$/); + if (prettyName) { + calendar.name = decodeURIComponent(prettyName[1]); + } else { + calendar.name = cal.l10n.getCalString("untitledCalendarName"); + } + + cal.manager.registerCalendar(calendar); + } + + let calendarList = document.getElementById("calendar-list"); + for (let index = 0; index < calendarList.rowCount; index++) { + if (calendarList.rows[index].getAttribute("calendar-id") == calendar.id) { + calendarList.selectedIndex = index; + break; + } + } + }); +} + +/** + * Calendar Offline Manager + */ +var calendarOfflineManager = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + init() { + if (this.initialized) { + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + Services.obs.addObserver(this, "network:offline-status-changed"); + + this.updateOfflineUI(!this.isOnline()); + this.initialized = true; + }, + + uninit() { + if (!this.initialized) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + Services.obs.removeObserver(this, "network:offline-status-changed"); + this.initialized = false; + }, + + isOnline() { + return !Services.io.offline; + }, + + updateOfflineUI(aIsOffline) { + // Refresh the current view + currentView().goToDay(currentView().selectedDay); + + // Set up disabled locks for offline + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + observe(aSubject, aTopic, aState) { + if (aTopic == "network:offline-status-changed") { + this.updateOfflineUI(aState == "offline"); + } + }, +}; diff --git a/comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml b/comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml new file mode 100644 index 0000000000..6967fad022 --- /dev/null +++ b/comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml @@ -0,0 +1,105 @@ +# 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/. + +<menu id="menu_Event_Task" + class="hide-when-calendar-deactivated" + label="&lightning.menu.eventtask.label;" + accesskey="&lightning.menu.eventtask.accesskey;"> + <menupopup id="menu_Event_Task_Popup" onpopupshowing="changeMenuForTask(event); setupDeleteMenuitem('calDeleteSelectedCalendar')"> + <menuitem id="calNewEvent2" + label="&event.new.event;" + accesskey="&event.new.event.accesskey;" + key="calendar-new-event-key" + command="calendar_new_event_command"/> + <menuitem id="calNewTask2" + label="&event.new.task;" + accesskey="&event.new.task.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_command"/> + <menuseparator id="before-Calendar-Mode-Section"/> + <menuitem id="calMenuSwitchToCalendar" + type="checkbox" + label="&lightning.toolbar.calendar.label;" + accesskey="&lightning.toolbar.calendar.accesskey;" + command="switch2calendar" + value="calendar" + autocheck="false" + data-l10n-attrs="acceltext"> + </menuitem> + <menuitem id="calMenuSwitchToTask" + type="checkbox" + label="&lightning.toolbar.task.label;" + accesskey="&lightning.toolbar.task.accesskey;" + command="switch2task" + value="task" + autocheck="false" + data-l10n-attrs="acceltext"> + </menuitem> + <menuseparator id="calBeforeCalendarSection"/> + <menuitem id="calExportCalendar" + label="&calendar.export.label;" + accesskey="&calendar.export.accesskey;" + command="calendar_export_command"/> + <menuitem id="calImportCalendar" + label="&calendar.import.label;" + accesskey="&calendar.import.accesskey;" + command="calendar_import_command"/> + <menuitem id="calPublishCalendar" + label="&calendar.publish.label;" + accesskey="&calendar.publish.accesskey;" + command="calendar_publish_calendar_command"/> + <menuitem id="calDeleteSelectedCalendar" + labeldelete="&calendar.deletecalendar.label;" + labelremove="&calendar.removecalendar.label;" + labelunsubscribe="&calendar.unsubscribecalendar.label;" + accesskeydelete="&calendar.deletecalendar.accesskey;" + accesskeyremove="&calendar.removecalendar.accesskey;" + accesskeyunsubscribe="&calendar.unsubscribecalendar.accesskey;" + command="calendar_delete_calendar_command"/> + <menuseparator id="calBeforeTaskActions"/> + <menuitem id="calTaskActionsMarkCompletedMenuitem" + type="checkbox" + label="&calendar.context.markcompleted.label;" + accesskey="&calendar.context.markcompleted.accesskey;" + command="calendar_toggle_completed_command"/> + <menu id="calTaskActionsProgressMenuitem" + label="&calendar.context.progress.label;" + accesskey="&calendar.context.progress.accesskey;" + command="calendar_general-progress_command"> + <menupopup is="calendar-task-progress-menupopup"/> + </menu> + <menu id="calTaskActionsPriorityMenuitem" + label="&calendar.context.priority.label;" + accesskey="&calendar.context.priority.accesskey;" + command="calendar_general-priority_command"> + <menupopup is="calendar-task-priority-menupopup"/> + </menu> + <menu id="calTaskActionsPostponeMenuitem" + label="&calendar.context.postpone.label;" + accesskey="&calendar.context.postpone.accesskey;" + command="calendar_general-postpone_command"> + <menupopup id="calTaskActionsPostponeMenuPopup"> + <menuitem id="calTaskActionsPostponeMenu-1hour" + label="&calendar.context.postpone.1hour.label;" + accesskey="&calendar.context.postpone.1hour.accesskey;" + command="calendar_postpone-1hour_command"/> + <menuitem id="calTaskActionsPostponeMenu-1day" + label="&calendar.context.postpone.1day.label;" + accesskey="&calendar.context.postpone.1day.accesskey;" + command="calendar_postpone-1day_command"/> + <menuitem id="calTaskActionsPostponeMenu-1week" + label="&calendar.context.postpone.1week.label;" + accesskey="&calendar.context.postpone.1week.accesskey;" + command="calendar_postpone-1week_command"/> + </menupopup> + </menu> + <menuseparator id="calBeforeUnifinderSection" /> + <menuitem id="calShowUnifinder" + type="checkbox" + checked="true" + label="&showUnifinderCmd.label;" + accesskey="&showUnifinderCmd.accesskey;" + command="calendar_show_unifinder_command"/> + </menupopup> +</menu> diff --git a/comm/calendar/base/content/calendar-menus.js b/comm/calendar/base/content/calendar-menus.js new file mode 100644 index 0000000000..7dd71c3649 --- /dev/null +++ b/comm/calendar/base/content/calendar-menus.js @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from item-editing/calendar-item-panel.js */ + +// Importing from calendar-task-tree-utils.js puts ESLint in a fatal loop. +/* globals getSelectedTasks, MozElements, MozXULElement, + setAttributeOnChildrenOrTheirCommands */ + +"use strict"; + +// Wrap in a block and use const to define functions to prevent leaking to window scope. +{ + /** + * Get a property value for a group of tasks. If all the tasks have the same property value + * then return that value, otherwise return null. + * + * @param {string} propertyKey - The property key. + * @param {object[]} tasks - The tasks. + * @returns {string|null} The property value or null. + */ + const getPropertyValue = (propertyKey, tasks) => { + let propertyValue = null; + const tasksSelected = tasks != null && tasks.length > 0; + if (tasksSelected && tasks.every(task => task[propertyKey] == tasks[0][propertyKey])) { + propertyValue = tasks[0][propertyKey]; + } + return propertyValue; + }; + + /** + * Updates the 'checked' state of menu items so they reflect the state of the relevant task(s), + * for example, tasks currently selected in the task list, or a task being edited in the + * current tab. It operates on commands that are named using the following pattern: + * + * 'calendar_' + propertyKey + ' + '-' + propertyValue + '_command' + * + * When the propertyValue part of a command's name matches the propertyValue of the tasks, + * set the command to 'checked=true', as long as the tasks all have the same propertyValue. + * + * @param parent {Element} - Parent element that contains the menu items as direct children. + * @param propertyKey {string} - The property key, for example "priority" or "percentComplete". + */ + const updateMenuItemsState = (parent, propertyKey) => { + setAttributeOnChildrenOrTheirCommands("checked", false, parent); + + const inSingleTaskTab = + gTabmail && gTabmail.currentTabInfo && gTabmail.currentTabInfo.mode.type == "calendarTask"; + + const propertyValue = inSingleTaskTab + ? gConfig[propertyKey] + : getPropertyValue(propertyKey, getSelectedTasks()); + + if (propertyValue || propertyValue === 0) { + const commandName = "calendar_" + propertyKey + "-" + propertyValue + "_command"; + const command = document.getElementById(commandName); + if (command) { + command.setAttribute("checked", "true"); + } + } + }; + + /** + * A menupopup for changing the "progress" (percent complete) status for a task or tasks. It + * indicates the current status by displaying a checkmark next to the menu item for that status. + * + * @augments MozElements.MozMenuPopup + */ + class CalendarTaskProgressMenupopup extends MozElements.MozMenuPopup { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true in super.connectedCallback + super.connectedCallback(); + + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <menuitem class="percent-0-menuitem" + type="checkbox" + label="&progress.level.0;" + accesskey="&progress.level.0.accesskey;" + command="calendar_percentComplete-0_command"/> + <menuitem class="percent-25-menuitem" + type="checkbox" + label="&progress.level.25;" + accesskey="&progress.level.25.accesskey;" + command="calendar_percentComplete-25_command"/> + <menuitem class="percent-50-menuitem" + type="checkbox" + label="&progress.level.50;" + accesskey="&progress.level.50.accesskey;" + command="calendar_percentComplete-50_command"/> + <menuitem class="percent-75-menuitem" + type="checkbox" + label="&progress.level.75;" + accesskey="&progress.level.75.accesskey;" + command="calendar_percentComplete-75_command"/> + <menuitem class="percent-100-menuitem" + type="checkbox" + label="&progress.level.100;" + accesskey="&progress.level.100.accesskey;" + command="calendar_percentComplete-100_command"/> + `, + ["chrome://calendar/locale/calendar.dtd"] + ) + ); + + this.addEventListener( + "popupshowing", + updateMenuItemsState.bind(null, this, "percentComplete"), + true + ); + } + } + + customElements.define("calendar-task-progress-menupopup", CalendarTaskProgressMenupopup, { + extends: "menupopup", + }); + + /** + * A menupopup for changing the "priority" status for a task or tasks. It indicates the current + * status by displaying a checkmark next to the menu item for that status. + * + * @augments MozElements.MozMenuPopup + */ + class CalendarTaskPriorityMenupopup extends MozElements.MozMenuPopup { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true in super.connectedCallback + super.connectedCallback(); + + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <menuitem class="priority-0-menuitem" + type="checkbox" + label="&priority.level.none;" + accesskey="&priority.level.none.accesskey;" + command="calendar_priority-0_command"/> + <menuitem class="priority-9-menuitem" + type="checkbox" + label="&priority.level.low;" + accesskey="&priority.level.low.accesskey;" + command="calendar_priority-9_command"/> + <menuitem class="priority-5-menuitem" + type="checkbox" + label="&priority.level.normal;" + accesskey="&priority.level.normal.accesskey;" + command="calendar_priority-5_command"/> + <menuitem class="priority-1-menuitem" + type="checkbox" + label="&priority.level.high;" + accesskey="&priority.level.high.accesskey;" + command="calendar_priority-1_command"/> + `, + ["chrome://calendar/locale/calendar.dtd"] + ) + ); + + this.addEventListener( + "popupshowing", + updateMenuItemsState.bind(null, this, "priority"), + true + ); + } + } + + customElements.define("calendar-task-priority-menupopup", CalendarTaskPriorityMenupopup, { + extends: "menupopup", + }); +} diff --git a/comm/calendar/base/content/calendar-migration.js b/comm/calendar/base/content/calendar-migration.js new file mode 100644 index 0000000000..e5a5a1add2 --- /dev/null +++ b/comm/calendar/base/content/calendar-migration.js @@ -0,0 +1,323 @@ +/* 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 putItemsIntoCal*/ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); + +/** + * A data migrator prototype, holding the information for migration + * + * @class + * @param aTitle The title of the migrator + * @param aMigrateFunction The function to call when migrating + * @param aArguments The arguments to pass in. + */ +function dataMigrator(aTitle, aMigrateFunction, aArguments) { + this.title = aTitle; + this.migrate = aMigrateFunction; + this.args = aArguments || []; +} + +var gDataMigrator = { + /** + * Call to do a general data migration (for a clean profile) Will run + * through all of the known migrator-checkers. These checkers will return + * an array of valid dataMigrator objects, for each kind of data they find. + * If there is at least one valid migrator, we'll pop open the migration + * wizard, otherwise, we'll return silently. + */ + checkAndMigrate() { + let DMs = []; + let migrators = [this.checkEvolution, this.checkWindowsMail, this.checkIcal]; + for (let migrator of migrators) { + let migs = migrator.call(this); + for (let mig of migs) { + DMs.push(mig); + } + } + + if (DMs.length == 0) { + // No migration available + return; + } + + let url = "chrome://calendar/content/calendar-migration-dialog.xhtml"; + if (AppConstants.platform == "macosx") { + let win = Services.wm.getMostRecentWindow("Calendar:MigrationWizard"); + if (win) { + win.focus(); + } else { + openDialog(url, "migration", "centerscreen,chrome,resizable=no,width=500,height=400", DMs); + } + } else { + openDialog( + url, + "migration", + "modal,centerscreen,chrome,resizable=no,width=500,height=400", + DMs + ); + } + }, + + /** + * Checks to see if Apple's iCal is installed and offers to migrate any data + * the user has created in it. + */ + checkIcal() { + function icalMigrate(aDataDir, aCallback) { + aDataDir.append("Sources"); + + let i = 1; + for (let dataDir of aDataDir.directoryEntries) { + let dataStore = dataDir.clone(); + dataStore.append("corestorage.ics"); + if (!dataStore.exists()) { + continue; + } + + let fileStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + + fileStream.init(dataStore, 0x01, parseInt("0444", 8), {}); + let convIStream = Cc["@mozilla.org/intl/converter-input-stream;1"].getService( + Ci.nsIConverterInputStream + ); + convIStream.init(fileStream, "UTF-8", 0, 0x0000); + let tmpStr = {}; + let str = ""; + while (convIStream.readString(-1, tmpStr)) { + str += tmpStr.value; + } + + // Strip out the timezone definitions, since it makes the file + // invalid otherwise + let index = str.indexOf(";TZID="); + while (index != -1) { + let endIndex = str.indexOf(":", index); + let otherEnd = str.indexOf(";", index + 2); + if (otherEnd < endIndex) { + endIndex = otherEnd; + } + let sub = str.substring(index, endIndex); + str = str.split(sub).join(""); + index = str.indexOf(";TZID="); + } + let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("icalTemp.ics"); + tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8)); + + let stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + stream.init(tempFile, 0x2a, parseInt("0600", 8), 0); + let convOStream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance( + Ci.nsIConverterOutputStream + ); + convOStream.init(stream, "UTF-8"); + convOStream.writeString(str); + + let calendar = gDataMigrator.importICSToStorage(tempFile); + calendar.name = "iCalendar" + i; + i++; + cal.manager.registerCalendar(calendar); + cal.view.getCompositeCalendar(window).addCalendar(calendar); + } + console.debug("icalMig making callback"); + aCallback(); + } + + console.debug("Checking for ical data"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + let icalSpec = profileDir.path; + let diverge = icalSpec.indexOf("Thunderbird"); + if (diverge == -1) { + return []; + } + icalSpec = icalSpec.substr(0, diverge); + let icalFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + icalFile.initWithPath(icalSpec); + icalFile.append("Application Support"); + + icalFile.append("iCal"); + if (icalFile.exists()) { + return [new dataMigrator("Apple iCal", icalMigrate, [icalFile])]; + } + + return []; + }, + + /** + * Checks to see if Evolution is installed and offers to migrate any data + * stored there. + */ + checkEvolution() { + function evoMigrate(aDataDir, aCallback) { + let i = 1; + let evoDataMigrate = function (dataStore) { + console.debug("Migrating evolution data file in " + dataStore.path); + if (dataStore.exists()) { + let calendar = gDataMigrator.importICSToStorage(dataStore); + calendar.name = "Evolution " + i++; + cal.manager.registerCalendar(calendar); + cal.view.getCompositeCalendar(window).addCalendar(calendar); + } + return dataStore.exists(); + }; + + for (let dataDir of aDataDir.directoryEntries) { + let dataStore = dataDir.clone(); + dataStore.append("calendar.ics"); + evoDataMigrate(dataStore); + } + + aCallback(); + } + + let evoDir = Services.dirsvc.get("Home", Ci.nsIFile); + evoDir.append(".evolution"); + evoDir.append("calendar"); + evoDir.append("local"); + return evoDir.exists() ? [new dataMigrator("Evolution", evoMigrate, [evoDir])] : []; + }, + + checkWindowsMail() { + function doMigrate(aCalendarNodes, aMailDir, aCallback) { + for (let node of aCalendarNodes) { + let name = node.getElementsByTagName("Name")[0].textContent; + let color = node.getElementsByTagName("Color")[0].textContent; + let enabled = node.getElementsByTagName("Enabled")[0].textContent == "True"; + + // The name is quoted, and the color also contains an alpha + // value. Lets just ignore the alpha value and take the + // color part. + name = name.replace(/(^'|'$)/g, ""); + color = color.replace(/0x[0-9a-fA-F]{2}([0-9a-fA-F]{4})/, "#$1"); + + let calfile = aMailDir.clone(); + calfile.append(name + ".ics"); + + if (calfile.exists()) { + let storage = gDataMigrator.importICSToStorage(calfile); + storage.name = name; + + if (color) { + storage.setProperty("color", color); + } + cal.manager.registerCalendar(storage); + + if (enabled) { + cal.view.getCompositeCalendar(window).addCalendar(storage); + } + } + } + aCallback(); + } + + if (!Services.dirsvc.has("LocalAppData")) { + // We are probably not on windows + return []; + } + + let maildir = Services.dirsvc.get("LocalAppData", Ci.nsIFile); + + maildir.append("Microsoft"); + maildir.append("Windows Calendar"); + maildir.append("Calendars"); + + let settingsxml = maildir.clone(); + settingsxml.append("Settings.xml"); + + let migrators = []; + if (settingsxml.exists()) { + let settingsXmlUri = Services.io.newFileURI(settingsxml); + + let req = new XMLHttpRequest(); + req.open("GET", settingsXmlUri.spec, false); + req.send(null); + if (req.status == 0) { + // The file was found, it seems we are on windows vista. + let doc = req.responseXML; + + // Get all calendar property tags and return the migrator. + let calendars = doc.getElementsByTagName("VCalendar"); + if (calendars.length > 0) { + migrators = [ + new dataMigrator("Windows Calendar", doMigrate.bind(null, calendars, maildir)), + ]; + } + } + } + return migrators; + }, + + /** + * Creates and registers a storage calendar and imports the given ics file into it. + * + * @param icsFile The nsI(Local)File to import. + */ + importICSToStorage(icsFile) { + const uri = "moz-storage-calendar://"; + let calendar = cal.manager.createCalendar("storage", Services.io.newURI(uri)); + let icsImporter = Cc["@mozilla.org/calendar/import;1?type=ics"].getService(Ci.calIImporter); + + let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + let items = []; + + calendar.id = cal.getUUID(); + + try { + const MODE_RDONLY = 0x01; + inputStream.init(icsFile, MODE_RDONLY, parseInt("0444", 8), {}); + items = icsImporter.importFromStream(inputStream); + } catch (ex) { + switch (ex.result) { + case Ci.calIErrors.INVALID_TIMEZONE: + cal.showError(cal.l10n.getCalString("timezoneError", [icsFile.path]), window); + break; + default: + cal.showError(cal.l10n.getCalString("unableToRead") + icsFile.path + "\n" + ex, window); + } + } finally { + inputStream.close(); + } + + // Defined in import-export.js + putItemsIntoCal(calendar, items, { + duplicateCount: 0, + failedCount: 0, + lastError: null, + + onDuplicate(item, error) { + this.duplicateCount++; + }, + onError(item, error) { + this.failedCount++; + this.lastError = error; + }, + onEnd() { + if (this.failedCount) { + cal.showError( + cal.l10n.getCalString("importItemsFailed", [ + this.failedCount, + this.lastError.toString(), + ]), + window + ); + } else if (this.duplicateCount) { + cal.showError( + cal.l10n.getCalString("duplicateError", [this.duplicateCount, icsFile.path]), + window + ); + } + }, + }); + + return calendar; + }, +}; diff --git a/comm/calendar/base/content/calendar-modes.js b/comm/calendar/base/content/calendar-modes.js new file mode 100644 index 0000000000..280a1c7a54 --- /dev/null +++ b/comm/calendar/base/content/calendar-modes.js @@ -0,0 +1,125 @@ +/* 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 TodayPane, switchToView, gLastShownCalendarView, ensureUnifinderLoaded */ + +/* exported calSwitchToCalendarMode, calSwitchToMode, calSwitchToTaskMode, + * changeMode + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * The current mode defining the current mode we're in. Allowed values are: + * - 'mail' + * - 'calendar' + * - 'task' + * - 'chat' + * - 'calendarEvent' + * - 'calendarTask' + * - 'special' - For special tabs like preferences, add-ons manager, about:xyz, etc. + * + * @global + */ +var gCurrentMode = "mail"; + +/** + * Changes the mode (gCurrentMode) and adapts the UI to the new mode. + * + * @param {string} [mode="mail"] - the new mode: 'mail', 'calendar', 'task', etc. + */ +function changeMode(mode = "mail") { + gCurrentMode = mode; // eslint-disable-line no-global-assign + + document + .querySelectorAll( + `menuitem[command="switch2calendar"],menuitem[command="switch2task"], + toolbarbutton[command="switch2calendar"],toolbarbutton[command="switch2task"]` + ) + .forEach(elem => { + elem.setAttribute("checked", elem.getAttribute("value") == gCurrentMode); + }); + + document.querySelectorAll("calendar-modebox,calendar-modevbox").forEach(elem => { + elem.setAttribute("current", gCurrentMode); + }); + + TodayPane.onModeModified(); +} + +/** + * For switching to modes like "mail", "chat", "calendarEvent", "calendarTask", or "special". + * (For "calendar" and "task" modes use calSwitchToCalendarMode and calSwitchToTaskMode.) + * + * @param {string} mode - The mode to switch to. + */ +function calSwitchToMode(mode) { + if (!["mail", "chat", "calendarEvent", "calendarTask", "special"].includes(mode)) { + cal.WARN("Attempted to switch to unknown mode: " + mode); + return; + } + if (gCurrentMode != mode) { + const previousMode = gCurrentMode; + changeMode(mode); + + if (previousMode == "calendar" || previousMode == "task") { + document.commandDispatcher.updateCommands("calendar_commands"); + } + window.setCursor("auto"); + } +} + +/** + * Switches to the calendar mode. + */ +function calSwitchToCalendarMode() { + if (gCurrentMode != "calendar") { + changeMode("calendar"); + + // display the calendar panel on the display deck + document.getElementById("calendar-view-box").collapsed = false; + document.getElementById("calendar-task-box").collapsed = true; + document.getElementById("sidePanelNewEvent").hidden = false; + document.getElementById("sidePanelNewTask").hidden = true; + + // show the last displayed type of calendar view + switchToView(gLastShownCalendarView.get()); + document.getElementById("calMinimonth").setAttribute("freebusy", "true"); + + document.commandDispatcher.updateCommands("calendar_commands"); + window.setCursor("auto"); + + // make sure the view is sized correctly + window.dispatchEvent(new CustomEvent("viewresize")); + + // Load the unifinder if it isn't already loaded. + ensureUnifinderLoaded(); + } +} + +/** + * Switches to the task mode. + */ +function calSwitchToTaskMode() { + if (gCurrentMode != "task") { + changeMode("task"); + + // display the task panel on the display deck + document.getElementById("calendar-view-box").collapsed = true; + document.getElementById("calendar-task-box").collapsed = false; + document.getElementById("sidePanelNewEvent").hidden = true; + document.getElementById("sidePanelNewTask").hidden = false; + + document.getElementById("calMinimonth").setAttribute("freebusy", "true"); + + let tree = document.getElementById("calendar-task-tree"); + if (!tree.hasBeenVisible) { + tree.hasBeenVisible = true; + tree.refresh(); + } + + document.commandDispatcher.updateCommands("calendar_commands"); + window.setCursor("auto"); + } +} diff --git a/comm/calendar/base/content/calendar-month-view.js b/comm/calendar/base/content/calendar-month-view.js new file mode 100644 index 0000000000..f4bd93b02d --- /dev/null +++ b/comm/calendar/base/content/calendar-month-view.js @@ -0,0 +1,1242 @@ +/* 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 calendarNavigationBar, MozElements, MozXULElement */ + +/* import-globals-from calendar-ui-utils.js */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + /** + * Implements the Drag and Drop class for the Month Day Box view. + * + * @augments {MozElements.CalendarDnDContainer} + */ + class CalendarMonthDayBox extends MozElements.CalendarDnDContainer { + static get inheritedAttributes() { + return { + ".calendar-month-week-label": "relation,selected", + ".calendar-month-day-label": "relation,selected,text=value", + }; + } + + constructor() { + super(); + this.addEventListener("mousedown", this.onMouseDown); + this.addEventListener("dblclick", this.onDblClick); + this.addEventListener("click", this.onClick); + this.addEventListener("wheel", this.onWheel); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true in super.connectedCallback. + super.connectedCallback(); + + this.mDate = null; + this.mItemHash = {}; + this.mShowMonthLabel = false; + + this.setAttribute("orient", "vertical"); + + let monthDayLabels = document.createElement("h2"); + monthDayLabels.classList.add("calendar-month-day-box-dates"); + + let weekLabel = document.createElement("span"); + weekLabel.setAttribute("data-label", "week"); + weekLabel.setAttribute("hidden", "true"); + weekLabel.style.pointerEvents = "none"; + weekLabel.classList.add("calendar-month-day-box-week-label", "calendar-month-week-label"); + + let dayLabel = document.createElement("span"); + dayLabel.setAttribute("data-label", "day"); + dayLabel.style.pointerEvents = "none"; + dayLabel.classList.add("calendar-month-day-box-date-label", "calendar-month-day-label"); + + monthDayLabels.appendChild(weekLabel); + monthDayLabels.appendChild(dayLabel); + + this.dayList = document.createElement("ol"); + this.dayList.classList.add("calendar-month-day-box-list"); + + this.appendChild(monthDayLabels); + this.appendChild(this.dayList); + + this.initializeAttributeInheritance(); + } + + get date() { + return this.mDate; + } + + set date(val) { + this.setDate(val); + } + + get selected() { + let sel = this.getAttribute("selected"); + if (sel && sel == "true") { + return true; + } + + return false; + } + + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + this.parentNode.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + this.parentNode.removeAttribute("selected"); + } + } + + get showMonthLabel() { + return this.mShowMonthLabel; + } + + set showMonthLabel(val) { + if (this.mShowMonthLabel == val) { + return; + } + this.mShowMonthLabel = val; + + if (!this.mDate) { + return; + } + if (val) { + this.setAttribute("value", cal.dtz.formatter.formatDateWithoutYear(this.mDate)); + } else { + this.setAttribute("value", this.mDate.day); + } + } + + clear() { + // Remove all the old events. + this.mItemHash = {}; + while (this.dayList.lastChild) { + this.dayList.lastChild.remove(); + } + } + + setDate(aDate) { + this.clear(); + + if (this.mDate && aDate && this.mDate.compare(aDate) == 0) { + return; + } + + this.mDate = aDate; + + if (!aDate) { + // Clearing out these attributes isn't strictly necessary but saves some confusion. + this.removeAttribute("year"); + this.removeAttribute("month"); + this.removeAttribute("week"); + this.removeAttribute("day"); + this.removeAttribute("value"); + return; + } + + // Set up DOM attributes for custom CSS coloring. + let weekTitle = cal.weekInfoService.getWeekTitle(aDate); + this.setAttribute("year", aDate.year); + this.setAttribute("month", aDate.month + 1); + this.setAttribute("week", weekTitle); + this.setAttribute("day", aDate.day); + + if (this.mShowMonthLabel) { + this.setAttribute("value", cal.dtz.formatter.formatDateWithoutYear(this.mDate)); + } else { + this.setAttribute("value", aDate.day); + } + } + + addItem(aItem) { + if (aItem.hashId in this.mItemHash) { + this.removeItem(aItem); + } + + let cssSafeId = cal.view.formatStringForCSSRule(aItem.calendar.id); + let box = document.createXULElement("calendar-month-day-box-item"); + let context = this.getAttribute("item-context") || this.getAttribute("context"); + box.setAttribute("context", context); + box.style.setProperty("--item-backcolor", `var(--calendar-${cssSafeId}-backcolor)`); + box.style.setProperty("--item-forecolor", `var(--calendar-${cssSafeId}-forecolor)`); + + let listItemWrapper = document.createElement("li"); + listItemWrapper.classList.add("calendar-month-day-box-list-item"); + listItemWrapper.appendChild(box); + cal.data.binaryInsertNode( + this.dayList, + listItemWrapper, + aItem, + cal.view.compareItems, + false, + // Access the calendar item from a list item wrapper. + wrapper => wrapper.firstChild.item + ); + + box.calendarView = this.calendarView; + box.item = aItem; + box.parentBox = this; + box.occurrence = aItem; + + this.mItemHash[aItem.hashId] = box; + return box; + } + + selectItem(aItem) { + if (aItem.hashId in this.mItemHash) { + this.mItemHash[aItem.hashId].selected = true; + } + } + + unselectItem(aItem) { + if (aItem.hashId in this.mItemHash) { + this.mItemHash[aItem.hashId].selected = false; + } + } + + removeItem(aItem) { + if (aItem.hashId in this.mItemHash) { + // Delete the list item wrapper. + let node = this.mItemHash[aItem.hashId].parentNode; + node.remove(); + delete this.mItemHash[aItem.hashId]; + } + } + + setDropShadow(on) { + let existing = this.dayList.querySelector(".dropshadow"); + if (on) { + if (!existing) { + // Insert an empty list item. + let dropshadow = document.createElement("li"); + dropshadow.classList.add("dropshadow", "calendar-month-day-box-list-item"); + this.dayList.insertBefore(dropshadow, this.dayList.firstElementChild); + } + } else if (existing) { + existing.remove(); + } + } + + onDropItem(aItem) { + // When item's timezone is different than the default one, the + // item might get moved on a day different than the drop day. + // Changing the drop day allows to compensate a possible difference. + + // Figure out if the timezones cause a days difference. + let start = ( + aItem[cal.dtz.startDateProp(aItem)] || aItem[cal.dtz.endDateProp(aItem)] + ).clone(); + let dayboxDate = this.mDate.clone(); + if (start.timezone != dayboxDate.timezone) { + let startInDefaultTz = start.clone().getInTimezone(dayboxDate.timezone); + start.isDate = true; + startInDefaultTz.isDate = true; + startInDefaultTz.timezone = start.timezone; + let dayDiff = start.subtractDate(startInDefaultTz); + // Change the day where to drop the item. + dayboxDate.addDuration(dayDiff); + } + + return cal.item.moveToDate(aItem, dayboxDate); + } + + onMouseDown(event) { + event.stopPropagation(); + if (this.mDate) { + this.calendarView.selectedDay = this.mDate; + } + } + + onDblClick(event) { + event.stopPropagation(); + this.calendarView.controller.createNewEvent(); + } + + onClick(event) { + if (event.button != 0) { + return; + } + + if (!(event.ctrlKey || event.metaKey)) { + this.calendarView.setSelectedItems([]); + } + } + + onWheel(event) { + if (cal.view.getParentNodeOrThisByAttribute(event.target, "data-label", "day") == null) { + if (this.dayList.scrollHeight > this.dayList.clientHeight) { + event.stopPropagation(); + } + } + } + } + + customElements.define("calendar-month-day-box", CalendarMonthDayBox); + + /** + * The MozCalendarMonthDayBoxItem widget is used as event item in the + * Multiweek and Month views of the calendar. It displays the event name, + * alarm icon and the category type color. + * + * @augments {MozElements.MozCalendarEditableItem} + */ + class MozCalendarMonthDayBoxItem extends MozElements.MozCalendarEditableItem { + static get inheritedAttributes() { + return { + ".alarm-icons-box": "flashing", + }; + } + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + // NOTE: This is the same structure as EditableItem, except this has a + // time label and we are missing the location-desc. + this.appendChild( + MozXULElement.parseXULToFragment(` + <html:img class="item-type-icon" alt="" /> + <html:div class="item-time-label"></html:div> + <html:div class="event-name-label"></html:div> + <html:input class="plain event-name-input" + hidden="hidden" + placeholder='${cal.l10n.getCalString("newEvent")}' /> + <html:div class="alarm-icons-box"></html:div> + <html:img class="item-classification-icon" /> + <html:img class="item-recurrence-icon" /> + <html:div class="calendar-category-box"></html:div> + `) + ); + this.timeLabel = this.querySelector(".item-time-label"); + + this.classList.add("calendar-color-box", "calendar-item-flex"); + + // We have two event listeners for dragstart. This event listener is for the capturing phase + // where we are setting up the document.monthDragEvent which will be used in the event listener + // in the bubbling phase which is set up in the calendar-editable-item. + this.addEventListener( + "dragstart", + event => { + document.monthDragEvent = this; + }, + true + ); + + this.style.pointerEvents = "auto"; + this.setAttribute("tooltip", "itemTooltip"); + this.addEventNameTextboxListener(); + this.initializeAttributeInheritance(); + } + + set occurrence(val) { + cal.ASSERT(!this.mOccurrence, "Code changes needed to set the occurrence twice", true); + this.mOccurrence = val; + let displayTime; + if (val.isEvent()) { + let type; + if (!val.startDate.isDate) { + let formatter = cal.dtz.formatter; + let parentTime = this.parentBox.date.clone(); + // Convert to the date-time for the start of the day. + parentTime.isDate = false; + // NOTE: Since this event was placed in this box, then we should be + // able to assume that the event starts before or on the same day, and + // it ends after or on the same day. + let startCompare = val.startDate.compare(parentTime); + // Go to the end of the day (midnight). + parentTime.day++; + let endCompare = val.endDate.compare(parentTime); + if (startCompare == -1) { + // Starts before this day. + switch (endCompare) { + case 1: // Ends on a later day. + type = "continue"; + // We have no time to show in this case. + break; + case 0: // Ends at midnight. + case -1: // Ends on this day. + type = "end"; + displayTime = formatter.formatTime( + val.endDate.getInTimezone(this.parentBox.date.timezone), + // We prefer to show midnight as 24:00 if possible to indicate + // that the event ends at the end of this day, rather than the + // start of the next day. + true + ); + break; + } + } else { + // Starts on this day. + if (endCompare == 1) { + // Ends on a later day. + type = "start"; + } + // Use the same format as ending on the day. + displayTime = formatter.formatTime( + val.startDate.getInTimezone(this.parentBox.date.timezone) + ); + } + } + let icon = this.querySelector(".item-type-icon"); + icon.classList.toggle("rotated-to-read-direction", !!type); + switch (type) { + case "start": + icon.setAttribute("src", "chrome://calendar/skin/shared/event-start.svg"); + document.l10n.setAttributes(icon, "calendar-editable-item-multiday-event-icon-start"); + break; + case "continue": + icon.setAttribute("src", "chrome://calendar/skin/shared/event-continue.svg"); + document.l10n.setAttributes( + icon, + "calendar-editable-item-multiday-event-icon-continue" + ); + break; + case "end": + icon.setAttribute("src", "chrome://calendar/skin/shared/event-end.svg"); + document.l10n.setAttributes(icon, "calendar-editable-item-multiday-event-icon-end"); + break; + default: + icon.removeAttribute("src"); + icon.removeAttribute("data-l10n-id"); + icon.setAttribute("alt", ""); + } + } + + if (displayTime) { + this.timeLabel.textContent = displayTime; + this.timeLabel.hidden = false; + } else { + this.timeLabel.textContent = ""; + this.timeLabel.hidden = true; + } + + this.setEditableLabel(); + this.setCSSClasses(); + } + + get occurrence() { + return this.mOccurrence; + } + } + + customElements.define("calendar-month-day-box-item", MozCalendarMonthDayBoxItem); + + /** + * Abstract base class that is used for the month and multiweek calendar view custom elements. + * + * @implements {calICalendarView} + * @augments {MozElements.CalendarBaseView} + * @abstract + */ + class CalendarMonthBaseView extends MozElements.CalendarBaseView { + ensureInitialized() { + if (this.isInitialized) { + return; + } + super.ensureInitialized(); + + this.appendChild( + MozXULElement.parseXULToFragment(` + <html:table class="mainbox monthtable"> + <html:thead> + <html:tr></html:tr> + </html:thead> + <html:tbody class="monthbody"></html:tbody> + </html:table> + `) + ); + + this.addEventListener("wheel", event => { + const pixelThreshold = 150; + const scrollEnabled = Services.prefs.getBoolPref("calendar.view.mousescroll", true); + if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey && scrollEnabled) { + // In the month view, the only thing that can be scrolled + // is the month the user is in. calendar-base-view takes care of + // the shift key, so only move the view when no modifier is pressed. + let deltaView = 0; + if (event.deltaMode == event.DOM_DELTA_LINE) { + if (event.deltaY != 0) { + deltaView = event.deltaY < 0 ? -1 : 1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.mPixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.moveView(deltaView); + } + } + }); + + this.mDateBoxes = null; + this.mSelectedDayBox = null; + + this.mShowFullMonth = true; + this.mShowWeekNumber = true; + + this.mClickedTime = null; + + let dayHeaderRow = this.querySelector("thead > tr"); + this.dayHeaders = new Array(7); + for (let i = 0; i < 7; i++) { + let hdr = document.createXULElement("calendar-day-label"); + let headerCell = document.createElement("th"); + headerCell.setAttribute("scope", "col"); + // NOTE: At the time of implementation, the natural columnheader role is + // lost, probably from setting the CSS display of the container table + // and row (Bug 1711273). + // For now, we restore the role explicitly. + headerCell.setAttribute("role", "columnheader"); + headerCell.appendChild(hdr); + this.dayHeaders[i] = hdr; + dayHeaderRow.appendChild(headerCell); + hdr.weekDay = (i + this.weekStartOffset) % 7; + hdr.shortWeekNames = false; + hdr.style.gridRow = 1; + } + + this.monthbody = this.querySelector(".monthbody"); + for (let week = 1; week <= 6; week++) { + let weekRow = document.createElement("tr"); + for (let day = 1; day <= 7; day++) { + let dayCell = document.createElement("td"); + let dayContent = document.createXULElement("calendar-month-day-box"); + dayCell.appendChild(dayContent); + weekRow.appendChild(dayCell); + // Set the grid row for the element. This is needed to ensure the + // elements appear on different lines. We don't set the gridColumn + // because some days may become hidden. + dayContent.style.gridRow = week + 1; + } + this.monthbody.appendChild(weekRow); + } + + // Set the preference for displaying the week number. + this.mShowWeekNumber = Services.prefs.getBoolPref( + "calendar.view-minimonth.showWeekNumber", + true + ); + } + + // calICalendarView Properties + + get supportsDisjointDates() { + return false; + } + + get hasDisjointDates() { + return false; + } + + set selectedDay(day) { + if (this.mSelectedDayBox) { + this.mSelectedDayBox.selected = false; + } + + let realDay = day; + if (!realDay.isDate) { + realDay = day.clone(); + realDay.isDate = true; + } + const box = this.findDayBoxForDate(realDay); + if (box) { + box.selected = true; + this.mSelectedDayBox = box; + } + this.fireEvent("dayselect", realDay); + } + + get selectedDay() { + if (this.mSelectedDayBox) { + return this.mSelectedDayBox.date.clone(); + } + + return null; + } + + // End calICalendarView Properties + + set selectedDateTime(dateTime) { + this.mClickedTime = dateTime; + } + + get selectedDateTime() { + return cal.dtz.getDefaultStartDate(this.selectedDay); + } + + set showFullMonth(showFullMonth) { + this.mShowFullMonth = showFullMonth; + } + + get showFullMonth() { + return this.mShowFullMonth; + } + + // This property may be overridden by subclasses if needed. + set weeksInView(weeksInView) {} + + get weeksInView() { + return 0; + } + + // calICalendarView Methods + + setSelectedItems(items, suppressEvent) { + if (this.mSelectedItems.length) { + for (const item of this.mSelectedItems) { + const oldboxes = this.findDayBoxesForItem(item); + for (const oldbox of oldboxes) { + oldbox.unselectItem(item); + } + } + } + + this.mSelectedItems = items || []; + + if (this.mSelectedItems.length) { + for (const item of this.mSelectedItems) { + const newboxes = this.findDayBoxesForItem(item); + for (const newbox of newboxes) { + newbox.selectItem(item); + } + } + } + + if (!suppressEvent) { + this.fireEvent("itemselect", this.mSelectedItems); + } + } + + centerSelectedItems() {} + + showDate(date) { + if (date) { + this.setDateRange(date.startOfMonth, date.endOfMonth); + this.selectedDay = date; + } else { + this.setDateRange(this.rangeStartDate, this.rangeEndDate); + } + } + + setDateRange(startDate, endDate) { + this.rangeStartDate = startDate; + this.rangeEndDate = endDate; + + const viewStart = cal.weekInfoService.getStartOfWeek(startDate.getInTimezone(this.mTimezone)); + + const viewEnd = cal.weekInfoService.getEndOfWeek(endDate.getInTimezone(this.mTimezone)); + + viewStart.isDate = true; + viewStart.makeImmutable(); + viewEnd.isDate = true; + viewEnd.makeImmutable(); + + this.mStartDate = viewStart; + this.mEndDate = viewEnd; + + // The start and end dates to query calendars with (in CalendarFilteredViewMixin). + this.startDate = viewStart; + let viewEndPlusOne = viewEnd.clone(); + viewEndPlusOne.day++; + this.endDate = viewEndPlusOne; + + // Check values of tasksInView, workdaysOnly, showCompleted. + // See setDateRange comment in calendar-multiday-base-view.js. + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + // Update the navigation bar only when changes are related to the current view. + if (this.isVisible()) { + calendarNavigationBar.setDateRange(startDate, endDate); + } + + // Check whether view range has been changed since last call to relayout(). + if ( + !this.mViewStart || + !this.mViewEnd || + this.mViewEnd.compare(viewEnd) != 0 || + this.mViewStart.compare(viewStart) != 0 || + this.mToggleStatus != toggleStatus + ) { + this.relayout(); + } + } + + getDateList() { + if (!this.mStartDate || !this.mEndDate) { + return []; + } + + const results = []; + const curDate = this.mStartDate.clone(); + curDate.isDate = true; + + while (curDate.compare(this.mEndDate) <= 0) { + results.push(curDate.clone()); + curDate.day += 1; + } + return results; + } + + // End calICalendarView Methods + + /** + * Set an attribute on the view element, and do re-layout if needed. + * + * @param {string} attr - The attribute to set. + * @param {string} value - The value to set. + */ + setAttribute(attr, value) { + const needsRelayout = attr == "context" || attr == "item-context"; + + const ret = XULElement.prototype.setAttribute.call(this, attr, value); + + if (needsRelayout) { + this.relayout(); + } + + return ret; + } + + /** + * Handle preference changes. Typically called by a preference observer. + * + * @param {object} subject - The subject, a prefs object. + * @param {string} topic - The notification topic. + * @param {string} preference - The preference to handle. + */ + handlePreference(subject, topic, preference) { + subject.QueryInterface(Ci.nsIPrefBranch); + + switch (preference) { + case "calendar.previousweeks.inview": + this.updateDaysOffPrefs(); + this.refreshView(); + break; + + case "calendar.week.start": + // Refresh the view so the settings take effect. + this.refreshView(); + break; + + case "calendar.weeks.inview": + this.weeksInView = subject.getIntPref(preference); + break; + + case "calendar.view-minimonth.showWeekNumber": + this.mShowWeekNumber = subject.getBoolPref(preference); + if (this.mShowWeekNumber) { + this.refreshView(); + } else { + this.hideWeekNumbers(); + } + break; + + default: + this.handleCommonPreference(subject, topic, preference); + break; + } + } + + /** + * Guarantee that the labels are clipped when an overflow occurs, to + * prevent horizontal scrollbars from appearing briefly. + */ + adjustWeekdayLength() { + let dayLabels = this.querySelectorAll("calendar-day-label"); + if (!this.longWeekdayTotalPixels) { + let maxDayWidth = 0; + + for (const label of dayLabels) { + label.shortWeekNames = false; + maxDayWidth = Math.max(maxDayWidth, label.getLongWeekdayPixels()); + } + if (maxDayWidth > 0) { + // FIXME: Where does the + 10 come from? + this.longWeekdayTotalPixels = maxDayWidth * dayLabels.length + 10; + } else { + this.longWeekdayTotalPixels = 0; + } + } + let useShortNames = this.longWeekdayTotalPixels > 0.95 * this.clientWidth; + + for (let label of dayLabels) { + label.shortWeekNames = useShortNames; + } + } + + /** + * Handle resizing by adjusting the view to the new size. + * + * @param {Element} viewElement - A calendar view element (calICalendarView). + */ + onResize() { + let { width, height } = this.getBoundingClientRect(); + if (width == this.mWidth && height == this.mHeight) { + // Return early if we're still the previous size. + return; + } + this.mWidth = width; + this.mHeight = height; + + this.adjustWeekdayLength(); + } + + /** + * Re-render the view. + */ + relayout() { + // Adjust headers based on the starting day of the week, if necessary. + if (this.dayHeaders[0].weekDay != this.weekStartOffset) { + for (let i = 0; i < this.dayHeaders.length; i++) { + this.dayHeaders[i].weekDay = (i + this.weekStartOffset) % 7; + } + } + + if (this.mSelectedItems.length) { + this.mSelectedItems = []; + } + + if (!this.mStartDate || !this.mEndDate) { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + + // Days that are not in the main month on display are displayed with + // a gray background. Unless the month actually starts on a Sunday, + // this means that mStartDate.month is 1 month less than the main month. + let mainMonth = this.mStartDate.month; + if (this.mStartDate.day != 1) { + mainMonth++; + mainMonth = mainMonth % 12; + } + + const dateBoxes = []; + + // This gets set to true, telling us to collapse the rest of the rows. + let finished = false; + const dateList = this.getDateList(); + + // This allows finding the first column of dayboxes where to set the + // week labels, taking into account whether days-off are displayed or not. + let weekLabelColumnPos = -1; + + const rows = this.monthbody.children; + + // Iterate through each monthbody row and set up the day-boxes that + // are its child nodes. Remember, children is not a normal array, + // so don't use the in operator if you don't want extra properties + // coming out. + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + // If we've already assigned all of the day-boxes that we need, just + // collapse the rest of the rows, otherwise expand them if needed. + row.toggleAttribute("hidden", finished); + if (finished) { + for (let cell of row.cells) { + // Clear out the hidden cells for to avoid holding events in memory + // for no reason. Also prevents tests failing due to stray event + // boxes from months that are no longer displayed. + cell.firstElementChild.setDate(); + } + continue; + } + for (let j = 0; j < row.children.length; j++) { + const daybox = row.children[j].firstElementChild; + const date = dateList[dateBoxes.length]; + + // Remove the attribute "relation" for all the column headers. + // Consider only the first row index otherwise it will be + // removed again afterwards the correct setting. + if (i == 0) { + this.dayHeaders[j].removeAttribute("relation"); + } + + daybox.setAttribute("context", this.getAttribute("context")); + + daybox.setAttribute( + "item-context", + this.getAttribute("item-context") || this.getAttribute("context") + ); + + // Set the box-class depending on if this box displays a day in + // the month being currently shown or not. + let boxClass; + if (this.showFullMonth) { + boxClass = + "calendar-month-day-box-" + + (mainMonth == date.month ? "current-month" : "other-month"); + } else { + boxClass = "calendar-month-day-box-current-month"; + } + if (this.mDaysOffArray.some(dayOffNum => dayOffNum == date.weekday)) { + boxClass = "calendar-month-day-box-day-off " + boxClass; + } + + // Set up label with the week number in the first day of the row. + if (this.mShowWeekNumber) { + const weekLabel = daybox.querySelector("[data-label='week']"); + if (weekLabelColumnPos < 0) { + const isDayOff = this.mDaysOffArray.includes((j + this.weekStartOffset) % 7); + if (this.mDisplayDaysOff || !isDayOff) { + weekLabelColumnPos = j; + } + } + // Build and set the label. + if (j == weekLabelColumnPos) { + weekLabel.removeAttribute("hidden"); + const weekNumber = cal.weekInfoService.getWeekTitle(date); + const weekString = cal.l10n.getCalString("multiweekViewWeek", [weekNumber]); + weekLabel.textContent = weekString; + } else { + weekLabel.hidden = true; + } + } + + daybox.setAttribute("class", boxClass); + + daybox.calendarView = this; + daybox.showMonthLabel = date.day == 1 || date.day == date.endOfMonth.day; + daybox.date = date; + dateBoxes.push(daybox); + + // If we've now assigned all of our dates, set this to true so we + // know we can just collapse the rest of the rows. + if (dateBoxes.length == dateList.length) { + finished = true; + } + } + } + + // If we're not showing a full month, then add a few extra labels to + // help the user orient themselves in the view. + if (!this.mShowFullMonth) { + dateBoxes[0].showMonthLabel = true; + dateBoxes[dateBoxes.length - 1].showMonthLabel = true; + } + + // Store these, so that we can access them later. + this.mDateBoxes = dateBoxes; + this.setDateBoxRelations(); + this.hideDaysOff(); + + this.adjustWeekdayLength(); + + // Store the start and end of current view. Next time when + // setDateRange is called, it will use mViewStart and mViewEnd to + // check if view range has been changed. + this.mViewStart = this.mStartDate; + this.mViewEnd = this.mEndDate; + + // Store toggle status of current view. + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + this.mToggleStatus = toggleStatus; + this.refreshItems(true); + } + + /** + * Marks the box for today and the header for the current day of the week. + */ + setDateBoxRelations() { + const today = this.today(); + + for (let header of this.dayHeaders) { + if (header.weekDay == today.weekday) { + header.setAttribute("relation", "today"); + } else { + header.removeAttribute("relation"); + } + } + + for (let daybox of this.mDateBoxes) { + // Set up date relations. + switch (daybox.mDate.compare(today)) { + case -1: + daybox.setAttribute("relation", "past"); + break; + case 0: + daybox.setAttribute("relation", "today"); + break; + case 1: + daybox.setAttribute("relation", "future"); + break; + } + } + } + + /** + * Hide the week numbers. + */ + hideWeekNumbers() { + const rows = this.monthbody.children; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + for (let j = 0; j < row.children.length; j++) { + const daybox = row.children[j].firstElementChild; + const weekLabel = daybox.querySelector("[data-label='week']"); + weekLabel.hidden = true; + } + } + } + + /** + * Hide the days off. + */ + hideDaysOff() { + const rows = this.monthbody.children; + + const lastColNum = rows[0].children.length - 1; + for (let colNum = 0; colNum <= lastColNum; colNum++) { + const dayForColumn = (colNum + this.weekStartOffset) % 7; + const dayOff = this.mDaysOffArray.includes(dayForColumn) && !this.mDisplayDaysOff; + // Set the hidden attribute on the parentNode td. + this.dayHeaders[colNum].parentNode.toggleAttribute("hidden", dayOff); + for (let row of rows) { + row.children[colNum].toggleAttribute("hidden", dayOff); + } + } + } + + /** + * Return the day box element for a given date. + * + * @param {calIDateTime} date - A date. + * @returns {?Element} A `calendar-month-day-box` element. + */ + findDayBoxForDate(date) { + if (!this.mDateBoxes) { + return null; + } + for (const box of this.mDateBoxes) { + if (box.mDate.compare(date) == 0) { + return box; + } + } + return null; + } + + /** + * Return the day box elements for a given calendar item. + * + * @param {calIItemBase} item - A calendar item. + * @returns {Element[]} An array of `calendar-month-day-box` elements. + */ + findDayBoxesForItem(item) { + let targetDate = null; + let finishDate = null; + const boxes = []; + + // All our boxes are in default time zone, so we need these times in them too. + if (item.isEvent()) { + targetDate = item.startDate.getInTimezone(this.mTimezone); + finishDate = item.endDate.getInTimezone(this.mTimezone); + } else if (item.isTodo()) { + // Consider tasks without entry OR due date. + if (item.entryDate || item.dueDate) { + targetDate = (item.entryDate || item.dueDate).getInTimezone(this.mTimezone); + finishDate = (item.dueDate || item.entryDate).getInTimezone(this.mTimezone); + } + } + + if (!targetDate) { + return boxes; + } + + if (!finishDate) { + const maybeBox = this.findDayBoxForDate(targetDate); + if (maybeBox) { + boxes.push(maybeBox); + } + return boxes; + } + + if (targetDate.compare(this.mStartDate) < 0) { + targetDate = this.mStartDate.clone(); + } + + if (finishDate.compare(this.mEndDate) > 0) { + finishDate = this.mEndDate.clone(); + finishDate.day++; + } + + // Reset the time to 00:00, so that we really get all the boxes. + targetDate.isDate = false; + targetDate.hour = 0; + targetDate.minute = 0; + targetDate.second = 0; + + if (targetDate.compare(finishDate) == 0) { + // We have also to handle zero length events in particular for + // tasks without entry or due date. + const box = this.findDayBoxForDate(targetDate); + if (box) { + boxes.push(box); + } + } + + while (targetDate.compare(finishDate) == -1) { + const box = this.findDayBoxForDate(targetDate); + + // This might not exist if the event spans the view start or end. + if (box) { + boxes.push(box); + } + targetDate.day += 1; + } + + return boxes; + } + + /** + * Display a calendar item. + * + * @param {calIItemBase} item - A calendar item. + */ + doAddItem(item) { + this.findDayBoxesForItem(item).forEach(box => box.addItem(item)); + } + + /** + * Remove a calendar item so it is no longer displayed. + * + * @param {calIItemBase} item - A calendar item. + */ + doRemoveItem(item) { + const boxes = this.findDayBoxesForItem(item); + + if (!boxes.length) { + return; + } + + const oldLength = this.mSelectedItems.length; + + const isNotItem = a => a.hashId != item.hashId; + this.mSelectedItems = this.mSelectedItems.filter(isNotItem); + + boxes.forEach(box => box.removeItem(item)); + + // If a deleted event was selected, announce that the selection changed. + if (oldLength != this.mSelectedItems.length) { + this.fireEvent("itemselect", this.mSelectedItems); + } + } + + // CalendarFilteredViewMixin implementation. + + /** + * Removes all items so they are no longer displayed. + */ + clearItems() { + for (let dayBox of this.querySelectorAll("calendar-month-day-box")) { + dayBox.clear(); + } + } + + /** + * Remove all items for a given calendar so they are no longer displayed. + * + * @param {string} calendarId - The ID of the calendar to remove items from. + */ + removeItemsFromCalendar(calendarId) { + if (!this.mDateBoxes) { + return; + } + for (const box of this.mDateBoxes) { + for (const id in box.mItemHash) { + const node = box.mItemHash[id]; + const item = node.item; + + if (item.calendar.id == calendarId) { + box.removeItem(item); + } + } + } + } + + // End of CalendarFilteredViewMixin implementation. + + /** + * Make a calendar item flash. Used when an alarm goes off to make the related item flash. + * + * @param {object} item - The calendar item to flash. + * @param {boolean} stop - Whether to stop flashing that's already started. + */ + flashAlarm(item, stop) { + if (!this.mStartDate || !this.mEndDate) { + return; + } + + const showIndicator = Services.prefs.getBoolPref("calendar.alarms.indicator.show", true); + const totaltime = Services.prefs.getIntPref("calendar.alarms.indicator.totaltime", 3600); + + if (!stop && (!showIndicator || totaltime < 1)) { + // No need to animate if the indicator should not be shown. + return; + } + + // Make sure the flashing attribute is set or reset on all visible boxes. + const boxes = this.findDayBoxesForItem(item); + for (const box of boxes) { + for (const id in box.mItemHash) { + const itemData = box.mItemHash[id]; + + if (itemData.item.hasSameIds(item)) { + if (stop) { + itemData.removeAttribute("flashing"); + } else { + itemData.setAttribute("flashing", "true"); + } + } + } + } + + if (stop) { + // We are done flashing, prevent newly created event boxes from flashing. + delete this.mFlashingEvents[item.hashId]; + } else { + // Set up a timer to stop the flashing after the total time. + this.mFlashingEvents[item.hashId] = item; + setTimeout(() => this.flashAlarm(item, true), totaltime); + } + } + } + + MozElements.CalendarMonthBaseView = CalendarMonthBaseView; +} diff --git a/comm/calendar/base/content/calendar-multiday-view.js b/comm/calendar/base/content/calendar-multiday-view.js new file mode 100644 index 0000000000..041c8ae335 --- /dev/null +++ b/comm/calendar/base/content/calendar-multiday-view.js @@ -0,0 +1,3512 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from widgets/mouseoverPreviews.js */ +/* import-globals-from calendar-ui-utils.js */ + +/* global calendarNavigationBar, currentView, gCurrentMode, getSelectedCalendar, + invokeEventDragSession, MozElements, MozXULElement, timeIndicator */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + const MINUTES_IN_DAY = 24 * 60; + + /** + * Get the nearest or next snap point for the given minute. The set of snap + * points is given by `n * snapInterval`, where `n` is some integer. + * + * @param {number} minute - The minute to snap. + * @param {number} snapInterval - The integer number of minutes between snap + * points. + * @param {"nearest","forward","backward"} [direction="nearest"] - Where to + * find the snap point. "nearest" will return the closest snap point, + * "forward" will return the closest snap point that is greater (and not + * equal), and "backward" will return the closest snap point that is lower + * (and not equal). + * + * @returns {number} - The nearest snap point. + */ + function snapMinute(minute, snapInterval, direction = "nearest") { + switch (direction) { + case "forward": + return Math.floor((minute + snapInterval) / snapInterval) * snapInterval; + case "backward": + return Math.ceil((minute - snapInterval) / snapInterval) * snapInterval; + case "nearest": + return Math.round(minute / snapInterval) * snapInterval; + default: + throw new RangeError(`"${direction}" is not one of the allowed values for the direction`); + } + } + + /** + * Determine whether the given event item can be edited by the user. + * + * @param {calItemBase} eventItem - The event item. + * + * @returns {boolean} - Whether the given event can be edited by the user. + */ + function canEditEventItem(eventItem) { + return ( + cal.acl.isCalendarWritable(eventItem.calendar) && + cal.acl.userCanModifyItem(eventItem) && + !( + eventItem.calendar instanceof Ci.calISchedulingSupport && + eventItem.calendar.isInvitation(eventItem) + ) && + eventItem.calendar.getProperty("capabilities.events.supported") !== false + ); + } + + /** + * The MozCalendarEventColumn widget used for displaying event boxes in one column per day. + * It is used to make the week view layout in the calendar. It manages the layout of the + * events given via add/deleteEvent. + */ + class MozCalendarEventColumn extends MozXULElement { + static get inheritedAttributes() { + return { + ".multiday-events-list": "context", + ".timeIndicator": "orient", + }; + } + + /** + * The background hour box elements this event column owns, ordered and + * indexed by their starting hour. + * + * @type {Element[]} + */ + hourBoxes = []; + + /** + * The date of the day this event column represents. + * + * @type {calIDateTime} + */ + date; + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + this.appendChild( + MozXULElement.parseXULToFragment(` + <stack class="multiday-column-box-stack" flex="1"> + <html:div class="multiday-hour-box-container"></html:div> + <html:ol class="multiday-events-list"></html:ol> + <box class="timeIndicator" hidden="true"/> + <box class="fgdragcontainer" flex="1"> + <box class="fgdragspacer"> + <spacer flex="1"/> + <label class="fgdragbox-label fgdragbox-startlabel"/> + </box> + <box class="fgdragbox"/> + <label class="fgdragbox-label fgdragbox-endlabel"/> + </box> + </stack> + <calendar-event-box hidden="true"/> + `) + ); + this.hourBoxContainer = this.querySelector(".multiday-hour-box-container"); + for (let hour = 0; hour < 24; hour++) { + let hourBox = document.createElement("div"); + hourBox.classList.add("multiday-hour-box"); + this.hourBoxContainer.appendChild(hourBox); + this.hourBoxes.push(hourBox); + } + + this.eventsListElement = this.querySelector(".multiday-events-list"); + + this.addEventListener("dblclick", event => { + if (event.button != 0) { + return; + } + + if (this.calendarView.controller) { + event.stopPropagation(); + this.calendarView.controller.createNewEvent(null, this.getMouseDateTime(event), null); + } + }); + + this.addEventListener("click", event => { + if (event.button != 0 || event.ctrlKey || event.metaKey) { + return; + } + this.calendarView.setSelectedItems([]); + this.focus(); + }); + + // Mouse down handler, in empty event column regions. Starts sweeping out a new event. + this.addEventListener("mousedown", event => { + // Select this column. + this.calendarView.selectedDay = this.date; + + // If the selected calendar is readOnly, we don't want any sweeping. + let calendar = getSelectedCalendar(); + if ( + !cal.acl.isCalendarWritable(calendar) || + calendar.getProperty("capabilities.events.supported") === false + ) { + return; + } + + if (event.button == 2) { + // Set a selected datetime for the context menu. + this.calendarView.selectedDateTime = this.getMouseDateTime(event); + return; + } + // Only start sweeping out an event if the left button was clicked. + if (event.button != 0) { + return; + } + + this.mDragState = { + origColumn: this, + dragType: "new", + mouseMinuteOffset: 0, + offset: null, + shadows: null, + limitStartMin: null, + limitEndMin: null, + jumpedColumns: 0, + }; + + // Snap interval: 15 minutes or 1 minute if modifier key is pressed. + this.mDragState.origMin = snapMinute( + this.getMouseMinute(event), + event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15 + ); + + if (this.getAttribute("orient") == "vertical") { + this.mDragState.origLoc = event.clientY; + this.mDragState.limitEndMin = this.mDragState.origMin; + this.mDragState.limitStartMin = this.mDragState.origMin; + this.fgboxes.dragspacer.setAttribute( + "height", + this.mDragState.origMin * this.pixelsPerMinute + ); + } else { + this.mDragState.origLoc = event.clientX; + this.fgboxes.dragspacer.setAttribute( + "width", + this.mDragState.origMin * this.pixelsPerMinute + ); + } + + document.calendarEventColumnDragging = this; + + window.addEventListener("mousemove", this.onEventSweepMouseMove); + window.addEventListener("mouseup", this.onEventSweepMouseUp); + window.addEventListener("keypress", this.onEventSweepKeypress); + }); + + /** + * An internal collection of data for events. + * + * @typedef {object} EventData + * @property {calItemBase} eventItem - The event item. + * @property {Element} element - The displayed event in this column. + * @property {boolean} selected - Whether the event is selected. + * @property {boolean} needsUpdate - True whilst the eventItem has changed + * and we are still pending updating the 'element' property. + */ + /** + * Event data for all the events displayed in this column. + * + * @type {Map<string, EventData} - A map from an event item's hashId to + * its data. + */ + this.eventDataMap = new Map(); + + this.mCalendarView = null; + + this.mDragState = null; + + this.mLayoutBatchCount = 0; + + // Since we'll often be getting many events in rapid succession, this + // timer helps ensure that we don't re-compute the event map too many + // times in a short interval, and therefore improves performance. + this.mEventMapTimeout = null; + + // Whether the next added event should be created in the editing state. + this.newEventNeedsEditing = false; + // The hashId of the event we should set to editing in the next relayout. + this.eventToEdit = null; + + this.mSelected = false; + + this.mFgboxes = null; + + this.initializeAttributeInheritance(); + } + + /** + * The number of pixels that a one minute duration should occupy in the + * column. + * + * @type {number} + */ + set pixelsPerMinute(val) { + this._pixelsPerMinute = val; + this.relayout(); + } + + get pixelsPerMinute() { + return this._pixelsPerMinute; + } + + set calendarView(val) { + this.mCalendarView = val; + } + + get calendarView() { + return this.mCalendarView; + } + + get fgboxes() { + if (this.mFgboxes == null) { + this.mFgboxes = { + box: this.querySelector(".fgdragcontainer"), + dragbox: this.querySelector(".fgdragbox"), + dragspacer: this.querySelector(".fgdragspacer"), + startlabel: this.querySelector(".fgdragbox-startlabel"), + endlabel: this.querySelector(".fgdragbox-endlabel"), + }; + } + return this.mFgboxes; + } + + get timeIndicatorBox() { + return this.querySelector(".timeIndicator"); + } + + get events() { + return this.methods; + } + + /** + * Set whether the calendar-event-box element for the given event item + * should be displayed as selected or unselected. + * + * @param {calItemBase} eventItem - The event item. + * @param {boolean} select - Whether to show the corresponding event element + * as selected. + */ + selectEvent(eventItem, select) { + let data = this.eventDataMap.get(eventItem.hashId); + if (!data) { + return; + } + data.selected = select; + if (data.element) { + // There is a small window between an event item being added and it + // actually having an element. If it doesn't have an element yet, it + // will be selected on its creation instead. + data.element.selected = select; + } + } + + /** + * Return the displayed calendar-event-box element for the given event item. + * + * @param {calItemBase} eventItem - The event item. + * + * @returns {Element} - The corresponding element, or undefined if none. + */ + findElementForEventItem(eventItem) { + return this.eventDataMap.get(eventItem.hashId)?.element; + } + + /** + * Return all the event items that are displayed in this columns. + * + * @returns {calItemBase[]} - An array of all the displayed event items. + */ + getAllEventItems() { + return Array.from(this.eventDataMap.values(), data => data.eventItem); + } + + startLayoutBatchChange() { + this.mLayoutBatchCount++; + } + + endLayoutBatchChange() { + this.mLayoutBatchCount--; + if (this.mLayoutBatchCount == 0) { + this.relayout(); + } + } + + setAttribute(attr, val) { + // this should be done using lookupMethod(), see bug 286629 + let ret = super.setAttribute(attr, val); + + if (attr == "orient" && this.getAttribute("orient") != val) { + this.relayout(); + } + + return ret; + } + + /** + * Create or update a displayed calendar-event-box element for the given + * event item. + * + * @param {calItemBase} eventItem - The event item to create or update an + * element for. + */ + addEvent(eventItem) { + let eventData = this.eventDataMap.get(eventItem.hashId); + if (!eventData) { + // New event with no pre-existing data. + eventData = { selected: false }; + this.eventDataMap.set(eventItem.hashId, eventData); + } + eventData.needsUpdate = true; + + // We set the eventItem property here, the rest will be updated in + // relayout(). + // NOTE: If we already have an event with the given hashId, then the + // eventData.element will still refer to the previous display of the event + // until we call relayout(). + eventData.eventItem = eventItem; + + if (this.mEventMapTimeout) { + clearTimeout(this.mEventMapTimeout); + } + + if (this.newEventNeedsEditing) { + this.eventToEdit = eventItem.hashId; + this.newEventNeedsEditing = false; + } + + this.mEventMapTimeout = setTimeout(() => this.relayout(), 5); + } + + /** + * Remove the displayed calendar-event-box element for the given event item + * from this column + * + * @param {calItemBase} eventItem - The event item to remove the element of. + */ + deleteEvent(eventItem) { + if (this.eventDataMap.delete(eventItem.hashId)) { + this.relayout(); + } + } + + _clearElements() { + while (this.eventsListElement.hasChildNodes()) { + this.eventsListElement.lastChild.remove(); + } + } + + /** + * Clear the column of all events. + */ + clear() { + this._clearElements(); + this.eventDataMap.clear(); + } + + relayout() { + if (this.mLayoutBatchCount > 0) { + return; + } + this._clearElements(); + + let orient = this.getAttribute("orient"); + + let configBox = this.querySelector("calendar-event-box"); + configBox.removeAttribute("hidden"); + let minSize = configBox.getOptimalMinSize(orient); + configBox.setAttribute("hidden", "true"); + // The minimum event duration in minutes that would give at least the + // desired minSize in the layout. + let minDuration = Math.ceil(minSize / this.pixelsPerMinute); + + let dayPx = `${MINUTES_IN_DAY * this.pixelsPerMinute}px`; + if (orient == "vertical") { + this.hourBoxContainer.style.height = dayPx; + this.hourBoxContainer.style.width = null; + } else { + this.hourBoxContainer.style.width = dayPx; + this.hourBoxContainer.style.height = null; + } + + // 'fgbox' is used for dragging events. + this.fgboxes.box.setAttribute("orient", orient); + this.querySelector(".fgdragspacer").setAttribute("orient", orient); + + for (let eventData of this.eventDataMap.values()) { + if (!eventData.needsUpdate) { + continue; + } + eventData.needsUpdate = false; + // Create a new wrapper. + let eventElement = document.createElement("li"); + eventElement.classList.add("multiday-event-listitem"); + // Set up the event box. + let eventBox = document.createXULElement("calendar-event-box"); + eventElement.appendChild(eventBox); + + // Trigger connectedCallback + this.eventsListElement.appendChild(eventElement); + + eventBox.setAttribute( + "context", + this.getAttribute("item-context") || this.getAttribute("context") + ); + + eventBox.calendarView = this.calendarView; + eventBox.occurrence = eventData.eventItem; + eventBox.parentColumn = this; + // An event item can technically be 'selected' between a call to + // addEvent and this method (because of the setTimeout). E.g. clicking + // the event in the unifinder tree will select the item through + // selectEvent. If the element wasn't yet created in that method, we set + // the selected status here as well. + // + // Similarly, if an event has the same hashId, we maintain its + // selection. + // NOTE: In this latter case we are relying on the fact that + // eventData.element.selected is never out of sync with + // eventData.selected. + eventBox.selected = eventData.selected; + eventData.element = eventBox; + + // Remove the element to be added again later. + eventElement.remove(); + } + + let eventLayoutList = this.computeEventLayoutInfo(minDuration); + + for (let eventInfo of eventLayoutList) { + // Note that we store the calendar-event-box in the eventInfo, so we + // grab its parent to get the wrapper list item. + // NOTE: This may be a newly created element or a non-updated element + // that was removed from the eventsListElement in _clearElements. We + // still hold a reference to it, so we can re-add it in the new ordering + // and change its dimensions. + let eventElement = eventInfo.element.parentNode; + // FIXME: offset and length should be in % of parent's dimension, so we + // can avoid pixelsPerMinute. + let offset = `${eventInfo.start * this.pixelsPerMinute}px`; + let length = `${(eventInfo.end - eventInfo.start) * this.pixelsPerMinute}px`; + let secondaryOffset = `${eventInfo.secondaryOffset * 100}%`; + let secondaryLength = `${eventInfo.secondaryLength * 100}%`; + if (orient == "vertical") { + eventElement.style.height = length; + eventElement.style.width = secondaryLength; + eventElement.style.insetBlockStart = offset; + eventElement.style.insetInlineStart = secondaryOffset; + } else { + eventElement.style.width = length; + eventElement.style.height = secondaryLength; + eventElement.style.insetInlineStart = offset; + eventElement.style.insetBlockStart = secondaryOffset; + } + this.eventsListElement.appendChild(eventElement); + } + + let boxToEdit = this.eventDataMap.get(this.eventToEdit)?.element; + if (boxToEdit) { + boxToEdit.startEditing(); + } + this.eventToEdit = null; + } + + /** + * Layout information for displaying an event in the calendar column. The + * calendar column has two dimensions: a primary-dimension, in minutes, + * that runs from the start of the day to the end of the day; and a + * secondary-dimension which runs from 0 to 1. This object describes how + * an event can be placed on these axes. + * + * @typedef {object} EventLayoutInfo + * @property {MozCalendarEventBox} element - The displayed event. + * @property {number} start - The number of minutes from the start of this + * column's day to when the event should start. + * @property {number} end - The number of minutes from the start of this + * column's day to when the event ends. + * @property {number} secondaryOffset - The position of the event on the + * secondary axis (between 0 and 1). + * @property {number} secondaryLength - The length of the event on the + * secondary axis (between 0 and 1). + */ + /** + * Get an ordered list of events and their layout information. The list is + * ordered relative to the event's layout. + * + * @param {number} minDuration - The minimum number of minutes that an event + * should be *shown* to last. This should be large enough to ensure that + * events are readable in the layout. + * + * @returns {EventLayoutInfo[]} - An ordered list of event layout + * information. + */ + computeEventLayoutInfo(minDuration) { + if (!this.eventDataMap.size) { + return []; + } + + function sortByStart(aEventInfo, bEventInfo) { + // If you pass in tasks without both entry and due dates, I will + // kill you. + let startComparison = aEventInfo.startDate.compare(bEventInfo.startDate); + if (startComparison == 0) { + // If the items start at the same time, return the longer one + // first. + return bEventInfo.endDate.compare(aEventInfo.endDate); + } + return startComparison; + } + + // Construct the ordered list of EventLayoutInfo objects that we will + // eventually return. + // To begin, we construct the objects with a 'startDate' and 'endDate' + // properties, as opposed to using minutes from the start of the day + // because we want to sort the events relative to their absolute start + // times. + let eventList = Array.from(this.eventDataMap.values(), eventData => { + let element = eventData.element; + let { startDate, endDate, startMinute, endMinute } = element.updateRelativeStartEndDates( + this.date + ); + // If there is no startDate, we use the element's endDate for both the + // start and the end times. Similarly if there is no endDate. Such items + // will automatically have the minimum duration. + if (!startDate) { + startDate = endDate; + startMinute = endMinute; + } else if (!endDate) { + endDate = startDate; + endMinute = startMinute; + } + // Any events that start or end on a different day are clipped to the + // start/end minutes of this day instead. + let start = Math.max(startMinute, 0); + // NOTE: The end can overflow the end of the day due to the minDuration. + let end = Math.max(start + minDuration, Math.min(endMinute, MINUTES_IN_DAY)); + return { element, startDate, endDate, start, end }; + }); + eventList.sort(sortByStart); + + // Some Events in the calendar column will overlap in time. When they do, + // we want them to share the horizontal space (assuming the column is + // vertical). + // + // To do this, we split the events into Blocks, each of which contains a + // variable number of Columns, each of which contain non-overlapping + // Events. + // + // Note that the end time of one event is equal to the start time of + // another, we consider them non-overlapping. + // + // We choose each Block to form a continuous block of time in the + // calendar column. Specifically, two Events are in the same Block if and + // only if there exists some sequence of pairwise overlapping Events that + // includes them both. This ensures that no Block will overlap another + // Block, and each contains the least number of Events possible. + // + // Each Column will share the same horizontal width, and will be placed + // adjacent to each other. + // + // Note that each Block may have a different number of Columns, and then + // may not share a common factor, so the Columns may not line up in the + // view. + + // All the event Blocks in this calendar column, ordered by their start + // time. Each Block will be an array of Columns, which will in turn be an + // array of Events. + let allEventBlocks = []; + // The current Block. + let blockColumns = []; + let blockEnd = eventList[0].end; + + for (let eventInfo of eventList) { + let start = eventInfo.start; + if (blockColumns.length && start >= blockEnd) { + // There is a gap between this Event and the end of the Block. We also + // know from the ordering of eventList that all other Events start at + // the same time or later. So there are no more Events that can be + // added to this Block. So we finish it and start a new one. + allEventBlocks.push(blockColumns); + blockColumns = []; + } + + if (eventInfo.end > blockEnd) { + blockEnd = eventInfo.end; + } + + // Find the earliest Column that the Event fits in. + let foundCol = false; + for (let column of blockColumns) { + // We know from the ordering of eventList that all Events already in a + // Column have a start time that is equal to or earlier than this + // Event's start time. Therefore, in order for this Event to not + // overlap anything else in this Column, it must have a start time + // that is later than or equal to the end time of the last Event in + // this column. + let colEnd = column[column.length - 1].end; + if (start >= colEnd) { + // It fits in this Column, so we push it to the end (preserving the + // eventList ordering within the Column). + column.push(eventInfo); + foundCol = true; + break; + } + } + + if (!foundCol) { + // This Event doesn't fit in any column, so we create a new one. + blockColumns.push([eventInfo]); + } + } + if (blockColumns.length) { + allEventBlocks.push(blockColumns); + } + + for (let blockColumns of allEventBlocks) { + let totalCols = blockColumns.length; + for (let colIndex = 0; colIndex < totalCols; colIndex++) { + for (let eventInfo of blockColumns[colIndex]) { + if (eventInfo.processed) { + // Already processed this Event in an earlier Column. + continue; + } + let { start, end } = eventInfo; + let colSpan = 1; + // Currently, the Event is only contained in one Column. We want to + // first try and stretch it across several continuous columns. + // For this Event, we go through each later Column one by one and + // see if there is a gap in it that it can fit in. + // Note, we only look forward in the Columns because we already know + // that we did not fit in the previous Columns. + for ( + let neighbourColIndex = colIndex + 1; + neighbourColIndex < totalCols; + neighbourColIndex++ + ) { + let neighbourColumn = blockColumns[neighbourColIndex]; + // Test if this Event overlaps any of the other Events in the + // neighbouring Column. + let overlapsCol = false; + let indexInCol; + for (indexInCol = 0; indexInCol < neighbourColumn.length; indexInCol++) { + let otherEventInfo = neighbourColumn[indexInCol]; + if (end <= otherEventInfo.start) { + // The end of this Event is before or equal to the start of + // the other Event, so it cannot overlap. + // Moreover, the rest of the Events in this neighbouring + // Column have a later or equal start time, so we know that + // this Event cannot overlap any of them. So we can break + // early. + // We also know that indexInCol now points to the *first* + // Event in this neighbouring Column that starts after this + // Event. + break; + } else if (start < otherEventInfo.end) { + // The end of this Event is after the start of the other + // Event, and the start of this Event is before the end of + // the other Event. So they must overlap. + overlapsCol = true; + break; + } + } + if (overlapsCol) { + // An Event must span continuously across Columns, so we must + // break. + break; + } + colSpan++; + // Add this Event to the Column. Note that indexInCol points to + // the *first* other Event that is later than this Event, or + // points to the end of the Column. So we place ourselves there to + // preserve the ordering. + neighbourColumn.splice(indexInCol, 0, eventInfo); + } + eventInfo.processed = true; + eventInfo.secondaryOffset = colIndex / totalCols; + eventInfo.secondaryLength = colSpan / totalCols; + } + } + } + return eventList; + } + + /** + * Get information about which columns, relative to this column, are + * covered by the given time interval. + * + * @param {number} start - The starting time of the interval, in minutes + * from the start of this column's day. Should be negative for times on + * previous days. This must be on this column's day or earlier. + * @param {number} end - The ending time of the interval, in minutes from + * the start of this column's day. This can go beyond the end of this day. + * This must be greater than 'start' and on this column's day or later. + * + * @returns {object} - Data determining which columns are covered by the + * interval. Each column that is in the given range is covered from the + * start of the day to the end, apart from the first and last columns. + * @property {number} shadows - The number of columns that have some cover. + * @property {number} offset - The number of columns before this column that + * have some cover. For example, if 'start' is the day before, this is 1. + * @property {number} startMin - The starting time of the time interval, in + * minutes relative to the start of the first column's day. + * @property {number} endMin - The ending time of the time interval, in + * minutes relative to the start of the last column's day. + */ + getShadowElements(start, end) { + let shadows = 1; + let offset = 0; + let startMin; + if (start < 0) { + offset = Math.ceil(Math.abs(start) / MINUTES_IN_DAY); + shadows += offset; + let remainder = Math.abs(start) % MINUTES_IN_DAY; + startMin = remainder ? MINUTES_IN_DAY - remainder : 0; + } else { + startMin = start; + } + shadows += Math.floor(end / MINUTES_IN_DAY); + return { shadows, offset, startMin, endMin: end % MINUTES_IN_DAY }; + } + + /** + * Clear a dragging sequence that is owned by this column. + */ + clearDragging() { + for (let col of this.calendarView.getEventColumns()) { + col.fgboxes.dragbox.removeAttribute("dragging"); + col.fgboxes.box.removeAttribute("dragging"); + // We remove the height and width attributes as well. + // In particular, this means we won't accidentally preserve the height + // attribute if we switch to the rotated view, or the width if we + // switch back. + col.fgboxes.dragbox.removeAttribute("width"); + col.fgboxes.dragbox.removeAttribute("height"); + col.fgboxes.dragspacer.removeAttribute("width"); + col.fgboxes.dragspacer.removeAttribute("height"); + } + + window.removeEventListener("mousemove", this.onEventSweepMouseMove); + window.removeEventListener("mouseup", this.onEventSweepMouseUp); + window.removeEventListener("keypress", this.onEventSweepKeypress); + document.calendarEventColumnDragging = null; + this.mDragState = null; + } + + /** + * Update the shown drag state of all event columns in the same view using + * the mDragState of the current column. + */ + updateColumnShadows() { + let startStr; + // Tasks without Entry or Due date have a string as first label + // instead of the time. + let item = this.mDragState.dragOccurrence; + if (item?.isTodo()) { + if (!item.dueDate) { + startStr = cal.l10n.getCalString("dragLabelTasksWithOnlyEntryDate"); + } else if (!item.entryDate) { + startStr = cal.l10n.getCalString("dragLabelTasksWithOnlyDueDate"); + } + } + + let { startMin, endMin, offset, shadows } = this.mDragState; + let jsTime = new Date(); + let formatter = cal.dtz.formatter; + if (!startStr) { + jsTime.setHours(0, startMin, 0); + startStr = formatter.formatTime(cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating)); + } + jsTime.setHours(0, endMin, 0); + let endStr = formatter.formatTime(cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating)); + + let allColumns = this.calendarView.getEventColumns(); + let thisIndex = allColumns.indexOf(this); + // NOTE: startIndex and endIndex be before or after the start and end of + // the week, respectively, if the event spans multiple days. + let startIndex = thisIndex - offset; + let endIndex = startIndex + shadows - 1; + + // All columns have the same orient and pixels per minutes. + let sizeProp = this.getAttribute("orient") == "vertical" ? "height" : "width"; + let pixPerMin = this.pixelsPerMinute; + + for (let i = 0; i < allColumns.length; i++) { + let fgboxes = allColumns[i].fgboxes; + if (i == startIndex) { + fgboxes.dragbox.setAttribute("dragging", "true"); + fgboxes.box.setAttribute("dragging", "true"); + fgboxes.dragspacer.style[sizeProp] = `${startMin * pixPerMin}px`; + fgboxes.dragbox.style[sizeProp] = `${ + ((i == endIndex ? endMin : MINUTES_IN_DAY) - startMin) * pixPerMin + }px`; + fgboxes.startlabel.value = startStr; + fgboxes.endlabel.value = i == endIndex ? endStr : ""; + } else if (i == endIndex) { + fgboxes.dragbox.setAttribute("dragging", "true"); + fgboxes.box.setAttribute("dragging", "true"); + fgboxes.dragspacer.style[sizeProp] = "0"; + fgboxes.dragbox.style[sizeProp] = `${endMin * pixPerMin}px`; + fgboxes.startlabel.value = ""; + fgboxes.endlabel.value = endStr; + } else if (i > startIndex && i < endIndex) { + fgboxes.dragbox.setAttribute("dragging", "true"); + fgboxes.box.setAttribute("dragging", "true"); + fgboxes.dragspacer.style[sizeProp] = "0"; + fgboxes.dragbox.style[sizeProp] = `${MINUTES_IN_DAY * pixPerMin}px`; + fgboxes.startlabel.value = ""; + fgboxes.endlabel.value = ""; + } else { + fgboxes.dragbox.removeAttribute("dragging"); + fgboxes.box.removeAttribute("dragging"); + } + } + } + + onEventSweepKeypress(event) { + let col = document.calendarEventColumnDragging; + if (col && event.key == "Escape") { + col.clearDragging(); + } + } + + // Event sweep handlers. + onEventSweepMouseMove(event) { + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + + let dragState = col.mDragState; + + // FIXME: Use mouseenter and mouseleave to detect column changes since + // they fire when scrolling changes the mouse target, but mousemove does + // not. + let newcol = col.calendarView.findEventColumnThatContains(event.target); + // If we leave the view, then stop our internal sweeping and start a + // real drag session. Someday we need to fix the sweep to soely be a + // drag session, no sweeping. + if (dragState.dragType == "move" && !newcol) { + // Remove the drag state. + col.clearDragging(); + + let item = dragState.dragOccurrence; + + // The multiday view currently exhibits a less than optimal strategy + // in terms of item selection. items don't get automatically selected + // when clicked and dragged, as to differentiate inline editing from + // the act of selecting an event. but the application internal drop + // targets will ask for selected items in order to pull the data from + // the packets. that's why we need to make sure at least the currently + // dragged event is contained in the set of selected items. + let selectedItems = this.getSelectedItems(); + if (!selectedItems.some(aItem => aItem.hashId == item.hashId)) { + col.calendarView.setSelectedItems([event.ctrlKey ? item.parentItem : item]); + } + // NOTE: Dragging to the allday header will fail (bug 1675056). + invokeEventDragSession(dragState.dragOccurrence, col); + return; + } + + // Snap interval: 15 minutes or 1 minute if modifier key is pressed. + dragState.snapIntMin = + event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15; + + // Check if we need to jump a column. + if (newcol && newcol != col) { + // Find how many columns we are jumping by subtracting the dates. + let dur = newcol.date.subtractDate(col.date); + let jumpedColumns = dur.isNegative ? -dur.days : dur.days; + if (dragState.dragType == "modify-start") { + // Prevent dragging the start date after the end date in a new column. + let limitEndMin = dragState.limitEndMin - MINUTES_IN_DAY * jumpedColumns; + if (limitEndMin < 0) { + return; + } + dragState.limitEndMin = limitEndMin; + } else if (dragState.dragType == "modify-end") { + let limitStartMin = dragState.limitStartMin - MINUTES_IN_DAY * jumpedColumns; + // Prevent dragging the end date before the start date in a new column. + if (limitStartMin > MINUTES_IN_DAY) { + return; + } + dragState.limitStartMin = limitStartMin; + } else if (dragState.dragType == "new") { + dragState.limitEndMin -= MINUTES_IN_DAY * jumpedColumns; + dragState.limitStartMin -= MINUTES_IN_DAY * jumpedColumns; + dragState.jumpedColumns += jumpedColumns; + } + + // Move drag state to the new column. + col.mDragState = null; + newcol.mDragState = dragState; + document.calendarEventColumnDragging = newcol; + // The same event handlers are still valid, + // because they use document.calendarEventColumnDragging. + } + + col.updateDragPosition(event.clientX, event.clientY); + } + + /** + * Update the drag position to point to the given client position. + * + * Note, this method will not switch the drag state between columns. + * + * @param {number} clientX - The x position. + * @param {number} clientY - The y position. + */ + updateDragPosition(clientX, clientY) { + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + // If we scroll, we call this method again using the same mouse positions. + // NOTE: if the magic scroll makes the mouse move over a different column, + // this won't be updated until another mousemove. + this.calendarView.setupMagicScroll(clientX, clientY, () => + this.updateDragPosition(clientX, clientY) + ); + + let dragState = col.mDragState; + + let mouseMinute = this.getMouseMinute({ clientX, clientY }); + if (mouseMinute < 0) { + mouseMinute = 0; + } else if (mouseMinute > MINUTES_IN_DAY) { + mouseMinute = MINUTES_IN_DAY; + } + let snappedMouseMinute = snapMinute( + mouseMinute - dragState.mouseMinuteOffset, + dragState.snapIntMin + ); + + let deltamin = snappedMouseMinute - dragState.origMin; + + let shadowElements; + if (dragState.dragType == "new") { + // Extend deltamin in a linear way over the columns. + deltamin += MINUTES_IN_DAY * dragState.jumpedColumns; + if (deltamin < 0) { + // Create a new event modifying the start. End time is fixed. + shadowElements = { + shadows: 1 - dragState.jumpedColumns, + offset: 0, + startMin: snappedMouseMinute, + endMin: dragState.origMin, + }; + } else { + // Create a new event modifying the end. Start time is fixed. + shadowElements = { + shadows: dragState.jumpedColumns + 1, + offset: dragState.jumpedColumns, + startMin: dragState.origMin, + endMin: snappedMouseMinute, + }; + } + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + } else if (dragState.dragType == "move") { + // If we're moving, we modify startMin and endMin of the shadow. + shadowElements = col.getShadowElements( + dragState.origMinStart + deltamin, + dragState.origMinEnd + deltamin + ); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + // Keep track of the last start position because it will help to + // build the event at the end of the drag session. + dragState.lastStart = dragState.origMinStart + deltamin; + } else if (dragState.dragType == "modify-start") { + // If we're modifying the start, the end time is fixed. + shadowElements = col.getShadowElements(dragState.origMin + deltamin, dragState.limitEndMin); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + + // But we need to not go past the end; if we hit + // the end, then we'll clamp to the previous snap interval minute. + if (dragState.startMin >= dragState.limitEndMin) { + dragState.startMin = snapMinute(dragState.limitEndMin, dragState.snapIntMin, "backward"); + } + } else if (dragState.dragType == "modify-end") { + // If we're modifying the end, the start time is fixed. + shadowElements = col.getShadowElements( + dragState.limitStartMin, + dragState.origMin + deltamin + ); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + + // But we need to not go past the start; if we hit + // the start, then we'll clamp to the next snap interval minute. + if (dragState.endMin <= dragState.limitStartMin) { + dragState.endMin = snapMinute(dragState.limitStartMin, dragState.snapIntMin, "forward"); + } + } + dragState.offset = shadowElements.offset; + dragState.shadows = shadowElements.shadows; + + // Now we can update the shadow boxes position and size. + col.updateColumnShadows(); + } + + onEventSweepMouseUp(event) { + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + + let dragState = col.mDragState; + + col.clearDragging(); + col.calendarView.clearMagicScroll(); + + // If the user didn't sweep out at least a few pixels, ignore + // unless we're in a different column. + if (dragState.origColumn == col) { + let position = col.getAttribute("orient") == "vertical" ? event.clientY : event.clientX; + if (Math.abs(position - dragState.origLoc) < 3) { + return; + } + } + + let newStart; + let newEnd; + let startTZ; + let endTZ; + let dragDay = col.date; + if (dragState.dragType != "new") { + let oldStart = + dragState.dragOccurrence.startDate || + dragState.dragOccurrence.entryDate || + dragState.dragOccurrence.dueDate; + let oldEnd = + dragState.dragOccurrence.endDate || + dragState.dragOccurrence.dueDate || + dragState.dragOccurrence.entryDate; + newStart = oldStart.clone(); + newEnd = oldEnd.clone(); + + // Our views are pegged to the default timezone. If the event + // isn't also in the timezone, we're going to need to do some + // tweaking. We could just do this for every event but + // getInTimezone is slow, so it's much better to only do this + // when the timezones actually differ from the view's. + if (col.date.timezone != newStart.timezone || col.date.timezone != newEnd.timezone) { + startTZ = newStart.timezone; + endTZ = newEnd.timezone; + newStart = newStart.getInTimezone(col.date.timezone); + newEnd = newEnd.getInTimezone(col.date.timezone); + } + } + + if (dragState.dragType == "modify-start") { + newStart.resetTo( + dragDay.year, + dragDay.month, + dragDay.day, + 0, + dragState.startMin, + 0, + newStart.timezone + ); + } else if (dragState.dragType == "modify-end") { + newEnd.resetTo( + dragDay.year, + dragDay.month, + dragDay.day, + 0, + dragState.endMin, + 0, + newEnd.timezone + ); + } else if (dragState.dragType == "new") { + let startDay = dragState.origColumn.date; + let draggedForward = dragDay.compare(startDay) > 0; + newStart = draggedForward ? startDay.clone() : dragDay.clone(); + newEnd = draggedForward ? dragDay.clone() : startDay.clone(); + newStart.isDate = false; + newEnd.isDate = false; + newStart.resetTo( + newStart.year, + newStart.month, + newStart.day, + 0, + dragState.startMin, + 0, + newStart.timezone + ); + newEnd.resetTo( + newEnd.year, + newEnd.month, + newEnd.day, + 0, + dragState.endMin, + 0, + newEnd.timezone + ); + + // Edit the event title on the first of the new event's occurrences + // FIXME: This newEventNeedsEditing flag is read and unset in addEvent, + // but this is only called after some delay: after the event creation + // transaction completes. So there is a race between this creation and + // other actions that call addEvent. + // Bug 1710985 would be a way to address this: i.e. at this point we + // immediately create an element that the user can type a title into + // without creating a calendar item until they submit the title. Then + // we won't need any special flag for addEvent. + if (draggedForward) { + dragState.origColumn.newEventNeedsEditing = true; + } else { + col.newEventNeedsEditing = true; + } + } else if (dragState.dragType == "move") { + // Figure out the new date-times of the event by adding the duration + // of the total movement (days and minutes) to the old dates. + let duration = dragDay.subtractDate(dragState.origColumn.date); + let minutes = dragState.lastStart - dragState.realStart; + + // Since both boxDate and beginMove are dates (note datetimes), + // subtractDate will only give us a non-zero number of hours on + // DST changes. While strictly speaking, subtractDate's behavior + // is correct, we need to move the event a discrete number of + // days here. There is no need for normalization here, since + // addDuration does the job for us. Also note, the duration used + // here is only used to move over multiple days. Moving on the + // same day uses the minutes from the dragState. + if (duration.hours == 23) { + // Entering DST. + duration.hours++; + } else if (duration.hours == 1) { + // Leaving DST. + duration.hours--; + } + + if (duration.isNegative) { + // Adding negative minutes to a negative duration makes the + // duration more positive, but we want more negative, and + // vice versa. + minutes *= -1; + } + duration.minutes = minutes; + duration.normalize(); + + newStart.addDuration(duration); + newEnd.addDuration(duration); + } + + // If we tweaked tzs, put times back in their original ones. + if (startTZ) { + newStart = newStart.getInTimezone(startTZ); + } + if (endTZ) { + newEnd = newEnd.getInTimezone(endTZ); + } + + if (dragState.dragType == "new") { + // We won't pass a calendar, since the display calendar is the + // composite anyway. createNewEvent() will use the selected + // calendar. + col.calendarView.controller.createNewEvent(null, newStart, newEnd); + } else if ( + dragState.dragType == "move" || + dragState.dragType == "modify-start" || + dragState.dragType == "modify-end" + ) { + col.calendarView.controller.modifyOccurrence(dragState.dragOccurrence, newStart, newEnd); + } + } + + /** + * Start modifying an item through a mouse motion. + * + * @param {calItemBase} eventItem - The event item to start modifying. + * @param {"start"|"end"|"middle"} where - Whether to modify the starting + * time, ending time, or moving the entire event (modify the start and + * end, but preserve the duration). + * @param {object} position - The mouse position of the event that + * initialized* the motion. + * @param {number} position.clientX - The client x position. + * @param {number} position.clientY - The client y position. + * @param {number} position.offsetStartMinute - The minute offset of the + * mouse relative to the event item's starting time edge. + * @param {number} [snapIntMin=15] - The snapping interval to apply to the + * mouse position, in minutes. + */ + startSweepingToModifyEvent(eventItem, where, position, snapIntMin = 15) { + if (!canEditEventItem(eventItem)) { + return; + } + + this.mDragState = { + origColumn: this, + dragOccurrence: eventItem, + mouseMinuteOffset: 0, + offset: null, + shadows: null, + limitStartMin: null, + lastStart: 0, + jumpedColumns: 0, + }; + + if (this.getAttribute("orient") == "vertical") { + this.mDragState.origLoc = position.clientY; + } else { + this.mDragState.origLoc = position.clientX; + } + + let stdate = eventItem.startDate || eventItem.entryDate || eventItem.dueDate; + let enddate = eventItem.endDate || eventItem.dueDate || eventItem.entryDate; + + // Get the start and end times in minutes, relative to the start of the + // day. This may be negative or exceed the length of the day if the event + // spans more than one day. + let realStart = Math.floor(stdate.subtractDate(this.date).inSeconds / 60); + let realEnd = Math.floor(enddate.subtractDate(this.date).inSeconds / 60); + + if (where == "start") { + this.mDragState.dragType = "modify-start"; + // We have to use "realEnd" as fixed end value. + this.mDragState.limitEndMin = realEnd; + + // Snap start. + // Since we are modifying the start, we know the event starts on this + // day, so realStart is not negative. + this.mDragState.origMin = snapMinute(realStart, snapIntMin); + + // Show the shadows and drag labels when clicking on gripbars. + let shadowElements = this.getShadowElements( + this.mDragState.origMin, + this.mDragState.limitEndMin + ); + this.mDragState.startMin = shadowElements.startMin; + this.mDragState.endMin = shadowElements.endMin; + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + this.updateColumnShadows(); + } else if (where == "end") { + this.mDragState.dragType = "modify-end"; + // We have to use "realStart" as fixed end value. + this.mDragState.limitStartMin = realStart; + + // Snap end. + // Since we are modifying the end, we know the event end on this day, + // so realEnd is before midnight on this day. + this.mDragState.origMin = snapMinute(realEnd, snapIntMin); + + // Show the shadows and drag labels when clicking on gripbars. + let shadowElements = this.getShadowElements( + this.mDragState.limitStartMin, + this.mDragState.origMin + ); + this.mDragState.startMin = shadowElements.startMin; + this.mDragState.endMin = shadowElements.endMin; + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + this.updateColumnShadows(); + } else if (where == "middle") { + this.mDragState.dragType = "move"; + // In a move, origMin will be the start minute of the element where + // the drag occurs. Along with mouseMinuteOffset, it allows to track the + // shadow position. origMinStart and origMinEnd allow to figure out + // the real shadow size. + this.mDragState.mouseMinuteOffset = position.offsetStartMinute; + // We use origMin to get the number of minutes since the start of *this* + // day, which is 0 if realStart is negative. + this.mDragState.origMin = Math.max(0, snapMinute(realStart, snapIntMin)); + // We snap to the start and add the real duration to find the end. + this.mDragState.origMinStart = snapMinute(realStart, snapIntMin); + this.mDragState.origMinEnd = realEnd + this.mDragState.origMinStart - realStart; + // Keep also track of the real Start, it will be used at the end + // of the drag session to calculate the new start and end datetimes. + this.mDragState.realStart = realStart; + + let shadowElements = this.getShadowElements( + this.mDragState.origMinStart, + this.mDragState.origMinEnd + ); + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + // Do not show the shadow yet. + } else { + // Invalid grabbed element. + } + + document.calendarEventColumnDragging = this; + + window.addEventListener("mousemove", this.onEventSweepMouseMove); + window.addEventListener("mouseup", this.onEventSweepMouseUp); + window.addEventListener("keypress", this.onEventSweepKeypress); + } + + /** + * Set the hours when the day starts and ends. + * + * @param {number} dayStartHour - Hour at which the day starts. + * @param {number} dayEndHour - Hour at which the day ends. + */ + setDayStartEndHours(dayStartHour, dayEndHour) { + if (dayStartHour < 0 || dayStartHour > dayEndHour || dayEndHour > 24) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + for (let [hour, hourBox] of this.hourBoxes.entries()) { + hourBox.classList.toggle( + "multiday-hour-box-off-time", + hour < dayStartHour || hour >= dayEndHour + ); + } + } + + /** + * Get the minute since the starting edge of the given element that a mouse + * event points to. + * + * @param {{clientX: number, clientY: number}} mouseEvent - The pointer + * position in the viewport. + * @param {Element} [element] - The element to use the starting edge of as + * reference. Defaults to using the starting edge of the column itself, + * such that the returned minute is the number of minutes since the start + * of the day. + * + * @returns {number} - The number of minutes since the starting edge of + * 'element' that this event points to. + */ + getMouseMinute(mouseEvent, element = this) { + let rect = element.getBoundingClientRect(); + let pos; + if (this.getAttribute("orient") == "vertical") { + pos = mouseEvent.clientY - rect.top; + } else if (document.dir == "rtl") { + pos = rect.right - mouseEvent.clientX; + } else { + pos = mouseEvent.clientX - rect.left; + } + return pos / this.pixelsPerMinute; + } + + /** + * Get the datetime that the mouse event points to, snapped to the nearest + * 15 minutes. + * + * @param {MouseEvent} mouseEvent - The pointer event. + * + * @returns {calDateTime} - A new datetime that the mouseEvent points to. + */ + getMouseDateTime(mouseEvent) { + let clickMinute = this.getMouseMinute(mouseEvent); + let newStart = this.date.clone(); + newStart.isDate = false; + newStart.hour = 0; + // Round to nearest 15 minutes. + newStart.minute = snapMinute(clickMinute, 15); + return newStart; + } + } + + customElements.define("calendar-event-column", MozCalendarEventColumn); + + /** + * Implements the Drag and Drop class for the Calendar Header Container. + * + * @augments {MozElements.CalendarDnDContainer} + */ + class CalendarHeaderContainer extends MozElements.CalendarDnDContainer { + /** + * The date of the day this header represents. + * + * @type {calIDateTime} + */ + date; + + constructor() { + super(); + this.addEventListener("dblclick", this.onDblClick); + this.addEventListener("mousedown", this.onMouseDown); + this.addEventListener("click", this.onClick); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true in super.connectedCallback. + super.connectedCallback(); + + // Map from an event item's hashId to its calendar-editable-item. + this.eventElements = new Map(); + + this.eventsListElement = document.createElement("ol"); + this.eventsListElement.classList.add("allday-events-list"); + this.appendChild(this.eventsListElement); + } + + /** + * Return the displayed calendar-editable-item element for the given event + * item. + * + * @param {calItemBase} eventItem - The event item. + * + * @returns {Element} - The corresponding element, or undefined if none. + */ + findElementForEventItem(eventItem) { + return this.eventElements.get(eventItem.hashId); + } + + /** + * Return all the event items that are displayed in this columns. + * + * @returns {calItemBase[]} - An array of all the displayed event items. + */ + getAllEventItems() { + return Array.from(this.eventElements.values(), element => element.occurrence); + } + + /** + * Create or update a displayed calendar-editable-item element for the given + * event item. + * + * @param {calItemBase} eventItem - The event item to create or update an + * element for. + */ + addEvent(eventItem) { + let existing = this.eventElements.get(eventItem.hashId); + if (existing) { + // Remove the wrapper list item. We'll insert a replacement below. + existing.parentNode.remove(); + } + + let itemBox = document.createXULElement("calendar-editable-item"); + let listItemWrapper = document.createElement("li"); + listItemWrapper.classList.add("allday-event-listitem"); + listItemWrapper.appendChild(itemBox); + cal.data.binaryInsertNode( + this.eventsListElement, + listItemWrapper, + eventItem, + cal.view.compareItems, + false, + wrapper => wrapper.firstChild.occurrence + ); + + itemBox.calendarView = this.calendarView; + itemBox.occurrence = eventItem; + itemBox.setAttribute( + "context", + this.calendarView.getAttribute("item-context") || this.calendarView.getAttribute("context") + ); + + if (eventItem.hashId in this.calendarView.mFlashingEvents) { + itemBox.setAttribute("flashing", "true"); + } + + this.eventElements.set(eventItem.hashId, itemBox); + + itemBox.parentBox = this; + } + + /** + * Remove the displayed calendar-editable-item element for the given event + * item from this column + * + * @param {calItemBase} eventItem - The event item to remove the element of. + */ + deleteEvent(eventItem) { + let current = this.eventElements.get(eventItem.hashId); + if (current) { + // Need to remove the wrapper list item. + current.parentNode.remove(); + this.eventElements.delete(eventItem.hashId); + } + } + + /** + * Clear the header of all events. + */ + clear() { + this.eventElements.clear(); + while (this.eventsListElement.hasChildNodes()) { + this.eventsListElement.lastChild.remove(); + } + } + + /** + * Set whether to show a drop shadow in the event list. + * + * @param {boolean} on - True to show the drop shadow, otherwise hides the + * drop shadow. + */ + setDropShadow(on) { + // NOTE: Adding or removing drop shadows may change our size, but we won't + // let the calendar view know about these since they are temporary and we + // don't want the view to be re-adjusting on every hover. + let existing = this.eventsListElement.querySelector(".dropshadow"); + if (on) { + if (!existing) { + // Insert an empty list item. + let dropshadow = document.createElement("li"); + dropshadow.classList.add("dropshadow", "allday-event-listitem"); + this.eventsListElement.insertBefore(dropshadow, this.eventsListElement.firstElementChild); + } + } else if (existing) { + existing.remove(); + } + } + + onDropItem(aItem) { + let newItem = cal.item.moveToDate(aItem, this.date); + newItem = cal.item.setToAllDay(newItem, true); + return newItem; + } + + /** + * Set whether the calendar-editable-item element for the given event item + * should be displayed as selected or unselected. + * + * @param {calItemBase} eventItem - The event item. + * @param {boolean} select - Whether to show the corresponding event element + * as selected. + */ + selectEvent(eventItem, select) { + let element = this.eventElements.get(eventItem.hashId); + if (!element) { + return; + } + element.selected = select; + } + + onDblClick(event) { + if (event.button == 0) { + this.calendarView.controller.createNewEvent(null, this.date, null, true); + } + } + + onMouseDown(event) { + this.calendarView.selectedDay = this.date; + } + + onClick(event) { + if (event.button == 0) { + if (!(event.ctrlKey || event.metaKey)) { + this.calendarView.setSelectedItems([]); + } + } + if (event.button == 2) { + let newStart = this.calendarView.selectedDay.clone(); + newStart.isDate = true; + this.calendarView.selectedDateTime = newStart; + event.stopPropagation(); + } + } + + /** + * Determine whether the given wheel event is above a scrollable area and + * matches the scroll direction. + * + * @param {WheelEvent} - The wheel event. + * + * @returns {boolean} - True if this event is above a scrollable area and + * matches its scroll direction. + */ + wheelOnScrollableArea(event) { + let scrollArea = this.eventsListElement; + return ( + event.deltaY && + scrollArea.contains(event.target) && + scrollArea.scrollHeight != scrollArea.clientHeight + ); + } + } + customElements.define("calendar-header-container", CalendarHeaderContainer); + + /** + * The MozCalendarMonthDayBoxItem widget is used as event item in the + * Day and Week views of the calendar. It displays the event name, + * alarm icon and the category type color. It also displays the gripbar + * components on hovering over the event. It is used to change the event + * timings. + * + * @augments {MozElements.MozCalendarEditableItem} + */ + class MozCalendarEventBox extends MozElements.MozCalendarEditableItem { + static get inheritedAttributes() { + return { + ".alarm-icons-box": "flashing", + }; + } + constructor() { + super(); + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + event.stopPropagation(); + + if (this.mEditing) { + return; + } + + this.parentColumn.calendarView.selectedDay = this.parentColumn.date; + + this.mouseDownPosition = { + clientX: event.clientX, + clientY: event.clientY, + // We calculate the offsetStartMinute here because the clientX and + // clientY coordinates might become 'stale' by the time we actually + // call startItemDrag. E.g. if we scroll the view. + offsetStartMinute: this.parentColumn.getMouseMinute( + event, + // We use the listitem wrapper, since that is positioned relative to + // the event's start time. + this.closest(".multiday-event-listitem") + ), + }; + + let side; + if (this.startGripbar.contains(event.target)) { + side = "start"; + } else if (this.endGripbar.contains(event.target)) { + side = "end"; + } + + if (side) { + this.calendarView.setSelectedItems([ + event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence, + ]); + + // Start edge resize drag + this.parentColumn.startSweepingToModifyEvent( + this.mOccurrence, + side, + this.mouseDownPosition, + event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15 + ); + } else { + // May be click or drag, + // So wait for mousemove (or mouseout if fast) to start item move drag. + this.mInMouseDown = true; + } + }); + + this.addEventListener("mousemove", event => { + if (!this.mInMouseDown) { + return; + } + + let deltaX = Math.abs(event.clientX - this.mouseDownPosition.clientX); + let deltaY = Math.abs(event.clientY - this.mouseDownPosition.clientY); + // More than a 3 pixel move? + const movedMoreThan3Pixels = deltaX * deltaX + deltaY * deltaY > 9; + if (movedMoreThan3Pixels && this.parentColumn) { + this.startItemDrag(); + } + }); + + this.addEventListener("mouseout", event => { + if (!this.mEditing && this.mInMouseDown && this.parentColumn) { + this.startItemDrag(); + } + }); + + this.addEventListener("mouseup", event => { + if (!this.mEditing) { + this.mInMouseDown = false; + } + }); + + this.addEventListener("mouseover", event => { + if (this.calendarView && this.calendarView.controller) { + event.stopPropagation(); + onMouseOverItem(event); + } + }); + + this.addEventListener("mouseenter", event => { + // Update the event-readonly class to determine whether to show the + // gripbars, which are otherwise shown on hover. + this.classList.toggle("event-readonly", !canEditEventItem(this.occurrence)); + }); + + // We have two event listeners for dragstart. This event listener is for the capturing phase + // where we are setting up the document.monthDragEvent which will be used in the event listener + // in the bubbling phase which is set up in the calendar-editable-item. + this.addEventListener( + "dragstart", + event => { + document.monthDragEvent = this; + }, + true + ); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.appendChild( + MozXULElement.parseXULToFragment(` + <!-- NOTE: The following div is the same markup as EditableItem. --> + <html:div class="calendar-item-container"> + <html:div class="calendar-item-flex"> + <html:img class="item-type-icon" alt="" /> + <html:div class="event-name-label"></html:div> + <html:input class="plain event-name-input" + hidden="hidden" + placeholder='${cal.l10n.getCalString("newEvent")}'/> + <html:div class="alarm-icons-box"></html:div> + <html:img class="item-classification-icon" /> + <html:img class="item-recurrence-icon" /> + </html:div> + <html:div class="location-desc"></html:div> + <html:div class="calendar-category-box"></html:div> + </html:div> + `) + ); + + this.startGripbar = this.createGripbar("start"); + this.endGripbar = this.createGripbar("end"); + this.appendChild(this.startGripbar); + this.appendChild(this.endGripbar); + + this.classList.add("calendar-color-box"); + + this.style.pointerEvents = "auto"; + this.setAttribute("tooltip", "itemTooltip"); + + this.addEventNameTextboxListener(); + this.initializeAttributeInheritance(); + } + + /** + * Create one of the box's gripbars that can be dragged to resize the event. + * + * @param {"start"|"end"} side - The side the gripbar controls. + * + * @returns {Element} - A newly created gripbar. + */ + createGripbar(side) { + let gripbar = document.createElement("div"); + gripbar.classList.add(side == "start" ? "gripbar-start" : "gripbar-end"); + let img = document.createElement("img"); + img.setAttribute("src", "chrome://calendar/skin/shared/event-grippy.png"); + /* Make sure the img doesn't interfere with dragging the gripbar to + * resize. */ + img.setAttribute("draggable", "false"); + img.setAttribute("alt", ""); + gripbar.appendChild(img); + return gripbar; + } + + /** + * Update and retrieve the event's start and end dates relative to the given + * day. This updates the gripbars. + * + * @param {calIDateTime} day - The day that this event is shown on. + * + * @returns {object} - The start and end time information. + * @property {calIDateTime|undefined} startDate - The start date-time of the + * event in the timezone of the given day. Or the entry date-time for + * tasks, if they have one. + * @property {calIDateTime|undefined} endDate - The end date-time of the + * event in the timezone of the given day. Or the due date-time for + * tasks, if they have one. + * @property {number} startMinute - The number of minutes since the start of + * the given day that the event starts. + * @property {number} endMinute - The number of minutes since the end of the + * given day that the event ends. + */ + updateRelativeStartEndDates(day) { + let item = this.occurrence; + + // Get closed bounds for the day. I.e. inclusive of midnight the next day. + let closedDayStart = day.clone(); + closedDayStart.isDate = false; + let closedDayEnd = day.clone(); + closedDayEnd.day++; + closedDayEnd.isDate = false; + + function relativeTime(date) { + if (!date) { + return null; + } + date = date.getInTimezone(day.timezone); + return { + date, + minute: date.subtractDate(closedDayStart).inSeconds / 60, + withinClosedDay: date.compare(closedDayStart) >= 0 && date.compare(closedDayEnd) <= 0, + }; + } + + let start; + let end; + if (item.isEvent()) { + start = relativeTime(item.startDate); + end = relativeTime(item.endDate); + } else { + start = relativeTime(item.entryDate); + end = relativeTime(item.dueDate); + } + + this.startGripbar.hidden = !(end && start?.withinClosedDay); + this.endGripbar.hidden = !(start && end?.withinClosedDay); + + return { + startDate: start?.date, + endDate: end?.date, + startMinute: start?.minute, + endMinute: end?.minute, + }; + } + + getOptimalMinSize(orient) { + let label = this.querySelector(".event-name-label"); + if (orient == "vertical") { + let minHeight = + getOptimalMinimumHeight(label) + + getSummarizedStyleValues(label.parentNode, ["padding-bottom", "padding-top"]) + + getSummarizedStyleValues(this, ["border-bottom-width", "border-top-width"]); + this.style.minHeight = minHeight + "px"; + this.style.minWidth = "1px"; + return minHeight; + } + label.style.minWidth = "2em"; + let minWidth = getOptimalMinimumWidth(this.eventNameLabel); + this.style.minWidth = minWidth + "px"; + this.style.minHeight = "1px"; + return minWidth; + } + + startItemDrag() { + if (this.editingTimer) { + clearTimeout(this.editingTimer); + this.editingTimer = null; + } + + this.calendarView.setSelectedItems([this.mOccurrence]); + + this.mEditing = false; + + this.parentColumn.startSweepingToModifyEvent( + this.mOccurrence, + "middle", + this.mouseDownPosition + ); + this.mInMouseDown = false; + } + } + + customElements.define("calendar-event-box", MozCalendarEventBox); + + /** + * Abstract class used for the day and week calendar view elements. (Not month or multiweek.) + * + * @implements {calICalendarView} + * @augments {MozElements.CalendarBaseView} + * @abstract + */ + class CalendarMultidayBaseView extends MozElements.CalendarBaseView { + // mDateList will always be sorted before being set. + mDateList = null; + + /** + * A column in the view representing a particular date. + * + * @typedef {object} DayColumn + * @property {calIDateTime} date - The day's date. + * @property {Element} container - The container that holds the other + * elements. + * @property {Element} headingContainer - The day heading. This holds both + * the short and long headings, with only one being visible at any given + * time. + * @property {Element} longHeading - The day heading that uses the full + * day of the week. For example, "Monday". + * @property {Element} shortHeading - The day heading that uses an + * abbreviation for the day of the week. For example, "Mon". + * @property {number} longHeadingContentAreaWidth - The content area width + * of the headingContainer when the long heading is shown. + * @property {Element} column - A calendar-event-column where regular + * (not "all day") events appear. + * @property {Element} header - A calendar-header-container where allday + * events appear. + */ + /** + * An ordered list of the shown day columns. + * + * @type {DayColumn[]} + */ + dayColumns = []; + + /** + * Whether the number of headings, or the heading dates have changed, and + * the view still needs to be adjusted accordingly. + * + * @type {boolean} + */ + headingDatesChanged = true; + /** + * Whether the view has been rotated and the view still needs to be fully + * adjusted. + * + * @type {boolean} + */ + rotationChanged = true; + + mSelectedDayCol = null; + mSelectedDay = null; + + /** + * The hour that a 'day' starts. Any time before this is considered + * off-time. + * + * @type {number} + */ + dayStartHour = 0; + /** + * The hour that a 'day' ends. Any time equal to or after this is + * considered off-time. + * + * @type {number} + */ + dayEndHour = 0; + + /** + * How many hours to show in the scrollable area. + * + * @type {number} + */ + visibleHours = 9; + + /** + * The number of pixels that a one minute duration should occupy in the + * view. + * + * @type {number} + */ + pixelsPerMinute; + + /** + * The timebar hour box elements in this view, ordered and indexed by their + * starting hour. + * + * @type {Element[]} + */ + hourBoxes = []; + + mClickedTime = null; + + mTimeIndicatorInterval = 15; + mTimeIndicatorMinutes = 0; + + mModeHandler = null; + scrollMinute = 0; + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + super.connectedCallback(); + + // Get day start/end hour from prefs and set on the view. + // This happens here to keep tests happy. + this.setDayStartEndHours( + Services.prefs.getIntPref("calendar.view.daystarthour", 8), + Services.prefs.getIntPref("calendar.view.dayendhour", 17) + ); + + // We set the scrollMinute, so that when onResize is eventually triggered + // by refresh, we will scroll to this. + // FIXME: Find a cleaner solution. + this.scrollMinute = this.dayStartHour * 60; + } + + ensureInitialized() { + if (this.isInitialized) { + return; + } + + this.grid = document.createElement("div"); + this.grid.classList.add("multiday-grid"); + this.appendChild(this.grid); + + this.headerCorner = document.createElement("div"); + this.headerCorner.classList.add("multiday-header-corner"); + + this.grid.appendChild(this.headerCorner); + + this.timebar = document.createElement("div"); + this.timebar.classList.add("multiday-timebar", "multiday-hour-box-container"); + this.nowIndicator = document.createElement("div"); + this.nowIndicator.classList.add("multiday-timebar-now-indicator"); + this.nowIndicator.hidden = true; + this.timebar.appendChild(this.nowIndicator); + + let formatter = cal.dtz.formatter; + let jsTime = new Date(); + for (let hour = 0; hour < 24; hour++) { + let hourBox = document.createElement("div"); + hourBox.classList.add("multiday-hour-box", "multiday-timebar-time"); + // Set the time label. + jsTime.setHours(hour, 0, 0); + hourBox.textContent = formatter.formatTime( + cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating) + ); + this.timebar.appendChild(hourBox); + this.hourBoxes.push(hourBox); + } + this.grid.appendChild(this.timebar); + + this.endBorder = document.createElement("div"); + this.endBorder.classList.add("multiday-end-border"); + this.grid.appendChild(this.endBorder); + + this.initializeAttributeInheritance(); + + // super.connectedCallback has to be called after the time bar is added to the DOM. + super.ensureInitialized(); + + this.addEventListener("click", event => { + if (event.button != 2) { + return; + } + this.selectedDateTime = null; + }); + + this.addEventListener("wheel", event => { + // Only shift hours if no modifier is pressed. + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) { + return; + } + let deltaTime = this.getAttribute("orient") == "horizontal" ? event.deltaX : event.deltaY; + if (!deltaTime) { + // Scroll is not in the same direction as the time axis, so just do + // the default scroll (if any). + return; + } + if ( + this.headerCorner.contains(event.target) || + this.dayColumns.some(col => col.headingContainer.contains(event.target)) + ) { + // Prevent any scrolling in these sticky headers. + event.preventDefault(); + return; + } + let header = this.dayColumns.find(col => col.header.contains(event.target))?.header; + if (header) { + if (!header.wheelOnScrollableArea(event)) { + // Prevent any scrolling in this header. + event.preventDefault(); + // Otherwise, we let the default wheel handler scroll the header. + // NOTE: We have the CSS overscroll-behavior set to "none", to stop + // the default wheel handler from scrolling the parent if the header + // is already at its scrolling edge. + } + return; + } + let minute = this.scrollMinute; + if (event.deltaMode == event.DOM_DELTA_LINE) { + // We snap from the current hour to the next one. + let scrollHour = deltaTime < 0 ? Math.floor(minute / 60) : Math.ceil(minute / 60); + if (Math.abs(scrollHour * 60 - minute) < 10) { + // If the change in minutes would be less than 10 minutes, go to the + // next hour. This means that anything in the close neighbourhood of + // the hour line will scroll to the same hour. + scrollHour += Math.sign(deltaTime); + } + minute = scrollHour * 60; + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + let minDiff = deltaTime / this.pixelsPerMinute; + minute += minDiff < 0 ? Math.floor(minDiff) : Math.ceil(minDiff); + } else { + return; + } + event.preventDefault(); + this.scrollToMinute(minute); + }); + + this.grid.addEventListener("scroll", event => { + if (!this.clientHeight) { + // Hidden, so don't store the scroll position. + // FIXME: We don't expect scrolling whilst we are hidden, so we should + // try and remove. This is only seems to happen in mochitests. + return; + } + let scrollPx; + if (this.getAttribute("orient") == "horizontal") { + scrollPx = document.dir == "rtl" ? -this.grid.scrollLeft : this.grid.scrollLeft; + } else { + scrollPx = this.grid.scrollTop; + } + this.scrollMinute = Math.round(scrollPx / this.pixelsPerMinute); + }); + + // Get visible hours from prefs and set on the view. + this.setVisibleHours(Services.prefs.getIntPref("calendar.view.visiblehours", 9)); + } + + // calICalendarView Properties + + get supportsZoom() { + return true; + } + + get supportsRotation() { + return true; + } + + get supportsDisjointDates() { + return true; + } + + get hasDisjointDates() { + return this.mDateList != null; + } + + set selectedDay(day) { + // Ignore if just 1 visible, it's always selected, but we don't indicate it. + if (this.numVisibleDates == 1) { + this.fireEvent("dayselect", day); + return; + } + + if (this.mSelectedDayCol) { + this.mSelectedDayCol.container.classList.remove("day-column-selected"); + } + + if (day) { + this.mSelectedDayCol = this.findColumnForDate(day); + if (this.mSelectedDayCol) { + this.mSelectedDay = this.mSelectedDayCol.date; + this.mSelectedDayCol.container.classList.add("day-column-selected"); + } else { + this.mSelectedDay = day; + } + } + this.fireEvent("dayselect", day); + } + + get selectedDay() { + let selected; + if (this.numVisibleDates == 1) { + selected = this.dayColumns[0].date; + } else if (this.mSelectedDay) { + selected = this.mSelectedDay; + } else if (this.mSelectedDayCol) { + selected = this.mSelectedDayCol.date; + } + + // TODO Make sure the selected day is valid. + // TODO Select now if it is in the range? + return selected; + } + + // End calICalendarView Properties + + set selectedDateTime(dateTime) { + this.mClickedTime = dateTime; + } + + get selectedDateTime() { + return this.mClickedTime; + } + + // Private + + get numVisibleDates() { + if (this.mDateList) { + return this.mDateList.length; + } + + let count = 0; + + if (!this.mStartDate || !this.mEndDate) { + // The view has not been initialized, so there are 0 visible dates. + return count; + } + + const date = this.mStartDate.clone(); + while (date.compare(this.mEndDate) <= 0) { + count++; + date.day += 1; + } + + return count; + } + + /** + * Update the position of the time indicator. + */ + updateTimeIndicatorPosition() { + // Calculate the position of the indicator based on how far into the day + // it is and the size of the current view. + const now = cal.dtz.now(); + const nowMinutes = now.hour * 60 + now.minute; + + let position = `${this.pixelsPerMinute * nowMinutes - 1}px`; + let isVertical = this.getAttribute("orient") == "vertical"; + + // Control the position of the dot in the time bar, which is present even + // when the view does not show the current day. Inline start controls + // horizontal position of the dot, block controls vertical. + this.nowIndicator.style.insetInlineStart = isVertical ? null : position; + this.nowIndicator.style.insetBlockStart = isVertical ? position : null; + + // Control the position of the bar, which should be visible only for the + // current day. + const todayIndicator = this.findColumnForDate(this.today())?.column.timeIndicatorBox; + if (todayIndicator) { + todayIndicator.style.marginInlineStart = isVertical ? null : position; + todayIndicator.style.marginBlockStart = isVertical ? position : null; + } + } + + /** + * Handle preference changes. Typically called by a preference observer. + * + * @param {object} subject - The subject, a prefs object. + * @param {string} topic - The notification topic. + * @param {string} preference - The preference to handle. + */ + handlePreference(subject, topic, preference) { + subject.QueryInterface(Ci.nsIPrefBranch); + switch (preference) { + case "calendar.view.daystarthour": + this.setDayStartEndHours(subject.getIntPref(preference), this.dayEndHour); + break; + + case "calendar.view.dayendhour": + this.setDayStartEndHours(this.dayStartHour, subject.getIntPref(preference)); + break; + + case "calendar.view.visiblehours": + this.setVisibleHours(subject.getIntPref(preference)); + this.readjustView(true, true, this.scrollMinute); + break; + + default: + this.handleCommonPreference(subject, topic, preference); + break; + } + } + + /** + * Handle resizing by adjusting the view to the new size. + */ + onResize() { + // Assume resize in both directions. + this.readjustView(true, true, this.scrollMinute); + } + + /** + * Perform an operation on the header that may cause it to resize, such that + * the view can adjust itself accordingly. + * + * @param {Element} header - The header that may resize. + * @param {Function} operation - An operation to run. + */ + doResizingHeaderOperation(header, operation) { + // Capture scrollMinute before we potentially change the size of the view. + let scrollMinute = this.scrollMinute; + let beforeRect = header.getBoundingClientRect(); + + operation(); + + let afterRect = header.getBoundingClientRect(); + this.readjustView( + beforeRect.height != afterRect.height, + beforeRect.width != afterRect.width, + scrollMinute + ); + } + + /** + * Adjust the view based an a change in rotation, layout, view size, or + * header size. + * + * Note, this method will do nothing whilst the view is hidden, so must be + * called again once it is shown. + * + * @param {boolean} verticalResize - There may have been a change in the + * vertical direction. + * @param {boolean} horizontalResize - There may have been a change in the + * horizontal direction. + * @param {number} scrollMinute - The minute we should scroll after + * adjusting the view in the time-direction. + */ + readjustView(verticalResize, horizontalResize, scrollMinute) { + if (!this.clientHeight || !this.clientWidth) { + // Do nothing if we have zero width or height since we cannot measure + // elements. Should be called again once we can. + return; + } + + let isHorizontal = this.getAttribute("orient") == "horizontal"; + + // Adjust the headings. We do this before measuring the pixels per minute + // because this may adjust the size of the headings. + if (this.headingDatesChanged) { + this.shortHeadingContentWidth = 0; + for (let dayCol of this.dayColumns) { + // Make sure both headings are visible for measuring. + // We will hide one of them again further below. + dayCol.shortHeading.hidden = false; + dayCol.longHeading.hidden = false; + + // We can safely measure the widths of the short and long headings + // because their headingContainer does not grow or shrink them. + let longHeadingRect = dayCol.longHeading.getBoundingClientRect(); + if (!this.headingContentHeight) { + // We assume this is constant and the same for each heading. + this.headingContentHeight = longHeadingRect.height; + } + + dayCol.longHeadingContentAreaWidth = longHeadingRect.width; + this.shortHeadingContentWidth = Math.max( + this.shortHeadingContentWidth, + dayCol.shortHeading.getBoundingClientRect().width + ); + } + // Unset the other properties that use these values. + // NOTE: We do not calculate new values for these properties here + // because they can only be measured in one of the rotated or + // non-rotated states. So we will calculate them as needed. + delete this.rotatedHeadingWidth; + delete this.minHeadingWidth; + } + + // Whether the headings need readjusting. + let adjustHeadingPositioning = this.headingDatesChanged || this.rotationChanged; + // Position headers. + if (isHorizontal) { + // We're in the rotated state, so we can measure the corresponding + // header dimensions. + // NOTE: we always use short headings in the rotated view. + if (!this.rotatedHeadingWidth) { + // Width is shared by all headings in the rotated view, so we set it + // so that its large enough to fit the text of each heading. + if (!this.rotatedHeadingContentToBorderWidthOffset) { + // We cache the value since we assume it is constant within the + // rotated view. + this.rotatedHeadingContentToBorderOffset = this.measureHeadingContentToBorderOffset(); + } + this.rotatedHeadingWidth = + this.shortHeadingContentWidth + this.rotatedHeadingContentToBorderOffset.inline; + adjustHeadingPositioning = true; + } + if (adjustHeadingPositioning) { + for (let dayCol of this.dayColumns) { + // The header is sticky, so we need to position it. We want a constant + // position, so we offset the header by the heading width. + // NOTE: We assume there is no margin between the two. + dayCol.header.style.insetBlockStart = null; + dayCol.header.style.insetInlineStart = `${this.rotatedHeadingWidth}px`; + // NOTE: The heading must have its box-sizing set to border-box for + // this to work properly. + dayCol.headingContainer.style.width = `${this.rotatedHeadingWidth}px`; + dayCol.headingContainer.style.minWidth = null; + } + } + } else { + // We're in the non-rotated state, so we can measure the corresponding + // header dimensions. + if (!this.headingContentToBorderOffset) { + // We cache the value since we assume it is constant within the + // non-rotated view. + this.headingContentToBorderOffset = this.measureHeadingContentToBorderOffset(); + } + if (!this.headingHeight) { + this.headingHeight = this.headingContentHeight + this.headingContentToBorderOffset.block; + } + if (!this.minHeadingWidth) { + // Make the minimum width large enough to fit the short heading. + this.minHeadingWidth = + this.shortHeadingContentWidth + this.headingContentToBorderOffset.inline; + adjustHeadingPositioning = true; + } + if (adjustHeadingPositioning) { + for (let dayCol of this.dayColumns) { + // We offset the header by the heading height. + dayCol.header.style.insetBlockStart = `${this.headingHeight}px`; + dayCol.header.style.insetInlineStart = null; + dayCol.headingContainer.style.minWidth = `${this.minHeadingWidth}px`; + dayCol.headingContainer.style.width = null; + } + } + } + + // If the view is horizontal, we always use the short headings. + // We do this before calculating the pixelsPerMinute since the width of + // the heading is important to determining the size of the scroll area. + // We only need to do this when the view has been rotated, or when new + // headings have been added. adjustHeadingPosition covers both of these. + if (isHorizontal && adjustHeadingPositioning) { + for (let dayCol of this.dayColumns) { + dayCol.shortHeading.hidden = false; + dayCol.longHeading.hidden = true; + } + } + // Otherwise, if the view is vertical, we determine whether to use short + // or long headings after changing the pixelsPerMinute, which can change + // the amount of horizontal space. + // NOTE: when the view is vertical, both the short and long headings + // should take up the same vertical space, so this shouldn't effect the + // pixelsPerMinute calculation. + + if (this.rotationChanged) { + // Clear the set widths/heights or positions before calculating the + // scroll area. Otherwise they will remain extended in the wrong + // direction, and keep the grid content larger than necessary, which can + // cause the grid content to overflow, which in turn shrinks the + // calculated scroll area due to extra scrollbars. + // The timebar will be corrected when the pixelsPerMinute is calculated. + this.timebar.style.width = null; + this.timebar.style.height = null; + // The time indicators will be corrected in updateTimeIndicatorPosition. + this.nowIndicator.style.insetInlineStart = null; + this.nowIndicator.style.insetBlockStart = null; + let todayIndicator = this.findColumnForDate(this.today())?.column.timeIndicatorBox; + if (todayIndicator) { + todayIndicator.style.marginInlineStart = null; + todayIndicator.style.marginBlockStart = null; + } + } + + // Adjust pixels per minute. + let ppmHasChanged = false; + if ( + adjustHeadingPositioning || + (isHorizontal && horizontalResize) || + (!isHorizontal && verticalResize) + ) { + if (isHorizontal && !this.timebarMinWidth) { + // Measure the minimum width such that the time labels do not overflow + // and are equal width. + this.timebar.style.height = null; + this.timebar.style.width = "min-content"; + let maxWidth = 0; + for (let hourBox of this.hourBoxes) { + maxWidth = Math.max(maxWidth, hourBox.getBoundingClientRect().width); + } + // NOTE: We assume no margin between the boxes. + this.timebarMinWidth = maxWidth * this.hourBoxes.length; + // width should be set to the correct value below when the + // pixelsPerMinute changes. + } else if (!isHorizontal && !this.timebarMinHeight) { + // Measure the minimum height such that the time labels do not + // overflow and are equal height. + this.timebar.style.width = null; + this.timebar.style.height = "min-content"; + let maxHeight = 0; + for (let hourBox of this.hourBoxes) { + maxHeight = Math.max(maxHeight, hourBox.getBoundingClientRect().height); + } + // NOTE: We assume no margin between the boxes. + this.timebarMinHeight = maxHeight * this.hourBoxes.length; + // height should be set to the correct value below when the + // pixelsPerMinute changes. + } + + // We want to know how much visible space is available in the + // "time-direction" of this view's scrollable area, which will be used + // to show 'this.visibleHour' hours in the timebar. + // NOTE: The area returned by getScrollAreaRect is the *current* + // scrollable area. We are working with the assumption that the length + // in the time-direction will not change when we change the pixels per + // minute. This assumption is broken if the changes cause the + // non-time-direction to switch from overflowing to not, or vis versa, + // which adds or removes a scrollbar. Since we are only changing the + // content length in the time-direction, this should only happen in edge + // cases (e.g. scrollbar being added from a time-direction overflow also + // causes the non-time-direction to overflow). + let scrollArea = this.getScrollAreaRect(); + let dayScale = 24 / this.visibleHours; + let dayPixels = isHorizontal + ? Math.max((scrollArea.right - scrollArea.left) * dayScale, this.timebarMinWidth) + : Math.max((scrollArea.bottom - scrollArea.top) * dayScale, this.timebarMinHeight); + let pixelsPerMinute = dayPixels / MINUTES_IN_DAY; + if (this.rotationChanged || pixelsPerMinute != this.pixelsPerMinute) { + ppmHasChanged = true; + this.pixelsPerMinute = pixelsPerMinute; + + // Use the same calculation as in the event columns. + let dayPx = `${MINUTES_IN_DAY * pixelsPerMinute}px`; + if (isHorizontal) { + this.timebar.style.width = dayPx; + this.timebar.style.height = null; + } else { + this.timebar.style.height = dayPx; + this.timebar.style.width = null; + } + + for (const col of this.dayColumns) { + col.column.pixelsPerMinute = pixelsPerMinute; + } + } + + // Scroll to the given minute. + this.scrollToMinute(scrollMinute); + // A change in pixels per minute can cause a scrollbar to appear or + // disappear, which can change the available space for headers. + if (ppmHasChanged) { + verticalResize = true; + horizontalResize = true; + } + } + + // Decide whether to use short headings. + if (!isHorizontal && (horizontalResize || adjustHeadingPositioning)) { + // Use short headings if *any* heading would horizontally overflow with + // a long heading. + let widthOffset = this.headingContentToBorderOffset.inline; + let useShortHeadings = this.dayColumns.some( + col => + col.headingContainer.getBoundingClientRect().width < + col.longHeadingContentAreaWidth + widthOffset + ); + for (let dayCol of this.dayColumns) { + dayCol.shortHeading.hidden = !useShortHeadings; + dayCol.longHeading.hidden = useShortHeadings; + } + } + + this.updateTimeIndicatorPosition(); + + // The changes have now been handled. + this.headingDatesChanged = false; + this.rotationChanged = false; + } + + /** + * Measure the total offset between the content width and border width of + * the day headings. + * + * @returns {{inline: number, block: number}} - The offsets in their + * respective directions. + */ + measureHeadingContentToBorderOffset() { + if (!this.dayColumns.length) { + // undefined properties. + return {}; + } + // We cache the offset. We expect these styles to differ between the + // rotated and non-rotated views, but to otherwise be constant. + let style = getComputedStyle(this.dayColumns[0].headingContainer); + return { + inline: + parseFloat(style.paddingInlineStart) + + parseFloat(style.paddingInlineEnd) + + parseFloat(style.borderInlineStartWidth) + + parseFloat(style.borderInlineEndWidth), + block: + parseFloat(style.paddingBlockStart) + + parseFloat(style.paddingBlockEnd) + + parseFloat(style.borderBlockStartWidth) + + parseFloat(style.borderBlockEndWidth), + }; + } + + /** + * Make a calendar item flash or stop flashing. Called when the item's alarm fires. + * + * @param {calIItemBase} item - The calendar item. + * @param {boolean} stop - Whether to stop the item from flashing. + */ + flashAlarm(item, stop) { + function setFlashingAttribute(box) { + if (stop) { + box.removeAttribute("flashing"); + } else { + box.setAttribute("flashing", "true"); + } + } + + const showIndicator = Services.prefs.getBoolPref("calendar.alarms.indicator.show", true); + const totaltime = Services.prefs.getIntPref("calendar.alarms.indicator.totaltime", 3600); + + if (!stop && (!showIndicator || totaltime < 1)) { + // No need to animate if the indicator should not be shown. + return; + } + + // Make sure the flashing attribute is set or reset on all visible boxes. + const columns = this.findColumnsForItem(item); + for (const col of columns) { + const colBox = col.column.findElementForEventItem(item); + const headerBox = col.header.findElementForEventItem(item); + + if (colBox) { + setFlashingAttribute(colBox); + } + if (headerBox) { + setFlashingAttribute(headerBox); + } + } + + if (stop) { + // We are done flashing, prevent newly created event boxes from flashing. + delete this.mFlashingEvents[item.hashId]; + } else { + // Set up a timer to stop the flashing after the total time. + this.mFlashingEvents[item.hashId] = item; + setTimeout(() => this.flashAlarm(item, true), totaltime); + } + } + + // calICalendarView Methods + + showDate(date) { + const targetDate = date.getInTimezone(this.mTimezone); + targetDate.isDate = true; + + if (this.mStartDate.timezone.tzid == date.timezone.tzid) { + if (this.mStartDate && this.mEndDate) { + if (this.mStartDate.compare(targetDate) <= 0 && this.mEndDate.compare(targetDate) >= 0) { + return; + } + } else if (this.mDateList) { + for (const listDate of this.mDateList) { + // If date is already visible, nothing to do. + if (listDate.compare(targetDate) == 0) { + return; + } + } + } + } + + // If we're only showing one date, then continue + // to only show one date; otherwise, show the week. + if (this.numVisibleDates == 1) { + this.setDateRange(date, date); + } else { + this.setDateRange(date.startOfWeek, date.endOfWeek); + } + + this.selectedDay = targetDate; + } + + setDateRange(startDate, endDate) { + this.rangeStartDate = startDate; + this.rangeEndDate = endDate; + + const viewStart = startDate.getInTimezone(this.mTimezone); + const viewEnd = endDate.getInTimezone(this.mTimezone); + + viewStart.isDate = true; + viewStart.makeImmutable(); + viewEnd.isDate = true; + viewEnd.makeImmutable(); + + this.mStartDate = viewStart; + this.mEndDate = viewEnd; + + // The start and end dates to query calendars with (in CalendarFilteredViewMixin). + this.startDate = viewStart; + let viewEndPlusOne = viewEnd.clone(); + viewEndPlusOne.day++; + this.endDate = viewEndPlusOne; + + // First, check values of tasksInView, workdaysOnly, showCompleted. + // Their status will determine the value of toggleStatus, which is + // saved to this.mToggleStatus during last call to relayout() + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + // Update the navigation bar only when changes are related to the current view. + if (this.isVisible()) { + calendarNavigationBar.setDateRange(viewStart, viewEnd); + } + + // Check whether view range has been changed since last call to relayout(). + if ( + !this.mViewStart || + !this.mViewEnd || + this.mViewStart.timezone.tzid != viewStart.timezone.tzid || + this.mViewEnd.compare(viewEnd) != 0 || + this.mViewStart.compare(viewStart) != 0 || + this.mToggleStatus != toggleStatus + ) { + this.relayout({ dates: true }); + } + } + + getDateList() { + const dates = []; + if (this.mStartDate && this.mEndDate) { + const date = this.mStartDate.clone(); + while (date.compare(this.mEndDate) <= 0) { + dates.push(date.clone()); + date.day += 1; + } + } else if (this.mDateList) { + for (const date of this.mDateList) { + dates.push(date.clone()); + } + } + + return dates; + } + + setSelectedItems(items, suppressEvent) { + if (this.mSelectedItems) { + for (const item of this.mSelectedItems) { + for (const occ of this.getItemOccurrencesInView(item)) { + const cols = this.findColumnsForItem(occ); + for (const col of cols) { + col.header.selectEvent(occ, false); + col.column.selectEvent(occ, false); + } + } + } + } + this.mSelectedItems = items || []; + + for (const item of this.mSelectedItems) { + for (const occ of this.getItemOccurrencesInView(item)) { + const cols = this.findColumnsForItem(occ); + if (cols.length == 0) { + continue; + } + const start = item.startDate || item.entryDate || item.dueDate; + for (const col of cols) { + if (start.isDate) { + col.header.selectEvent(occ, true); + } else { + col.column.selectEvent(occ, true); + } + } + } + } + + if (!suppressEvent) { + this.fireEvent("itemselect", this.mSelectedItems); + } + } + + centerSelectedItems() { + const displayTZ = cal.dtz.defaultTimezone; + let lowMinute = MINUTES_IN_DAY; + let highMinute = 0; + + for (const item of this.mSelectedItems) { + const startDateProperty = cal.dtz.startDateProp(item); + const endDateProperty = cal.dtz.endDateProp(item); + + let occs = []; + if (item.recurrenceInfo) { + // If selected a parent item, show occurrence(s) in view range. + occs = item.getOccurrencesBetween(this.startDate, this.queryEndDate); + } else { + occs = [item]; + } + + for (const occ of occs) { + let occStart = occ[startDateProperty]; + let occEnd = occ[endDateProperty]; + // Must have at least one of start or end. + if (!occStart && !occEnd) { + // Task with no dates. + continue; + } + + // If just has single datetime, treat as zero duration item + // (such as task with due datetime or start datetime only). + occStart = occStart || occEnd; + occEnd = occEnd || occStart; + // Now both occStart and occEnd are datetimes. + + // Skip occurrence if all-day: it won't show in time view. + if (occStart.isDate || occEnd.isDate) { + continue; + } + + // Trim dates to view. (Not mutated so just reuse view dates.) + if (this.startDate.compare(occStart) > 0) { + occStart = this.startDate; + } + if (this.queryEndDate.compare(occEnd) < 0) { + occEnd = this.queryEndDate; + } + + // Convert to display timezone if different. + if (occStart.timezone != displayTZ) { + occStart = occStart.getInTimezone(displayTZ); + } + if (occEnd.timezone != displayTZ) { + occEnd = occEnd.getInTimezone(displayTZ); + } + // If crosses midnight in current TZ, set end just + // before midnight after start so start/title usually visible. + if (!cal.dtz.sameDay(occStart, occEnd)) { + occEnd = occStart.clone(); + occEnd.day = occStart.day; + occEnd.hour = 23; + occEnd.minute = 59; + } + + // Ensure range shows occ. + lowMinute = Math.min(occStart.hour * 60 + occStart.minute, lowMinute); + highMinute = Math.max(occEnd.hour * 60 + occEnd.minute, highMinute); + } + } + + let halfDurationMinutes = (highMinute - lowMinute) / 2; + if (this.mSelectedItems.length && halfDurationMinutes >= 0) { + let halfVisibleMinutes = this.visibleHours * 30; + if (halfDurationMinutes <= halfVisibleMinutes) { + // If the full duration fits in the view, then center the middle of + // the region. + this.scrollToMinute(lowMinute + halfDurationMinutes - halfVisibleMinutes); + } else if (this.mSelectedItems.length == 1) { + // Else, if only one event is selected, then center the start. + this.scrollToMinute(lowMinute - halfVisibleMinutes); + } + // Else, don't scroll. + } + } + + zoomIn(level) { + let visibleHours = Services.prefs.getIntPref("calendar.view.visiblehours", 9); + visibleHours += level || 1; + + Services.prefs.setIntPref("calendar.view.visiblehours", Math.min(visibleHours, 24)); + } + + zoomOut(level) { + let visibleHours = Services.prefs.getIntPref("calendar.view.visiblehours", 9); + visibleHours -= level || 1; + + Services.prefs.setIntPref("calendar.view.visiblehours", Math.max(1, visibleHours)); + } + + zoomReset() { + Services.prefs.setIntPref("calendar.view.visiblehours", 9); + } + + // End calICalendarView Methods + + /** + * Return all the occurrences of a given item that are currently displayed in the view. + * + * @param {calIItemBase} item - A calendar item. + * @returns {calIItemBase[]} An array of occurrences. + */ + getItemOccurrencesInView(item) { + if (item.recurrenceInfo && item.recurrenceStartDate) { + // If a parent item is selected, show occurrence(s) in view range. + return item.getOccurrencesBetween(this.startDate, this.queryEndDate); + } else if (item.recurrenceStartDate) { + return [item]; + } + // Undated todo. + return []; + } + + /** + * Set an attribute on the view element, and do re-orientation and re-layout if needed. + * + * @param {string} attr - The attribute to set. + * @param {string} value - The value to set. + */ + setAttribute(attr, value) { + let rotated = attr == "orient" && this.getAttribute("orient") != value; + let context = attr == "context" || attr == "item-context"; + + // This should be done using lookupMethod(), see bug 286629. + const ret = XULElement.prototype.setAttribute.call(this, attr, value); + + if (rotated || context) { + this.relayout({ rotated, context }); + } + + return ret; + } + + /** + * Re-render the view based on the given changes. + * + * Note, changing the dates will wipe the columns of all events, otherwise + * the current events are kept in place. + * + * @param {object} [changes] - The relevant changes to the view. Defaults to + * all changes. + * @property {boolean} dates - A change in the column dates. + * @property {boolean} rotated - A change in the rotation. + * @property {boolean} context - A change in the context menu. + */ + relayout(changes) { + if (!this.mStartDate || !this.mEndDate) { + return; + } + if (!changes) { + changes = { dates: true, rotated: true, context: true }; + } + let scrollMinute = this.scrollMinute; + + const orient = this.getAttribute("orient") || "vertical"; + this.grid.classList.toggle("multiday-grid-rotated", orient == "horizontal"); + + let context = this.getAttribute("context"); + let itemContext = this.getAttribute("item-context") || context; + + for (let dayCol of this.dayColumns) { + dayCol.column.startLayoutBatchChange(); + } + + if (changes.dates) { + const computedDateList = []; + const startDate = this.mStartDate.clone(); + while (startDate.compare(this.mEndDate) <= 0) { + const workday = startDate.clone(); + workday.makeImmutable(); + + if (this.mDisplayDaysOff || !this.mDaysOffArray.includes(startDate.weekday)) { + computedDateList.push(workday); + } + startDate.day += 1; + } + this.mDateList = computedDateList; + + this.grid.style.setProperty("--multiday-num-days", computedDateList.length); + + // Deselect the previously selected event upon switching views, + // otherwise those events will stay selected forever, if other events + // are selected after changing the view. + this.setSelectedItems([], true); + + // Get today's date. + let today = this.today(); + + let dateFormatter = cal.dtz.formatter; + + // Assume the heading widths are no longer valid because the displayed + // dates are likely to change. + // We do not measure them here since we may be hidden. Instead we do so + // in readjustView. + this.headingDatesChanged = true; + let colIndex; + for (colIndex = 0; colIndex < computedDateList.length; colIndex++) { + let dayDate = computedDateList[colIndex]; + let dayCol = this.dayColumns[colIndex]; + if (dayCol) { + dayCol.column.clear(); + dayCol.header.clear(); + } else { + dayCol = {}; + dayCol.container = document.createElement("article"); + dayCol.container.classList.add("day-column-container"); + this.grid.insertBefore(dayCol.container, this.endBorder); + + dayCol.headingContainer = document.createElement("h2"); + dayCol.headingContainer.classList.add("day-column-heading"); + dayCol.longHeading = document.createElement("span"); + dayCol.shortHeading = document.createElement("span"); + dayCol.headingContainer.appendChild(dayCol.longHeading); + dayCol.headingContainer.appendChild(dayCol.shortHeading); + dayCol.container.appendChild(dayCol.headingContainer); + + dayCol.header = document.createXULElement("calendar-header-container"); + dayCol.header.setAttribute("orient", "vertical"); + dayCol.container.appendChild(dayCol.header); + dayCol.header.calendarView = this; + + dayCol.column = document.createXULElement("calendar-event-column"); + dayCol.container.appendChild(dayCol.column); + dayCol.column.calendarView = this; + dayCol.column.startLayoutBatchChange(); + dayCol.column.pixelsPerMinute = this.pixelsPerMinute; + dayCol.column.setDayStartEndHours(this.dayStartHour, this.dayEndHour); + dayCol.column.setAttribute("orient", orient); + dayCol.column.setAttribute("context", context); + dayCol.column.setAttribute("item-context", itemContext); + + this.dayColumns[colIndex] = dayCol; + } + dayCol.date = dayDate.clone(); + dayCol.date.isDate = true; + dayCol.date.makeImmutable(); + + /* Set up day of the week headings. */ + dayCol.shortHeading.textContent = cal.l10n.getCalString("dayHeaderLabel", [ + dateFormatter.shortDayName(dayDate.weekday), + dateFormatter.formatDateWithoutYear(dayDate), + ]); + dayCol.longHeading.textContent = cal.l10n.getCalString("dayHeaderLabel", [ + dateFormatter.dayName(dayDate.weekday), + dateFormatter.formatDateWithoutYear(dayDate), + ]); + + /* Set up all-day header. */ + dayCol.header.date = dayDate; + + /* Set up event column. */ + dayCol.column.date = dayDate; + + /* Set up styling classes for day-off and today. */ + dayCol.container.classList.toggle( + "day-column-weekend", + this.mDaysOffArray.includes(dayDate.weekday) + ); + + let isToday = dayDate.compare(today) == 0; + dayCol.column.timeIndicatorBox.hidden = !isToday; + dayCol.container.classList.toggle("day-column-today", isToday); + } + // Remove excess columns. + for (let dayCol of this.dayColumns.splice(colIndex)) { + dayCol.column.endLayoutBatchChange(); + dayCol.container.remove(); + } + } + + if (changes.rotated) { + this.rotationChanged = true; + for (let dayCol of this.dayColumns) { + dayCol.column.setAttribute("orient", orient); + } + } + + if (changes.context) { + for (let dayCol of this.dayColumns) { + dayCol.column.setAttribute("context", context); + dayCol.column.setAttribute("item-context", itemContext); + } + } + + // Let the columns relayout themselves before we readjust the view. + for (let dayCol of this.dayColumns) { + dayCol.column.endLayoutBatchChange(); + } + + if (changes.dates || changes.rotated) { + // Fix pixels-per-minute and headers, now or when next visible. + this.readjustView(false, false, scrollMinute); + } + + // Store the start and end of current view. Next time when + // setDateRange is called, it will use mViewStart and mViewEnd to + // check if view range has been changed. + this.mViewStart = this.mStartDate; + this.mViewEnd = this.mEndDate; + + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + this.mToggleStatus = toggleStatus; + if (changes.dates) { + // Fetch new items for the new dates. + this.refreshItems(true); + } + } + + /** + * Return the column object for a given date. + * + * @param {calIDateTime} date - A date. + * @returns {?DateColumn} A column object. + */ + findColumnForDate(date) { + for (const col of this.dayColumns) { + if (col.date.compare(date) == 0) { + return col; + } + } + return null; + } + + /** + * Return the day box (column header) for a given date. + * + * @param {calIDateTime} date - A date. + * @returns {Element} A `calendar-header-container` where "all day" events appear. + */ + findDayBoxForDate(date) { + const col = this.findColumnForDate(date); + return col && col.header; + } + + /** + * Return the column objects for a given calendar item. + * + * @param {calIItemBase} item - A calendar item. + * @returns {DateColumn[]} An array of column objects. + */ + findColumnsForItem(item) { + const columns = []; + + if (!this.dayColumns.length) { + return columns; + } + + // Note that these may be dates or datetimes. + const startDate = item.startDate || item.entryDate || item.dueDate; + if (!startDate) { + return columns; + } + const timezone = this.dayColumns[0].date.timezone; + let targetDate = startDate.getInTimezone(timezone); + let finishDate = (item.endDate || item.dueDate || item.entryDate || startDate).getInTimezone( + timezone + ); + + if (targetDate.compare(this.mStartDate) < 0) { + targetDate = this.mStartDate.clone(); + } + + if (finishDate.compare(this.mEndDate) > 0) { + finishDate = this.mEndDate.clone(); + finishDate.day++; + } + + // Set the time to 00:00 so that we get all the boxes. + targetDate.isDate = false; + targetDate.hour = 0; + targetDate.minute = 0; + targetDate.second = 0; + + if (targetDate.compare(finishDate) == 0) { + // We have also to handle zero length events in particular for + // tasks without entry or due date. + const col = this.findColumnForDate(targetDate); + if (col) { + columns.push(col); + } + } + + while (targetDate.compare(finishDate) == -1) { + const col = this.findColumnForDate(targetDate); + + // This might not exist if the event spans the view start or end. + if (col) { + columns.push(col); + } + targetDate.day += 1; + } + + return columns; + } + + /** + * Get an ordered list of all the calendar-event-column elements in this + * view. + * + * @returns {MozCalendarEventColumn[]} - The columns in this view. + */ + getEventColumns() { + return Array.from(this.dayColumns, col => col.column); + } + + /** + * Find the calendar-event-column that contains the given node. + * + * @param {Node} node - The node to search for. + * + * @returns {?MozCalendarEventColumn} - The column that contains the node, or + * null if none do. + */ + findEventColumnThatContains(node) { + return this.dayColumns.find(col => col.column.contains(node))?.column; + } + + /** + * Display a calendar item. + * + * @param {calIItemBase} event - A calendar item. + */ + doAddItem(event) { + const cols = this.findColumnsForItem(event); + if (!cols.length) { + return; + } + + for (const col of cols) { + const estart = event.startDate || event.entryDate || event.dueDate; + + if (estart.isDate) { + this.doResizingHeaderOperation(col.header, () => col.header.addEvent(event)); + } else { + col.column.addEvent(event); + } + } + } + + /** + * Remove a calendar item so it is no longer displayed. + * + * @param {calIItemBase} event - A calendar item. + */ + doRemoveItem(event) { + const cols = this.findColumnsForItem(event); + if (!cols.length) { + return; + } + + const oldLength = this.mSelectedItems.length; + this.mSelectedItems = this.mSelectedItems.filter(item => { + return item.hashId != event.hashId; + }); + + for (const col of cols) { + const estart = event.startDate || event.entryDate || event.dueDate; + + if (estart.isDate) { + this.doResizingHeaderOperation(col.header, () => col.header.deleteEvent(event)); + } else { + col.column.deleteEvent(event); + } + } + + // If a deleted event was selected, we need to announce that the selection changed. + if (oldLength != this.mSelectedItems.length) { + this.fireEvent("itemselect", this.mSelectedItems); + } + } + + // CalendarFilteredViewMixin implementation. + + /** + * Removes all items so they are no longer displayed. + */ + clearItems() { + for (let dayCol of this.dayColumns) { + dayCol.column.clear(); + dayCol.header.clear(); + } + } + + /** + * Remove all items for a given calendar so they are no longer displayed. + * + * @param {string} calendarId - The ID of the calendar to remove items from. + */ + removeItemsFromCalendar(calendarId) { + for (const col of this.dayColumns) { + // Get all-day events in column header and events within the column. + const colEvents = col.header.getAllEventItems().concat(col.column.getAllEventItems()); + + for (const event of colEvents) { + if (event.calendar.id == calendarId) { + this.doRemoveItem(event); + } + } + } + } + + // End of CalendarFilteredViewMixin implementation. + + /** + * Clear the pending magic scroll update method. + */ + clearMagicScroll() { + if (this.magicScrollTimer) { + clearTimeout(this.magicScrollTimer); + this.magicScrollTimer = null; + } + } + + /** + * Get the amount to scroll the view by. + * + * @param {number} startDiff - The number of pixels the mouse is from the + * starting edge. + * @param {number} endDiff - The number of pixels the mouse is from the + * ending edge. + * @param {number} scrollzone - The number of pixels from the edge at which + * point scrolling is triggered. + * @param {number} factor - The number of pixels to scroll by if touching + * the edge. + * + * @returns {number} - The number of pixels to scroll by scaled by the depth + * within the scrollzone. Zero if outside the scrollzone, negative if + * we're closer to the starting edge and positive if we're closer to the + * ending edge. + */ + getScrollBy(startDiff, endDiff, scrollzone, factor) { + if (startDiff >= scrollzone && endDiff >= scrollzone) { + return 0; + } else if (startDiff < endDiff) { + return Math.floor((-1 + startDiff / scrollzone) * factor); + } + return Math.ceil((1 - endDiff / scrollzone) * factor); + } + + /** + * Start scrolling the view if the given positions are close to or beyond + * its edge. + * + * Note, any pending updater sent to this method previously will be + * cancelled. + * + * @param {number} clientX - The horizontal viewport position. + * @param {number} clientY - The vertical viewport position. + * @param {Function} updater - A method to call, with some delay, if we + * scroll successfully. + */ + setupMagicScroll(clientX, clientY, updater) { + this.clearMagicScroll(); + + // If we are at the bottom or top of the view (or left/right when + // rotated), calculate the difference and start accelerating the + // scrollbar. + let scrollArea = this.getScrollAreaRect(); + + // Distance the mouse is from the edge. + let diffTop = Math.max(clientY - scrollArea.top, 0); + let diffBottom = Math.max(scrollArea.bottom - clientY, 0); + let diffLeft = Math.max(clientX - scrollArea.left, 0); + let diffRight = Math.max(scrollArea.right - clientX, 0); + + // How close to the edge we need to be to trigger scrolling. + let primaryZone = 50; + let secondaryZone = 20; + // How many pixels to scroll by. + let primaryFactor = Math.max(4 * this.pixelsPerMinute, 8); + let secondaryFactor = 4; + + let left; + let top; + if (this.getAttribute("orient") == "horizontal") { + left = this.getScrollBy(diffLeft, diffRight, primaryZone, primaryFactor); + top = this.getScrollBy(diffTop, diffBottom, secondaryZone, secondaryFactor); + } else { + top = this.getScrollBy(diffTop, diffBottom, primaryZone, primaryFactor); + left = this.getScrollBy(diffLeft, diffRight, secondaryZone, secondaryFactor); + } + + if (top || left) { + this.grid.scrollBy({ top, left, behaviour: "smooth" }); + this.magicScrollTimer = setTimeout(updater, 20); + } + } + + /** + * Get the position of the view's scrollable area (the padding area minus + * sticky headers and scrollbars) in the viewport. + * + * @returns {{top: number, bottom: number, left: number, right: number}} - + * The viewport positions of the respective scrollable area edges. + */ + getScrollAreaRect() { + // We want the viewport coordinates of the view's scrollable area. This is + // the same as the padding area minus the sticky headers and scrollbars. + let scrollTop; + let scrollBottom; + let scrollLeft; + let scrollRight; + let view = this.grid; + let viewRect = view.getBoundingClientRect(); + let headerRect = this.headerCorner.getBoundingClientRect(); + + // paddingTop is the top of the view's padding area. We translate from + // the border area of the view to the padding area by adding clientTop, + // which is the view's top border width. + let paddingTop = viewRect.top + view.clientTop; + // The top of the scroll area is the bottom of the sticky header. + scrollTop = headerRect.bottom; + // To get the bottom we add the clientHeight, which is the height of the + // padding area minus the scrollbar. + scrollBottom = paddingTop + view.clientHeight; + + // paddingLeft is the left of the view's padding area. We translate from + // the border area to the padding area by adding clientLeft, which is the + // left border width (plus the scrollbar in right-to-left). + let paddingLeft = viewRect.left + view.clientLeft; + if (document.dir == "rtl") { + scrollLeft = paddingLeft; + // The right of the scroll area is the left of the sticky header. + scrollRight = headerRect.left; + } else { + // The left of the scroll area is the right of the sticky header. + scrollLeft = headerRect.right; + // To get the right we add the clientWidth, which is the width of the + // padding area minus the scrollbar. + scrollRight = paddingLeft + view.clientWidth; + } + return { top: scrollTop, bottom: scrollBottom, left: scrollLeft, right: scrollRight }; + } + + /** + * Scroll the view to a given minute. + * + * @param {number} minute - The minute to scroll to. + */ + scrollToMinute(minute) { + let pos = Math.round(Math.max(0, minute) * this.pixelsPerMinute); + if (this.getAttribute("orient") == "horizontal") { + this.grid.scrollLeft = document.dir == "rtl" ? -pos : pos; + } else { + this.grid.scrollTop = pos; + } + // NOTE: this.scrollMinute is set by the "scroll" callback. + // This means that if we tried to scroll further than possible, the + // scrollMinute will be capped. + // Also, if pixelsPerMinute < 1, then scrollMinute may differ from the + // given 'minute' due to rounding errors. + } + + /** + * Set the hours when the day starts and ends. + * + * @param {number} dayStartHour - Hour at which the day starts. + * @param {number} dayEndHour - Hour at which the day ends. + */ + setDayStartEndHours(dayStartHour, dayEndHour) { + if (dayStartHour < 0 || dayStartHour > dayEndHour || dayEndHour > 24) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this.dayStartHour = dayStartHour; + this.dayEndHour = dayEndHour; + // Also update on the timebar. + for (let [hour, hourBox] of this.hourBoxes.entries()) { + hourBox.classList.toggle( + "multiday-hour-box-off-time", + hour < dayStartHour || hour >= dayEndHour + ); + } + for (let dayCol of this.dayColumns) { + dayCol.column.setDayStartEndHours(dayStartHour, dayEndHour); + } + } + + /** + * Set how many hours are visible in the scrollable area. + * + * @param {number} hours - The number of visible hours. + */ + setVisibleHours(hours) { + if (hours <= 0 || hours > 24) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this.visibleHours = hours; + } + } + + MozElements.CalendarMultidayBaseView = CalendarMultidayBaseView; +} diff --git a/comm/calendar/base/content/calendar-print.js b/comm/calendar/base/content/calendar-print.js new file mode 100644 index 0000000000..17021a0cd9 --- /dev/null +++ b/comm/calendar/base/content/calendar-print.js @@ -0,0 +1,311 @@ +/* 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/. */ + +/** + * This file is loaded into the printing options page by calPrintUtils.jsm if + * we are printing the calendar. It injects a new form (from + * calendar-tab-panels.inc.xhtml) for choosing the print output. It also + * contains the javascript for the form. + */ + +/* import-globals-from ../../../../toolkit/components/printing/content/print.js */ + +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +// In a block to avoid polluting the global scope. +{ + let ownerWindow = window.browsingContext.topChromeWindow; + let ownerDocument = ownerWindow.document; + + for (let href of [ + "chrome://messenger/skin/icons.css", + "chrome://messenger/skin/variables.css", + "chrome://messenger/skin/widgets.css", + "chrome://calendar/skin/shared/widgets/minimonth.css", + ]) { + let link = document.head.appendChild(document.createElement("link")); + link.rel = "stylesheet"; + link.href = href; + } + + let otherForm = document.querySelector("form"); + otherForm.hidden = true; + + let form = document.importNode( + ownerDocument.getElementById("calendarPrintForm").content.firstElementChild, + true + ); + if (AppConstants.platform != "win") { + // Move the Next button to the end if this isn't Windows. + let nextButton = form.querySelector("#next-button"); + nextButton.parentElement.append(nextButton); + } + form.addEventListener("submit", event => { + event.preventDefault(); + form.hidden = true; + otherForm.hidden = false; + }); + otherForm.parentNode.insertBefore(form, otherForm); + + let backButton = form.querySelector("#back-button"); + backButton.addEventListener("click", () => { + otherForm.hidden = true; + form.hidden = false; + }); + let backButtonContainer = form.querySelector("#back-button-container"); + let printButtonContainer = otherForm.querySelector("#button-container"); + printButtonContainer.parentNode.insertBefore(backButtonContainer, printButtonContainer); + + let eventsCheckbox = form.querySelector("input#events"); + let tasksCheckbox = form.querySelector("input#tasks"); + let tasksNotDueCheckbox = form.querySelector("input#tasks-with-no-due-date"); + let tasksCompletedCheckbox = form.querySelector("input#completed-tasks"); + + let layout = form.querySelector("select#layout"); + + let fromMinimonth = form.querySelector("calendar-minimonth#from-minimonth"); + let fromMonth = form.querySelector("select#from-month"); + let fromYear = form.querySelector("input#from-year"); + let fromDate = form.querySelector("select#from-date"); + + let toMinimonth = form.querySelector("calendar-minimonth#to-minimonth"); + let toMonth = form.querySelector("select#to-month"); + let toYear = form.querySelector("input#to-year"); + let toDate = form.querySelector("select#to-date"); + + for (let i = 0; i < 12; i++) { + let option = document.createElement("option"); + option.value = i; + option.label = cal.l10n.formatMonth(i + 1, "calendar", "monthInYear"); + fromMonth.appendChild(option.cloneNode(false)); + toMonth.appendChild(option); + } + + eventsCheckbox.addEventListener("change", updatePreview); + tasksCheckbox.addEventListener("change", function () { + tasksNotDueCheckbox.disabled = !this.checked; + tasksCompletedCheckbox.disabled = !this.checked; + updatePreview(); + }); + tasksNotDueCheckbox.addEventListener("change", updatePreview); + tasksCompletedCheckbox.addEventListener("change", updatePreview); + + layout.addEventListener("change", onLayoutChange); + + fromMinimonth.addEventListener("change", function () { + if (toMinimonth.value < fromMinimonth.value) { + toMinimonth.value = fromMinimonth.value; + } + + updatePreview(); + }); + toMinimonth.addEventListener("change", updatePreview); + + fromMonth.addEventListener("keydown", function (event) { + if (event.key == "ArrowDown" && fromMonth.selectedIndex == 11) { + fromMonth.selectedIndex = 0; + fromYear.value++; + onMonthChange(); + event.preventDefault(); + } else if (event.key == "ArrowUp" && fromMonth.selectedIndex == 0) { + fromMonth.selectedIndex = 11; + fromYear.value--; + onMonthChange(); + event.preventDefault(); + } + }); + fromMonth.addEventListener("change", onMonthChange); + fromYear.addEventListener("change", onMonthChange); + toMonth.addEventListener("keydown", function (event) { + if (event.key == "ArrowDown" && toMonth.selectedIndex == 11) { + toMonth.selectedIndex = 0; + toYear.value++; + onMonthChange(); + event.preventDefault(); + } else if (event.key == "ArrowUp" && toMonth.selectedIndex == 0) { + toMonth.selectedIndex = 11; + toYear.value--; + onMonthChange(); + event.preventDefault(); + } + }); + toMonth.addEventListener("change", onMonthChange); + toYear.addEventListener("change", onMonthChange); + + fromDate.addEventListener("change", function () { + let fromValue = parseInt(fromDate.value, 10); + for (let option of toDate.options) { + option.hidden = option.value < fromValue; + } + if (toDate.value < fromValue) { + toDate.value = fromValue; + } + + updatePreview(); + }); + toDate.addEventListener("change", updatePreview); + + // Ensure the layout selector is focused and has a focus ring to make it + // more obvious. The ring won't be added if already focused, so blur first. + requestAnimationFrame(() => { + layout.blur(); + Services.focus.setFocus(layout, Services.focus.FLAG_SHOWRING); + }); + + /** Show something in the preview as soon as it is ready. */ + function updateWhenReady() { + document.removeEventListener("page-count", updateWhenReady); + onLayoutChange(); + } + document.addEventListener("page-count", updateWhenReady); + + /** + * Update the available date options to sensible ones for the selected layout. + * It would be nice to use HTML date inputs here but the browser this form is + * loaded into won't allow it. Instead use lists of the most likely values, + * which actually fits better for some print layouts. + */ + function onLayoutChange() { + if (layout.value == "list") { + fromMinimonth.hidden = toMinimonth.hidden = false; + fromMonth.hidden = fromYear.hidden = toMonth.hidden = toYear.hidden = true; + fromDate.hidden = toDate.hidden = true; + } else if (layout.value == "monthGrid") { + let today = new Date(); + fromMonth.value = toMonth.value = today.getMonth(); + fromYear.value = toYear.value = today.getFullYear(); + + fromMinimonth.hidden = toMinimonth.hidden = true; + fromMonth.hidden = fromYear.hidden = toMonth.hidden = toYear.hidden = false; + fromDate.hidden = toDate.hidden = true; + } else { + const FIRST_WEEK = -53; + const LAST_WEEK = 53; + + while (fromDate.lastChild) { + fromDate.lastChild.remove(); + } + while (toDate.lastChild) { + toDate.lastChild.remove(); + } + + // Always use Monday - Sunday week, regardless of prefs, because the layout requires it. + let monday = cal.dtz.now(); + monday.isDate = true; + monday.day = monday.day - monday.weekday + 1 + FIRST_WEEK * 7; + + for (let i = FIRST_WEEK; i < LAST_WEEK; i++) { + let option = document.createElement("option"); + option.value = i; + option.label = cal.dtz.formatter.formatDateLong(monday); + fromDate.appendChild(option.cloneNode(false)); + + let sunday = monday.clone(); + sunday.day += 6; + option.label = cal.dtz.formatter.formatDateLong(sunday); + option.hidden = i < 0; + toDate.appendChild(option); + + monday.day += 7; + } + + fromDate.value = toDate.value = 0; + + fromMinimonth.hidden = toMinimonth.hidden = true; + fromMonth.hidden = fromYear.hidden = toMonth.hidden = toYear.hidden = true; + fromDate.hidden = toDate.hidden = false; + } + + updatePreview(); + } + + function onMonthChange() { + if (parseInt(toYear.value, 10) < fromYear.value) { + toYear.value = fromYear.value; + toMonth.value = fromMonth.value; + } else if (toYear.value == fromYear.value && parseInt(toMonth.value, 10) < fromMonth.value) { + toMonth.value = fromMonth.value; + } + updatePreview(); + } + + /** + * Read the selected options and update the preview document. + */ + async function updatePreview() { + let startDate = cal.dtz.now(); + startDate.isDate = true; + let endDate = cal.dtz.now(); + endDate.isDate = true; + + if (layout.value == "list") { + let fromValue = fromMinimonth.value; + let toValue = toMinimonth.value; + + startDate.resetTo( + fromValue.getFullYear(), + fromValue.getMonth(), + fromValue.getDate(), + 0, + 0, + 0, + cal.dtz.floating + ); + startDate.isDate = true; + if (toValue > fromValue) { + endDate.resetTo( + toValue.getFullYear(), + toValue.getMonth(), + toValue.getDate(), + 0, + 0, + 0, + cal.dtz.floating + ); + endDate.isDate = true; + } else { + endDate = startDate.clone(); + } + endDate.day++; + } else if (layout.value == "monthGrid") { + startDate.day = 1; + startDate.month = parseInt(fromMonth.value, 10); + startDate.year = parseInt(fromYear.value, 10); + endDate.day = 1; + endDate.month = parseInt(toMonth.value, 10); + endDate.year = parseInt(toYear.value, 10); + endDate.month++; + } else { + startDate.day = startDate.day - startDate.weekday + 1; + startDate.day += parseInt(fromDate.value, 10) * 7; + endDate.day = endDate.day - endDate.weekday + 1; + endDate.day += parseInt(toDate.value, 10) * 7 + 7; + } + + let filter = 0; + if (tasksCheckbox.checked) { + filter |= Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + if (tasksCompletedCheckbox.checked) { + filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + } else { + filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + } + } + + if (eventsCheckbox.checked) { + filter |= + Ci.calICalendar.ITEM_FILTER_TYPE_EVENT | Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + } + + await cal.print.draw( + PrintEventHandler.printPreviewEl.querySelector("browser").contentDocument, + layout.value, + startDate, + endDate, + filter, + tasksNotDueCheckbox.checked + ); + PrintEventHandler._updatePrintPreview(); + } +} diff --git a/comm/calendar/base/content/calendar-status-bar.inc.xhtml b/comm/calendar/base/content/calendar-status-bar.inc.xhtml new file mode 100644 index 0000000000..e99078a27b --- /dev/null +++ b/comm/calendar/base/content/calendar-status-bar.inc.xhtml @@ -0,0 +1,75 @@ +# 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/. + +<!-- event/task in tab statusbarpanels --> +<hbox id="status-privacy" + class="statusbarpanel event-dialog" + 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 event-dialog" + 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 event-dialog" + 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 event-dialog" + 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> +<!-- end event/task in tab statusbarpanels --> + +<calendar-modebox id="calendar-show-todaypane-panel" + class="statusbarpanel themeable-brighttext hide-when-calendar-deactivated" + mode="mail,calendar,task,chat,calendarEvent,calendarTask" + collapsedinmodes="special" + pack="center"> + <toolbarbutton id="calendar-status-todaypane-button" + type="checkbox" + label="&todaypane.statusButton.label;" + tooltiptext="&calendar.todaypane.button.tooltip;" + command="calendar_toggle_todaypane_command"/> +</calendar-modebox> diff --git a/comm/calendar/base/content/calendar-statusbar.js b/comm/calendar/base/content/calendar-statusbar.js new file mode 100644 index 0000000000..8d463ffc67 --- /dev/null +++ b/comm/calendar/base/content/calendar-statusbar.js @@ -0,0 +1,110 @@ +/* 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"); + +/* exported gCalendarStatusFeedback */ + +/** + * This code might change soon if we support Thunderbird's activity manager. + * NOTE: The naming "Meteors" is historical. + */ +var gCalendarStatusFeedback = { + mCalendarStep: 0, + mCalendarCount: 0, + mWindow: null, + mStatusText: null, + mStatusBar: null, + mStatusProgressPanel: null, + mThrobber: null, + mProgressMode: Ci.calIStatusObserver.NO_PROGRESS, + mCurIndex: 0, + mInitialized: false, + mCalendars: {}, + + QueryInterface: ChromeUtils.generateQI(["calIStatusObserver"]), + + initialize(aWindow) { + if (!this.mInitialized) { + this.mWindow = aWindow; + this.mStatusText = this.mWindow.document.getElementById("statusText"); + this.mStatusBar = this.mWindow.document.getElementById("statusbar-icon"); + this.mStatusProgressPanel = this.mWindow.document.getElementById("statusbar-progresspanel"); + this.mThrobber = this.mWindow.document.getElementById("navigator-throbber"); + this.mInitialized = true; + } + }, + + showStatusString(status) { + this.mStatusText.setAttribute("label", status); + }, + + get spinning() { + return this.mProgressMode; + }, + + startMeteors(aProgressMode, aCalendarCount) { + if (aProgressMode != Ci.calIStatusObserver.NO_PROGRESS) { + if (!this.mInitialized) { + console.error("StatusObserver has not been initialized!"); + return; + } + this.mCalendars = {}; + this.mCurIndex = 0; + if (aCalendarCount) { + this.mCalendarCount = this.mCalendarCount + aCalendarCount; + this.mCalendarStep = Math.trunc(100 / this.mCalendarCount); + } + this.mProgressMode = aProgressMode; + this.mStatusProgressPanel.removeAttribute("collapsed"); + if (this.mProgressMode == Ci.calIStatusObserver.DETERMINED_PROGRESS) { + this.mStatusBar.value = 0; + let commonStatus = cal.l10n.getCalString("gettingCalendarInfoCommon"); + this.showStatusString(commonStatus); + } + if (this.mThrobber) { + this.mThrobber.setAttribute("busy", true); + } + } + }, + + stopMeteors() { + if (!this.mInitialized) { + return; + } + if (this.spinning != Ci.calIStatusObserver.NO_PROGRESS) { + this.mProgressMode = Ci.calIStatusObserver.NO_PROGRESS; + this.mStatusProgressPanel.collapsed = true; + this.mStatusBar.value = 0; + this.mCalendarCount = 0; + this.showStatusString(""); + if (this.mThrobber) { + this.mThrobber.setAttribute("busy", false); + } + } + }, + + calendarCompleted(aCalendar) { + if (!this.mInitialized) { + return; + } + if (this.spinning != Ci.calIStatusObserver.NO_PROGRESS) { + if (this.spinning == Ci.calIStatusObserver.DETERMINED_PROGRESS) { + if (!this.mCalendars[aCalendar.id] || this.mCalendars[aCalendar.id] === undefined) { + this.mCalendars[aCalendar.id] = true; + this.mStatusBar.value = parseInt(this.mStatusBar.value, 10) + this.mCalendarStep; + this.mCurIndex++; + let curStatus = cal.l10n.getCalString("gettingCalendarInfoDetail", [ + this.mCurIndex, + this.mCalendarCount, + ]); + this.showStatusString(curStatus); + } + } + if (this.mThrobber) { + this.mThrobber.setAttribute("busy", true); + } + } + }, +}; diff --git a/comm/calendar/base/content/calendar-tab-panels.inc.xhtml b/comm/calendar/base/content/calendar-tab-panels.inc.xhtml new file mode 100644 index 0000000000..d1eb617ef2 --- /dev/null +++ b/comm/calendar/base/content/calendar-tab-panels.inc.xhtml @@ -0,0 +1,661 @@ +# 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/. + +<vbox id="calendarTabPanel"> + <hbox id="calendarContent" flex="1"> + <vbox id="calSidebar" + persist="collapsed width"> + <html:div id="primaryButtonSidePanel" + xmlns="http://www.w3.org/1999/xhtml"> + <button id="sidePanelNewEvent" + data-l10n-id="calendar-new-event-primary-button" + class="button button-primary icon-button" + onclick="goDoCommand('calendar_new_event_command')" + hidden="hidden" + type="button"> + </button> + <button id="sidePanelNewTask" + data-l10n-id="calendar-new-task-primary-button" + class="button button-primary icon-button" + onclick="goDoCommand('calendar_new_todo_command')" + hidden="hidden" + type="button"> + </button> + </html:div> + <calendar-modevbox id="minimonth-pane" + mode="calendar,task" + refcontrol="calendar_toggle_minimonthpane_command"> + <vbox align="center"> + <hbox id="calMinimonthBox" pack="center"> + <calendar-minimonth id="calMinimonth" onchange="minimonthPick(this.value);"/> + </hbox> + </vbox> + </calendar-modevbox> + <separator id="minimonth-splitter" style="min-width:100px;"/> + <vbox id="calendar-panel" flex="1"> + <calendar-modevbox id="task-filter-pane" + mode="task" + refcontrol="calendar_toggle_filter_command"> + <checkbox id="task-tree-filter-header" + checked="true" + class="treenode-checkbox" + label="&calendar.task.filter.title.label;"/> + <calendar-modevbox id="task-filtertree-pane" + flex="1" + mode="task" + refcontrol="task-tree-filter-header"> + <radiogroup id="task-tree-filtergroup" class="task-tree-subpane" + persist="value"> + <radio id="opt_throughcurrent_filter" label="&calendar.task.filter.current.label;" value="throughcurrent" command="calendar_task_filter_command"/> + <radio id="opt_today_filter" label="&calendar.task.filter.today.label;" value="throughtoday" command="calendar_task_filter_command"/> + <radio id="opt_next7days_filter" label="&calendar.task.filter.next7days.label;" value="throughsevendays" command="calendar_task_filter_command"/> + <radio id="opt_notstarted_filter" label="&calendar.task.filter.notstarted.label;" value="notstarted" command="calendar_task_filter_command"/> + <radio id="opt_overdue_filter" label="&calendar.task.filter.overdue.label;" value="overdue" command="calendar_task_filter_command"/> + <radio id="opt_completed_filter" label="&calendar.task.filter.completed.label;" value="completed" command="calendar_task_filter_command"/> + <radio id="opt_open_filter" label="&calendar.task.filter.open.label;" value="open" command="calendar_task_filter_command"/> + <radio id="opt_all_filter" label="&calendar.task.filter.all.label;" value="all" command="calendar_task_filter_command"/> + </radiogroup> + </calendar-modevbox> + </calendar-modevbox> + <calendar-modevbox id="calendar-list-pane" + flex="1" + mode="calendar,task" + refcontrol="calendar_toggle_calendarlist_command"> + <html:button id="calendarListHeader" + class="calendar-list-header button-flat" + onclick="toggleVisibilityCalendarsList(event);"> + <html:span data-l10n-id="calendar-list-header"></html:span> + <html:img id="toggleCalendarIcon" + src="chrome://messenger/skin/icons/new/nav-down-sm.svg" + alt="" /> + </html:button> + <calendar-modevbox id="calendar-list-inner-pane" + flex="1" + mode="calendar,task" + refcontrol="calendarListHeader" + oncontextmenu="openCalendarListItemContext(event);"> + <html:ol id="calendar-list" is="orderable-tree-listbox" + role="listbox"></html:ol> + <template id="calendar-list-item" xmlns="http://www.w3.org/1999/xhtml"> + <li draggable="true" role="option"> + <div class="calendar-color"></div> + <div class="calendar-name"></div> + <img class="calendar-readstatus calendar-list-icon" + src="chrome://messenger/skin/icons/new/compact/lock.svg" + alt="" /> + <img class="calendar-mute-status calendar-list-icon" + src="chrome://messenger/skin/icons/new/bell-disabled.svg" + alt="" /> + <button class="calendar-enable-button"></button> + <input type="checkbox" + class="calendar-displayed" /> + <button class="calendar-more-button button icon-button icon-only" + onclick="openCalendarListItemContext(event);" + type="button"> + </button> + </li> + </template> + </calendar-modevbox> + <html:div id="sideButtonsBottom" xmlns="http://www.w3.org/1999/xhtml"> + <button id="newCalendarSidebarButton" + class="button button-flat icon-button" + data-l10n-id="calendar-import-new-calendar" + onclick="goDoCommand('calendar_new_calendar_command')" + type="button"> + </button> + <button id="refreshCalendar" + class="button button-flat icon-button icon-only" + data-l10n-id="calendar-refresh-calendars" + onclick="goDoCommand('calendar_reload_remote_calendars')" + type="button"> + </button> + </html:div> + </calendar-modevbox> + </vbox> + </vbox> + + <splitter id="calsidebar_splitter" + collapse="before" + persist="state" + class="calendar-sidebar-splitter"/> + + <hbox id="calendarDisplayBox" flex="1"> + <!-- Events View ("Unifinder") --> + <vbox id="calendar-view-box" + flex="1" + context="calendar-view-context-menu" + collapsed="true"> + <vbox id="calendar-deactivated-notification-location-events"> + <!-- Calendar deactivated notificationbox for events will be added here lazily. --> + </vbox> + <vbox id="bottom-events-box" persist="height"> + <hbox id="unifinder-searchBox" class="themeable-brighttext" persist="collapsed"> + <box align="center"> + <menulist id="event-filter-menulist" value="P7D" persist="value"> + <menupopup id="event-filter-menupopup" oncommand="refreshEventTree()"> + <menuitem id="event-filter-all" + label="&calendar.events.filter.all.label;" + value="all"/> + <menuitem id="event-filter-today" + label="&calendar.events.filter.today.label;" + value="today"/> + <menuitem id="event-filter-next7days" + label="&calendar.events.filter.next7Days.label;" + value="P7D"/> + <menuitem id="event-filter-next14Days" + label="&calendar.events.filter.next14Days.label;" + value="P14D"/> + <menuitem id="event-filter-next31Days" + label="&calendar.events.filter.next31Days.label;" + value="P31D"/> + <menuitem id="event-filter-thisCalendarMonth" + label="&calendar.events.filter.thisCalendarMonth.label;" + value="thisCalendarMonth"/> + <menuitem id="event-filter-future" + label="&calendar.events.filter.future.label;" + value="future"/> + <menuitem id="event-filter-current" + label="&calendar.events.filter.current.label;" + value="current"/> + <menuitem id="event-filter-currentview" + label="&calendar.events.filter.currentview.label;" + value="currentview"/> + </menupopup> + </menulist> + </box> + <box align="center" flex="1"> + <label control="unifinder-search-field" value="&calendar.search.options.searchfor;"/> + <search-textbox id="unifinder-search-field" + class="themeableSearchBox" + oncommand="refreshEventTree();" + flex="1"/> + </box> + <toolbarbutton id="unifinder-closer" + class="unifinder-closebutton close-icon" + command="calendar_show_unifinder_command" + tooltiptext="&calendar.unifinder.close.tooltip;"/> + </hbox> + <tree id="unifinder-search-results-tree" flex="1" + onselect="unifinderSelect(event); calendarController.onSelectionChanged()" + onkeypress="unifinderKeyPress(event)" + _selectDelay="500" + persist="sort-active sort-direction" + enableColumnDrag="true"> + <treecols id="unifinder-search-results-tree-cols"> + <treecol id="unifinder-search-results-tree-col-title" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + itemproperty="title" + label="&calendar.unifinder.tree.title.label;" + tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-startdate" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + itemproperty="startDate" + label="&calendar.unifinder.tree.startdate.label;" + tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-enddate" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + itemproperty="endDate" + label="&calendar.unifinder.tree.enddate.label;" + tooltiptext="&calendar.unifinder.tree.enddate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-categories" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + itemproperty="categories" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-location" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + hidden="true" + itemproperty="location" + label="&calendar.unifinder.tree.location.label;" + tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-status" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + hidden="true" + itemproperty="status" + label="&calendar.unifinder.tree.status.label;" + tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/> + <treecol id="unifinder-search-results-tree-col-calendarname" + persist="hidden ordinal width" + style="flex: 1 auto" + closemenu="none" + hidden="true" + itemproperty="calendar" + label="&calendar.unifinder.tree.calendarname.label;" + tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/> + </treecols> + + <!-- on mousedown here happens before onclick above --> + <treechildren tooltip="eventTreeTooltip" + context="calendar-item-context-menu" + onkeypress="if (event.key == 'Enter') { unifinderEditCommand(); }" + ondragenter="return false;" + ondblclick="unifinderDoubleClick(event)" + onfocus="focusFirstItemIfNoSelection();"/> + </tree> + </vbox> + <splitter id="calendar-view-splitter" + resizebefore="closest" + resizeafter="farthest" + persist="state" + class="chromeclass-extrachrome sidebar-splitter calendar-splitter" + orient="vertical" + onmouseup="setTimeout(refreshEventTree, 10);"/> + + <!-- Calendar Navigation Control Bar --> + <html:div id="calendarViewHeader" + xmlns="http://www.w3.org/1999/xhtml"> + <div class="navigation-inner-box" > + <!-- If you are extending a view, add attributes to these + nodes for your view. i.e if your view has the id + "foobar-view", then you need to add the attribute + tooltiptext-foobar="..." --> + <div id="calendarControls"> + <div class="button-group"> + <button id="previousViewButton" + class="button icon-button icon-only" + onclick="goDoCommand('calendar_view_prev_command')" + type="button"> + </button> + <button id="todayViewButton" + class="button icon-button icon-only" + data-l10n-id="calendar-today-button-tooltip" + onclick="currentView().moveView()" + type="button"> + </button> + <button id="nextViewButton" + class="button icon-button icon-only" + onclick="goDoCommand('calendar_view_next_command')" + type="button"> + </button> + </div> + </div> + <span id="intervalDescription" class="view-header"/> + </div> + <div class="navigation-inner-box"> + <span id="calendarWeek" class="view-header"/> + <div id="viewToggle" role="tablist" class="calview-toggle"> + <button id="calTabDay" + class="calview-toggle-item" + onclick="goDoCommand('calendar_day-view_command')" + role="tab" + aria-controls="day-view" + aria-selected="false" + data-l10n-id="calendar-view-toggle-day"></button> + <button id="calTabWeek" + class="calview-toggle-item" + onclick="goDoCommand('calendar_week-view_command')" + role="tab" + aria-controls="week-view" + aria-selected="false" + data-l10n-id="calendar-view-toggle-week"></button> + <button id="calTabMultiweek" + class="calview-toggle-item" + onclick="goDoCommand('calendar_multiweek-view_command')" + role="tab" + aria-controls="multiweek-view" + aria-selected="false" + data-l10n-id="calendar-view-toggle-multiweek"></button> + <button id="calTabMonth" + class="calview-toggle-item" + onclick="goDoCommand('calendar_month-view_command')" + role="tab" + aria-controls="month-view" + aria-selected="false" + data-l10n-id="calendar-view-toggle-month"></button> + </div> + <button id="calendarControlBarMenu" + class="button button-flat icon-button icon-only" + onclick="showCalControlBarMenuPopup(event)" + data-l10n-id="calendar-control-bar-menu-button" + type="button"> + </button> + </div> + </html:div> + <vbox flex="1" + id="view-box" + persist="selectedIndex"> + <!-- Note: the "id" attributes of the calendar panes **must** follow the + notation 'type + "-" + "view"', where "type" should refer to the + displayed time period as described in base/public/calICalendarView.idl --> + <calendar-day-view id="day-view" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + <calendar-week-view id="week-view" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + <calendar-multiweek-view id="multiweek-view" flex="1" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + <calendar-month-view id="month-view" flex="1" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + </vbox> + </vbox> + <!-- Tasks View --> + <vbox id="calendar-task-box" flex="1" + collapsed="true"> + <vbox id="calendar-deactivated-notification-location-tasks"> + <!-- Calendar deactivated notificationbox for tasks will be added here lazily. --> + </vbox> + <hbox id="task-addition-box" class="themeable-brighttext" align="center"> + <box align="center" flex="1"> + <toolbarbutton id="calendar-add-task-button" + label="&calendar.newtask.button.label;" + tooltiptext="&calendar.newtask.button.tooltip;" + command="calendar_new_todo_command"/> + <hbox align="center" flex="1" class="input-container"> + <html:input id="view-task-edit-field" + class="task-edit-field themeableSearchBox" + onfocus="taskEdit.onFocus(event)" + onblur="taskEdit.onBlur(event)" + onkeypress="taskEdit.onKeyPress(event)"/> + </hbox> + </box> + <box align="center" flex="1"> + <search-textbox id="task-text-filter-field" + class="themeableSearchBox" + flex="1" + placeholder="" + emptytextbase="&calendar.task.text-filter.textbox.emptytext.base1;" + keyLabelNonMac="&calendar.task.text-filter.textbox.emptytext.keylabel.nonmac;" + keyLabelMac="&calendar.task.text-filter.textbox.emptytext.keylabel.mac;" + oncommand="taskViewUpdate();"/> + </box> + </hbox> + <vbox flex="1"> + <tree is="calendar-task-tree" id="calendar-task-tree" + flex="1" + visible-columns="completed priority title entryDate dueDate" + persist="visible-columns ordinals widths sort-active sort-direction height" + context="taskitem-context-menu" + onselect="taskDetailsView.onSelect(event);"/> + <splitter id="calendar-task-view-splitter" collapse="none" persist="state" class="calendar-splitter"/> + <vbox id="calendar-task-details-container" + flex="1" + persist="height" + hidden="true"> + <hbox id="calendar-task-details"> + <hbox id="other-actions-box"> + <vbox id="task-actions-toolbox" class="inline-toolbox"> + <hbox id="task-actions-toolbar" class="themeable-brighttext"> + <toolbarbutton id="task-actions-category" + type="menu" + wantdropmarker="true" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.task.category.button.tooltip;" + command="calendar_task_category_command" + class="toolbarbutton-1 message-header-view-button"> + <menupopup id="task-actions-category-popup" + onpopupshowing="taskDetailsView.loadCategories(event);" + onpopuphiding="return taskDetailsView.saveCategories(event);"> + <html:input id="task-actions-category-textbox" + placeholder="&event.categories.textbox.label;" + onblur="this.parentNode.removeAttribute("ignorekeys");" + onfocus="this.parentNode.setAttribute("ignorekeys", "true");" + onkeypress="taskDetailsView.categoryTextboxKeypress(event);"/> + <menuseparator/> + </menupopup> + </toolbarbutton> + <toolbarbutton is="toolbarbutton-menu-button" id="task-actions-markcompleted" + type="menu" + label="&calendar.context.markcompleted.label;" + tooltiptext="&calendar.task.complete.button.tooltip;" + command="calendar_toggle_completed_command" + class="toolbarbutton-1 message-header-view-button"> + <menupopup is="calendar-task-progress-menupopup" id="task-actions-markcompleted-menupopup"/> + </toolbarbutton> + <toolbarbutton id="task-actions-priority" + type="menu" + wantdropmarker="true" + label="&calendar.context.priority.label;" + tooltiptext="&calendar.task.priority.button.tooltip;" + command="calendar_general-priority_command" + class="toolbarbutton-1 message-header-view-button"> + <menupopup is="calendar-task-priority-menupopup" id="task-actions-priority-menupopup"/> + </toolbarbutton> + <toolbarbutton id="calendar-delete-task-button" + class="toolbarbutton-1 message-header-view-button" + label="&calendar.taskview.delete.label;" + tooltiptext="&calendar.context.deletetask.label;" + command="calendar_delete_todo_command"/> + </hbox> + </vbox> + </hbox> + <hbox id ="calendar-task-details-box"> + <html:table id="calendar-task-details-grid"> + <html:tr id="calendar-task-details-title-row" + hidden="hidden"> + <html:th class="task-details-name"> + &calendar.task-details.title.label; + </html:th> + <html:td id="calendar-task-details-title" + class="task-details-value"> + </html:td> + </html:tr> + <html:tr id="calendar-task-details-priority-row" + hidden="hidden"> + <html:th id="calendar-task-details-priority-label" + class="task-details-name"> + &calendar.task-details.priority.label; + </html:th> + <html:td id="calendar-task-details-priority-td"> + <label id="calendar-task-details-priority-low" + value="&calendar.task-details.priority.low.label;" + class="task-details-value" + hidden="true"/> + <label id="calendar-task-details-priority-normal" + value="&calendar.task-details.priority.normal.label;" + class="task-details-value" + hidden="true"/> + <label id="calendar-task-details-priority-high" + value="&calendar.task-details.priority.high.label;" + class="task-details-value" + hidden="true"/> + </html:td> + </html:tr> + <html:tr id="calendar-task-details-organizer-row" + hidden="hidden"> + <html:th class="task-details-name"> + &calendar.task-details.organizer.label; + </html:th> + <html:td id="calendar-task-details-organizer" + class="task-details-value text-link" + onclick="sendMailToOrganizer()"> + </html:td> + </html:tr> + <html:tr id="calendar-task-details-status-row" + hidden="hidden"> + <html:th class="task-details-name"> + &calendar.task-details.status.label; + </html:th> + <html:td id="calendar-task-details-status" + class="task-details-value"> + </html:td> + </html:tr> + <html:tr id="calendar-task-details-category-row" + hidden="hidden"> + <html:th class="task-details-name"> + &calendar.task-details.category.label; + </html:th> + <html:td id="calendar-task-details-category" + class="task-details-value"> + </html:td> + </html:tr> + <html:tr id="task-start-row" + class="item-date-row" + hidden="hidden"> + <html:th class="headline"> + &calendar.task-details.start.label; + </html:th> + <html:td id="task-start-date"> + </html:td> + </html:tr> + <html:tr id="task-due-row" + class="item-date-row" + hidden="hidden"> + <html:th class="headline"> + &calendar.task-details.due.label; + </html:th> + <html:td id="task-due-date"> + </html:td> + </html:tr> + <html:tr id="calendar-task-details-repeat-row" + hidden="hidden"> + <html:th class="task-details-name"> + &calendar.task-details.repeat.label; + </html:th> + <html:td id="calendar-task-details-repeat" + class="task-details-value"> + </html:td> + </html:tr> + </html:table> + </hbox> + </hbox> + <hbox id="calendar-task-details-description-wrapper" flex="1"> + <iframe id="calendar-task-details-description" type="content"/> + </hbox> + <hbox id="calendar-task-details-attachment-row" + align="start" + hidden="true"> + <hbox pack="end"> + <label value="&calendar.task-details.attachments.label;" + class="task-details-name"/> + </hbox> + <vbox id="calendar-task-details-attachment-rows" + align="start" + flex="1" + style="overflow: auto;" + hidden="true"> + </vbox> + </hbox> + </vbox> + </vbox> + </vbox> + </hbox> + </hbox> + + <!-- This form is injected into the printing options page (by calendar-print.js) + if we are printing the calendar. The script for the form is also in + calendar-print.js and CSS in calendar-print.css. --> + <template xmlns="http://www.w3.org/1999/xhtml" id="calendarPrintForm"> + <form id="calendar-print"> + <link rel="localization" href="calendar/calendar-print.ftl"/> + <link rel="stylesheet" href="chrome://calendar/skin/shared/calendar-print.css"/> + + <section class="body-container"> + <section class="section-block"> + <label for="layout" class="block-label" data-l10n-id="calendar-print-layout-label"></label> + <div class="layout-wrapper"> + <select id="layout" autocomplete="off"> + <option value="list" data-l10n-id="calendar-print-layout-list"></option> + <option value="monthGrid" data-l10n-id="calendar-print-layout-month-grid"></option> + <option value="weekPlanner" data-l10n-id="calendar-print-layout-week-planner"></option> + </select> + </div> + </section> + + <section class="section-block"> + <label class="block-label" data-l10n-id="calendar-print-filter-label"></label> + <label class="row cols-2"> + <input type="checkbox" id="events" checked="checked" autocomplete="off" /> + <span data-l10n-id="calendar-print-filter-events"></span> + </label> + <label class="row cols-2"> + <input type="checkbox" id="tasks" checked="checked" autocomplete="off" /> + <span data-l10n-id="calendar-print-filter-tasks"></span> + </label> + <label class="row cols-2 indent"> + <input type="checkbox" id="completed-tasks" checked="checked" autocomplete="off" /> + <span data-l10n-id="calendar-print-filter-completedtasks"></span> + </label> + <label class="row cols-2 indent"> + <input type="checkbox" id="tasks-with-no-due-date" checked="checked" autocomplete="off" /> + <span data-l10n-id="calendar-print-filter-taskswithnoduedate"></span> + </label> + </section> + + <fieldset class="section-block"> + <legend class="block-label" data-l10n-id="calendar-print-range-from"></legend> + <xul:calendar-minimonth id="from-minimonth"></xul:calendar-minimonth> + <select id="from-month"></select> + <input id="from-year" type="number" size="1"/> + <select id="from-date"></select> + </fieldset> + <fieldset class="section-block"> + <legend class="block-label" data-l10n-id="calendar-print-range-to"></legend> + <xul:calendar-minimonth id="to-minimonth"></xul:calendar-minimonth> + <select id="to-month"></select> + <input id="to-year" type="number" size="1"/> + <select id="to-date"></select> + </fieldset> + </section> + + <hr /> + + <footer class="footer-container" role="none"> + <section id="next-button-container" class="section-block"> + <button id="next-button" + class="primary" + type="submit" + showfocus="" + autocomplete="off" + data-l10n-id="calendar-print-next-button"></button> + <button is="cancel-button" + type="button" + data-l10n-id="printui-cancel-button" + data-close-l10n-id="printui-close-button" + data-cancel-l10n-id="printui-cancel-button"></button> + </section> + </footer> + + <!-- This section will be added to the footer of the original form. --> + <section id="back-button-container" class="section-block"> + <button id="back-button" + type="button" + autocomplete="off" + data-l10n-id="calendar-print-back-button"></button> + </section> + </form> + </template> +</vbox> + +<!-- Menus --> + +<menupopup id="calControlBarMenuPopup" position="bottomleft topleft"> + <menuitem id="findEventsButton" + data-l10n-id="calendar-find-events-menu-option" + type="checkbox" + checked="true" + command="calendar_show_unifinder_command"/> + <menuseparator id="separatorBeforeHideWeekends"/> + <menuitem id="hideWeekendsButton" + data-l10n-id="calendar-hide-weekends-option" + type="checkbox" + command="calendar_toggle_workdays_only_command"/> + <menuitem id="defineWorkweekButton" + data-l10n-id="calendar-define-workweek-option" + oncommand="showCalendarWeekPreferences();"/> + <menuseparator id="separatorBeforeTasks"/> + <menuitem id="showTasksInCalendarButton" + data-l10n-id="calendar-show-tasks-calendar-option" + type="checkbox" + command="calendar_toggle_tasks_in_view_command"/> +</menupopup> diff --git a/comm/calendar/base/content/calendar-tabs.js b/comm/calendar/base/content/calendar-tabs.js new file mode 100644 index 0000000000..4681c5f6f9 --- /dev/null +++ b/comm/calendar/base/content/calendar-tabs.js @@ -0,0 +1,419 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from item-editing/calendar-item-editing.js */ +/* import-globals-from item-editing/calendar-item-panel.js */ +/* import-globals-from calendar-command-controller.js */ +/* import-globals-from calendar-modes.js */ +/* import-globals-from calendar-views-utils.js */ + +/* globals MozElements */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var calendarTabMonitor = { + monitorName: "calendarTabMonitor", + + // Unused, but needed functions + onTabTitleChanged() {}, + onTabOpened() {}, + onTabClosing() {}, + onTabPersist() {}, + onTabRestored() {}, + + onTabSwitched(aNewTab, aOldTab) { + // Unfortunately, tabmail doesn't provide a hideTab function on the tab + // type definitions. To make sure the commands are correctly disabled, + // we want to update calendar/task commands when switching away from + // those tabs. + if (aOldTab?.mode.name == "calendar" || aOldTab?.mode.name == "task") { + calendarController.updateCommands(); + calendarController2.updateCommands(); + } + // we reset the save menu controls when moving away (includes closing) + // from an event or task editor tab + if (aNewTab.mode.name == "calendarEvent" || aNewTab.mode.name == "calendarTask") { + sendMessage({ command: "triggerUpdateSaveControls" }); + } else if (window.calItemSaveControls) { + // we need to reset the labels of the menu controls for saving if we + // are not switching to an item tab and displayed an item tab before + let saveMenu = document.getElementById("calendar-save-menuitem"); + let saveandcloseMenu = document.getElementById("calendar-save-and-close-menuitem"); + saveMenu.label = window.calItemSaveControls.saveMenu.label; + saveandcloseMenu.label = window.calItemSaveControls.saveandcloseMenu.label; + } + + // Change the mode (gCurrentMode) to match the new tab. + switch (aNewTab.mode.name) { + case "calendar": + calSwitchToCalendarMode(); + break; + case "tasks": + calSwitchToTaskMode(); + break; + case "chat": + case "calendarEvent": + case "calendarTask": + calSwitchToMode(aNewTab.mode.name); + break; + case "addressBookTab": + case "preferencesTab": + case "contentTab": + calSwitchToMode("special"); + break; + default: + calSwitchToMode("mail"); + break; + } + }, +}; + +var calendarTabType = { + name: "calendar", + panelId: "calendarTabPanel", + modes: { + calendar: { + type: "calendar", + maxTabs: 1, + openTab(tab) { + tab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/calendar.svg"); + gLastShownCalendarView.get(); + tab.title = cal.l10n.getLtnString("tabTitleCalendar"); + }, + showTab(tab) {}, + closeTab(tab) {}, + + persistTab(tab) { + let tabmail = document.getElementById("tabmail"); + return { + // Since we do strange tab switching logic in calSwitchToCalendarMode, + // we should store the current tab state ourselves. + background: tab != tabmail.currentTabInfo, + }; + }, + + restoreTab(tabmail, state) { + tabmail.openTab("calendar", state); + }, + + onTitleChanged(tab) { + tab.title = cal.l10n.getLtnString("tabTitleCalendar"); + }, + + supportsCommand: (aCommand, aTab) => calendarController2.supportsCommand(aCommand), + isCommandEnabled: (aCommand, aTab) => calendarController2.isCommandEnabled(aCommand), + doCommand: (aCommand, aTab) => calendarController2.doCommand(aCommand), + onEvent: (aEvent, aTab) => calendarController2.onEvent(aEvent), + }, + + tasks: { + type: "tasks", + maxTabs: 1, + openTab(tab) { + tab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/tasks.svg"); + tab.title = cal.l10n.getLtnString("tabTitleTasks"); + }, + showTab(tab) {}, + closeTab(tab) {}, + + persistTab(tab) { + let tabmail = document.getElementById("tabmail"); + return { + // Since we do strange tab switching logic in calSwitchToTaskMode, + // we should store the current tab state ourselves. + background: tab != tabmail.currentTabInfo, + }; + }, + + restoreTab(tabmail, state) { + tabmail.openTab("tasks", state); + }, + + onTitleChanged(tab) { + tab.title = cal.l10n.getLtnString("tabTitleTasks"); + }, + + supportsCommand: (aCommand, aTab) => calendarController2.supportsCommand(aCommand), + isCommandEnabled: (aCommand, aTab) => calendarController2.isCommandEnabled(aCommand), + doCommand: (aCommand, aTab) => calendarController2.doCommand(aCommand), + onEvent: (aEvent, aTab) => calendarController2.onEvent(aEvent), + }, + }, + + saveTabState(tab) {}, +}; + +XPCOMUtils.defineLazyGetter(calendarTabType.modes.calendar, "notificationbox", () => { + return new MozElements.NotificationBox(element => { + document.getElementById("calendar-deactivated-notification-location-events").append(element); + }); +}); + +XPCOMUtils.defineLazyGetter(calendarTabType.modes.tasks, "notificationbox", () => { + return new MozElements.NotificationBox(element => { + document.getElementById("calendar-deactivated-notification-location-tasks").append(element); + }); +}); + +/** + * For details about tab info objects and the tabmail interface see: + * comm/mail/base/content/mailTabs.js + * comm/mail/base/content/tabmail.js + */ +var calendarItemTabType = { + name: "calendarItem", + perTabPanel: "vbox", + idNumber: 0, + modes: { + calendarEvent: { type: "calendarEvent" }, + calendarTask: { type: "calendarTask" }, + }, + /** + * Opens an event tab or a task tab. + * + * @param {object} aTab - A tab info object + * @param {object} aArgs - Contains data about the event/task + */ + openTab(aTab, aArgs) { + // Create a clone to use for this tab. Remove the cloned toolbox + // and move the original toolbox into its place. There is only + // one toolbox/toolbar so its settings are the same for all item tabs. + let original = document.getElementById("calendarItemPanel").firstElementChild; + let clone = original.cloneNode(true); + + clone.querySelector("toolbox").remove(); + moveEventToolbox(clone); + clone.setAttribute("id", "calendarItemTab" + this.idNumber); + + if (aTab.mode.type == "calendarTask") { + // For task tabs, css class hides event-specific toolbar buttons. + clone.setAttribute("class", "calendar-task-dialog-tab"); + } + + aTab.panel.setAttribute("id", "calendarItemTabWrapper" + this.idNumber); + aTab.panel.appendChild(clone); + + // Set up the iframe and store the iframe's id. The iframe's + // src is set in onLoadCalendarItemPanel() that is called below. + aTab.iframe = aTab.panel.querySelector("iframe"); + let iframeId = "calendarItemTabIframe" + this.idNumber; + aTab.iframe.setAttribute("id", iframeId); + gItemTabIds.push(iframeId); + + // Generate and set the tab title. + let strName; + if (aTab.mode.type == "calendarEvent") { + strName = aArgs.calendarEvent.title ? "editEventDialog" : "newEventDialog"; + aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/calendar.svg"); + } else if (aTab.mode.type == "calendarTask") { + strName = aArgs.calendarEvent.title ? "editTaskDialog" : "newTaskDialog"; + aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/tasks.svg"); + } else { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + // name is "New Event", "Edit Task", etc. + let name = cal.l10n.getCalString(strName); + aTab.title = name + ": " + (aArgs.calendarEvent.title || name); + + // allowTabClose prevents the tab from being closed until we ask + // the user if they want to save any unsaved changes. + aTab.allowTabClose = false; + + // Put the arguments where they can be accessed easily + // from the iframe. (window.arguments[0]) + aTab.iframe.contentWindow.arguments = [aArgs]; + + // activate or de-activate 'Events and Tasks' menu items + document.commandDispatcher.updateCommands("calendar_commands"); + + onLoadCalendarItemPanel(iframeId, aArgs.url); + + this.idNumber += 1; + }, + /** + * Saves a tab's state when it is deactivated / hidden. The opposite of showTab. + * + * @param {object} aTab - A tab info object + */ + saveTabState(aTab) { + // save state + aTab.itemTabConfig = {}; + Object.assign(aTab.itemTabConfig, gConfig); + + // clear statusbar + let statusbar = document.getElementById("status-bar"); + let items = statusbar.getElementsByClassName("event-dialog"); + for (let item of items) { + item.setAttribute("collapsed", true); + } + // move toolbox to the place where it can be accessed later + let to = document.getElementById("calendarItemPanel").firstElementChild; + moveEventToolbox(to); + }, + /** + * Called when a tab is activated / shown. The opposite of saveTabState. + * + * @param {object} aTab - A tab info object + */ + showTab(aTab) { + // move toolbox into place then load state + moveEventToolbox(aTab.panel.firstElementChild); + Object.assign(gConfig, aTab.itemTabConfig); + updateItemTabState(gConfig); + + // activate or de-activate 'Events and Tasks' menu items + document.commandDispatcher.updateCommands("calendar_commands"); + }, + /** + * Called when there is a request to close a tab. Using aTab.allowTabClose + * we first prevent the tab from closing so we can prompt the user + * about saving changes, then we allow the tab to close. + * + * @param {object} aTab - A tab info object + */ + tryCloseTab(aTab) { + if (aTab.allowTabClose) { + return true; + } + onCancel(aTab.iframe.id); + return false; + }, + /** + * Closes a tab. + * + * @param {object} aTab - A tab info object + */ + closeTab(aTab) { + // Remove the iframe id from the array where they are stored. + let index = gItemTabIds.indexOf(aTab.iframe.id); + if (index != -1) { + gItemTabIds.splice(index, 1); + } + aTab.itemTabConfig = null; + + // If this is the last item tab that is closing, then delete + // window.calItemSaveControls, so mochitests won't complain. + let tabmail = document.getElementById("tabmail"); + let calendarItemTabCount = + tabmail.tabModes.calendarEvent.tabs.length + tabmail.tabModes.calendarTask.tabs.length; + if (calendarItemTabCount == 1) { + delete window.calItemSaveControls; + } + }, + /** + * Called when quitting the application (and/or closing the window). + * Saves an open tab's state to be able to restore it later. + * + * @param {object} aTab - A tab info object + */ + persistTab(aTab) { + let args = aTab.iframe.contentWindow.arguments[0]; + // Serialize args, with manual handling of some properties. + // persistTab is called even for new events/tasks in tabs that + // were closed and never saved (for 'undo close tab' + // functionality), thus we confirm we have the expected values. + if ( + !args || + !args.calendar || + !args.calendar.id || + !args.calendarEvent || + !args.calendarEvent.id + ) { + return {}; + } + + let calendarId = args.calendar.id; + let itemId = args.calendarEvent.id; + // Handle null args.initialStartDateValue, just for good measure. + // Note that this is not the start date for the event or task. + let hasDateValue = args.initialStartDateValue && args.initialStartDateValue.icalString; + let initialStartDate = hasDateValue ? args.initialStartDateValue.icalString : null; + + args.calendar = null; + args.calendarEvent = null; + args.initialStartDateValue = null; + + return { + calendarId, + itemId, + initialStartDate, + args, + tabType: aTab.mode.type, + }; + }, + /** + * Called when starting the application (and/or opening the window). + * Restores a tab that was open when the application was quit previously. + * + * @param {object} aTabmail - The tabmail interface + * @param {object} aState - The state of the tab to restore + */ + restoreTab(aTabmail, aState) { + // Sometimes restoreTab is called for tabs that were never saved + // and never meant to be persisted or restored. See persistTab. + if (aState.args && aState.calendarId && aState.itemId) { + aState.args.initialStartDateValue = aState.initialStartDate + ? cal.createDateTime(aState.initialStartDate) + : cal.dtz.getDefaultStartDate(); + + aState.args.onOk = doTransaction.bind(null, "modify"); + + aState.args.calendar = cal.manager.getCalendarById(aState.calendarId); + if (aState.args.calendar) { + aState.args.calendar.getItem(aState.itemId).then(item => { + if (item) { + aState.args.calendarEvent = item; + aTabmail.openTab(aState.tabType, aState.args); + } + }); + } + } + }, +}; + +window.addEventListener("load", e => { + let tabmail = document.getElementById("tabmail"); + tabmail.registerTabType(calendarTabType); + tabmail.registerTabType(calendarItemTabType); + tabmail.registerTabMonitor(calendarTabMonitor); +}); + +/** + * Switch the calendar view, and optionally switch to calendar mode. + * + * @param aType The type of view to select. + * @param aShow If true, the mode will be switched to calendar if not + * already there. + */ +function switchCalendarView(aType, aShow) { + gLastShownCalendarView.set(aType); + + if (aShow && gCurrentMode != "calendar") { + // This function in turn calls switchToView(), so return afterwards. + calSwitchToCalendarMode(); + return; + } + document + .querySelector(`.calview-toggle-item[aria-selected="true"]`) + ?.setAttribute("aria-selected", false); + document + .querySelector(`.calview-toggle-item[aria-controls="${aType}-view"]`) + ?.setAttribute("aria-selected", true); + switchToView(aType); +} + +/** + * Move the event toolbox, containing the toolbar, into view for a tab + * or back to its hiding place where it is accessed again for other tabs. + * + * @param {Node} aDestination - Destination where the toolbox will be moved + */ +function moveEventToolbox(aDestination) { + let toolbox = document.getElementById("event-toolbox"); + // the <toolbarpalette> has to be copied manually + let palette = toolbox.palette; + let iframe = aDestination.querySelector("iframe"); + aDestination.insertBefore(toolbox, iframe); + toolbox.palette = palette; +} diff --git a/comm/calendar/base/content/calendar-task-tree-utils.js b/comm/calendar/base/content/calendar-task-tree-utils.js new file mode 100644 index 0000000000..92dcb8513c --- /dev/null +++ b/comm/calendar/base/content/calendar-task-tree-utils.js @@ -0,0 +1,341 @@ +/* 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 addCalendarNames, calendars, changeContextMenuForTask, + * contextChangeTaskCalendar, contextChangeTaskPriority, + * contextPostponeTask, modifyTaskFromContext, deleteToDoCommand, + * tasksToMail, tasksToEvents, toggleCompleted, + */ + +/* import-globals-from ../../../mail/base/content/globalOverlay.js */ +/* import-globals-from item-editing/calendar-item-editing.js */ +/* import-globals-from item-editing/calendar-item-panel.js */ +/* import-globals-from calendar-command-controller.js */ +/* import-globals-from calendar-dnd-listener.js */ +/* import-globals-from calendar-ui-utils.js */ +/* import-globals-from calendar-views-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Add registered calendars to the given menupopup. Removes all previous + * children. + * + * @param aEvent The popupshowing event of the opening menu + */ +function addCalendarNames(aEvent) { + let calendarMenuPopup = aEvent.target; + while (calendarMenuPopup.hasChildNodes()) { + calendarMenuPopup.lastChild.remove(); + } + let tasks = getSelectedTasks(); + let tasksSelected = tasks.length > 0; + if (tasksSelected) { + let selIndex = appendCalendarItems( + tasks[0], + calendarMenuPopup, + null, + "contextChangeTaskCalendar(event);" + ); + if (tasks.every(task => task.calendar == tasks[0].calendar) && selIndex > -1) { + calendarMenuPopup.children[selIndex].setAttribute("checked", "true"); + } + } +} + +/** + * For each child of an element (for example all menuitems in a menu), if it defines a command + * set an attribute on the command, otherwise set it on the child node itself. + * + * @param aAttribute {string} - The attribute to set. + * @param aValue {boolean|string} - The value to set. + * @param aElement {Element} - The parent node. + */ +function setAttributeOnChildrenOrTheirCommands(aAttribute, aValue, aElement) { + for (let child of aElement.children) { + const commandName = child.getAttribute("command"); + const command = commandName && document.getElementById(commandName); + + const domObject = command || child; + domObject.setAttribute(aAttribute, aValue); + } +} + +/** + * Change the opening context menu for the selected tasks. + * + * @param aEvent The popupshowing event of the opening menu. + */ +function changeContextMenuForTask(aEvent) { + if (aEvent.target.id !== "taskitem-context-menu") { + return; + } + + handleTaskContextMenuStateChange(aEvent); + + const treeNodeId = aEvent.target.triggerNode.closest(".calendar-task-tree").id; + const isTodaypane = treeNodeId == "unifinder-todo-tree"; + const isMainTaskTree = treeNodeId == "calendar-task-tree"; + + document.getElementById("task-context-menu-new").hidden = isTodaypane; + document.getElementById("task-context-menu-modify").hidden = isTodaypane; + document.getElementById("task-context-menu-new-todaypane").hidden = isMainTaskTree; + document.getElementById("task-context-menu-modify-todaypane").hidden = isMainTaskTree; + document.getElementById("task-context-menu-filter-todaypane").hidden = isMainTaskTree; + document.getElementById("task-context-menu-separator-filter").hidden = isMainTaskTree; + + let items = getSelectedTasks(); + let tasksSelected = items.length > 0; + + setAttributeOnChildrenOrTheirCommands("disabled", !tasksSelected, aEvent.target); + + if ( + calendarController.isCommandEnabled("calendar_new_todo_command") && + calendarController.isCommandEnabled("calendar_new_todo_todaypane_command") + ) { + document.getElementById("calendar_new_todo_command").removeAttribute("disabled"); + document.getElementById("calendar_new_todo_todaypane_command").removeAttribute("disabled"); + } else { + document.getElementById("calendar_new_todo_command").setAttribute("disabled", "true"); + document.getElementById("calendar_new_todo_todaypane_command").setAttribute("disabled", "true"); + } + + // make sure the "Paste" and "Cut" menu items are enabled + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_cut"); + + // make sure the filter menu is enabled + document.getElementById("task-context-menu-filter-todaypane").removeAttribute("disabled"); + + setAttributeOnChildrenOrTheirCommands( + "disabled", + false, + document.getElementById("task-context-menu-filter-todaypane-popup") + ); + + changeMenuForTask(); + + let menu = document.getElementById("task-context-menu-attendance-menu"); + setupAttendanceMenu(menu, items); +} + +/** + * Notify the task tree that the context menu open state has changed. + * + * @param aEvent The popupshowing or popuphiding event of the menu. + */ +function handleTaskContextMenuStateChange(aEvent) { + if (aEvent.target.id !== "taskitem-context-menu") { + return; + } + + let tree = aEvent.target.triggerNode.closest(".calendar-task-tree"); + + if (tree) { + tree.updateFocus(); + } +} + +/** + * Change the opening menu for the selected tasks. + */ +function changeMenuForTask() { + // Make sure to update the status of some commands. + let commands = [ + "calendar_delete_todo_command", + "calendar_toggle_completed_command", + "calendar_general-progress_command", + "calendar_general-priority_command", + "calendar_general-postpone_command", + ]; + commands.forEach(goUpdateCommand); + + let tasks = getSelectedTasks(); + let tasksSelected = tasks.length > 0; + if (tasksSelected) { + let cmd = document.getElementById("calendar_toggle_completed_command"); + if (tasks.every(task => task.isCompleted == tasks[0].isCompleted)) { + cmd.checked = tasks[0].isCompleted; + } else { + cmd.checked = false; + } + } +} + +/** + * Handler function to change the progress of all selected tasks, or of + * the task loaded in the current tab. + * + * @param {short} aProgress - The new progress percentage + */ +function contextChangeTaskProgress(aProgress) { + if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") { + editToDoStatus(aProgress); + } else { + startBatchTransaction(); + let tasks = getSelectedTasks(); + for (let task of tasks) { + let newTask = task.clone().QueryInterface(Ci.calITodo); + newTask.percentComplete = aProgress; + switch (aProgress) { + case 0: + newTask.isCompleted = false; + break; + case 100: + newTask.isCompleted = true; + break; + default: + newTask.status = "IN-PROCESS"; + newTask.completedDate = null; + break; + } + doTransaction("modify", newTask, newTask.calendar, task, null); + } + endBatchTransaction(); + } +} + +/** + * Handler function to change the calendar of the selected tasks. The targeted + * menuitem must have "calendar" property that implements calICalendar. + * + * @param aEvent The DOM event that triggered this command. + */ +function contextChangeTaskCalendar(aEvent) { + startBatchTransaction(); + let tasks = getSelectedTasks(); + for (let task of tasks) { + let newTask = task.clone(); + newTask.calendar = aEvent.target.calendar; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + endBatchTransaction(); +} + +/** + * Handler function to change the priority of the selected tasks, or of + * the task loaded in the current tab. + * + * @param {short} aPriority - The priority to set on the task(s) + */ +function contextChangeTaskPriority(aPriority) { + let tabType = gTabmail && gTabmail.currentTabInfo.mode.type; + if (tabType == "calendarTask" || tabType == "calendarEvent") { + editConfigState({ priority: aPriority }); + } else { + startBatchTransaction(); + let tasks = getSelectedTasks(); + for (let task of tasks) { + let newTask = task.clone().QueryInterface(Ci.calITodo); + newTask.priority = aPriority; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + endBatchTransaction(); + } +} + +/** + * Handler function to postpone the start and due dates of the selected + * tasks, or of the task loaded in the current tab. ISO 8601 format: + * "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We use this + * format intentionally instead of a calIDuration object because those + * objects cannot be serialized for message passing with iframes.) + * + * @param {string} aDuration - The duration to postpone in ISO 8601 format + */ +function contextPostponeTask(aDuration) { + let duration = cal.createDuration(aDuration); + if (!duration) { + cal.LOG("[calendar-task-tree] Postpone Task - Invalid duration " + aDuration); + return; + } + + if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") { + postponeTask(aDuration); + } else { + startBatchTransaction(); + let tasks = getSelectedTasks(); + + tasks.forEach(task => { + if (task.entryDate || task.dueDate) { + let newTask = task.clone(); + cal.item.shiftOffset(newTask, duration); + doTransaction("modify", newTask, newTask.calendar, task, null); + } + }); + + endBatchTransaction(); + } +} + +/** + * Modifies the selected tasks with the event dialog + * + * @param initialDate (optional) The initial date for new task datepickers + */ +function modifyTaskFromContext(initialDate) { + let tasks = getSelectedTasks(); + for (let task of tasks) { + modifyEventWithDialog(task, true, initialDate); + } +} + +/** + * Delete the current selected item with focus from the task tree + * + * @param aDoNotConfirm If true, the user will not be asked to delete. + */ +function deleteToDoCommand(aDoNotConfirm) { + let tasks = getSelectedTasks(); + calendarViewController.deleteOccurrences(tasks, false, aDoNotConfirm); +} + +/** + * Gets the currently visible task tree + * + * @returns The XUL task tree element. + */ +function getTaskTree() { + if (gCurrentMode == "task") { + return document.getElementById("calendar-task-tree"); + } + return document.getElementById("unifinder-todo-tree"); +} + +/** + * Gets the tasks selected in the currently visible task tree. + */ +function getSelectedTasks() { + let taskTree = getTaskTree(); + return taskTree ? taskTree.selectedTasks : []; +} + +/** + * Convert selected tasks to emails. + */ +function tasksToMail() { + let tasks = getSelectedTasks(); + calendarMailButtonDNDObserver.onDropItems(tasks); +} + +/** + * Convert selected tasks to events. + */ +function tasksToEvents() { + let tasks = getSelectedTasks(); + calendarCalendarButtonDNDObserver.onDropItems(tasks); +} + +/** + * Toggle the completed state on selected tasks. + * + * @param aEvent The originating event, can be null. + */ +function toggleCompleted(aEvent) { + if (aEvent.target.getAttribute("checked") == "true") { + contextChangeTaskProgress(0); + } else { + contextChangeTaskProgress(100); + } +} diff --git a/comm/calendar/base/content/calendar-task-tree-view.js b/comm/calendar/base/content/calendar-task-tree-view.js new file mode 100644 index 0000000000..ebe258419f --- /dev/null +++ b/comm/calendar/base/content/calendar-task-tree-view.js @@ -0,0 +1,495 @@ +/* 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 CalendarTaskTreeView */ + +/* import-globals-from item-editing/calendar-item-editing.js */ +/* import-globals-from widgets/mouseoverPreviews.js */ + +/* globals cal */ + +/** + * The tree view for a CalendarTaskTree. + */ +class CalendarTaskTreeView { + /** + * Creates a new task tree view and connects it to a given task tree. + * + * @param {CalendarTaskTree} taskTree - The task tree to connect the view to. + */ + constructor(taskTree) { + this.tree = taskTree; + this.mSelectedColumn = null; + this.sortDirection = null; + } + + QueryInterface = ChromeUtils.generateQI(["nsITreeView"]); + + /** + * Get the selected column. + * + * @returns {Element} A treecol element. + */ + get selectedColumn() { + return this.mSelectedColumn; + } + + /** + * Set the selected column and sort by that column. + * + * @param {Element} column - A treecol element. + */ + set selectedColumn(column) { + const columnProperty = column.getAttribute("itemproperty"); + + this.tree.querySelectorAll("treecol").forEach(col => { + if (col.getAttribute("sortActive")) { + col.removeAttribute("sortActive"); + col.removeAttribute("sortDirection"); + } + if (columnProperty == col.getAttribute("itemproperty")) { + col.setAttribute("sortActive", "true"); + col.setAttribute("sortDirection", this.sortDirection); + } + }); + this.mSelectedColumn = column; + } + + // High-level task tree manipulation + + /** + * Adds an array of items (tasks) to the list if they match the currently applied filter. + * + * @param {object[]} items - An array of task objects to add. + * @param {boolean} [doNotSort] - Whether to re-sort after adding the tasks. + */ + addItems(items, doNotSort) { + this.modifyItems(items, [], doNotSort, true); + } + /** + * Removes an array of items (tasks) from the list. + * + * @param {object[]} items - An array of task objects to remove. + */ + removeItems(items) { + this.modifyItems([], items, true, false); + } + + /** + * Removes an array of old items from the list, and adds an array of new items if + * they match the currently applied filter. + * + * @param {object[]} newItems - An array of new items to add. + * @param {object[]} oldItems - An array of old items to remove. + * @param {boolean} [doNotSort] - Whether to re-sort the list after modifying it. + * @param {boolean} [selectNew] - Whether to select the new tasks. + */ + modifyItems(newItems = [], oldItems = [], doNotSort, selectNew) { + let selItem = this.tree.currentTask; + let selIndex = this.tree.currentIndex; + let firstHash = null; + let remIndexes = []; + + this.tree.beginUpdateBatch(); + + let idiff = new cal.item.ItemDiff(); + idiff.load(oldItems); + idiff.difference(newItems); + idiff.complete(); + let delItems = idiff.deletedItems; + let addItems = idiff.addedItems; + let modItems = idiff.modifiedItems; + + // Find the indexes of the old items that need to be removed. + for (let item of delItems.mArray) { + if (item.hashId in this.tree.mHash2Index) { + // The old item needs to be removed. + remIndexes.push(this.tree.mHash2Index[item.hashId]); + delete this.tree.mHash2Index[item.hashId]; + } + } + + // Modified items need to be updated. + for (let item of modItems.mArray) { + if (item.hashId in this.tree.mHash2Index) { + // Make sure we're using the new version of a modified item. + this.tree.mTaskArray[this.tree.mHash2Index[item.hashId]] = item; + } + } + + // Remove the old items working backward from the end so the indexes stay valid. + remIndexes + .sort((a, b) => b - a) + .forEach(index => { + this.tree.mTaskArray.splice(index, 1); + this.tree.rowCountChanged(index, -1); + }); + + // Add the new items. + for (let item of addItems.mArray) { + if (!(item.hashId in this.tree.mHash2Index)) { + let index = this.tree.mTaskArray.length; + this.tree.mTaskArray.push(item); + this.tree.mHash2Index[item.hashId] = index; + this.tree.rowCountChanged(index, 1); + firstHash = firstHash || item.hashId; + } + } + + if (doNotSort) { + this.tree.recreateHashTable(); + } else { + this.tree.sortItems(); + } + + if (selectNew && firstHash && firstHash in this.tree.mHash2Index) { + // Select the first item added into the list. + selIndex = this.tree.mHash2Index[firstHash]; + } else if (selItem && selItem.hashId in this.tree.mHash2Index) { + // Select the previously selected item. + selIndex = this.tree.mHash2Index[selItem.hashId]; + } else if (selIndex >= this.tree.mTaskArray.length) { + // Make sure the previously selected index is valid. + selIndex = this.tree.mTaskArray.length - 1; + } + + if (selIndex > -1) { + this.tree.view.selection.select(selIndex); + this.tree.ensureRowIsVisible(selIndex); + } + + this.tree.endUpdateBatch(); + } + + /** + * Remove all tasks from the list/tree. + */ + clear() { + let count = this.tree.mTaskArray.length; + if (count > 0) { + this.tree.mTaskArray = []; + this.tree.mHash2Index = {}; + this.tree.rowCountChanged(0, -count); + this.tree.view.selection.clearSelection(); + } + } + + /** + * Refresh the display for a given task. + * + * @param {object} item - The task object to refresh. + */ + updateItem(item) { + let index = this.tree.mHash2Index[item.hashId]; + if (index) { + this.tree.invalidateRow(index); + } + } + + /** + * Return the item (task) object that's related to a given event. If passed a column and/or row + * object, set their 'value' property to the column and/or row related to the event. + * + * @param {Event} event - An event. + * @param {object} [col] - A column object. + * @param {object} [row] - A row object. + * @returns {object | false} The task object related to the event or false if none found. + */ + getItemFromEvent(event, col, row) { + let { col: eventColumn, row: eventRow } = this.tree.getCellAt(event.clientX, event.clientY); + if (col) { + col.value = eventColumn; + } + if (row) { + row.value = eventRow; + } + return eventRow > -1 && this.tree.mTaskArray[eventRow]; + } + + // nsITreeView Methods and Properties + + get rowCount() { + return this.tree.mTaskArray.length; + } + + getCellProperties(row, col) { + let rowProps = this.getRowProperties(row); + let colProps = this.getColumnProperties(col); + return rowProps + (rowProps && colProps ? " " : "") + colProps; + } + + getColumnProperties(col) { + return col.element.getAttribute("id") || ""; + } + + getRowProperties(row) { + let properties = []; + let item = this.tree.mTaskArray[row]; + if (item.priority > 0 && item.priority < 5) { + properties.push("highpriority"); + } else if (item.priority > 5 && item.priority < 10) { + properties.push("lowpriority"); + } + properties.push(cal.item.getProgressAtom(item)); + + // Add calendar name and id atom. + properties.push("calendar-" + cal.view.formatStringForCSSRule(item.calendar.name)); + properties.push("calendarid-" + cal.view.formatStringForCSSRule(item.calendar.id)); + + // Add item status atom. + if (item.status) { + properties.push("status-" + item.status.toLowerCase()); + } + + // Alarm status atom. + if (item.getAlarms().length) { + properties.push("alarm"); + } + + // Task categories. + properties = properties.concat(item.getCategories().map(cal.view.formatStringForCSSRule)); + + return properties.join(" "); + } + + cycleCell(row, col) { + let task = this.tree.mTaskArray[row]; + + // Prevent toggling completed status for parent items of + // repeating tasks or when the calendar is read-only. + if (!task || task.recurrenceInfo || task.calendar.readOnly) { + return; + } + if (col != null) { + let content = col.element.getAttribute("itemproperty"); + if (content == "completed") { + let newTask = task.clone().QueryInterface(Ci.calITodo); + newTask.isCompleted = !task.completedDate; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + } + } + + cycleHeader(col) { + if (!this.selectedColumn) { + this.sortDirection = "ascending"; + } else if (!this.sortDirection || this.sortDirection == "descending") { + this.sortDirection = "ascending"; + } else { + this.sortDirection = "descending"; + } + this.selectedColumn = col.element; + let selectedItems = this.tree.selectedTasks; + this.tree.sortItems(); + if (selectedItems != undefined) { + this.tree.view.selection.clearSelection(); + for (let item of selectedItems) { + let index = this.tree.mHash2Index[item.hashId]; + this.tree.view.selection.toggleSelect(index); + } + } + } + + getCellText(row, col) { + let task = this.tree.mTaskArray[row]; + if (!task) { + return ""; + } + + const property = col.element.getAttribute("itemproperty"); + switch (property) { + case "title": + // Return title, or "Untitled" if empty/null. + return task.title ? task.title.replace(/\n/g, " ") : cal.l10n.getCalString("eventUntitled"); + case "entryDate": + case "dueDate": + case "completedDate": + return task.recurrenceInfo + ? cal.l10n.getDateFmtString("Repeating") + : this._formatDateTime(task[property]); + case "percentComplete": + return task.percentComplete > 0 ? task.percentComplete + "%" : ""; + case "categories": + // TODO This is l10n-unfriendly. + return task.getCategories().join(", "); + case "location": + return task.getProperty("LOCATION"); + case "status": + return getToDoStatusString(task); + case "calendar": + return task.calendar.name; + case "duration": + return this.tree.duration(task); + case "completed": + case "priority": + default: + return ""; + } + } + + getCellValue(row, col) { + let task = this.tree.mTaskArray[row]; + if (!task) { + return null; + } + switch (col.element.getAttribute("itemproperty")) { + case "percentComplete": + return task.percentComplete; + } + return null; + } + + setCellValue(row, col, value) { + return null; + } + + getImageSrc(row, col) { + return ""; + } + + isEditable(row, col) { + return true; + } + + /** + * Called to link the task tree to the tree view. A null argument un-sets/un-links the tree. + * + * @param {object | null} tree + */ + setTree(tree) { + const hasOldTree = this.tree != null; + if (hasOldTree && !tree) { + // Balances the addObserver calls from the refresh method in the tree. + + // Remove the composite calendar observer. + const composite = cal.view.getCompositeCalendar(window); + composite.removeObserver(this.tree.mTaskTreeObserver); + + // Remove the preference observer. + const branch = Services.prefs.getBranch(""); + branch.removeObserver("calendar.", this.tree.mPrefObserver); + } + this.tree = tree; + } + + isContainer(row) { + return false; + } + isContainerOpen(row) { + return false; + } + isContainerEmpty(row) { + return false; + } + + isSeparator(row) { + return false; + } + + isSorted(row) { + return false; + } + + canDrop() { + return false; + } + + drop(row, orientation) {} + + getParentIndex(row) { + return -1; + } + + getLevel(row) { + return 0; + } + + // End nsITreeView Methods and Properties + // Task Tree Event Handlers + + onSelect(event) {} + + /** + * Handle double click events. + * + * @param {Event} event - The double click event. + */ + onDoubleClick(event) { + // Only handle left mouse button clicks. + if (event.button != 0) { + return; + } + const initialDate = cal.dtz.getDefaultStartDate(this.tree.getInitialDate()); + const col = {}; + const item = this.getItemFromEvent(event, col); + if (item) { + const itemProperty = col.value.element.getAttribute("itemproperty"); + + // If itemProperty == "completed" then the user has clicked a "completed" checkbox + // and `item` holds the checkbox state toggled by the first click. So, to make sure the + // user notices that the state changed, don't call modifyEventWithDialog. + if (itemProperty != "completed") { + modifyEventWithDialog(item, true, initialDate); + } + } else { + createTodoWithDialog(null, null, null, null, initialDate); + } + } + + /** + * Handle key press events. + * + * @param {Event} event - The key press event. + */ + onKeyPress(event) { + switch (event.key) { + case "Delete": { + event.target.triggerNode = this.tree; + document.getElementById("calendar_delete_todo_command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + break; + } + case " ": { + if (this.tree.currentIndex > -1) { + let col = this.tree.querySelector("[itemproperty='completed']"); + this.cycleCell(this.tree.currentIndex, { element: col }); + } + break; + } + case "Enter": { + let index = this.tree.currentIndex; + if (index > -1) { + modifyEventWithDialog(this.tree.mTaskArray[index]); + } + break; + } + } + } + + /** + * Set the context menu on mousedown to change it before it is opened. + * + * @param {Event} event - The mousedown event. + */ + onMouseDown(event) { + if (!this.getItemFromEvent(event)) { + this.tree.view.selection.invalidateSelection(); + } + } + + // Private Methods and Attributes + + /** + * Format a datetime object for display. + * + * @param {object} dateTime - From a todo object, not a JavaScript date. + * @returns {string} Formatted string version of the datetime ("" if invalid). + */ + _formatDateTime(dateTime) { + return dateTime && dateTime.isValid + ? cal.dtz.formatter.formatDateTime(dateTime.getInTimezone(cal.dtz.defaultTimezone)) + : ""; + } +} diff --git a/comm/calendar/base/content/calendar-task-tree.js b/comm/calendar/base/content/calendar-task-tree.js new file mode 100644 index 0000000000..b7cc069e42 --- /dev/null +++ b/comm/calendar/base/content/calendar-task-tree.js @@ -0,0 +1,685 @@ +/* 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, calendarController, invokeEventDragSession, CalendarTaskTreeView, + calFilter, TodayPane, currentView */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + const { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); + + /** + * An observer for the calendar event data source. This keeps the unifinder + * display up to date when the calendar event data is changed. + * + * @implements {calIObserver} + * @implements {calICompositeObserver} + */ + class TaskTreeObserver { + /** + * Creates and connects the new observer to a CalendarTaskTree and sets up Query Interface. + * + * @param {CalendarTaskTree} taskTree - The tree to observe. + */ + constructor(taskTree) { + this.tree = taskTree; + this.QueryInterface = ChromeUtils.generateQI(["calICompositeObserver", "calIObserver"]); + } + + // calIObserver Methods + + onStartBatch() {} + + onEndBatch() {} + + onLoad() { + this.tree.refresh(); + } + + onAddItem(item) { + if (!this.tree.hasBeenVisible) { + return; + } + + if (item.isTodo()) { + this.tree.mTreeView.addItems(this.tree.mFilter.getOccurrences(item)); + } + } + + onModifyItem(newItem, oldItem) { + if (!this.tree.hasBeenVisible) { + return; + } + + if (newItem.isTodo() || oldItem.isTodo()) { + this.tree.mTreeView.modifyItems( + this.tree.mFilter.getOccurrences(newItem), + this.tree.mFilter.getOccurrences(oldItem) + ); + // We also need to notify potential listeners. + let event = document.createEvent("Events"); + event.initEvent("select", true, false); + this.tree.dispatchEvent(event); + } + } + + onDeleteItem(deletedItem) { + if (!this.tree.hasBeenVisible) { + return; + } + + if (deletedItem.isTodo()) { + this.tree.mTreeView.removeItems(this.tree.mFilter.getOccurrences(deletedItem)); + } + } + + onError(calendar, errNo, message) {} + + onPropertyChanged(calendar, name, value, oldValue) { + switch (name) { + case "disabled": + if (value) { + this.tree.onCalendarRemoved(calendar); + } else { + this.tree.onCalendarAdded(calendar); + } + break; + } + } + + onPropertyDeleting(calendar, name) { + this.onPropertyChanged(calendar, name, null, null); + } + + // End calIObserver Methods + // calICompositeObserver Methods + + onCalendarAdded(calendar) { + if (!calendar.getProperty("disabled")) { + this.tree.onCalendarAdded(calendar); + } + } + + onCalendarRemoved(calendar) { + this.tree.onCalendarRemoved(calendar); + } + + onDefaultCalendarChanged(newDefaultCalendar) {} + + // End calICompositeObserver Methods + } + + /** + * Custom element for table-style display of tasks (rows and columns). + * + * @augments {MozTree} + */ + class CalendarTaskTree extends customElements.get("tree") { + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <treecols> + <treecol is="treecol-image" id="calendar-task-tree-col-completed" + class="calendar-task-tree-col-completed" + style="min-width: 18px" + fixed="true" + cycler="true" + sortKey="completedDate" + itemproperty="completed" + closemenu="none" + src="chrome://messenger/skin/icons/new/compact/checkbox.svg" + label="&calendar.unifinder.tree.done.label;" + tooltiptext="&calendar.unifinder.tree.done.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="calendar-task-tree-col-priority" + class="calendar-task-tree-col-priority" + style="min-width: 17px" + fixed="true" + itemproperty="priority" + closemenu="none" + src="chrome://messenger/skin/icons/new/compact/priority.svg" + label="&calendar.unifinder.tree.priority.label;" + tooltiptext="&calendar.unifinder.tree.priority.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-title" + itemproperty="title" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.title.label;" + tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-entrydate" + itemproperty="entryDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.startdate.label;" + tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-duedate" + itemproperty="dueDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.duedate.label;" + tooltiptext="&calendar.unifinder.tree.duedate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-duration" + itemproperty="duration" + sortKey="dueDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.duration.label;" + tooltiptext="&calendar.unifinder.tree.duration.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-completeddate" + itemproperty="completedDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.completeddate.label;" + tooltiptext="&calendar.unifinder.tree.completeddate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-percentcomplete" + itemproperty="percentComplete" + style="flex: 1 auto; min-width: 40px;" + closemenu="none" + label="&calendar.unifinder.tree.percentcomplete.label;" + tooltiptext="&calendar.unifinder.tree.percentcomplete.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-categories" + itemproperty="categories" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-location" + itemproperty="location" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.location.label;" + tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-status" + itemproperty="status" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.status.label;" + tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-calendar" + itemproperty="calendar" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.calendarname.label;" + tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/> + </treecols> + <treechildren class="calendar-task-treechildren" + tooltip="taskTreeTooltip" + ondblclick="mTreeView.onDoubleClick(event)"/> + `, + ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"] + ) + ); + + this.classList.add("calendar-task-tree"); + this.setAttribute("enableColumnDrag", "true"); + this.setAttribute("keepcurrentinview", "true"); + + this.addEventListener("select", event => { + this.mTreeView.onSelect(event); + if (calendarController.todo_tasktree_focused) { + calendarController.onSelectionChanged({ detail: this.selectedTasks }); + } + }); + + this.addEventListener("focus", event => { + this.updateFocus(); + }); + + this.addEventListener("blur", event => { + this.updateFocus(); + }); + + this.addEventListener("keypress", event => { + this.mTreeView.onKeyPress(event); + }); + + this.addEventListener("mousedown", event => { + this.mTreeView.onMouseDown(event); + }); + + this.addEventListener("dragstart", event => { + if (event.target.localName != "treechildren") { + // We should only drag treechildren, not for example the scrollbar. + return; + } + let item = this.mTreeView.getItemFromEvent(event); + if (!item || item.calendar.readOnly) { + return; + } + invokeEventDragSession(item, event.target); + }); + + this.mTaskArray = []; + this.mHash2Index = {}; + this.mPendingRefreshJobs = {}; + this.mShowCompletedTasks = true; + this.mFilter = null; + this.mStartDate = null; + this.mEndDate = null; + this.mDateRangeFilter = null; + this.mTextFilterField = null; + + this.mTreeView = new CalendarTaskTreeView(this); + this.mTaskTreeObserver = new TaskTreeObserver(this); + + // Observes and responds to changes to calendar preferences. + this.mPrefObserver = (subject, topic, prefName) => { + switch (prefName) { + case "calendar.date.format": + case "calendar.timezone.local": + this.refresh(); + break; + } + }; + + // Set up the tree filter. + this.mFilter = new calFilter(); + this.mFilter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + + this.restoreColumnState(); + + window.addEventListener("unload", this.persistColumnState.bind(this)); + } + + get currentTask() { + const index = this.currentIndex; + + const isSelected = this.view && this.view.selection && this.view.selection.isSelected(index); + + return isSelected ? this.mTaskArray[index] : null; + } + + get selectedTasks() { + let tasks = []; + let start = {}; + let end = {}; + if (!this.mTreeView.selection) { + return tasks; + } + + const rangeCount = this.mTreeView.selection.getRangeCount(); + + for (let range = 0; range < rangeCount; range++) { + this.mTreeView.selection.getRangeAt(range, start, end); + + for (let i = start.value; i <= end.value; i++) { + let task = this.getTaskAtRow(i); + if (task) { + tasks.push(this.getTaskAtRow(i)); + } + } + } + return tasks; + } + + set showCompleted(val) { + this.mShowCompletedTasks = val; + } + + get showCompleted() { + return this.mShowCompletedTasks; + } + + set textFilterField(val) { + this.mTextFilterField = val; + } + + get textFilterField() { + return this.mTextFilterField; + } + + /** + * We want to make several attributes of the calendar-task-tree column elements persist + * across restarts. Unfortunately there's no reliable way by using the XUL 'persist' + * attribute on the column elements. So instead we store the data on the calendar-task-tree + * element before Thunderbird quits (using `persistColumnState`), and then restore the + * attributes on the columns when Thunderbird starts up again (using `restoreColumnState`). + * + * This function reads data from column attributes and sets it on several attributes on the + * task tree element, which are persisted because they are in the "persist" attribute of + * the task tree element. + * (E.g. `persist="visible-columns ordinals widths sort-active sort-direction"`.) + */ + persistColumnState() { + const columns = Array.from(this.querySelectorAll("treecol")); + const widths = columns.map(col => col.getBoundingClientRect().width || 0); + const ordinals = columns.map(col => col.ordinal); + const visibleColumns = columns + .filter(col => !col.hidden) + .map(col => col.getAttribute("itemproperty")); + + this.setAttribute("widths", widths.join(" ")); + this.setAttribute("ordinals", ordinals.join(" ")); + this.setAttribute("visible-columns", visibleColumns.join(" ")); + + const sorted = this.mTreeView.selectedColumn; + if (sorted) { + this.setAttribute("sort-active", sorted.getAttribute("itemproperty")); + this.setAttribute("sort-direction", this.mTreeView.sortDirection); + } else { + this.removeAttribute("sort-active"); + this.removeAttribute("sort-direction"); + } + } + + /** + * Reads data from several attributes on the calendar-task-tree element and sets it on the + * attributes of the columns of the tree. Called on Thunderbird startup to persist the + * state of the columns across restarts. Used with `persistTaskTreeColumnState` function. + */ + restoreColumnState() { + let visibleColumns = this.getAttribute("visible-columns").split(" "); + let ordinals = this.getAttribute("ordinals").split(" "); + let widths = this.getAttribute("widths").split(" "); + let sorted = this.getAttribute("sort-active"); + let sortDirection = this.getAttribute("sort-direction") || "ascending"; + + this.querySelectorAll("treecol").forEach(col => { + const itemProperty = col.getAttribute("itemproperty"); + if (visibleColumns.includes(itemProperty)) { + col.removeAttribute("hidden"); + } else { + col.setAttribute("hidden", "true"); + } + if (ordinals && ordinals.length > 0) { + col.ordinal = ordinals.shift(); + } + if (widths && widths.length > 0) { + col.style.width = Number(widths.shift()) + "px"; + } + if (sorted && sorted == itemProperty) { + this.mTreeView.sortDirection = sortDirection; + this.mTreeView.selectedColumn = col; + } + }); + // Update the ordinal positions of splitters to even numbers, so that + // they are in between columns. + let splitters = this.getElementsByTagName("splitter"); + for (let i = 0; i < splitters.length; i++) { + splitters[i].style.MozBoxOrdinalGroup = (i + 1) * 2; + } + } + + /** + * Calculates the text to display in the "Due In" column for the given task, + * the amount of time between now and when the task is due. + * + * @param {object} task - A task object. + * @returns {string} A formatted string for the "Due In" column for the task. + */ + duration(task) { + const noValidDueDate = !(task && task.dueDate && task.dueDate.isValid); + if (noValidDueDate) { + return ""; + } + + const isCompleted = task.completedDate && task.completedDate.isValid; + const dur = task.dueDate.subtractDate(cal.dtz.now()); + if (isCompleted && dur.isNegative) { + return ""; + } + + const absSeconds = Math.abs(dur.inSeconds); + const absMinutes = Math.ceil(absSeconds / 60); + const prefix = dur.isNegative ? "-" : ""; + + if (absMinutes >= 1440) { + // 1 day or more. + // Convert weeks to days; duration objects look like this (for 6, 7, and 8 days): + // { weeks: 0, days: 6 } + // { weeks: 1, days: 0 } + // { weeks: 0, days: 8 } + const days = dur.days + dur.weeks * 7; + return ( + prefix + PluralForm.get(days, cal.l10n.getCalString("dueInDays")).replace("#1", days) + ); + } else if (absMinutes >= 60) { + // 1 hour or more. + return ( + prefix + + PluralForm.get(dur.hours, cal.l10n.getCalString("dueInHours")).replace("#1", dur.hours) + ); + } + // Less than one hour. + return cal.l10n.getCalString("dueInLessThanOneHour"); + } + + /** + * Return the task object at a given row. + * + * @param {number} row - The index number identifying the row. + * @returns {object | null} A task object or null if none found. + */ + getTaskAtRow(row) { + return row > -1 ? this.mTaskArray[row] : null; + } + + /** + * Return the task object related to a given event. + * + * @param {Event} event - The event. + * @returns {object | false} The task object related to the event or false if none found. + */ + getTaskFromEvent(event) { + return this.mTreeView.getItemFromEvent(event); + } + + refreshFromCalendar(calendar) { + if (!this.hasBeenVisible) { + return; + } + + let refreshJob = { + QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]), + tree: this, + calendar: null, + items: null, + operation: null, + + async cancel() { + if (this.operation) { + await this.operation.cancel(); + this.operation = null; + this.items = []; + } + }, + + async execute() { + if (calendar.id in this.tree.mPendingRefreshJobs) { + this.tree.mPendingRefreshJobs[calendar.id].cancel(); + } + this.calendar = calendar; + this.items = []; + this.tree.mPendingRefreshJobs[calendar.id] = this; + this.operation = cal.iterate.streamValues(this.tree.mFilter.getItems(calendar)); + + for await (let items of this.operation) { + this.items = this.items.concat(items); + } + + if (!this.tree.mTreeView.tree) { + // Looks like we've been disconnected from the DOM, there's no point in continuing. + return; + } + + if (calendar.id in this.tree.mPendingRefreshJobs) { + delete this.tree.mPendingRefreshJobs[calendar.id]; + } + + let oldItems = this.tree.mTaskArray.filter(item => item.calendar.id == calendar.id); + this.tree.mTreeView.modifyItems(this.items, oldItems); + this.tree.dispatchEvent(new CustomEvent("refresh", { bubbles: false })); + }, + }; + + refreshJob.execute(); + } + + selectAll() { + if (this.mTreeView.selection) { + this.mTreeView.selection.selectAll(); + } + } + + /** + * Refreshes the display. Called during connectedCallback and by event observers. + * Sets up the tree view, calendar event observer, and preference observer. + */ + refresh() { + // Only set the view if it's not already mTreeView, otherwise things get confused. + if (this.view?.wrappedJSObject != this.mTreeView) { + this.view = this.mTreeView; + } + + cal.view.getCompositeCalendar(window).addObserver(this.mTaskTreeObserver); + + Services.prefs.getBranch("").addObserver("calendar.", this.mPrefObserver); + + const cals = cal.view.getCompositeCalendar(window).getCalendars() || []; + const enabledCals = cals.filter(calendar => !calendar.getProperty("disabled")); + + enabledCals.forEach(calendar => this.refreshFromCalendar(calendar)); + } + + onCalendarAdded(calendar) { + if (!calendar.getProperty("disabled")) { + this.refreshFromCalendar(calendar); + } + } + + onCalendarRemoved(calendar) { + const tasks = this.mTaskArray.filter(task => task.calendar.id == calendar.id); + this.mTreeView.removeItems(tasks); + } + + sortItems() { + if (this.mTreeView.selectedColumn) { + let column = this.mTreeView.selectedColumn; + let modifier = this.mTreeView.sortDirection == "descending" ? -1 : 1; + let sortKey = column.getAttribute("sortKey") || column.getAttribute("itemproperty"); + + cal.unifinder.sortItems(this.mTaskArray, sortKey, modifier); + } + + this.recreateHashTable(); + } + + recreateHashTable() { + this.mHash2Index = this.mTaskArray.reduce((hash2Index, task, i) => { + hash2Index[task.hashId] = i; + return hash2Index; + }, {}); + + if (this.mTreeView.tree) { + this.mTreeView.tree.invalidate(); + } + } + + getInitialDate() { + return currentView().selectedDay || cal.dtz.now(); + } + + doUpdateFilter(filter) { + let needsRefresh = false; + let oldStart = this.mFilter.mStartDate; + let oldEnd = this.mFilter.mEndDate; + let filterText = this.mFilter.filterText || ""; + + if (filter) { + let props = this.mFilter.filterProperties; + this.mFilter.applyFilter(filter); + needsRefresh = !props || !props.equals(this.mFilter.filterProperties); + } else { + this.mFilter.updateFilterDates(); + } + + if (this.mTextFilterField) { + let field = document.getElementById(this.mTextFilterField); + if (field) { + this.mFilter.filterText = field.value; + needsRefresh = + needsRefresh || filterText.toLowerCase() != this.mFilter.filterText.toLowerCase(); + } + } + + // We only need to refresh the tree if the filter properties or date range changed. + const start = this.mFilter.startDate; + const end = this.mFilter.mEndDate; + + const sameStartDates = start && oldStart && oldStart.compare(start) == 0; + const sameEndDates = end && oldEnd && oldEnd.compare(end) == 0; + + if ( + needsRefresh || + ((start || oldStart) && !sameStartDates) || + ((end || oldEnd) && !sameEndDates) + ) { + this.refresh(); + } + } + + updateFilter(filter) { + this.doUpdateFilter(filter); + } + + updateFocus() { + let menuOpen = false; + + // We need to consider the tree focused if the context menu is open. + if (this.hasAttribute("context")) { + let context = document.getElementById(this.getAttribute("context")); + if (context && context.state) { + menuOpen = context.state == "open" || context.state == "showing"; + } + } + + let focused = document.activeElement == this || menuOpen; + + calendarController.onSelectionChanged({ detail: focused ? this.selectedTasks : [] }); + calendarController.todo_tasktree_focused = focused; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.persistColumnState(); + this.mTreeView = null; + } + } + + customElements.define("calendar-task-tree", CalendarTaskTree, { extends: "tree" }); + + /** + * Custom element for the task tree that appears in the todaypane. + */ + class CalendarTaskTreeTodaypane extends CalendarTaskTree { + getInitialDate() { + return TodayPane.start || cal.dtz.now(); + } + updateFilter(filter) { + this.mFilter.selectedDate = this.getInitialDate(); + this.doUpdateFilter(filter); + } + } + + customElements.define("calendar-task-tree-todaypane", CalendarTaskTreeTodaypane, { + extends: "tree", + }); +} diff --git a/comm/calendar/base/content/calendar-task-view.js b/comm/calendar/base/content/calendar-task-view.js new file mode 100644 index 0000000000..f63fe9ffde --- /dev/null +++ b/comm/calendar/base/content/calendar-task-view.js @@ -0,0 +1,470 @@ +/* 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 taskDetailsView, sendMailToOrganizer, taskViewCopyLink */ + +/* import-globals-from ../../../mail/base/content/mailCore.js */ +/* import-globals-from item-editing/calendar-item-editing.js */ +/* import-globals-from ../src/calApplicationUtils.js */ +/* import-globals-from calendar-ui-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { recurrenceRule2String } = ChromeUtils.import( + "resource:///modules/calendar/calRecurrenceUtils.jsm" +); +var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); + +var taskDetailsView = { + /** + * Task Details Events + * + * XXXberend Please document this function, possibly also consolidate since + * its the only function in taskDetailsView. + */ + onSelect(event) { + function displayElement(id, flag) { + document.getElementById(id).hidden = !flag; + return flag; + } + + let dateFormatter = cal.dtz.formatter; + + let item = document.getElementById("calendar-task-tree").currentTask; + if ( + displayElement("calendar-task-details-container", item != null) && + displayElement("calendar-task-view-splitter", item != null) + ) { + document.getElementById("calendar-task-details-title-row").toggleAttribute("hidden", false); + document.getElementById("calendar-task-details-title").textContent = item.title + ? item.title.replace(/\n/g, " ") + : ""; + + let organizer = item.organizer; + if ( + !document + .getElementById("calendar-task-details-organizer-row") + .toggleAttribute("hidden", !organizer) + ) { + let name = organizer.commonName; + if (!name || name.length <= 0) { + if (organizer.id && organizer.id.length) { + name = organizer.id; + let re = new RegExp("^mailto:(.*)", "i"); + let matches = re.exec(name); + if (matches) { + name = matches[1]; + } + } + } + if ( + !document + .getElementById("calendar-task-details-organizer-row") + .toggleAttribute("hidden", !name) + ) { + document.getElementById("calendar-task-details-organizer").textContent = name; + } + } + + let priority = 0; + if (item.calendar.getProperty("capabilities.priority.supported")) { + priority = parseInt(item.priority, 10); + } + document + .getElementById("calendar-task-details-priority-row") + .toggleAttribute("hidden", priority == 0); + displayElement("calendar-task-details-priority-low", priority >= 6 && priority <= 9); + displayElement("calendar-task-details-priority-normal", priority == 5); + displayElement("calendar-task-details-priority-high", priority >= 1 && priority <= 4); + + let status = item.getProperty("STATUS"); + if ( + !document + .getElementById("calendar-task-details-status-row") + .toggleAttribute("hidden", !status) + ) { + let statusDetails = document.getElementById("calendar-task-details-status"); + switch (status) { + case "NEEDS-ACTION": { + statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusNeedsAction"); + break; + } + case "IN-PROCESS": { + let percent = 0; + let property = item.getProperty("PERCENT-COMPLETE"); + if (property != null) { + percent = parseInt(property, 10); + } + statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusInProgress", [ + percent, + ]); + break; + } + case "COMPLETED": { + if (item.completedDate) { + let completedDate = item.completedDate.getInTimezone(cal.dtz.defaultTimezone); + statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusCompletedOn", [ + dateFormatter.formatDateTime(completedDate), + ]); + } + break; + } + case "CANCELLED": { + statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusCancelled"); + break; + } + default: { + document + .getElementById("calendar-task-details-status-row") + .toggleAttribute("hidden", true); + break; + } + } + } + let categories = item.getCategories(); + if ( + !document + .getElementById("calendar-task-details-category-row") + .toggleAttribute("hidden", categories.length == 0) + ) { + document.getElementById("calendar-task-details-category").textContent = + categories.join(", "); + } + + let taskStartDate = item[cal.dtz.startDateProp(item)]; + if (taskStartDate) { + document.getElementById("task-start-date").textContent = + cal.dtz.getStringForDateTime(taskStartDate); + } + document.getElementById("task-start-row").toggleAttribute("hidden", !taskStartDate); + + let taskDueDate = item[cal.dtz.endDateProp(item)]; + if (taskDueDate) { + document.getElementById("task-due-date").textContent = + cal.dtz.getStringForDateTime(taskDueDate); + } + document.getElementById("task-due-row").toggleAttribute("hidden", !taskDueDate); + + let parentItem = item; + if (parentItem.parentItem != parentItem) { + // XXXdbo Didn't we want to get rid of these checks? + parentItem = parentItem.parentItem; + } + let recurrenceInfo = parentItem.recurrenceInfo; + let recurStart = parentItem.recurrenceStartDate; + if ( + !document + .getElementById("calendar-task-details-repeat-row") + .toggleAttribute("hidden", !recurrenceInfo || !recurStart) + ) { + let kDefaultTimezone = cal.dtz.defaultTimezone; + let startDate = recurStart.getInTimezone(kDefaultTimezone); + let endDate = item.dueDate ? item.dueDate.getInTimezone(kDefaultTimezone) : null; + let detailsString = recurrenceRule2String( + recurrenceInfo, + startDate, + endDate, + startDate.isDate + ); + if (detailsString) { + let rpv = document.getElementById("calendar-task-details-repeat"); + rpv.textContent = detailsString.split("\n").join(" "); + } + } + let iframe = document.getElementById("calendar-task-details-description"); + let docFragment = cal.view.textToHtmlDocumentFragment( + item.descriptionText, + iframe.contentDocument, + item.descriptionHTML + ); + + // Make any links open in the user's default browser, not in Thunderbird. + for (let anchor of docFragment.querySelectorAll("a")) { + anchor.addEventListener("click", function (event) { + event.preventDefault(); + if (event.isTrusted) { + launchBrowser(anchor.getAttribute("href"), event); + } + }); + } + iframe.contentDocument.body.replaceChildren(docFragment); + let link = iframe.contentDocument.createElement("link"); + link.rel = "stylesheet"; + link.href = "chrome://messenger/skin/shared/editorContent.css"; + iframe.contentDocument.head.replaceChildren(link); + let attachmentRows = document.getElementById("calendar-task-details-attachment-rows"); + while (attachmentRows.lastChild) { + attachmentRows.lastChild.remove(); + } + let attachments = item.getAttachments(); + if (displayElement("calendar-task-details-attachment-row", attachments.length > 0)) { + displayElement("calendar-task-details-attachment-rows", true); + for (let attachment of attachments) { + let url = attachment.calIAttachment.uri.spec; + let urlLabel = document.createXULElement("label"); + urlLabel.setAttribute("class", "text-link"); + urlLabel.setAttribute("value", url); + urlLabel.setAttribute("tooltiptext", url); + urlLabel.setAttribute("crop", "end"); + urlLabel.setAttribute("onclick", "if (event.button != 2) launchBrowser(this.value);"); + urlLabel.setAttribute("context", "taskview-link-context-menu"); + attachmentRows.appendChild(urlLabel); + } + } + } + }, + + loadCategories() { + let categoryPopup = document.getElementById("task-actions-category-popup"); + let item = document.getElementById("calendar-task-tree").currentTask; + + let itemCategories = item.getCategories(); + let categoryList = cal.category.fromPrefs(); + for (let cat of itemCategories) { + if (!categoryList.includes(cat)) { + categoryList.push(cat); + } + } + cal.l10n.sortArrayByLocaleCollator(categoryList); + + let maxCount = item.calendar.getProperty("capabilities.categories.maxCount"); + + while (categoryPopup.childElementCount > 2) { + categoryPopup.lastChild.remove(); + } + if (maxCount == 1) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("class", "menuitem-iconic"); + menuitem.setAttribute("label", cal.l10n.getCalString("None")); + menuitem.setAttribute("type", "radio"); + if (itemCategories.length === 0) { + menuitem.setAttribute("checked", "true"); + } + categoryPopup.appendChild(menuitem); + } + for (let cat of categoryList) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("class", "menuitem-iconic calendar-category"); + menuitem.setAttribute("label", cat); + menuitem.setAttribute("value", cat); + menuitem.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio"); + if (itemCategories.includes(cat)) { + menuitem.setAttribute("checked", "true"); + } + categoryPopup.appendChild(menuitem); + } + }, + + saveCategories(event) { + let categoryPopup = document.getElementById("task-actions-category-popup"); + let item = document.getElementById("calendar-task-tree").currentTask; + + let oldCategories = item.getCategories(); + let categories = Array.from( + categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"), + menuitem => menuitem.value + ); + let unchanged = oldCategories.length == categories.length; + for (let i = 0; unchanged && i < categories.length; i++) { + unchanged = oldCategories[i] == categories[i]; + } + + if (!unchanged) { + let newItem = item.clone(); + newItem.setCategories(categories); + doTransaction("modify", newItem, newItem.calendar, item, null); + return false; + } + + return true; + }, + + categoryTextboxKeypress(event) { + let category = event.target.value; + let categoryPopup = document.getElementById("task-actions-category-popup"); + + switch (event.key) { + case " ": { + // The menu popup seems to eat this keypress. + let start = event.target.selectionStart; + event.target.value = + category.substring(0, start) + " " + category.substring(event.target.selectionEnd); + event.target.selectionStart = event.target.selectionEnd = start + 1; + return; + } + case "Tab": + case "ArrowDown": + case "ArrowUp": { + event.target.blur(); + event.preventDefault(); + + let key = event.key == "ArrowUp" ? "ArrowUp" : "ArrowDown"; + categoryPopup.dispatchEvent(new KeyboardEvent("keydown", { key })); + categoryPopup.dispatchEvent(new KeyboardEvent("keyup", { key })); + return; + } + case "Escape": + if (category) { + event.target.value = ""; + } else { + categoryPopup.hidePopup(); + } + event.preventDefault(); + return; + case "Enter": + category = category.trim(); + if (category != "") { + break; + } + return; + default: { + return; + } + } + + event.preventDefault(); + + let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category"); + let categories = Array.from(categoryList, cat => cat.getAttribute("value")); + + let modified = false; + let newIndex = categories.indexOf(category); + if (newIndex > -1) { + if (categoryList[newIndex].getAttribute("checked") != "true") { + categoryList[newIndex].setAttribute("checked", "true"); + modified = true; + } + } else { + const localeCollator = new Intl.Collator(); + let compare = localeCollator.compare; + newIndex = cal.data.binaryInsert(categories, category, compare, true); + + let item = document.getElementById("calendar-task-tree").currentTask; + let maxCount = item.calendar.getProperty("capabilities.categories.maxCount"); + + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("class", "menuitem-iconic calendar-category"); + menuitem.setAttribute("label", category); + menuitem.setAttribute("value", category); + menuitem.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio"); + menuitem.setAttribute("checked", true); + categoryPopup.insertBefore(menuitem, categoryList[newIndex]); + + modified = true; + } + + if (modified) { + categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"); + categories = Array.from(categoryList, cat => cat.getAttribute("value")); + + let item = document.getElementById("calendar-task-tree").currentTask; + let newItem = item.clone(); + newItem.setCategories(categories); + doTransaction("modify", newItem, newItem.calendar, item, null); + } + + event.target.value = ""; + }, +}; + +/** + * Updates the currently applied filter for the task view and refreshes the task + * tree. + * + * @param {string} [filter] - The filter name to set. + */ +function taskViewUpdate(filter) { + if (!filter) { + let taskFilterGroup = document.getElementById("task-tree-filtergroup"); + filter = taskFilterGroup.value || "all"; + } + + let tree = document.getElementById("calendar-task-tree"); + let oldFilter = tree.getAttribute("filterValue"); + if (filter != oldFilter) { + tree.setAttribute("filterValue", filter); + document + .querySelectorAll( + `menuitem[command="calendar_task_filter_command"][type="radio"], + toolbarbutton[command="calendar_task_filter_command"][type="radio"]` + ) + .forEach(item => { + if (item.getAttribute("value") == filter) { + item.setAttribute("checked", "true"); + } else { + item.removeAttribute("checked"); + } + }); + let radio = document.querySelector( + `radio[command="calendar_task_filter_command"][value="${filter}"]` + ); + if (radio) { + radio.radioGroup.selectedItem = radio; + } + } + tree.updateFilter(filter); +} + +/** + * Prepares a dialog to send an email to the organizer of the currently selected + * task in the task view. + * + * XXX We already have a function with this name in the event dialog. Either + * consolidate or make name more clear. + */ +function sendMailToOrganizer() { + let item = document.getElementById("calendar-task-tree").currentTask; + if (item != null) { + 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); + } +} + +// Install event listeners for the display deck change and connect task tree to filter field +function taskViewOnLoad() { + let calendarDisplayBox = document.getElementById("calendarDisplayBox"); + let tree = document.getElementById("calendar-task-tree"); + + if (calendarDisplayBox && tree) { + tree.textFilterField = "task-text-filter-field"; + + // setup the platform-dependent placeholder for the text filter field + let textFilter = document.getElementById("task-text-filter-field"); + if (textFilter) { + let base = textFilter.getAttribute("emptytextbase"); + let keyLabel = textFilter.getAttribute( + AppConstants.platform == "macosx" ? "keyLabelMac" : "keyLabelNonMac" + ); + + textFilter.setAttribute("placeholder", base.replace("#1", keyLabel)); + textFilter.value = ""; + } + taskViewUpdate(); + } + + // Setup customizeDone handler for the task action toolbox. + let toolbox = document.getElementById("task-actions-toolbox"); + toolbox.customizeDone = function (aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeTaskActionsToolbar"); + }; + + Services.obs.notifyObservers(window, "calendar-taskview-startup-done"); +} + +/** + * Copy the value of the given link node to the clipboard + * + * @param linkNode The node containing the value to copy to the clipboard + */ +function taskViewCopyLink(linkNode) { + if (linkNode) { + let linkAddress = linkNode.value; + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); + clipboard.copyString(linkAddress); + } +} diff --git a/comm/calendar/base/content/calendar-today-pane.inc.xhtml b/comm/calendar/base/content/calendar-today-pane.inc.xhtml new file mode 100644 index 0000000000..70df2ed912 --- /dev/null +++ b/comm/calendar/base/content/calendar-today-pane.inc.xhtml @@ -0,0 +1,179 @@ +# 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/. + +<splitter id="today-splitter" + class="calendar-sidebar-splitter hide-when-calendar-deactivated" + collapse="after" + resizebefore="closest" + state="collapsed" + oncommand="TodayPane.onCommandTodaySplitter();"> +</splitter> +<calendar-modevbox id="today-pane-panel" + class="hide-when-calendar-deactivated" + mode="mail,calendar,task,chat,calendarEvent,calendarTask" + modewidths="200,200,200,200,200,200" + refcontrol="calendar_toggle_todaypane_command" + persist="modewidths"> + <box class="sidebar-header" align="center"> + <label id="today-pane-header"/> + <spacer flex="1"/> + <calendar-modebox mode="mail,calendar,chat,calendarEvent,calendarTask"> + <toolbarbutton id="today-pane-cycler-prev" + dir="prev" + class="today-pane-cycler" + oncommand="TodayPane.cyclePaneView(-1);"/> + <toolbarbutton id="today-pane-cycler-next" + dir="next" + class="today-pane-cycler" + oncommand="TodayPane.cyclePaneView(1);"/> + </calendar-modebox> + <spacer id="buttonspacer"/> + <toolbarbutton id="today-closer" class="today-closebutton close-icon" + oncommand="document.getElementById('today-pane-panel').setVisible(false, true, true); + TodayPane.updateDisplay(); + TodayPane.updateSplitterState();"/> + </box> + <vbox flex="1"> + <calendar-modevbox id="agenda-panel" + flex="1" + mode="mail,calendar,task,chat,calendarEvent,calendarTask" + collapsedinmodes="calendar" + persist="collapsed height collapsedinmodes"> + <calendar-modebox id="today-none-box" + mode="mail,calendar,task,chat,calendarEvent,calendarTask" + collapsedinmodes="mail,calendar,task,chat,calendarEvent,calendarTask" + refcontrol="calTodayPaneDisplayNone" + persist="collapsedinmodes"/> + <calendar-modebox id="today-minimonth-box" + pack="center" + class="today-subpane" + mode="mail,calendar,task,chat,calendarEvent,calendarTask" + collapsedinmodes="mail,calendar,task,chat,calendarEvent,calendarTask" + refcontrol="calTodayPaneDisplayMinimonth" + persist="collapsedinmodes"> + <calendar-minimonth id="today-minimonth" + onchange="TodayPane.setDaywithjsDate(this.value);"/> + </calendar-modebox> + <calendar-modebox id="mini-day-box" + mode="mail,calendar,task,chat,calendarEvent,calendarTask" + class="today-subpane" + refcontrol="calTodayPaneDisplayMiniday" + collapsedinmodes="" + persist="collapsedinmodes" + onwheel="TodayPane.advance(event.detail > 0 ? 1 : -1);"> + <hbox id="mini-day-image" flex="1"> + <stack id="dateContainer"> + <hbox pack="center" + align="center"> + <label id="datevalue-label" class="dateValue" + ondblclick="TodayPane.onDoubleClick(event);" + onmousedown="TodayPane.onMousedown(event);"/> + </hbox> + <hbox id="dragCenter-image-container" flex="1" pack="center" align="center"> + <html:img id="dragCenter-image" + src="chrome://calendar/skin/shared/widgets/drag-center.svg" + alt="" + hidden="true" /> + </hbox> + </stack> + <vbox flex="1"> + <hbox pack="center"> + <label id="weekdayNameLabel" + ondblclick="TodayPane.onDoubleClick(event);" + flex="1"/> + <hbox pack="end"> + <toolbarbutton id="previous-day-button" + class="miniday-nav-buttons" + tooltiptext="&onedaybackward.tooltip;" + onmousedown="TodayPane.onMousedown(event, -1);" + dir="-1"/> + <toolbarbutton id="today-button" + class="miniday-nav-buttons" + tooltiptext="&showToday.tooltip;" + oncommand="TodayPane.setDay(cal.dtz.now());"/> + <toolbarbutton id="next-day-button" + class="miniday-nav-buttons" + tooltiptext="&onedayforward.tooltip;" + onmousedown="TodayPane.onMousedown(event, 1);" + dir="1"/> + </hbox> + </hbox> + <hbox pack="start"> + <label id="monthNameContainer" class="monthlabel" + ondblclick="TodayPane.onDoubleClick(event);"/> + <label id="currentWeek-label" class="monthlabel" + ondblclick="TodayPane.onDoubleClick(event);"/> + <spacer flex="1"/> + </hbox> + </vbox> + <toolbarbutton id="miniday-dropdown-button" + tooltiptext="&showselectedday.tooltip;" + type="menu" + wantdropmarker="true"> + <panel id="miniday-month-panel" position="after_end" + onpopupshown="this.firstElementChild.focusCalendar();"> + <calendar-minimonth id="miniday-dropdown-minimonth" + flex="1" + onchange="TodayPane.setDaywithjsDate(this.value); + document.getElementById('miniday-month-panel').hidePopup();"/> + </panel> + </toolbarbutton> + </hbox> + </calendar-modebox> + <vbox id="agenda-container" tooltip="itemTooltip"> + <hbox id="agenda-toolbar" class="themeable-brighttext"> + <toolbarbutton id="todaypane-new-event-button" + mode="mail" + iconsize="small" + orient="horizontal" + label="&calendar.newevent.button.label;" + tooltiptext="&calendar.newevent.button.tooltip;" + command="calendar_new_event_todaypane_command"/> + </hbox> + <html:ul is="agenda-list" id="agenda" role="listbox"></html:ul> + <template id="agenda-listitem" xmlns="http://www.w3.org/1999/xhtml"> + <div class="agenda-date-header"></div> + <div class="agenda-listitem-details"> + <div class="agenda-listitem-calendar"></div> + <div class="agenda-listitem-details-inner"> + <time class="agenda-listitem-time"></time> + <span class="agenda-listitem-title"></span> + <span class="agenda-listitem-relative"></span> + </div> + <img class="agenda-listitem-overlap" /> + </div> + </template> + </vbox> + </calendar-modevbox> + <splitter id="today-pane-splitter" persist="hidden" orient="vertical"/> + <calendar-modevbox id="todo-tab-panel" + mode="mail,calendar,chat,calendarEvent,calendarTask" + collapsedinmodes="mail,task,chat,calendarEvent,calendarTask" + persist="height collapsedinmodes" + ondragover="calendarTaskButtonDNDObserver.onDragOver(event);" + ondrop="calendarTaskButtonDNDObserver.onDrop(event);"> + <box id="show-completed-checkbox-box" align="center"> + <checkbox id="show-completed-checkbox" + label="&calendar.unifinder.showcompletedtodos.label;" + flex="1" + crop="end" + oncommand="TodayPane.updateCalendarToDoUnifinder()" + persist="checked" + autocheck="false"/> + </box> + <vbox id="calendar-task-tree-detail" flex="1"> + <tree is="calendar-task-tree-todaypane" id="unifinder-todo-tree" + flex="1" + visible-columns="completed priority title" + persist="visible-columns ordinals widths sort-active sort-direction filterValue" + context="taskitem-context-menu"/> + <html:input id="unifinder-task-edit-field" + class="task-edit-field themeableSearchBox" + onfocus="taskEdit.onFocus(event)" + onblur="taskEdit.onBlur(event)" + onkeypress="taskEdit.onKeyPress(event)"/> + </vbox> + </calendar-modevbox> + </vbox> +</calendar-modevbox> diff --git a/comm/calendar/base/content/calendar-ui-utils.js b/comm/calendar/base/content/calendar-ui-utils.js new file mode 100644 index 0000000000..11d92ab6da --- /dev/null +++ b/comm/calendar/base/content/calendar-ui-utils.js @@ -0,0 +1,596 @@ +/* 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 disableElementWithLock, + * enableElementWithLock, + * appendCalendarItems, checkRadioControl, + * checkRadioControlAppmenu, + * updateUnitLabelPlural, updateMenuLabelsPlural, + * getOptimalMinimumWidth, getOptimalMinimumHeight, + * setupAttendanceMenu + */ + +/* import-globals-from ../../../mail/base/content/globalOverlay.js */ +/* import-globals-from ../../../mail/base/content/utilityOverlay.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +/** + * This function unconditionally disables the element for + * which the id has been passed as argument. Furthermore, it + * remembers who was responsible for this action by using + * the given key (lockId). In case the control should be + * enabled again the lock gets removed, but the control only + * gets enabled if *all* possibly held locks have been removed. + * + * @param elementId The element ID of the element to disable. + * @param lockId The ID of the lock to set. + */ +function disableElementWithLock(elementId, lockId) { + // unconditionally disable the element. + document.getElementById(elementId).setAttribute("disabled", "true"); + + // remember that this element has been locked with + // the key passed as argument. we keep a primitive + // form of ref-count in the attribute 'lock'. + let element = document.getElementById(elementId); + if (element) { + if (!element.hasAttribute(lockId)) { + element.setAttribute(lockId, "true"); + let n = parseInt(element.getAttribute("lock") || 0, 10); + element.setAttribute("lock", n + 1); + } + } +} + +/** + * This function is intended to be used in tandem with the + * above defined function 'disableElementWithLock()'. + * See the respective comment for further details. + * + * @see disableElementWithLock + * @param elementId The element ID of the element to enable. + * @param lockId The ID of the lock to set. + */ +function enableElementWithLock(elementId, lockId) { + let element = document.getElementById(elementId); + if (!element) { + dump("unable to find " + elementId + "\n"); + return; + } + + if (element.hasAttribute(lockId)) { + element.removeAttribute(lockId); + let n = parseInt(element.getAttribute("lock") || 0, 10) - 1; + if (n > 0) { + element.setAttribute("lock", n); + } else { + element.removeAttribute("lock"); + } + if (n <= 0) { + element.removeAttribute("disabled"); + } + } +} + +/** + * Sorts a sorted array of calendars by pref |calendar.list.sortOrder|. + * Repairs that pref if dangling entries exist. + * + * @param calendars An array of calendars to sort. + */ +function sortCalendarArray(calendars) { + let ret = calendars.concat([]); + let sortOrder = {}; + let sortOrderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "").split(" "); + for (let i = 0; i < sortOrderPref.length; ++i) { + sortOrder[sortOrderPref[i]] = i; + } + function sortFunc(cal1, cal2) { + let orderIdx1 = sortOrder[cal1.id] || -1; + let orderIdx2 = sortOrder[cal2.id] || -1; + if (orderIdx1 < orderIdx2) { + return -1; + } + if (orderIdx1 > orderIdx2) { + return 1; + } + return 0; + } + ret.sort(sortFunc); + + // check and repair pref when an array of all calendars has been passed: + let sortOrderString = Services.prefs.getStringPref("calendar.list.sortOrder", ""); + let wantedOrderString = ret.map(calendar => calendar.id).join(" "); + if (wantedOrderString != sortOrderString && cal.manager.getCalendars().length == ret.length) { + Services.prefs.setStringPref("calendar.list.sortOrder", wantedOrderString); + } + + return ret; +} + +/** + * Fills up a menu - either a menupopup or a menulist - with menuitems that refer + * to calendars. + * + * @param aItem The event or task + * @param aCalendarMenuParent The direct parent of the menuitems - either a + * menupopup or a menulist + * @param aCalendarToUse The default-calendar + * @param aOnCommand A string that is applied to the "oncommand" + * attribute of each menuitem + * @returns The index of the calendar that matches the + * default-calendar. By default 0 is returned. + */ +function appendCalendarItems(aItem, aCalendarMenuParent, aCalendarToUse, aOnCommand) { + let calendarToUse = aCalendarToUse || aItem.calendar; + let calendars = sortCalendarArray(cal.manager.getCalendars()); + let indexToSelect = 0; + let index = -1; + for (let i = 0; i < calendars.length; ++i) { + let calendar = calendars[i]; + if ( + calendar.id == calendarToUse.id || + (calendar && + cal.acl.isCalendarWritable(calendar) && + (cal.acl.userCanAddItemsToCalendar(calendar) || + (calendar == aItem.calendar && cal.acl.userCanModifyItem(aItem))) && + cal.item.isItemSupported(aItem, calendar)) + ) { + let menuitem = addMenuItem(aCalendarMenuParent, calendar.name, calendar.name); + menuitem.calendar = calendar; + index++; + if (aOnCommand) { + menuitem.setAttribute("oncommand", aOnCommand); + } + if (aCalendarMenuParent.localName == "menupopup") { + menuitem.setAttribute("type", "checkbox"); + } + if (calendarToUse && calendarToUse.id == calendar.id) { + indexToSelect = index; + } + let cssSafeId = cal.view.formatStringForCSSRule(calendar.id); + menuitem.style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`); + menuitem.classList.add("menuitem-iconic"); + } + } + return indexToSelect; +} + +/** + * Helper function to add a menuitem to a menulist or similar. + * + * @param aParent The XUL node to add the menuitem to. + * @param aLabel The label string of the menuitem. + * @param aValue The value attribute of the menuitem. + * @param aCommand The oncommand attribute of the menuitem. + * @returns The newly created menuitem + */ +function addMenuItem(aParent, aLabel, aValue, aCommand) { + let item = null; + if (aParent.localName == "menupopup") { + item = document.createXULElement("menuitem"); + item.setAttribute("label", aLabel); + if (aValue) { + item.setAttribute("value", aValue); + } + if (aCommand) { + item.command = aCommand; + } + aParent.appendChild(item); + } else if (aParent.localName == "menulist") { + item = aParent.appendItem(aLabel, aValue); + } + return item; +} + +/** + * Gets the correct plural form of a given unit. + * + * @param aLength The number to use to determine the plural form + * @param aUnit The unit to find the plural form of + * @param aIncludeLength (optional) If true, the length will be included in the + * result. If false, only the pluralized unit is returned. + * @returns A string containing the pluralized version of the unit + */ +function unitPluralForm(aLength, aUnit, aIncludeLength = true) { + let unitProp = + { + minutes: "unitMinutes", + hours: "unitHours", + days: "unitDays", + weeks: "unitWeeks", + }[aUnit] || "unitMinutes"; + + return PluralForm.get(aLength, cal.l10n.getCalString(unitProp)) + .replace("#1", aIncludeLength ? aLength : "") + .trim(); +} + +/** + * Update the given unit label to show the correct plural form. + * + * @param aLengthFieldId The ID of the element containing the number + * @param aLabelId The ID of the label to update. + * @param aUnit The unit to use for the label. + */ +function updateUnitLabelPlural(aLengthFieldId, aLabelId, aUnit) { + let label = document.getElementById(aLabelId); + let length = Number(document.getElementById(aLengthFieldId).value); + + label.value = unitPluralForm(length, aUnit, false); +} + +/** + * Update the given menu to show the correct plural form in the list. + * + * @param aLengthFieldId The ID of the element containing the number + * @param aMenuId The menu to update labels in. + */ +function updateMenuLabelsPlural(aLengthFieldId, aMenuId) { + let menu = document.getElementById(aMenuId); + let length = Number(document.getElementById(aLengthFieldId).value); + + // update the menu items + let items = menu.getElementsByTagName("menuitem"); + for (let menuItem of items) { + menuItem.label = unitPluralForm(length, menuItem.value, false); + } + + // force the menu selection to redraw + let saveSelectedIndex = menu.selectedIndex; + menu.selectedIndex = -1; + menu.selectedIndex = saveSelectedIndex; +} + +/** + * A helper function to calculate and add up certain css-values of a box. + * It is required, that all css values can be converted to integers + * see also + * http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSview-getComputedStyle + * + * @param aXULElement The xul element to be inspected. + * @param aStyleProps The css style properties for which values are to be retrieved + * e.g. 'font-size', 'min-width" etc. + * @returns An integer value denoting the optimal minimum width + */ +function getSummarizedStyleValues(aXULElement, aStyleProps) { + let retValue = 0; + let cssStyleDeclares = document.defaultView.getComputedStyle(aXULElement); + for (let prop of aStyleProps) { + retValue += parseInt(cssStyleDeclares.getPropertyValue(prop), 10); + } + return retValue; +} + +/** + * Calculates the optimal minimum width based on the set css style-rules + * by considering the css rules for the min-width, padding, border, margin + * and border of the box. + * + * @param aXULElement The xul element to be inspected. + * @returns An integer value denoting the optimal minimum width + */ +function getOptimalMinimumWidth(aXULElement) { + return getSummarizedStyleValues(aXULElement, [ + "min-width", + "padding-left", + "padding-right", + "margin-left", + "margin-top", + "border-left-width", + "border-right-width", + ]); +} + +/** + * Calculates the optimal minimum height based on the set css style-rules + * by considering the css rules for the font-size, padding, border, margin + * and border of the box. In its current state the line-height is considered + * by assuming that it's size is about one third of the size of the font-size + * + * @param aXULElement The xul-element to be inspected. + * @returns An integer value denoting the optimal minimum height + */ +function getOptimalMinimumHeight(aXULElement) { + // the following line of code presumes that the line-height is set to "normal" + // which is supposed to be a "reasonable distance" between the lines + let firstEntity = parseInt(1.35 * getSummarizedStyleValues(aXULElement, ["font-size"]), 10); + let secondEntity = getSummarizedStyleValues(aXULElement, [ + "padding-bottom", + "padding-top", + "margin-bottom", + "margin-top", + "border-bottom-width", + "border-top-width", + ]); + return firstEntity + secondEntity; +} + +/** + * Sets up the attendance context menu, based on the given items + * + * @param {Node} aMenu The context menu item containing the required + * menu or menuitem elements + * @param {Array} aItems - An array of the selected calEvent or calTodo + * items to display the context menu for + */ +function setupAttendanceMenu(aMenu, aItems) { + /** + * For menu items in scope, a check mark will be annotated corresponding to + * the partstat and removed for all others + * + * The user always selected single items or occurrences of series but never + * the master event of a series. That said, for the items in aItems, one of + * following scenarios applies: + * + * A. one none-recurring item which have attendees + * B. multiple none-recurring items which have attendees + * C. one occurrence of a series which has attendees + * D. multiple occurrences of the same series which have attendees + * E. multiple occurrences of different series which have attendees + * F. mixture of non-recurring and occurrences of one or more series which + * have attendees + * G. any mixture including a single item or an occurrence which doesn't + * have any attendees + * + * For scenarios A and B, the user will be prompted with a single set of + * available partstats and the according options to change it. + * + * For C, D and E the user was prompted with a set of partstats for both, + * the occurrence and the master. In case of E, no partstat information + * was annotated. + * + * For F, only a single set of available partstat options was prompted + * without annotating any partstat. + * + * For G, no context menu would be displayed, so we don't need to deal with + * that scenario here. + * + * Now the following matrix applies to take action of the users choice for + * the relevant participant (for columns, see explanation below): + * +---+------------------+-------------+--------+-----------------+ + * | # | SELECTED | DISPLAYED | STATUS | MENU ACTION | + * | | CAL ITEMS | SUBMENU | PRESET | APPLIES ON | + * +---+------------------+-------------+--------+-----------------+ + * | | | this-occ* | yes | selected item | + * | A | one +-------------+--------+-----------------+ + * | | single item | all-occ | n/a | + * | | | | menu not displayed | + * +---+------------------+-------------+--------+-----------------+ + * | | | this-occ* | no | selected items | + * | B | multiple +-------------+--------+-----------------+ + * | | single items | all-occ | n/a | + * | | | | menu not displayed | + * +---+------------------+-------------+--------+-----------------+ + * | | | this-occ | yes | sel. | + * | | one | | | occurrences | + * | C | occurrence +-------------+--------+-----------------+ + * | | of a master | all-occ | yes | master of sel. | + * | | | | | occurrence | + * +---+------------------+-------------+--------+-----------------+ + * | | | this-occ | no | sel. | + * | | multiple | | | occurrences | + * | D | occurrences +-------------+--------+-----------------+ + * | | of one master | all-occ | yes | master of sel. | + * | | | | | occurrences | + * +---+------------------+-------------+--------+-----------------+ + * | | | this-occ | no | sel. | + * | | multiple | | | occurrences | + * | E | occurrences of +-------------+--------+-----------------+ + * | | multiple masters | all-occ | no | masters of sel. | + * | | | | | occurrences | + * +---+------------------+-------------+--------+-----------------+ + * | | multiple single | this-occ* | no | selected items | + * | | and occurrences | | | and occurrences | + * | F | of multiple +-------------+--------+-----------------+ + * | | masters | all-occ | n/a | + * | | | | menu not displayed | + * +---+------------------+-------------+--------------------------+ + * | | any combination | | + * | G | including at | n/a | + * | | least one items | no attendance menu displayed | + * | | or occurrence | | + * | | w/o attendees | | + * +---+------------------+----------------------------------------+ + * + * #: scenario as described above + * SELECTED CAL ITEMS: item types the user selected to prompt the context + * menu for + * DISPLAYED SUBMENU: the subbmenu displayed + * STATUS PRESET: whether or not a partstat is annotated to the menu + * items, if the respective submenu is displayed + * MENU ACTION APPLIES ON: the cal item, the respective partstat should be + * applied on, if the respective submenu is + * displayed + * + * this-occ* means that in this cases the submenu label is not displayed - + * additionally, if status is not preset the menu item for 'NEEDS-ACTIONS' + * will not be displayed, if the status is already different (consistent + * how we deal with that case at other places) + * + * @param {NodeList} aMenuItems A list of DOM nodes + * @param {string} aScope Either 'this-occurrence' or + * 'all-occurrences' + * @param {string} aPartStat A valid participation status + * as per RfC 5545 + */ + function checkMenuItem(aMenuItems, aScope, aPartStat) { + let toRemove = []; + let toAdd = []; + for (let item of aMenuItems) { + if (item.getAttribute("scope") == aScope && item.nodeName != "label") { + if (item.getAttribute("value") == aPartStat) { + switch (item.nodeName) { + case "menu": { + // Since menu elements cannot have checkmarks, + // we add a menuitem for this partstat and hide + // the menu element instead + let checkedId = "checked-" + item.getAttribute("id"); + if (!document.getElementById(checkedId)) { + let checked = item.ownerDocument.createXULElement("menuitem"); + checked.setAttribute("type", "checkbox"); + checked.setAttribute("checked", "true"); + checked.setAttribute("label", item.getAttribute("label")); + checked.setAttribute("value", item.getAttribute("value")); + checked.setAttribute("scope", item.getAttribute("scope")); + checked.setAttribute("id", checkedId); + item.setAttribute("hidden", "true"); + toAdd.push([item, checked]); + } + break; + } + case "menuitem": { + item.removeAttribute("hidden"); + item.setAttribute("checked", "true"); + break; + } + } + } else if (item.nodeName == "menuitem") { + if (item.getAttribute("id").startsWith("checked-")) { + // we inserted a menuitem before for this partstat, so + // we revert that now + let menu = document.getElementById(item.getAttribute("id").substr(8)); + menu.removeAttribute("hidden"); + toRemove.push(item); + } else { + item.removeAttribute("checked"); + } + } else if (item.nodeName == "menu") { + item.removeAttribute("hidden"); + } + } + } + for (let [item, checked] of toAdd) { + item.before(checked); + } + for (let item of toRemove) { + item.remove(); + } + } + + /** + * Hides the items from the provided node list. If a partstat is provided, + * only the matching item will be hidden + * + * @param {NodeList} aMenuItems A list of DOM nodes + * @param {string} aPartStat [optional] A valid participation + * status as per RfC 5545 + */ + function hideItems(aNodeList, aPartStat = null) { + for (let item of aNodeList) { + if (aPartStat && aPartStat != item.getAttribute("value")) { + continue; + } + item.setAttribute("hidden", "true"); + } + } + + /** + * Provides the user's participation status for a provided item + * + * @param {calEvent|calTodo} aItem The calendar item to inspect + * @returns {?string} The participation status string + * as per RfC 5545 or null if no + * participant was detected + */ + function getInvitationStatus(aItem) { + let party = null; + if (cal.itip.isInvitation(aItem)) { + party = cal.itip.getInvitedAttendee(aItem); + } else if (aItem.organizer && aItem.getAttendees().length) { + let calOrgId = aItem.calendar.getProperty("organizerId"); + if (calOrgId && calOrgId.toLowerCase() == aItem.organizer.id.toLowerCase()) { + party = aItem.organizer; + } + } + return party && (party.participationStatus || "NEEDS-ACTION"); + } + + goUpdateCommand("calendar_attendance_command"); + + let singleMenuItems = aMenu.getElementsByAttribute("scope", "this-occurrence"); + let seriesMenuItems = aMenu.getElementsByAttribute("scope", "all-occurrences"); + let labels = aMenu.getElementsByAttribute("class", "calendar-context-heading-label"); + + if (aItems.length == 1) { + // we offer options for both single and recurring items. In case of the + // latter and the item is an occurrence, we offer status information and + // actions for both, the occurrence and the series + let thisPartStat = getInvitationStatus(aItems[0]); + + if (aItems[0].recurrenceId) { + // we get the partstat - if this is null, no participant could + // be identified, so we bail out + let seriesPartStat = getInvitationStatus(aItems[0].parentItem); + if (seriesPartStat) { + // let's make sure we display the labels to distinguish series + // and occurrence + for (let label of labels) { + label.removeAttribute("hidden"); + } + + checkMenuItem(seriesMenuItems, "all-occurrences", seriesPartStat); + + if (seriesPartStat != "NEEDS-ACTION") { + hideItems(seriesMenuItems, "NEEDS-ACTION"); + } + // until we support actively delegating items, we also only + // display this status if it is already set + if (seriesPartStat != "DELEGATED") { + hideItems(seriesMenuItems, "DELEGATED"); + } + } else { + hideItems(seriesMenuItems); + } + } else { + // here we don't need the all-occurrences scope, so let's hide all + // labels and related menu items + hideItems(labels); + hideItems(seriesMenuItems); + } + + // also for the single occurrence we check whether there's a partstat + // available and bail out otherwise - we also make sure to not display + // the NEEDS-ACTION menu item if the current status is already different + if (thisPartStat) { + checkMenuItem(singleMenuItems, "this-occurrence", thisPartStat); + if (thisPartStat != "NEEDS-ACTION") { + hideItems(singleMenuItems, "NEEDS-ACTION"); + } + // until we support actively delegating items, we also only display + // this status if it is already set (by another client or the server) + if (thisPartStat != "DELEGATED") { + hideItems(singleMenuItems, "DELEGATED"); + } + } else { + // in this case, we hide the entire attendance menu + aMenu.setAttribute("hidden", "true"); + } + } else if (aItems.length > 1) { + // the user displayed a context menu for multiple selected items. + // The selection might comprise single and recurring events, so we need + // to deal here with any combination thereof. To do so, we don't display + // a partstat control for the entire series but only for the selected + // occurrences. As we have a potential mixture of partstat, we also don't + // display the current status and no action towards NEEDS-ACTIONS. + hideItems(labels); + hideItems(seriesMenuItems); + hideItems(singleMenuItems, "NEEDS-ACTION"); + } else { + // there seems to be no item passed in, so we don't display anything + hideItems(labels); + hideItems(seriesMenuItems); + hideItems(singleMenuItems); + } +} + +/** + * Open the calendar settings to define the weekdays. + */ +function showCalendarWeekPreferences() { + openPreferencesTab("paneCalendar", "calendarPaneCategory"); +} diff --git a/comm/calendar/base/content/calendar-unifinder.js b/comm/calendar/base/content/calendar-unifinder.js new file mode 100644 index 0000000000..00839540e3 --- /dev/null +++ b/comm/calendar/base/content/calendar-unifinder.js @@ -0,0 +1,988 @@ +/* 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 calFilter, calFilter, getViewBox, openEventDialogForViewing, + modifyEventWithDialog, createEventWithDialog, currentView, + calendarController, editSelectedEvents, deleteSelectedEvents, + calendarUpdateDeleteCommand, getEventStatusString, goToggleToolbar */ + +/* exported gCalendarEventTreeClicked, unifinderDoubleClick, unifinderKeyPress, + * focusSearch, ensureUnifinderLoaded, toggleUnifinder + */ + +/** + * U N I F I N D E R + * + * This is a hacked in interface to the unifinder. We will need to + * improve this to make it usable in general. + * + * NOTE: Including this file will cause a load handler to be added to the + * window. + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +// Set this to true when the calendar event tree is clicked to allow for +// multiple selection +var gCalendarEventTreeClicked = false; + +// Store the start and enddate, because the providers can't be trusted when +// dealing with all-day events. So we need to filter later. See bug 306157 + +var gUnifinderNeedsRefresh = true; + +/** + * Checks if the unifinder is hidden + * + * @returns Returns true if the unifinder is hidden. + */ +function isUnifinderHidden() { + let tabmail = document.getElementById("tabmail"); + return ( + tabmail.currentTabInfo?.mode.type != "calendar" || + document.getElementById("bottom-events-box").hidden + ); +} + +/** + * Returns the current filter applied to the unifinder. + * + * @returns The string name of the applied filter. + */ +function getCurrentUnifinderFilter() { + return document.getElementById("event-filter-menulist").selectedItem.value; +} + +/** + * Observer for the calendar event data source. This keeps the unifinder + * display up to date when the calendar event data is changed + * + * @see calIObserver + * @see calICompositeObserver + */ +var unifinderObserver = { + QueryInterface: ChromeUtils.generateQI(["calICompositeObserver", "nsIObserver", "calIObserver"]), + + // calIObserver: + onStartBatch() { + gUnifinderNeedsRefresh = true; + }, + + onEndBatch() { + if (isUnifinderHidden()) { + // If the unifinder is hidden, all further item operations might + // produce invalid entries in the unifinder. From now on, ignore + // those operations and refresh as soon as the unifinder is shown + // again. + gUnifinderNeedsRefresh = true; + unifinderTreeView.clearItems(); + } else { + refreshEventTree(); + } + }, + + onLoad() {}, + + onAddItem(aItem) { + if (aItem.isEvent() && !gUnifinderNeedsRefresh) { + this.addItemToTree(aItem); + } + }, + + onModifyItem(aNewItem, aOldItem) { + this.onDeleteItem(aOldItem); + this.onAddItem(aNewItem); + }, + + onDeleteItem(aDeletedItem) { + if (aDeletedItem.isEvent() && !gUnifinderNeedsRefresh) { + this.removeItemFromTree(aDeletedItem); + } + }, + + onError(aCalendar, aErrNo, aMessage) {}, + + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "disabled": + refreshEventTree(); + break; + } + }, + + onPropertyDeleting(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, null, null); + }, + + // calICompositeObserver: + onCalendarAdded(aAddedCalendar) { + if (!aAddedCalendar.getProperty("disabled")) { + if (isUnifinderHidden()) { + gUnifinderNeedsRefresh = true; + } else { + addItemsFromCalendar(aAddedCalendar, addItemsFromSingleCalendarInternal); + } + } + }, + + onCalendarRemoved(aDeletedCalendar) { + if (!aDeletedCalendar.getProperty("disabled")) { + removeItemsFromCalendar(aDeletedCalendar); + } + }, + + onDefaultCalendarChanged(aNewDefaultCalendar) {}, + + /** + * Add an unifinder item to the tree. It is safe to call these for any + * event. The functions will determine whether or not anything actually + * needs to be done to the tree. + * + * @returns aItem The item to add to the tree. + */ + addItemToTree(aItem) { + let items; + let filter = unifinderTreeView.mFilter; + + if (filter.startDate && filter.endDate) { + items = aItem.getOccurrencesBetween(filter.startDate, filter.endDate); + } else { + items = [aItem]; + } + unifinderTreeView.addItems(items.filter(filter.isItemInFilters, filter)); + }, + + /** + * Remove an item from the unifinder tree. It is safe to call these for any + * event. The functions will determine whether or not anything actually + * needs to be done to the tree. + * + * @returns aItem The item to remove from the tree. + */ + removeItemFromTree(aItem) { + let items; + let filter = unifinderTreeView.mFilter; + if (filter.startDate && filter.endDate && aItem.parentItem == aItem) { + items = aItem.getOccurrencesBetween(filter.startDate, filter.endDate); + } else { + items = [aItem]; + } + // XXX: do we really still need this, we are always checking it in the refreshInternal + unifinderTreeView.removeItems(items.filter(filter.isItemInFilters, filter)); + }, + + observe() { + refreshEventTree(); + }, +}; + +/** + * Called when calendar component is loaded to prepare the unifinder. This function is + * used to add observers, event listeners, etc. + */ +function prepareCalendarUnifinder() { + let unifinderTree = document.getElementById("unifinder-search-results-tree"); + // Check that this is not the hidden window, which has no UI elements + if (!unifinderTree) { + return; + } + + // Add pref observer + Services.prefs.addObserver("calendar.date.format", unifinderObserver); + Services.obs.addObserver(unifinderObserver, "defaultTimezoneChanged"); + + // set up our calendar event observer + let ccalendar = cal.view.getCompositeCalendar(window); + ccalendar.addObserver(unifinderObserver); + + // Set up the filter + unifinderTreeView.mFilter = new calFilter(); + + // Set up the unifinder views. + unifinderTreeView.treeElement = unifinderTree; + unifinderTree.view = unifinderTreeView; + + // Listen for changes in the selected day, so we can update if need be + let viewBox = getViewBox(); + viewBox.addEventListener("dayselect", unifinderDaySelect); + viewBox.addEventListener("itemselect", unifinderItemSelect, true); + + // Set up sortDirection and sortActive, in case it persisted + let sorted = unifinderTree.getAttribute("sort-active"); + let sortDirection = unifinderTree.getAttribute("sort-direction"); + if (!sortDirection || sortDirection == "undefined") { + sortDirection = "ascending"; + } + let treecols = unifinderTree.getElementsByTagName("treecol"); + for (let col of treecols) { + let content = col.getAttribute("itemproperty"); + if (sorted && sorted.length > 0) { + if (sorted == content) { + unifinderTreeView.sortDirection = sortDirection; + unifinderTreeView.selectedColumn = col; + } + } + } + + unifinderTreeView.ready = true; + + // Display something upon first load. onLoad doesn't work properly for + // observers + if (!isUnifinderHidden()) { + refreshEventTree(); + } +} + +/** + * Called when the window is unloaded to clean up any observers and listeners + * added. + */ +function finishCalendarUnifinder() { + let ccalendar = cal.view.getCompositeCalendar(window); + ccalendar.removeObserver(unifinderObserver); + + // Remove pref observer + Services.prefs.removeObserver("calendar.date.format", unifinderObserver); + Services.obs.removeObserver(unifinderObserver, "defaultTimezoneChanged"); + + let viewBox = getViewBox(); + if (viewBox) { + viewBox.removeEventListener("dayselect", unifinderDaySelect); + viewBox.removeEventListener("itemselect", unifinderItemSelect, true); + } + + // Persist the sort + let unifinderTree = document.getElementById("unifinder-search-results-tree"); + let sorted = unifinderTreeView.selectedColumn; + if (sorted) { + unifinderTree.setAttribute("sort-active", sorted.getAttribute("itemproperty")); + unifinderTree.setAttribute("sort-direction", unifinderTreeView.sortDirection); + } else { + unifinderTree.removeAttribute("sort-active"); + unifinderTree.removeAttribute("sort-direction"); + } +} + +/** + * Event listener for the view deck's dayselect event. + */ +function unifinderDaySelect() { + let filter = getCurrentUnifinderFilter(); + if (filter == "current" || filter == "currentview") { + refreshEventTree(); + } +} + +/** + * Event listener for the view deck's itemselect event. + */ +function unifinderItemSelect(aEvent) { + unifinderTreeView.setSelectedItems(aEvent.detail); +} + +/** + * Helper function to display event datetimes in the unifinder. + * + * @param aDatetime A calIDateTime object to format. + * @returns The passed date's formatted in the default timezone. + */ +function formatUnifinderEventDateTime(aDatetime) { + return cal.dtz.formatter.formatDateTime(aDatetime.getInTimezone(cal.dtz.defaultTimezone)); +} + +/** + * Handler function for double clicking the unifinder. + * + * @param event The DOM doubleclick event. + */ +function unifinderDoubleClick(event) { + // We only care about button 0 (left click) events + if (event.button != 0) { + return; + } + + // find event by id + let calendarEvent = unifinderTreeView.getItemFromEvent(event); + + if (calendarEvent) { + if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) { + modifyEventWithDialog(calendarEvent, true); + return; + } + openEventDialogForViewing(calendarEvent); + } else { + createEventWithDialog(); + } +} + +/** + * Handler function for selection in the unifinder. + * + * @param event The DOM selection event. + */ +function unifinderSelect(event) { + let tree = unifinderTreeView.treeElement; + if (!tree.view.selection || tree.view.selection.getRangeCount() == 0) { + return; + } + + let selectedItems = []; + gCalendarEventTreeClicked = true; + + // Get the selected events from the tree + let start = {}; + let end = {}; + let numRanges = tree.view.selection.getRangeCount(); + + for (let range = 0; range < numRanges; range++) { + tree.view.selection.getRangeAt(range, start, end); + + for (let i = start.value; i <= end.value; i++) { + try { + selectedItems.push(unifinderTreeView.getItemAt(i)); + } catch (e) { + cal.WARN("Error getting Event from row: " + e + "\n"); + } + } + } + + if (selectedItems.length == 1) { + // Go to the day of the selected item in the current view. + currentView().goToDay(selectedItems[0].startDate); + } + + // Set up the selected items in the view. Pass in true, so we don't end + // up in a circular loop + currentView().setSelectedItems(selectedItems, true); + currentView().centerSelectedItems(); + calendarController.onSelectionChanged({ detail: selectedItems }); + document.getElementById("unifinder-search-results-tree").focus(); +} + +/** + * Handler function for keypress in the unifinder. + * + * @param aEvent The DOM Key event. + */ +function unifinderKeyPress(aEvent) { + switch (aEvent.key) { + case "Enter": + // Enter, edit the event + editSelectedEvents(); + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + case "Backspace": + case "Delete": + deleteSelectedEvents(); + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + } +} + +/** + * Tree controller for unifinder search results + */ +var unifinderTreeView = { + QueryInterface: ChromeUtils.generateQI(["nsITreeView"]), + + // Provide a default tree that holds all the functions used here to avoid + // cludgy if (this.tree) { this.tree.rowCountChanged(...); } constructs. + tree: { + rowCountChanged() {}, + beginUpdateBatch() {}, + endUpdateBatch() {}, + invalidate() {}, + }, + + ready: false, + treeElement: null, + doingSelection: false, + mFilter: null, + mSelectedColumn: null, + sortDirection: null, + + /** + * Returns the currently selected column in the unifinder (used for sorting). + */ + get selectedColumn() { + return this.mSelectedColumn; + }, + + /** + * Sets the currently selected column in the unifinder (used for sorting). + */ + set selectedColumn(aCol) { + let tree = document.getElementById("unifinder-search-results-tree"); + let treecols = tree.getElementsByTagName("treecol"); + for (let col of treecols) { + if (col.getAttribute("sortActive")) { + col.removeAttribute("sortActive"); + col.removeAttribute("sortDirection"); + } + if (aCol.getAttribute("itemproperty") == col.getAttribute("itemproperty")) { + col.setAttribute("sortActive", "true"); + col.setAttribute("sortDirection", this.sortDirection); + } + } + this.mSelectedColumn = aCol; + }, + + /** + * Event functions + */ + + eventArray: [], + eventIndexMap: {}, + + /** + * Add an item to the unifinder tree. + * + * @param aItemArray An array of items to add. + */ + addItems(aItemArray) { + this.tree.beginUpdateBatch(); + + let bulkSort = aItemArray.length > this.eventArray.length; + if (bulkSort || !this.selectedColumn) { + // If there's more items being added than already exist, + // just append them and sort the whole list afterwards. + // If there's no selected column, don't sort at all. + let index = this.eventArray.length; + this.eventArray = this.eventArray.concat(aItemArray); + if (bulkSort && this.selectedColumn) { + this.sortItems(); + } else { + this.tree.rowCountChanged(index, aItemArray.length); + } + } else { + // Otherwise, for each item to be added, work out its + // new position in the list and splice it in there. + // This saves a lot of function calls and calculation. + let modifier = this.sortDirection == "descending" ? -1 : 1; + let sortKey = unifinderTreeView.selectedColumn.getAttribute("itemproperty"); + let comparer = cal.unifinder.sortEntryComparer(sortKey); + + let values = this.eventArray.map(item => cal.unifinder.getItemSortKey(item, sortKey)); + for (let item of aItemArray) { + let itemValue = cal.unifinder.getItemSortKey(item, sortKey); + let index = values.findIndex(value => comparer(value, itemValue, modifier) >= 0); + if (index < 0) { + this.eventArray.push(item); + this.tree.rowCountChanged(values.length, 1); + values.push(itemValue); + } else { + this.eventArray.splice(index, 0, item); + this.tree.rowCountChanged(index, 1); + values.splice(index, 0, itemValue); + } + } + } + + this.tree.endUpdateBatch(); + this.calculateIndexMap(true); + }, + + /** + * Remove items from the unifinder tree. + * + * @param aItemArray An array of items to remove. + */ + removeItems(aItemArray) { + let indexesToRemove = []; + // Removing items is a bit tricky. Our getItemRow function takes the + // index from a cached map, so removing an item from the array will + // remove the wrong indexes. We don't want to just invalidate the map, + // since this will cause O(n^2) behavior. Instead, we keep a sorted + // array of the indexes to remove: + for (let item of aItemArray) { + let row = this.getItemRow(item); + if (row > -1) { + if (!indexesToRemove.length || row <= indexesToRemove[0]) { + indexesToRemove.unshift(row); + } else { + indexesToRemove.push(row); + } + } + } + + // Then we go through the indexes to remove, and remove then from the + // array. We subtract one delta for each removed index to make sure the + // correct element is removed from the array and the correct + // notification is sent. + this.tree.beginUpdateBatch(); + for (let delta = 0; delta < indexesToRemove.length; delta++) { + let index = indexesToRemove[delta]; + this.eventArray.splice(index - delta, 1); + this.tree.rowCountChanged(index - delta, -1); + } + this.tree.endUpdateBatch(); + + // Finally, we recalculate the index map once. This way we end up with + // (given that Array.prototype.unshift doesn't loop but just prepends or + // maps memory smartly) O(3n) behavior. Lets hope its worth it. + this.calculateIndexMap(true); + }, + + /** + * Clear all items from the unifinder. + */ + clearItems() { + let oldCount = this.eventArray.length; + this.eventArray = []; + if (this.tree) { + this.tree.rowCountChanged(0, -oldCount); + } + this.calculateIndexMap(); + }, + + /** + * Sets the items that should be in the unifinder. This removes all items + * that were previously in the unifinder. + */ + setItems(aItemArray) { + let oldCount = this.eventArray.length; + this.eventArray = aItemArray.slice(0); + this.tree.rowCountChanged(oldCount - 1, this.eventArray.length - oldCount); + this.sortItems(); + }, + + /** + * Recalculate the index map that improves performance when accessing + * unifinder items. This is usually done automatically when adding/removing + * items. + * + * @param aDontInvalidate (optional) Don't invalidate the tree, i.e if + * you correctly issued rowCountChanged + * notices. + */ + calculateIndexMap(aDontInvalidate) { + this.eventIndexMap = {}; + for (let i = 0; i < this.eventArray.length; i++) { + this.eventIndexMap[this.eventArray[i].hashId] = i; + } + + if (this.tree && !aDontInvalidate) { + this.tree.invalidate(); + } + }, + + /** + * Sort the items in the unifinder by the currently selected column. + */ + sortItems() { + if (this.selectedColumn) { + let modifier = this.sortDirection == "descending" ? -1 : 1; + let sortKey = unifinderTreeView.selectedColumn.getAttribute("itemproperty"); + + cal.unifinder.sortItems(this.eventArray, sortKey, modifier); + } + this.calculateIndexMap(); + }, + + /** + * Get the index of the row associated with the passed item. + * + * @param item The item to search for. + * @returns The row index of the passed item. + */ + getItemRow(item) { + if (this.eventIndexMap[item.hashId] === undefined) { + return -1; + } + return this.eventIndexMap[item.hashId]; + }, + + /** + * Get the item at the given row index. + * + * @param item The row index to get the item for. + * @returns The item at the given row. + */ + getItemAt(aRow) { + return this.eventArray[aRow]; + }, + + /** + * Get the calendar item from the given DOM event + * + * @param event The DOM mouse event to get the item for. + * @returns The item under the mouse position. + */ + getItemFromEvent(event) { + let row = this.tree.getRowAt(event.clientX, event.clientY); + + if (row > -1) { + return this.getItemAt(row); + } + return null; + }, + + /** + * Change the selection in the unifinder. + * + * @param aItemArray An array of items to select. + */ + setSelectedItems(aItemArray) { + if ( + this.doingSelection || + !this.tree || + !this.tree.view || + !("getSelectedItems" in currentView()) + ) { + return; + } + + this.doingSelection = true; + + // If no items were passed, get the selected items from the view. + aItemArray = aItemArray || currentView().getSelectedItems(); + + calendarUpdateDeleteCommand(aItemArray); + + /** + * The following is a brutal hack, caused by + * http://lxr.mozilla.org/mozilla1.0/source/layout/xul/base/src/tree/src/nsTreeSelection.cpp#555 + * and described in bug 168211 + * http://bugzilla.mozilla.org/show_bug.cgi?id=168211 + * Do NOT remove anything in the next 3 lines, or the selection in the tree will not work. + */ + this.treeElement.onselect = null; + this.treeElement.removeEventListener("select", unifinderSelect, true); + this.tree.view.selection.selectEventsSuppressed = true; + this.tree.view.selection.clearSelection(); + + if (aItemArray && aItemArray.length == 1) { + // If only one item is selected, scroll to it + let rowToScrollTo = this.getItemRow(aItemArray[0]); + if (rowToScrollTo > -1) { + this.tree.ensureRowIsVisible(rowToScrollTo); + this.tree.view.selection.select(rowToScrollTo); + } + } else if (aItemArray && aItemArray.length > 1) { + // If there is more than one item, just select them all. + for (let item of aItemArray) { + let row = this.getItemRow(item); + this.tree.view.selection.rangedSelect(row, row, true); + } + } + + // This needs to be in a setTimeout + setTimeout(() => unifinderTreeView.resetAllowSelection(), 1); + }, + + /** + * Due to a selection issue described in bug 168211 this method is needed to + * re-add the selection listeners selection listeners. + */ + resetAllowSelection() { + if (!this.tree) { + return; + } + /** + * Do not change anything in the following lines, they are needed as + * described in the selection observer above + */ + this.doingSelection = false; + + this.tree.view.selection.selectEventsSuppressed = false; + this.treeElement.addEventListener("select", unifinderSelect, true); + }, + + /** + * Tree View Implementation + * + * @see nsITreeView + */ + get rowCount() { + return this.eventArray.length; + }, + + // TODO this code is currently identical to the task tree. We should create + // an itemTreeView that these tree views can inherit, that contains this + // code, and possibly other code related to sorting and storing items. See + // bug 432582 for more details. + getCellProperties(aRow, aCol) { + let rowProps = this.getRowProperties(aRow); + let colProps = this.getColumnProperties(aCol); + return rowProps + (rowProps && colProps ? " " : "") + colProps; + }, + getRowProperties(aRow) { + let properties = []; + let item = this.eventArray[aRow]; + if (item.priority > 0 && item.priority < 5) { + properties.push("highpriority"); + } else if (item.priority > 5 && item.priority < 10) { + properties.push("lowpriority"); + } + + // Add calendar name atom + properties.push("calendar-" + cal.view.formatStringForCSSRule(item.calendar.name)); + + // Add item status atom + if (item.status) { + properties.push("status-" + item.status.toLowerCase()); + } + + // Alarm status atom + if (item.getAlarms().length) { + properties.push("alarm"); + } + + // Task categories + properties = properties.concat(item.getCategories().map(cal.view.formatStringForCSSRule)); + + return properties.join(" "); + }, + getColumnProperties(aCol) { + return ""; + }, + + isContainer() { + return false; + }, + + isContainerOpen(aRow) { + return false; + }, + + isContainerEmpty(aRow) { + return false; + }, + + isSeparator(aRow) { + return false; + }, + + isSorted(aRow) { + return false; + }, + + canDrop(aRow, aOrientation) { + return false; + }, + + drop(aRow, aOrientation) {}, + + getParentIndex(aRow) { + return -1; + }, + + hasNextSibling(aRow, aAfterIndex) {}, + + getLevel(aRow) { + return 0; + }, + + getImageSrc(aRow, aOrientation) {}, + + getCellValue(aRow, aCol) { + return null; + }, + + getCellText(row, column) { + let calendarEvent = this.eventArray[row]; + + switch (column.element.getAttribute("itemproperty")) { + case "title": { + return calendarEvent.title ? calendarEvent.title.replace(/\n/g, " ") : ""; + } + case "startDate": { + return formatUnifinderEventDateTime(calendarEvent.startDate); + } + case "endDate": { + let eventEndDate = calendarEvent.endDate.clone(); + // XXX reimplement + // let eventEndDate = getCurrentNextOrPreviousRecurrence(calendarEvent); + if (calendarEvent.startDate.isDate) { + // display enddate is ical enddate - 1 + eventEndDate.day = eventEndDate.day - 1; + } + return formatUnifinderEventDateTime(eventEndDate); + } + case "categories": { + return calendarEvent.getCategories().join(", "); + } + case "location": { + return calendarEvent.getProperty("LOCATION"); + } + case "status": { + return getEventStatusString(calendarEvent); + } + case "calendar": { + return calendarEvent.calendar.name; + } + default: { + return false; + } + } + }, + + setTree(tree) { + this.tree = tree; + }, + + toggleOpenState(aRow) {}, + + cycleHeader(col) { + if (!this.selectedColumn) { + this.sortDirection = "ascending"; + } else if (!this.sortDirection || this.sortDirection == "descending") { + this.sortDirection = "ascending"; + } else { + this.sortDirection = "descending"; + } + this.selectedColumn = col.element; + this.sortItems(); + }, + + isEditable(aRow, aCol) { + return false; + }, + + setCellValue(aRow, aCol, aValue) {}, + setCellText(aRow, aCol, aValue) {}, + + outParameter: {}, // used to obtain dates during sort +}; + +/** + * Refresh the unifinder tree by getting items from the composite calendar and + * applying the current filter. + */ +function refreshEventTree() { + if (!unifinderTreeView.ready) { + return; + } + + let field = document.getElementById("unifinder-search-field"); + if (field) { + unifinderTreeView.mFilter.filterText = field.value; + } + + addItemsFromCalendar( + cal.view.getCompositeCalendar(window), + addItemsFromCompositeCalendarInternal + ); + + gUnifinderNeedsRefresh = false; +} + +/** + * EXTENSION_POINTS + * Filters the passed event array according to the currently applied filter. + * Afterwards, applies the items to the unifinder view. + * + * If you are implementing a new filter, you can overwrite this function and + * filter the items accordingly and afterwards call this function with the + * result. + * + * @param eventArray The array of items to be set in the unifinder. + */ +function addItemsFromCompositeCalendarInternal(eventArray) { + let newItems = eventArray.filter( + unifinderTreeView.mFilter.isItemInFilters, + unifinderTreeView.mFilter + ); + unifinderTreeView.setItems(newItems); + + // Select selected events in the tree. Not passing the argument gets the + // items from the view. + unifinderTreeView.setSelectedItems(); +} + +function addItemsFromSingleCalendarInternal(eventArray) { + let newItems = eventArray.filter( + unifinderTreeView.mFilter.isItemInFilters, + unifinderTreeView.mFilter + ); + unifinderTreeView.setItems(unifinderTreeView.eventArray.concat(newItems)); + + // Select selected events in the tree. Not passing the argument gets the + // items from the view. + unifinderTreeView.setSelectedItems(); +} + +async function addItemsFromCalendar(aCalendar, aAddItemsInternalFunc) { + if (isUnifinderHidden()) { + // If the unifinder is hidden, don't refresh the events to reduce needed + // getItems calls. + return; + } + + let filter = 0; + + filter |= aCalendar.ITEM_FILTER_TYPE_EVENT; + + // Not all xul might be there yet... + if (!document.getElementById("unifinder-search-field")) { + return; + } + unifinderTreeView.mFilter.applyFilter(getCurrentUnifinderFilter()); + + if (unifinderTreeView.mFilter.startDate && unifinderTreeView.mFilter.endDate) { + filter |= aCalendar.ITEM_FILTER_CLASS_OCCURRENCES; + } + + let items = await aCalendar.getItemsAsArray( + filter, + 0, + unifinderTreeView.mFilter.startDate, + unifinderTreeView.mFilter.endDate + ); + + let refreshTreeInternalFunc = function () { + aAddItemsInternalFunc(items); + }; + setTimeout(refreshTreeInternalFunc, 0); +} + +function removeItemsFromCalendar(aCalendar) { + let filter = unifinderTreeView.mFilter; + let items = unifinderTreeView.eventArray.filter(item => item.calendar.id == aCalendar.id); + + unifinderTreeView.removeItems(items.filter(filter.isItemInFilters, filter)); +} + +/** + * Focuses the unifinder search field + */ +function focusSearch() { + document.getElementById("unifinder-search-field").focus(); +} + +/** + * The unifinder is hidden if the calendar tab is not selected. When the tab + * is selected, this function is called so that unifinder setup completes. + */ +function ensureUnifinderLoaded() { + if (!isUnifinderHidden() && gUnifinderNeedsRefresh) { + refreshEventTree(); + } +} + +/** + * Toggles the hidden state of the unifinder. + */ +function toggleUnifinder() { + // Toggle the elements + goToggleToolbar("bottom-events-box", "calendar_show_unifinder_command"); + goToggleToolbar("calendar-view-splitter"); + window.dispatchEvent(new CustomEvent("viewresize")); + + unifinderTreeView.treeElement.view = unifinderTreeView; + + // When the unifinder is hidden, refreshEventTree is not called. Make sure + // the event tree is refreshed now. + if (!isUnifinderHidden() && gUnifinderNeedsRefresh) { + refreshEventTree(); + } + + // Make sure the selection is correct + if (unifinderTreeView.doingSelection) { + unifinderTreeView.resetAllowSelection(); + } + unifinderTreeView.setSelectedItems(); +} diff --git a/comm/calendar/base/content/calendar-view-menu.inc.xhtml b/comm/calendar/base/content/calendar-view-menu.inc.xhtml new file mode 100644 index 0000000000..166807f363 --- /dev/null +++ b/comm/calendar/base/content/calendar-view-menu.inc.xhtml @@ -0,0 +1,195 @@ +# 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/. + +<menuseparator id="calViewMenuSeparator" + class="hide-when-calendar-deactivated"/> +<menu id="calTodayPaneMenu" + class="hide-when-calendar-deactivated" + label="&calendar.context.button.label;" + accesskey="&calendar.context.button.accesskey;"> + <menupopup id="calTodayPaneMenuPopup"> + <menuitem id="calShowTodayPane-2" + label="&todaypane.showTodayPane.label;" + accesskey="&todaypane.showTodayPane.accesskey;" + type="checkbox" + key="todaypanekey" + command="calendar_toggle_todaypane_command"/> + <menuseparator id="calSeparatorBeforeDisplayMiniday"/> + <menuitem id="calTodayPaneDisplayMiniday" + name="minidisplay" + value="miniday" + type="radio" + oncommand="TodayPane.displayMiniSection('miniday')" + label="&todaypane.showMiniday.label;" + accesskey="&todaypane.showMiniday.accesskey;"/> + <menuitem id="calTodayPaneDisplayMinimonth" + name="minidisplay" + value="minimonth" + type="radio" + oncommand="TodayPane.displayMiniSection('minimonth')" + label="&todaypane.showMinimonth.label;" + accesskey="&todaypane.showMinimonth.accesskey;"/> + <menuitem id="calTodayPaneDisplayNone" + name="minidisplay" + value="none" + type="radio" + oncommand="TodayPane.displayMiniSection('none')" + label="&todaypane.showNone.label;" + accesskey="&todaypane.showNone.accesskey;"/> + </menupopup> +</menu> +<menu id="calCalendarMenu" + class="hide-when-calendar-deactivated" + observes="calendar_in_foreground" + label="&lightning.menu.view.calendar.label;" + accesskey="&lightning.menu.view.calendar.accesskey;"> + <menupopup id="calCalendarMenuPopup"> + <menuitem id="calChangeViewDay" + label="&lightning.toolbar.day.label;" + accesskey="&lightning.toolbar.day.accesskey;" + type="radio" + name="calendarMenuViews" + command="calendar_day-view_command"/> + <menuitem id="calChangeViewWeek" + label="&lightning.toolbar.week.label;" + accesskey="&lightning.toolbar.week.accesskey;" + type="radio" + name="calendarMenuViews" + command="calendar_week-view_command"/> + <menuitem id="calChangeViewMultiweek" + label="&lightning.toolbar.multiweek.label;" + accesskey="&lightning.toolbar.multiweek.accesskey;" + type="radio" + name="calendarMenuViews" + command="calendar_multiweek-view_command"/> + <menuitem id="calChangeViewMonth" + label="&lightning.toolbar.month.label;" + accesskey="&lightning.toolbar.month.accesskey;" + type="radio" + name="calendarMenuViews" + command="calendar_month-view_command"/> + <menuseparator id="calBeforeCalendarViewSection"/> + <menu id="calCalendarPaneMenu" + label="&lightning.toolbar.calendarmenu.label;" + accesskey="&lightning.toolbar.calendarmenu.accesskey;"> + <menupopup id="calCalendarPanePopup" + onpopupshowing="initViewCalendarPaneMenu()"> + <menuitem id="calViewCalendarPane" + type="checkbox" + label="&lightning.toolbar.calendarpane.label;" + accesskey="&lightning.toolbar.calendarpane.accesskey;" + command="calendar_toggle_calendarsidebar_command"/> + <menuseparator id="calCalendarPaneMenuSeparator"/> + <menuitem id="calTasksViewMinimonth" + type="checkbox" + label="&calendar.tasks.view.minimonth.label;" + accesskey="&calendar.tasks.view.minimonth.accesskey;" + command="calendar_toggle_minimonthpane_command"/> + <menuitem id="calTasksViewCalendarlist" + type="checkbox" + label="&calendar.tasks.view.calendarlist.label;" + accesskey="&calendar.tasks.view.calendarlist.accesskey;" + command="calendar_toggle_calendarlist_command"/> + </menupopup> + </menu> + <menuseparator id="calBeforeCurrentViewMenu"/> + <menu id="calCalendarCurrentViewMenu" + observes="calendar_mode_calendar" + label="&showCurrentView.label;" + accesskey="&showCurrentView.accesskey;"> + <menupopup id="calCalendarCurrentViewMenuPopup"> + <menuitem type="checkbox" + id="calWorkdaysOnlyMenuitem" + label="&calendar.onlyworkday.checkbox.label;" + accesskey="&calendar.onlyworkday.checkbox.accesskey;" + command="calendar_toggle_workdays_only_command"/> + <menuitem type="checkbox" + id="calTasksInViewMenuitem" + label="&calendar.displaytodos.checkbox.label;" + accesskey="&calendar.displaytodos.checkbox.accesskey;" + command="calendar_toggle_tasks_in_view_command"/> + <menuitem type="checkbox" + id="calShowCompletedInViewMenuItem" + label="&calendar.completedtasks.checkbox.label;" + accesskey="&calendar.completedtasks.checkbox.accesskey;" + command="calendar_toggle_show_completed_in_view_command"/> + <menuitem type="checkbox" + id="calViewRotated" + label="&calendar.orientation.label;" + accesskey="&calendar.orientation.accesskey;" + command="calendar_toggle_orientation_command"/> + </menupopup> + </menu> + </menupopup> +</menu> +<menu id="calTasksMenu" + class="hide-when-calendar-deactivated" + observes="calendar_mode_task" + label="&lightning.menu.view.tasks.label;" + accesskey="&lightning.menu.view.tasks.accesskey;"> + <menupopup id="calTasksMenuPopup"> + <menuitem id="calTasksViewFilterTasks" + type="checkbox" + label="&calendar.tasks.view.filtertasks.label;" + accesskey="&calendar.tasks.view.filtertasks.accesskey;" + command="calendar_toggle_filter_command"/> + <menuseparator id="calTasksViewSeparator"/> + <menuitem id="calTasksViewFilterCurrent" + name="filtergroup" + value="throughcurrent" + type="radio" + command="calendar_task_filter_command" + label="&calendar.task.filter.current.label;" + accesskey="&calendar.task.filter.current.accesskey;"/> + <menuitem id="calTasksViewFilterToday" + name="filtergroup" + value="throughtoday" + type="radio" + command="calendar_task_filter_command" + label="&calendar.task.filter.today.label;" + accesskey="&calendar.task.filter.today.accesskey;"/> + <menuitem id="calTasksViewFilterNext7days" + name="filtergroup" + value="throughsevendays" + type="radio" + command="calendar_task_filter_command" + label="&calendar.task.filter.next7days.label;" + accesskey="&calendar.task.filter.next7days.accesskey;"/> + <menuitem id="calTasksViewFilterNotstartedtasks" + name="filtergroup" + value="notstarted" + type="radio" + command="calendar_task_filter_command" + label="&calendar.task.filter.notstarted.label;" + accesskey="&calendar.task.filter.notstarted.accesskey;"/> + <menuitem id="calTasksViewFilterOverdue" + name="filtergroup" + value="overdue" + type="radio" + command="calendar_task_filter_command" + label="&calendar.task.filter.overdue.label;" + accesskey="&calendar.task.filter.overdue.accesskey;"/> + <menuitem id="calTasksViewFilterCompleted" + name="filtergroup" + type="radio" + value="completed" + command="calendar_task_filter_command" + label="&calendar.task.filter.completed.label;" + accesskey="&calendar.task.filter.completed.accesskey;"/> + <menuitem id="calTasksViewFilterOpen" + name="filtergroup" + type="radio" + value="open" + command="calendar_task_filter_command" + label="&calendar.task.filter.open.label;" + accesskey="&calendar.task.filter.open.accesskey;"/> + <menuitem id="calTasksViewFilterAll" + name="filtergroup" + value="all" + type="radio" + command="calendar_task_filter_command" + label="&calendar.task.filter.all.label;" + accesskey="&calendar.task.filter.all.accesskey;"/> + </menupopup> +</menu> diff --git a/comm/calendar/base/content/calendar-views-utils.js b/comm/calendar/base/content/calendar-views-utils.js new file mode 100644 index 0000000000..b88f0e5954 --- /dev/null +++ b/comm/calendar/base/content/calendar-views-utils.js @@ -0,0 +1,617 @@ +/* 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 switchToView, minimonthPick, + * observeViewDaySelect, toggleOrientation, + * toggleWorkdaysOnly, toggleTasksInView, toggleShowCompletedInView, + * goToDate, gLastShownCalendarView, deleteSelectedEvents, + * editSelectedEvents, selectAllEvents, calendarNavigationBar + */ + +/* import-globals-from item-editing/calendar-item-editing.js */ +/* import-globals-from calendar-modes.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { countOccurrences } = ChromeUtils.import( + "resource:///modules/calendar/calRecurrenceUtils.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +/** + * Controller for the views + * + * @see calIcalendarViewController + */ +var calendarViewController = { + QueryInterface: ChromeUtils.generateQI(["calICalendarViewController"]), + + /** + * Creates a new event + * + * @see calICalendarViewController + */ + createNewEvent(calendar, startTime, endTime, forceAllday) { + // if we're given both times, skip the dialog + if (startTime && endTime && !startTime.isDate && !endTime.isDate) { + let item = new CalEvent(); + setDefaultItemValues(item, calendar, startTime, endTime); + doTransaction("add", item, item.calendar, null, null); + } else { + createEventWithDialog(calendar, startTime, null, null, null, forceAllday); + } + }, + + /** + * View the given occurrence. + * + * @param {calIItemBase} occurrence + * @see calICalendarViewController + */ + viewOccurrence(occurrence) { + openEventDialogForViewing(occurrence); + }, + + /** + * Modifies the given occurrence + * + * @see calICalendarViewController + */ + modifyOccurrence(occurrence, newStartTime, newEndTime, newTitle) { + // if modifying this item directly (e.g. just dragged to new time), + // then do so; otherwise pop up the dialog + if (newStartTime || newEndTime || newTitle) { + let instance = occurrence.clone(); + + if (newTitle) { + instance.title = newTitle; + } + + // When we made the executive decision (in bug 352862) that + // dragging an occurrence of a recurring event would _only_ act + // upon _that_ occurrence, we removed a bunch of code from this + // function. If we ever revert that decision, check CVS history + // here to get that code back. + + if (newStartTime || newEndTime) { + // Yay for variable names that make this next line look silly + if (instance.isEvent()) { + if (newStartTime && instance.startDate) { + instance.startDate = newStartTime; + } + if (newEndTime && instance.endDate) { + instance.endDate = newEndTime; + } + } else { + if (newStartTime && instance.entryDate) { + instance.entryDate = newStartTime; + } + if (newEndTime && instance.dueDate) { + instance.dueDate = newEndTime; + } + } + } + + doTransaction("modify", instance, instance.calendar, occurrence, null); + } else { + modifyEventWithDialog(occurrence, true); + } + }, + + /** + * Deletes the given occurrences + * + * @see calICalendarViewController + */ + deleteOccurrences(occurrencesArg, useParentItems, doNotConfirm, extResponseArg = null) { + if (!cal.window.promptDeleteItems(occurrencesArg)) { + return; + } + startBatchTransaction(); + let recurringItems = {}; + let extResponse = extResponseArg || { responseMode: Ci.calIItipItem.USER }; + + let getSavedItem = function (itemToDelete) { + // Get the parent item, saving it in our recurringItems object for + // later use. + let hashVal = itemToDelete.parentItem.hashId; + if (!recurringItems[hashVal]) { + recurringItems[hashVal] = { + oldItem: itemToDelete.parentItem, + newItem: itemToDelete.parentItem.clone(), + }; + } + return recurringItems[hashVal]; + }; + + // Make sure we are modifying a copy of aOccurrences, otherwise we will + // run into race conditions when the view's doRemoveItem removes the + // array elements while we are iterating through them. While we are at + // it, filter out any items that have readonly calendars, so that + // checking for one total item below also works out if all but one item + // are readonly. + let occurrences = occurrencesArg.filter(item => cal.acl.isCalendarWritable(item.calendar)); + + // we check how many occurrences the parent item has + let parents = new Map(); + for (let occ of occurrences) { + if (!parents.has(occ.id)) { + parents.set(occ.id, countOccurrences(occ)); + } + } + + let promptUser = !doNotConfirm; + let previousResponse = 0; + for (let itemToDelete of occurrences) { + if (parents.get(itemToDelete.id) == -1) { + // we have scheduled the master item for deletion in a previous + // loop already + continue; + } + if (useParentItems || parents.get(itemToDelete.id) == 1 || previousResponse == 3) { + // Usually happens when ctrl-click is used. In that case we + // don't need to ask the user if he wants to delete an + // occurrence or not. + // if an occurrence is the only one of a series or the user + // decided so before, we delete the series, too. + itemToDelete = itemToDelete.parentItem; + parents.set(itemToDelete.id, -1); + } else if (promptUser) { + let [targetItem, , response] = promptOccurrenceModification(itemToDelete, false, "delete"); + if (!response) { + // The user canceled the dialog, bail out + break; + } + itemToDelete = targetItem; + + // if we have multiple items and the user decided already for one + // item whether to delete the occurrence or the entire series, + // we apply that decision also to subsequent items + previousResponse = response; + promptUser = false; + } + + // Now some dirty work: Make sure more than one occurrence can be + // deleted by saving the recurring items and removing occurrences as + // they come in. If this is not an occurrence, we can go ahead and + // delete the whole item. + if (itemToDelete.parentItem.hashId == itemToDelete.hashId) { + doTransaction("delete", itemToDelete, itemToDelete.calendar, null, null, extResponse); + } else { + let savedItem = getSavedItem(itemToDelete); + savedItem.newItem.recurrenceInfo.removeOccurrenceAt(itemToDelete.recurrenceId); + // Dont start the transaction yet. Do so later, in case the + // parent item gets modified more than once. + } + } + + // Now handle recurring events. This makes sure that all occurrences + // that have been passed are deleted. + for (let hashVal in recurringItems) { + let ritem = recurringItems[hashVal]; + doTransaction( + "modify", + ritem.newItem, + ritem.newItem.calendar, + ritem.oldItem, + null, + extResponse + ); + } + endBatchTransaction(); + }, +}; + +/** + * This function does the common steps to switch between views. Should be called + * from app-specific view switching functions + * + * @param viewType The type of view to select. + */ +function switchToView(viewType) { + let viewBox = getViewBox(); + let selectedDay; + let currentSelection = []; + + // Set up the view commands + let views = viewBox.children; + for (let i = 0; i < views.length; i++) { + let view = views[i]; + let commandId = "calendar_" + view.id + "_command"; + let command = document.getElementById(commandId); + if (view.id == viewType + "-view") { + command.setAttribute("checked", "true"); + } else { + command.removeAttribute("checked"); + } + } + + document.l10n.setAttributes( + document.getElementById("previousViewButton"), + `calendar-nav-button-prev-tooltip-${viewType}` + ); + document.l10n.setAttributes( + document.getElementById("nextViewButton"), + `calendar-nav-button-next-tooltip-${viewType}` + ); + document.l10n.setAttributes( + document.getElementById("calendar-view-context-menu-previous"), + `calendar-context-menu-previous-${viewType}` + ); + document.l10n.setAttributes( + document.getElementById("calendar-view-context-menu-next"), + `calendar-context-menu-next-${viewType}` + ); + + // These are hidden until the calendar is loaded. + for (let node of document.querySelectorAll(".hide-before-calendar-loaded")) { + node.removeAttribute("hidden"); + } + + // Anyone wanting to plug in a view needs to follow this naming scheme + let view = document.getElementById(viewType + "-view"); + let oldView = currentView(); + if (oldView?.isActive) { + if (oldView == view) { + // Not actually changing view, there's nothing else to do. + return; + } + + selectedDay = oldView.selectedDay; + currentSelection = oldView.getSelectedItems(); + oldView.deactivate(); + } + + if (!selectedDay) { + selectedDay = cal.dtz.now(); + } + for (let i = 0; i < viewBox.children.length; i++) { + if (view.id == viewBox.children[i].id) { + viewBox.children[i].hidden = false; + viewBox.setAttribute("selectedIndex", i); + } else { + viewBox.children[i].hidden = true; + } + } + + view.ensureInitialized(); + if (!view.controller) { + view.timezone = cal.dtz.defaultTimezone; + view.controller = calendarViewController; + } + + view.goToDay(selectedDay); + view.setSelectedItems(currentSelection); + + view.onResize(view); + view.activate(); +} + +/** + * Returns the calendar view box element. + * + * @returns The view-box element. + */ +function getViewBox() { + return document.getElementById("view-box"); +} + +/** + * Returns the currently selected calendar view. + * + * @returns The selected calendar view + */ +function currentView() { + for (let element of getViewBox().children) { + if (!element.hidden) { + return element; + } + } + return null; +} + +/** + * Handler function to set the selected day in the minimonth to the currently + * selected day in the current view. + * + * @param event The "dayselect" event emitted from the views. + * + */ +function observeViewDaySelect(event) { + let date = event.detail; + let jsDate = new Date(date.year, date.month, date.day); + + // for the month and multiweek view find the main month, + // which is the month with the most visible days in the view; + // note, that the main date is the first day of the main month + let jsMainDate; + if (!event.target.supportsDisjointDates) { + let mainDate = null; + let maxVisibleDays = 0; + let startDay = currentView().startDay; + let endDay = currentView().endDay; + let firstMonth = startDay.startOfMonth; + let lastMonth = endDay.startOfMonth; + for (let month = firstMonth.clone(); month.compare(lastMonth) <= 0; month.month += 1) { + let visibleDays = 0; + if (month.compare(firstMonth) == 0) { + visibleDays = startDay.endOfMonth.day - startDay.day + 1; + } else if (month.compare(lastMonth) == 0) { + visibleDays = endDay.day; + } else { + visibleDays = month.endOfMonth.day; + } + if (visibleDays > maxVisibleDays) { + mainDate = month.clone(); + maxVisibleDays = visibleDays; + } + } + jsMainDate = new Date(mainDate.year, mainDate.month, mainDate.day); + } + + getMinimonth().selectDate(jsDate, jsMainDate); + currentView().focus(); +} + +/** + * Shows the given date in the current view, if in calendar mode. + * + * @param aNewDate The new date as a JSDate. + */ +function minimonthPick(aNewDate) { + if (gCurrentMode == "calendar" || gCurrentMode == "task") { + let cdt = cal.dtz.jsDateToDateTime(aNewDate, currentView().timezone); + cdt.isDate = true; + currentView().goToDay(cdt); + + // update date filter for task tree + let tree = document.getElementById("calendar-task-tree"); + tree.updateFilter(); + } +} + +/** + * Provides a neutral way to get the minimonth. + * + * @returns The XUL minimonth element. + */ +function getMinimonth() { + return document.getElementById("calMinimonth"); +} + +/** + * Update the view orientation based on the checked state of the command + */ +function toggleOrientation() { + let cmd = document.getElementById("calendar_toggle_orientation_command"); + let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true"; + cmd.setAttribute("checked", newValue); + + for (let view of getViewBox().children) { + view.rotated = newValue == "true"; + } + + // orientation refreshes automatically +} + +/** + * Toggle the workdays only checkbox and refresh the current view + * + * XXX We shouldn't need to refresh the view just to toggle the workdays. This + * should happen automatically. + */ +function toggleWorkdaysOnly() { + let cmd = document.getElementById("calendar_toggle_workdays_only_command"); + let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true"; + cmd.setAttribute("checked", newValue); + + for (let view of getViewBox().children) { + view.workdaysOnly = newValue == "true"; + } + + // Refresh the current view + currentView().goToDay(); +} + +/** + * Toggle the tasks in view checkbox and refresh the current view + */ +function toggleTasksInView() { + let cmd = document.getElementById("calendar_toggle_tasks_in_view_command"); + let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true"; + cmd.setAttribute("checked", newValue); + + for (let view of getViewBox().children) { + view.tasksInView = newValue == "true"; + } + + // Refresh the current view + currentView().goToDay(); +} + +/** + * Toggle the show completed in view checkbox and refresh the current view + */ +function toggleShowCompletedInView() { + let cmd = document.getElementById("calendar_toggle_show_completed_in_view_command"); + let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true"; + cmd.setAttribute("checked", newValue); + + for (let view of getViewBox().children) { + view.showCompleted = newValue == "true"; + } + + // Refresh the current view + currentView().goToDay(); +} + +/** + * Open the calendar layout options menu popup. + * + * @param {Event} event - The click DOMEvent. + */ +function showCalControlBarMenuPopup(event) { + let moreContext = document.getElementById("calControlBarMenuPopup"); + moreContext.openPopup(event.target, { triggerEvent: event }); +} + +/** + * Provides a neutral way to go to the current day in the views and minimonth. + * + * @param date The date to go. + */ +function goToDate(date) { + getMinimonth().value = cal.dtz.dateTimeToJsDate(date); + currentView().goToDay(date); +} + +var gLastShownCalendarView = { + _lastView: null, + + /** + * Returns the calendar view that was selected before restart, or the current + * calendar view if it has already been set in this session. + * + * @returns {string} The last calendar view. + */ + get() { + if (!this._lastView) { + if (Services.xulStore.hasValue(document.location.href, "view-box", "selectedIndex")) { + let viewBox = getViewBox(); + let selectedIndex = Services.xulStore.getValue( + document.location.href, + "view-box", + "selectedIndex" + ); + for (let i = 0; i < viewBox.children.length; i++) { + viewBox.children[i].hidden = selectedIndex != i; + } + let viewNode = viewBox.children[selectedIndex]; + this._lastView = viewNode.id.replace(/-view/, ""); + document + .querySelector(`.calview-toggle-item[aria-controls="${viewNode.id}"]`) + ?.setAttribute("aria-selected", true); + } else { + // No deck item was selected beforehand, default to week view. + this._lastView = "week"; + document + .querySelector(`.calview-toggle-item[aria-controls="week-view"]`) + ?.setAttribute("aria-selected", true); + } + } + return this._lastView; + }, + + set(view) { + this._lastView = view; + }, +}; + +/** + * Deletes items currently selected in the view and clears selection. + */ +function deleteSelectedEvents() { + let selectedItems = currentView().getSelectedItems(); + calendarViewController.deleteOccurrences(selectedItems, false, false); + // clear selection + currentView().setSelectedItems([], true); +} + +/** + * Open the items currently selected in the view. + */ +function viewSelectedEvents() { + let items = currentView().getSelectedItems(); + if (items.length >= 1) { + openEventDialogForViewing(items[0]); + } +} + +/** + * Edit the items currently selected in the view with the event dialog. + */ +function editSelectedEvents() { + let selectedItems = currentView().getSelectedItems(); + if (selectedItems && selectedItems.length >= 1) { + modifyEventWithDialog(selectedItems[0], true); + } +} + +/** + * Select all events from all calendars. Use with care. + */ +async function selectAllEvents() { + let composite = cal.view.getCompositeCalendar(window); + let filter = composite.ITEM_FILTER_CLASS_OCCURRENCES; + + if (currentView().tasksInView) { + filter |= composite.ITEM_FILTER_TYPE_ALL; + } else { + filter |= composite.ITEM_FILTER_TYPE_EVENT; + } + if (currentView().showCompleted) { + filter |= composite.ITEM_FILTER_COMPLETED_ALL; + } else { + filter |= composite.ITEM_FILTER_COMPLETED_NO; + } + + // Need to move one day out to get all events + let end = currentView().endDay.clone(); + end.day += 1; + + let items = await composite.getItemsAsArray(filter, 0, currentView().startDay, end); + currentView().setSelectedItems(items, false); +} + +var calendarNavigationBar = { + setDateRange(startDate, endDate) { + let docTitle = ""; + if (startDate) { + let intervalLabel = document.getElementById("intervalDescription"); + let firstWeekNo = cal.weekInfoService.getWeekTitle(startDate); + let secondWeekNo = firstWeekNo; + let weekLabel = document.getElementById("calendarWeek"); + if (startDate.nativeTime == endDate.nativeTime) { + intervalLabel.textContent = cal.dtz.formatter.formatDate(startDate); + } else { + intervalLabel.textContent = currentView().getRangeDescription(); + secondWeekNo = cal.weekInfoService.getWeekTitle(endDate); + } + if (secondWeekNo == firstWeekNo) { + weekLabel.textContent = cal.l10n.getCalString("singleShortCalendarWeek", [firstWeekNo]); + weekLabel.tooltipText = cal.l10n.getCalString("singleLongCalendarWeek", [firstWeekNo]); + } else { + weekLabel.textContent = cal.l10n.getCalString("severalShortCalendarWeeks", [ + firstWeekNo, + secondWeekNo, + ]); + weekLabel.tooltipText = cal.l10n.getCalString("severalLongCalendarWeeks", [ + firstWeekNo, + secondWeekNo, + ]); + } + docTitle = intervalLabel.textContent; + } + + if (gCurrentMode == "calendar") { + document.title = + (docTitle ? docTitle + " - " : "") + + cal.l10n.getAnyString("branding", "brand", "brandFullName"); + } + }, +}; + +var timezoneObserver = { + observe() { + let minimonth = getMinimonth(); + minimonth.update(minimonth.value); + }, +}; +Services.obs.addObserver(timezoneObserver, "defaultTimezoneChanged"); +window.addEventListener("unload", () => { + Services.obs.removeObserver(timezoneObserver, "defaultTimezoneChanged"); +}); diff --git a/comm/calendar/base/content/calendar-views.js b/comm/calendar/base/content/calendar-views.js new file mode 100644 index 0000000000..6d5e7faa3f --- /dev/null +++ b/comm/calendar/base/content/calendar-views.js @@ -0,0 +1,286 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements, MozXULElement */ + +"use strict"; + +// The calendar view class hierarchy. +// +// CalendarFilteredViewMixin +// | +// CalendarBaseView +// / \ +// CalendarMultidayBaseView CalendarMonthBaseView +// / \ / \ +// CalendarDayView CalendarWeekView CalendarMultiweekView CalendarMonthView + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + /** + * The calendar view for viewing a single day. + * + * @augments {MozElements.CalendarMultidayBaseView} + * @implements {calICalendarView} + */ + class CalendarDayView extends MozElements.CalendarMultidayBaseView { + get observerID() { + return "day-view-observer"; + } + + get supportsWorkdaysOnly() { + return false; + } + + goToDay(date) { + if (!date) { + this.relayout(); + return; + } + const timezoneDate = date.getInTimezone(this.timezone); + this.setDateRange(timezoneDate, timezoneDate); + this.selectedDay = timezoneDate; + } + + moveView(number) { + if (number) { + const currentDay = this.startDay.clone(); + currentDay.day += number; + this.goToDay(currentDay); + } else { + this.goToDay(cal.dtz.now()); + } + } + } + + MozXULElement.implementCustomInterface(CalendarDayView, [Ci.calICalendarView]); + + customElements.define("calendar-day-view", CalendarDayView); + + /** + * The calendar view for viewing a single week. + * + * @augments {MozElements.CalendarMultidayBaseView} + * @implements {calICalendarView} + */ + class CalendarWeekView extends MozElements.CalendarMultidayBaseView { + get observerID() { + return "week-view-observer"; + } + + goToDay(date) { + this.displayDaysOff = !this.mWorkdaysOnly; + + if (!date) { + this.relayout(); + return; + } + date = date.getInTimezone(this.timezone); + const weekStart = cal.weekInfoService.getStartOfWeek(date); + const weekEnd = weekStart.clone(); + weekEnd.day += 6; + this.setDateRange(weekStart, weekEnd); + this.selectedDay = date; + } + + moveView(number) { + if (number) { + const date = this.selectedDay.clone(); + date.day += 7 * number; + this.goToDay(date); + } else { + this.goToDay(cal.dtz.now()); + } + } + } + + MozXULElement.implementCustomInterface(CalendarWeekView, [Ci.calICalendarView]); + + customElements.define("calendar-week-view", CalendarWeekView); + + /** + * The calendar view for viewing multiple weeks. + * + * @augments {MozElements.CalendarMonthBaseView} + * @implements {calICalendarView} + */ + class CalendarMultiweekView extends MozElements.CalendarMonthBaseView { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true via super.connectedCallback. + super.connectedCallback(); + + this.mWeeksInView = Services.prefs.getIntPref("calendar.weeks.inview", 4); + } + + set weeksInView(weeks) { + this.mWeeksInView = weeks; + Services.prefs.setIntPref("calendar.weeks.inview", Number(weeks)); + this.refreshView(); + } + + get weeksInView() { + return this.mWeeksInView; + } + + get supportsZoom() { + return true; + } + + get observerID() { + return "multiweek-view-observer"; + } + + zoomIn(level = 1) { + const visibleWeeks = level + Services.prefs.getIntPref("calendar.weeks.inview", 4); + + Services.prefs.setIntPref("calendar.weeks.inview", Math.min(visibleWeeks, 6)); + } + + zoomOut(level = 1) { + const visibleWeeks = level + Services.prefs.getIntPref("calendar.weeks.inview", 4); + + Services.prefs.setIntPref("calendar.weeks.inview", Math.max(visibleWeeks, 2)); + } + + zoomReset() { + Services.prefs.setIntPref("calendar.view.visiblehours", 4); + } + + goToDay(date) { + this.showFullMonth = false; + this.displayDaysOff = !this.mWorkdaysOnly; + + // If date is null it means that only a refresh is needed + // without changing the start and end of the view. + if (date) { + date = date.getInTimezone(this.timezone); + + // Get the first date that should be shown. This is the + // start of the week of the day that we're centering around + // adjusted for the day the week starts on and the number + // of previous weeks we're supposed to display. + const dayStart = cal.weekInfoService.getStartOfWeek(date); + dayStart.day -= 7 * Services.prefs.getIntPref("calendar.previousweeks.inview", 0); + + // The last day we're supposed to show. + const dayEnd = dayStart.clone(); + dayEnd.day += 7 * this.mWeeksInView - 1; + this.setDateRange(dayStart, dayEnd); + this.selectedDay = date; + } else { + this.relayout(); + } + } + + moveView(weeksToMove) { + if (weeksToMove) { + const date = this.startDay.clone(); + const savedSelectedDay = this.selectedDay.clone(); + // weeksToMove only corresponds to the number of weeks to move + // make sure to compensate for previous weeks in view too. + const prevWeeks = Services.prefs.getIntPref("calendar.previousweeks.inview", 4); + date.day += 7 * (weeksToMove + prevWeeks); + this.goToDay(date); + savedSelectedDay.day += 7 * weeksToMove; + this.selectedDay = savedSelectedDay; + } else { + const date = cal.dtz.now(); + this.goToDay(date); + this.selectedDay = date; + } + } + } + + MozXULElement.implementCustomInterface(CalendarMultiweekView, [Ci.calICalendarView]); + + customElements.define("calendar-multiweek-view", CalendarMultiweekView); + + /** + * The calendar view for viewing a single month. + * + * @augments {MozElements.CalendarMonthBaseView} + * @implements {calICalendarView} + */ + class CalendarMonthView extends MozElements.CalendarMonthBaseView { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true via super.connectedCallback. + super.connectedCallback(); + } + + // calICalendarView Methods and Properties. + + get observerID() { + return "month-view-observer"; + } + + goToDay(date) { + this.displayDaysOff = !this.mWorkdaysOnly; + + this.showDate(date ? date.getInTimezone(this.timezone) : null); + if (!date) { + this.setDateBoxRelations(); + } + } + + getRangeDescription() { + const monthName = cal.l10n.formatMonth( + this.rangeStartDate.month + 1, + "calendar", + "monthInYear" + ); + + return cal.l10n.getCalString("monthInYear", [monthName, this.rangeStartDate.year]); + } + + moveView(number) { + const dates = this.getDateList(); + this.displayDaysOff = !this.mWorkdaysOnly; + + if (number) { + // The first few dates in this list are likely in the month + // prior to the one actually being shown (since the month + // probably doesn't start on a Sunday). The 7th item must + // be in correct month though. + const date = dates[6].clone(); + + date.month += number; + // Store selected day before we move. + const oldSelectedDay = this.selectedDay; + + this.goToDay(date); + + // Most of the time we want to select the date with the + // same day number in the next month. + const newSelectedDay = oldSelectedDay.clone(); + newSelectedDay.month += number; + + // Correct for accidental rollover into the next month. + if ((newSelectedDay.month - number + 12) % 12 != oldSelectedDay.month) { + newSelectedDay.month -= 1; + newSelectedDay.day = newSelectedDay.endOfMonth.day; + } + + this.selectedDay = newSelectedDay; + } else { + const date = cal.dtz.now(); + this.goToDay(date); + this.selectedDay = date; + } + } + + // End calICalendarView Methods and Properties. + } + + MozXULElement.implementCustomInterface(CalendarMonthView, [Ci.calICalendarView]); + + customElements.define("calendar-month-view", CalendarMonthView); +} 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> diff --git a/comm/calendar/base/content/imip-bar-overlay.inc.xhtml b/comm/calendar/base/content/imip-bar-overlay.inc.xhtml new file mode 100644 index 0000000000..06ac71a183 --- /dev/null +++ b/comm/calendar/base/content/imip-bar-overlay.inc.xhtml @@ -0,0 +1,296 @@ +# 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/. + +# This file requires the following localization files: +# chrome://lightning/locale/lightning.dtd + + <hbox id="imip-bar" + class="calendar-notification-bar" + collapsed="true" + label="&lightning.imipbar.description;" + align="center"> + <html:img src="chrome://messenger/skin/icons/new/normal/calendar-invite.svg" + alt="" /> + <description class="msgNotificationBarText" + flex="1"> + &lightning.imipbar.description; + </description> + + <!-- Some Toolbox implementation notes: + - + - css style: + - classes within toolbox are making use of existing TB css definitions - as used in + - /comm-central/source/mail/base/content/msgHdrView.inc, only icon defining + - classes like imipAcceptButton are noted separately and OS specific within + - skin/calendar.css (resp. the OS-specific theme folders) + - + - The toolbarbuttons will be adjusted dynamically in imip-bar.js based on their + - content of menuitems. To avoid breaking this, the following should be considered + - if adding/changing toolbarbutton definitions. + - general: + - * the toolbarbuttons will appear in order of definition + - within the toolbar if visible + - * must be hidden by default + - * menuitem inside must not be hidden by default + - simple button: + - * must not have a type attribute + - * may have menupopup/menuitem within (not displayed though) + - dropdown only: + - * must have type=menu + - * should have a menupopup with at least one menuitem + - smart-dropdown (toolbarbutton-menu-button) + - * must have type=menu + - * should have a menupopup with at least one menuitem + //--> + <vbox id="imip-view-toolbox" class="inline-toolbox"> + <hbox id="imip-view-toolbar" class="themeable-brighttext"> + + <!-- show event/invitation details --> + <toolbarbutton id="imipDetailsButton" + label="&lightning.imipbar.btnDetails.label;" + tooltiptext="&lightning.imipbar.btnDetails.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipDetailsButton" + oncommand="calImipBar.executeAction('X-SHOWDETAILS')" + hidden="true"/> + + <!-- decline counter --> + <toolbarbutton id="imipDeclineCounterButton" + label="&lightning.imipbar.btnDeclineCounter.label;" + tooltiptext="&lightning.imipbar.btnDeclineCounter.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipDeclineCounterButton" + oncommand="calImipBar.executeAction('X-DECLINECOUNTER')" + hidden="true"/> + + <!-- reschedule --> + <toolbarbutton id="imipRescheduleButton" + label="&lightning.imipbar.btnReschedule.label;" + tooltiptext="&lightning.imipbar.btnReschedule.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipRescheduleButton" + oncommand="calImipBar.executeAction('X-RESCHEDULE')" + hidden="true"/> + + <!-- add published events --> + <toolbarbutton id="imipAddButton" + label="&lightning.imipbar.btnAdd.label;" + tooltiptext="&lightning.imipbar.btnAdd.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipAddButton" + oncommand="calImipBar.executeAction()" + hidden="true"/> + + <!-- update published events and invitations --> + <toolbarbutton id="imipUpdateButton" + label="&lightning.imipbar.btnUpdate.label;" + tooltiptext="&lightning.imipbar.btnUpdate.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipUpdateButton" + oncommand="calImipBar.executeAction()" + hidden="true"/> + + <!-- delete cancelled events from calendar --> + <toolbarbutton id="imipDeleteButton" + label="&lightning.imipbar.btnDelete.label;" + tooltiptext="&lightning.imipbar.btnDelete.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipDeleteButton" + oncommand="calImipBar.executeAction()" + hidden="true"/> + + <!-- re-confirm partstat --> + <toolbarbutton id="imipReconfirmButton" + label="&lightning.imipbar.btnReconfirm2.label;" + tooltiptext="&lightning.imipbar.btnReconfirm.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipReconfirmButton" + oncommand="calImipBar.executeAction()" + hidden="true"/> + + <!-- go to calendar tab --> + <toolbarbutton id="imipGoToCalendarButton" + label="&lightning.imipbar.btnGoToCalendar.label;" + tooltiptext="&lightning.imipbar.btnGoToCalendar.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipGoToCalendarButton" + oncommand="cal.window.goToCalendar();" + hidden="true"/> + + <!-- accept --> + <toolbarbutton is="toolbarbutton-menu-button" id="imipAcceptButton" + tooltiptext="&lightning.imipbar.btnAccept2.tooltiptext;" + label="&lightning.imipbar.btnAccept.label;" + oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO');" + type="menu" + class="imip-button toolbarbutton-1 message-header-view-button imipAcceptButton" + hidden="true"> + <menupopup id="imipAcceptDropdown"> + <label id="imipAcceptButton_AcceptLabel" + class="imipAcceptLabel" + tooltiptext="&lightning.imipbar.btnAccept2.tooltiptext;" + value="&lightning.imipbar.btnAccept.label;"/> + <menuitem id="imipAcceptButton_Accept" + tooltiptext="&lightning.imipbar.btnSend.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipAcceptButton_AcceptDontSend" + tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('ACCEPTED', 'NONE'); event.stopPropagation();"/> + <separatpor flex="1" class="groove"/> + <label id="imipAcceptButton_TentativeLabel" + class="imipAcceptLabel" + tooltiptext="&lightning.imipbar.btnTentative2.tooltiptext;" + value="&lightning.imipbar.btnTentative.label;"/> + <menuitem id="imipAcceptButton_Tentative" + tooltiptext="&lightning.imipbar.btnSend.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipAcceptButton_TentativeDontSend" + tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/> + <!-- add here more menuitem as needed --> + </menupopup> + </toolbarbutton> + + <!-- accept recurrences --> + <toolbarbutton is="toolbarbutton-menu-button" id="imipAcceptRecurrencesButton" + tooltiptext="&lightning.imipbar.btnAcceptRecurrences2.tooltiptext;" + label="&lightning.imipbar.btnAcceptRecurrences.label;" + oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO');" + type="menu" + class="imip-button toolbarbutton-1 message-header-view-button imipAcceptRecurrencesButton" + hidden="true"> + <menupopup id="imipAcceptRecurrencesDropdown"> + <label id="imipAcceptRecurrencesButton_AcceptLabel" + class="imipAcceptLabel" + tooltiptext="&lightning.imipbar.btnAcceptRecurrences2.tooltiptext;" + value="&lightning.imipbar.btnAcceptRecurrences.label;"/> + <menuitem id="imipAcceptRecurrencesButton_Accept" + tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipAcceptRecurrencesButton_AcceptDontSend" + tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('ACCEPTED', 'NONE'); event.stopPropagation();"/> + <separatpor flex="1" class="groove"/> + <label id="imipAcceptRecurrencesButton_TentativeLabel" + class="imipAcceptLabel" + tooltiptext="&lightning.imipbar.btnTentativeRecurrences2.tooltiptext;" + value="&lightning.imipbar.btnTentativeRecurrences.label;"/> + <menuitem id="imipAcceptRecurrencesButton_Tentative" + tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipAcceptRecurrencesButton_TentativeDontSend" + tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/> + <!-- add here more menuitem as needed --> + </menupopup> + </toolbarbutton> + + <!-- tentative; should only be used, if no imipMoreButton is used and + - imipDeclineButton/imipAcceptButton have no visible menuitems //--> + <toolbarbutton is="toolbarbutton-menu-button" id="imipTentativeButton" + label="&lightning.imipbar.btnTentative.label;" + tooltiptext="&lightning.imipbar.btnTentative2.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipTentativeButton" + oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO');" + type="menu" + hidden="true"> + <menupopup id="imipTentativeDropdown"> + <menuitem id="imipTentativeButton_Tentative" + tooltiptext="&lightning.imipbar.btnSend.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipTentativeButton_TentativeDontSend" + tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/> + <!-- add here more menuitem as needed --> + </menupopup> + </toolbarbutton> + + <!-- tentative recurrences; should only be used, if no imipMoreButton is used and + - imipDeclineRecurrencesButton/imipAcceptRecurrencesButton have no visible menuitems //--> + <toolbarbutton is="toolbarbutton-menu-button" id="imipTentativeRecurrencesButton" + label="&lightning.imipbar.btnTentativeRecurrences.label;" + tooltiptext="&lightning.imipbar.btnTentativeRecurrences2.tooltiptext;" + class="toolbarbutton-1 message-header-view-button imipTentativeRecurrencesButton" + oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO');" + type="menu" + hidden="true"> + <menupopup id="imipTentativeRecurrencesDropdown"> + <menuitem id="imipTentativeRecurrencesButton_Tentative" + tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipTentativeRecurrencesButton_TentativeDontSend" + tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/> + <!-- add here more menuitem as needed --> + </menupopup> + </toolbarbutton> + + <!-- decline --> + <toolbarbutton is="toolbarbutton-menu-button" id="imipDeclineButton" + tooltiptext="&lightning.imipbar.btnDecline2.tooltiptext;" + label="&lightning.imipbar.btnDecline.label;" + oncommand="calImipBar.executeAction('DECLINED', 'AUTO');" + type="menu" + class="toolbarbutton-1 message-header-view-button imipDeclineButton" + hidden="true"> + <menupopup id="imipDeclineDropdown"> + <menuitem id="imipDeclineButton_Decline" + tooltiptext="&lightning.imipbar.btnSend.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('DECLINED', 'AUTO'); event.stopPropagation();"/> + <menuitem id="imipDeclineButton_DeclineDontSend" + tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('DECLINED', 'NONE'); event.stopPropagation();"/> + <!-- add here more menuitem as needed --> + </menupopup> + </toolbarbutton> + + <!-- decline recurrences --> + <toolbarbutton is="toolbarbutton-menu-button" id="imipDeclineRecurrencesButton" + tooltiptext="&lightning.imipbar.btnDeclineRecurrences2.tooltiptext;" + label="&lightning.imipbar.btnDeclineRecurrences.label;" + oncommand="calImipBar.executeAction('DECLINED', 'AUTO');" + type="menu" + class="toolbarbutton-1 message-header-view-button imipDeclineRecurrencesButton" + hidden="true"> + <menupopup id="imipDeclineRecurrencesDropdown"> + <menuitem id="imipDeclineRecurrencesButton_DeclineAll" + tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;" + label="&lightning.imipbar.btnSend.label;" + oncommand="calImipBar.executeAction('DECLINED'); event.stopPropagation();"/> + <menuitem id="imipDeclineRecurrencesButton_DeclineDontSend" + tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;" + label="&lightning.imipbar.btnDontSend.label;" + oncommand="calImipBar.executeAction('DECLINED', 'NONE'); event.stopPropagation();"/> + <!-- add here more menuitem as needed --> + </menupopup> + </toolbarbutton> + + <!-- more options --> + <toolbarbutton id="imipMoreButton" + type="menu" + wantdropmarker="true" + tooltiptext="&lightning.imipbar.btnMore.tooltiptext;" + label="&lightning.imipbar.btnMore.label;" + class="toolbarbutton-1 message-header-view-button imipMoreButton" + hidden="true"> + <menupopup id="imipMoreDropdown"> + <menuitem id="imipMoreButton_SaveCopy" + tooltiptext="&lightning.imipbar.btnSaveCopy.tooltiptext;" + label="&lightning.imipbar.btnSaveCopy.label;" + oncommand="calImipBar.executeAction('X-SAVECOPY'); event.stopPropagation();"/> + <menuitem id="imipMoreButton_DoNotShowImipBar" + label="&lightning.imipbar.btnDoNotShowImipBar.label;" + oncommand="calImipBar.doNotShowImipBar();"/> + <!-- add here a menuitem as needed --> + </menupopup> + </toolbarbutton> + </hbox> + </vbox> + </hbox> diff --git a/comm/calendar/base/content/imip-bar.js b/comm/calendar/base/content/imip-bar.js new file mode 100644 index 0000000000..f40e4cce22 --- /dev/null +++ b/comm/calendar/base/content/imip-bar.js @@ -0,0 +1,429 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../mail/base/content/msgHdrView.js */ +/* import-globals-from item-editing/calendar-item-editing.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Provides shortcuts to set label and collapsed attribute of imip-bar node. + */ +const imipBar = { + get bar() { + return document.querySelector(".calendar-notification-bar"); + }, + get label() { + return this.bar.querySelector(".msgNotificationBarText").textContent; + }, + set label(val) { + this.bar.querySelector(".msgNotificationBarText").textContent = val; + }, + get collapsed() { + return this.bar.collapsed; + }, + set collapsed(val) { + this.bar.collapsed = val; + }, +}; + +/** + * This bar lives inside the message window. + * Its lifetime is the lifetime of the main thunderbird message window. + */ +var calImipBar = { + actionFunc: null, + itipItem: null, + foundItems: null, + loadingItipItem: null, + + /** + * Thunderbird Message listener interface, hide the bar before we begin + */ + onStartHeaders() { + calImipBar.resetBar(); + }, + + /** + * Thunderbird Message listener interface + */ + onEndHeaders() {}, + + /** + * Load Handler called to initialize the imip bar + * NOTE: This function is called without a valid this-context! + */ + load() { + // Add a listener to gMessageListeners defined in msgHdrView.js + gMessageListeners.push(calImipBar); + + // Hook into this event to hide the message header pane otherwise, the imip + // bar will still be shown when changing folders. + document.getElementById("msgHeaderView").addEventListener("message-header-pane-hidden", () => { + calImipBar.resetBar(); + }); + + // Set up our observers + Services.obs.addObserver(calImipBar, "onItipItemCreation"); + }, + + /** + * Unload handler to clean up after the imip bar + * NOTE: This function is called without a valid this-context! + */ + unload() { + removeEventListener("messagepane-loaded", calImipBar.load, true); + removeEventListener("messagepane-unloaded", calImipBar.unload, true); + + calImipBar.resetBar(); + Services.obs.removeObserver(calImipBar, "onItipItemCreation"); + }, + + showImipBar(itipItem, imipMethod) { + if (!Services.prefs.getBoolPref("calendar.itip.showImipBar", true)) { + // Do not show the imip bar if the user has opted out of seeing it. + return; + } + + // How we get here: + // + // 1. `mime_find_class` finds the `CalMimeConverter` class matches the + // content-type of an attachment. + // 2. `mime_find_class` extracts the method from the attachments headers + // and sets `imipMethod` on the message's mail channel. + // 3. `CalMimeConverter` is called to generate the HTML in the message. + // It initialises `itipItem` and sets it on the channel. + // 4. msgHdrView.js gathers `itipItem` and `imipMethod` from the channel. + + cal.itip.initItemFromMsgData(itipItem, imipMethod, gMessage); + + if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { + window.dispatchEvent(new CustomEvent("onItipItemCreation", { detail: itipItem })); + } + + imipBar.collapsed = false; + imipBar.label = cal.itip.getMethodText(itipItem.receivedMethod); + + // This is triggered by CalMimeConverter.convertToHTML, so we know that + // the message is not yet loaded with the invite. Keep track of this for + // displayModifications. + calImipBar.overlayLoaded = false; + + if (!Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { + calImipBar.overlayLoaded = true; + + let doc = document.getElementById("messagepane").contentDocument; + let details = doc.getElementById("imipHTMLDetails"); + let msgbody = doc.querySelector("div.moz-text-html"); + if (!msgbody) { + details.setAttribute("open", "open"); + } else { + // The HTML representation can contain important notes. + + // For consistent appearance, move the generated meeting details first. + msgbody.prepend(details); + + if (Services.prefs.getBoolPref("calendar.itip.imipDetailsOpen", true)) { + // Expand the iMIP details if pref says so. + details.setAttribute("open", "open"); + } + } + } + // NOTE: processItipItem may call setupOptions asynchronously because the + // getItem method it triggers is async for *some* calendars. In theory, + // this could complete after a different item has been loaded, so we + // record the loading item now, and early exit setupOptions if the loading + // item has since changed. + // NOTE: loadingItipItem is reset on changing messages in resetBar. + calImipBar.loadingItipItem = itipItem; + cal.itip.processItipItem(itipItem, calImipBar.setupOptions); + + // NOTE: At this point we essentially have two parallel async operations: + // 1. Load the CalMimeConverter.convertToHTML into the #messagepane and + // then set overlayLoaded to true. + // 2. Find a corresponding event through processItipItem and then call + // setupOptions. Note that processItipItem may be instantaneous for + // some calendars. + // + // In the mean time, if we switch messages, then loadingItipItem will be + // set to some other value: either another item, or null by resetBar. + // + // Once setupOptions is called, if the message has since changed we do + // nothing and exit. Otherwise, if we found a corresponding item in the + // calendar, we proceed to displayModifications. If overlayLoaded is true + // we update the #messagepane immediately, otherwise we update it on + // DOMContentLoaded, which has not yet happened. + }, + + /** + * Hide the imip bar and reset the itip item. + */ + resetBar() { + imipBar.collapsed = true; + calImipBar.resetButtons(); + + // Clear our iMIP/iTIP stuff so it doesn't contain stale information. + cal.itip.cleanupItipItem(calImipBar.itipItem); + calImipBar.itipItem = null; + calImipBar.loadingItipItem = null; + }, + + /** + * Resets all buttons and its menuitems, all buttons are hidden thereafter + */ + resetButtons() { + let buttons = calImipBar.getButtons(); + for (let button of buttons) { + button.setAttribute("hidden", "true"); + for (let item of calImipBar.getMenuItems(button)) { + item.removeAttribute("hidden"); + } + } + }, + + /** + * Provides a list of all available buttons + */ + getButtons() { + let toolbarbuttons = document + .getElementById("imip-view-toolbar") + .getElementsByTagName("toolbarbutton"); + return Array.from(toolbarbuttons); + }, + + /** + * Provides a list of available menuitems of a button + * + * @param aButton button node + */ + getMenuItems(aButton) { + let items = []; + let mitems = aButton.getElementsByTagName("menuitem"); + if (mitems != null && mitems.length > 0) { + for (let mitem of mitems) { + items.push(mitem); + } + } + return items; + }, + + /** + * Checks and converts button types based on available menuitems of the buttons + * to avoid dropdowns which are empty or only replicating the default button action + * Should be called once the buttons are set up + */ + conformButtonType() { + // check only needed on visible and not simple buttons + let buttons = calImipBar + .getButtons() + .filter(aElement => aElement.hasAttribute("type") && !aElement.hidden); + // change button if appropriate + for (let button of buttons) { + let items = calImipBar.getMenuItems(button).filter(aItem => !aItem.hidden); + if (button.type == "menu" && items.length == 0) { + // hide non functional buttons + button.hidden = true; + } else if (button.type == "menu") { + if ( + items.length == 0 || + (items.length == 1 && + button.hasAttribute("oncommand") && + items[0].hasAttribute("oncommand") && + button.getAttribute("oncommand").endsWith(items[0].getAttribute("oncommand"))) + ) { + // convert to simple button + button.removeAttribute("type"); + } + } + } + }, + + /** + * This is our callback function that is called each time the itip bar UI needs updating. + * NOTE: This function is called without a valid this-context! + * + * @param itipItem The iTIP item to set up for + * @param rc The status code from processing + * @param actionFunc The action function called for execution + * @param foundItems An array of items found while searching for the item + * in subscribed calendars + */ + setupOptions(itipItem, rc, actionFunc, foundItems) { + if (itipItem !== calImipBar.loadingItipItem) { + // The given itipItem refers to an earlier displayed message. + return; + } + + let data = cal.itip.getOptionsText(itipItem, rc, actionFunc, foundItems); + + if (Components.isSuccessCode(rc)) { + calImipBar.itipItem = itipItem; + calImipBar.actionFunc = actionFunc; + calImipBar.foundItems = foundItems; + } + + // We need this to determine whether this is an outgoing or incoming message because + // Thunderbird doesn't provide a distinct flag on message level to do so. Relying on + // folder flags only may lead to false positives. + let isOutgoing = function (aMsgHdr) { + if (!aMsgHdr) { + return false; + } + let author = aMsgHdr.mime2DecodedAuthor; + let isSentFolder = aMsgHdr.folder && aMsgHdr.folder.flags & Ci.nsMsgFolderFlags.SentMail; + if (author && isSentFolder) { + for (let identity of MailServices.accounts.allIdentities) { + if (author.includes(identity.email) && !identity.fccReplyFollowsParent) { + return true; + } + } + } + return false; + }; + + // We override the bar label for sent out invitations and in case the event does not exist + // anymore, we also clear the buttons if any to avoid e.g. accept/decline buttons + if (isOutgoing(gMessage)) { + if (calImipBar.foundItems && calImipBar.foundItems[0]) { + data.label = cal.l10n.getLtnString("imipBarSentText"); + } else { + data = { + label: cal.l10n.getLtnString("imipBarSentButRemovedText"), + buttons: [], + hideMenuItems: [], + hideItems: [], + showItems: [], + }; + } + } + + imipBar.label = data.label; + // let's reset all buttons first + calImipBar.resetButtons(); + // now we update the visible items - buttons are hidden by default + // apart from that, we need this to adapt the accept button depending on + // whether three or four button style is present + for (let item of data.hideItems) { + document.getElementById(item).setAttribute("hidden", "true"); + } + for (let item of data.showItems) { + document.getElementById(item).removeAttribute("hidden"); + } + // adjust button style if necessary + calImipBar.conformButtonType(); + + calImipBar.displayModifications(); + }, + + /** + * Displays changes in case of invitation updates in invitation overlay. + * + * NOTE: This should only be called if the invitation is already loaded in the + * #messagepane, in which case calImipBar.overlayLoaded should be set to true, + * or is guaranteed to be loaded next in #messagepane. + */ + displayModifications() { + if ( + !calImipBar.foundItems || + !calImipBar.foundItems[0] || + !calImipBar.itipItem || + !Services.prefs.getBoolPref("calendar.itip.displayInvitationChanges", false) + ) { + return; + } + + let itipItem = calImipBar.itipItem; + let foundEvent = calImipBar.foundItems[0]; + let currentEvent = itipItem.getItemList()[0]; + let diff = cal.itip.compare(currentEvent, foundEvent); + if (diff != 0) { + let newEvent; + let oldEvent; + + if (diff == 1) { + // This is an update to previously accepted invitation. + oldEvent = foundEvent; + newEvent = currentEvent; + } else { + // This is a copy of a previously sent out invitation or a previous + // revision of a meanwhile accepted invitation, so we flip the order. + oldEvent = currentEvent; + newEvent = foundEvent; + } + + let browser = document.getElementById("messagepane"); + let doUpdate = () => { + if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { + return; + } + cal.invitation.updateInvitationOverlay( + browser.contentDocument, + newEvent, + itipItem, + oldEvent + ); + }; + if (calImipBar.overlayLoaded) { + // Document is already loaded. + doUpdate(); + } else { + // The event is not yet shown. This can happen if setupOptions is called + // before CalMimeConverter.convertToHTML has finished, or the + // corresponding HTML string has not yet been loaded. + // Wait until the event is shown, then immediately update it. + browser.addEventListener("DOMContentLoaded", doUpdate, { once: true }); + } + } + }, + + /** + * Executes an action triggered by an imip bar button + * + * @param {string} aParticipantStatus A partstat string as per RfC 5545 + * @param {string} aResponse Either 'AUTO', 'NONE' or 'USER', + * see calItipItem interface + * @returns {boolean} true, if the action succeeded + */ + executeAction(aParticipantStatus, aResponse) { + return cal.itip.executeAction( + window, + aParticipantStatus, + aResponse, + calImipBar.actionFunc, + calImipBar.itipItem, + calImipBar.foundItems, + ({ resetButtons, label }) => { + if (label != undefined) { + calImipBar.label = label; + } + if (resetButtons) { + calImipBar.resetButtons(); + } + } + ); + }, + + /** + * Hide the imip bar in all windows and set a pref to prevent it from being + * shown again. Called when clicking the imip bar's "do not show..." menu item. + */ + doNotShowImipBar() { + Services.prefs.setBoolPref("calendar.itip.showImipBar", false); + for (let window of Services.ww.getWindowEnumerator()) { + if (window.calImipBar) { + window.calImipBar.resetBar(); + } + } + }, +}; + +{ + let msgHeaderView = document.getElementById("msgHeaderView"); + if (msgHeaderView && msgHeaderView.loaded) { + calImipBar.load(); + } else { + addEventListener("messagepane-loaded", calImipBar.load, true); + } +} +addEventListener("messagepane-unloaded", calImipBar.unload, true); diff --git a/comm/calendar/base/content/import-export.js b/comm/calendar/base/content/import-export.js new file mode 100644 index 0000000000..1ee8f59ae7 --- /dev/null +++ b/comm/calendar/base/content/import-export.js @@ -0,0 +1,330 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from item-editing/calendar-item-editing.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/* exported loadEventsFromFile, exportEntireCalendar */ + +// File constants copied from file-utils.js +var MODE_RDONLY = 0x01; +var MODE_WRONLY = 0x02; +var MODE_CREATE = 0x08; +var MODE_TRUNCATE = 0x20; + +/** + * Loads events from a file into a calendar. If called without a file argument, + * the user is asked to pick a file. + * + * @param {nsIFile} [fileArg] - Optional, a file to load events from. + * @returns {Promise<boolean>} True if the import dialog was opened, false if + * not (e.g. on cancel of file picker dialog). + */ +async function loadEventsFromFile(fileArg) { + let file = fileArg; + if (!file) { + file = await pickFileToImport(); + if (!file) { + // Probably the user clicked "cancel" (no file and the promise was not + // rejected in pickFileToImport). + return false; + } + } + + Services.ww.openWindow( + null, + "chrome://calendar/content/calendar-ics-file-dialog.xhtml", + "_blank", + "chrome,titlebar,modal,centerscreen", + file + ); + return true; +} + +/** + * Show a file picker dialog and return the file. + * + * @returns {Promise<nsIFile | undefined>} The picked file or undefined if the + * user cancels the dialog. + */ +function pickFileToImport() { + return new Promise(resolve => { + let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + picker.init(window, cal.l10n.getCalString("filepickerTitleImport"), Ci.nsIFilePicker.modeOpen); + picker.defaultExtension = "ics"; + + let currentListLength = 0; + for (let { data } of Services.catMan.enumerateCategory("cal-importers")) { + let contractId = Services.catMan.getCategoryEntry("cal-importers", data); + let importer; + try { + importer = Cc[contractId].getService(Ci.calIImporter); + } catch (e) { + cal.WARN("Could not initialize importer: " + contractId + "\nError: " + e); + continue; + } + let types = importer.getFileTypes(); + for (let type of types) { + picker.appendFilter(type.description, type.extensionFilter); + if (type.extensionFilter == "*." + picker.defaultExtension) { + picker.filterIndex = currentListLength; + } + currentListLength++; + } + } + + picker.appendFilters(Ci.nsIFilePicker.filterAll); + picker.open(returnValue => { + if (returnValue == Ci.nsIFilePicker.returnCancel) { + resolve(); + return; + } + resolve(picker.file); + }); + }); +} + +/** + * Given an ICS file, return an array of calendar items parsed from it. + * + * @param {nsIFile} file - File to get items from. + * @returns {calIItemBase[]} Array of calendar items. + */ +function getItemsFromIcsFile(file) { + let importer = Cc["@mozilla.org/calendar/import;1?type=ics"].getService(Ci.calIImporter); + + let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + let items = []; + let exception; + + try { + inputStream.init(file, MODE_RDONLY, 0o444, {}); + items = importer.importFromStream(inputStream); + } catch (ex) { + exception = ex; + switch (ex.result) { + case Ci.calIErrors.INVALID_TIMEZONE: + cal.showError(cal.l10n.getCalString("timezoneError", [file.path]), window); + break; + default: + cal.showError(cal.l10n.getCalString("unableToRead") + file.path + "\n" + ex, window); + } + } finally { + inputStream.close(); + } + + if (!items.length && !exception) { + // The ics did not contain any events, so we should + // notify the user about it, if we haven't before. + cal.showError(cal.l10n.getCalString("noItemsInCalendarFile2", [file.path]), window); + } + + return items; +} + +/** + * @callback onProgress + * @param {number} count + * @param {number} total + */ + +/** + * @callback onError + * @param {calIItemBase} item - The item which failed to import. + * @param {number | nsIException} error The error number from Components.results, or + * the exception which contains the error number. + */ + +/** + * Listener for the stages of putItemsIntoCal(). + * + * @typedef PutItemsIntoCalListener + * @property {Function} onStart + * @property {onError} onDuplicate + * @property {onError} onError + * @property {onProgress} onProgress + * @property {Function} onEnd + */ + +/** + * Put items into a certain calendar, catching errors and showing them to the + * user. + * + * @param {calICalendar} destCal - The destination calendar. + * @param {calIItemBase[]} aItems - An array of items to put into the calendar. + * @param {string} aFilePath - The original file path, for error messages. + * @param {PutItemsIntoCalListener} [aListener] - Optional listener. + */ +async function putItemsIntoCal(destCal, aItems, aListener) { + async function callListener(method, ...args) { + if (aListener && typeof aListener[method] == "function") { + await aListener[method](...args); + } + } + + await callListener("onStart"); + + // Set batch for the undo/redo transaction manager + startBatchTransaction(); + + let count = 0; + let total = aItems.length; + + for (let item of aItems) { + try { + await destCal.addItem(item); + } catch (e) { + if (e == Ci.calIErrors.DUPLICATE_ID) { + await callListener("onDuplicate", item, e); + } else { + console.error(e); + await callListener("onError", item, e); + } + } + + count++; + await callListener("onProgress", count, total); + } + + // End transmgr batch + endBatchTransaction(); + + await callListener("onEnd"); +} + +/** + * Save data to a file. Create the file or overwrite an existing file. + * + * @param {calIEvent[]} calendarEventArray - Array of calendar events that should be saved to file. + * @param {string} [aDefaultFileName] - Initial filename shown in SaveAs dialog. + */ +function saveEventsToFile(calendarEventArray, aDefaultFileName) { + if (!calendarEventArray || !calendarEventArray.length) { + return; + } + + // Show the 'Save As' dialog and ask for a filename to save to + let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + picker.init(window, cal.l10n.getCalString("filepickerTitleExport"), Ci.nsIFilePicker.modeSave); + + let filename; + if (aDefaultFileName && aDefaultFileName.length && aDefaultFileName.length > 0) { + filename = aDefaultFileName; + } else if (calendarEventArray.length == 1 && calendarEventArray[0].title) { + filename = calendarEventArray[0].title; + } else { + filename = cal.l10n.getCalString("defaultFileName"); + } + // Remove characters usually illegal on the file system. + picker.defaultString = filename.replace(/[/\\?%*:|"<>]/g, "-"); + + picker.defaultExtension = "ics"; + + // Get a list of exporters + let contractids = []; + let currentListLength = 0; + let defaultCIDIndex = 0; + for (let { data } of Services.catMan.enumerateCategory("cal-exporters")) { + let contractid = Services.catMan.getCategoryEntry("cal-exporters", data); + let exporter; + try { + exporter = Cc[contractid].getService(Ci.calIExporter); + } catch (e) { + cal.WARN("Could not initialize exporter: " + contractid + "\nError: " + e); + continue; + } + let types = exporter.getFileTypes(); + for (let type of types) { + picker.appendFilter(type.description, type.extensionFilter); + if (type.extensionFilter == "*." + picker.defaultExtension) { + picker.filterIndex = currentListLength; + defaultCIDIndex = currentListLength; + } + contractids.push(contractid); + currentListLength++; + } + } + + // Now find out as what to save, convert the events and save to file. + picker.open(rv => { + if (rv == Ci.nsIFilePicker.returnCancel || !picker.file || !picker.file.path) { + return; + } + + let filterIndex = picker.filterIndex; + if (picker.filterIndex < 0 || picker.filterIndex > contractids.length) { + // For some reason the wrong filter was selected, assume default extension + filterIndex = defaultCIDIndex; + } + + let exporter = Cc[contractids[filterIndex]].getService(Ci.calIExporter); + + let filePath = picker.file.path; + if (!filePath.includes(".")) { + filePath += "." + exporter.getFileTypes()[0].defaultExtension; + } + + let outputStream; + let localFileInstance = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + localFileInstance.initWithPath(filePath); + + outputStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + try { + outputStream.init( + localFileInstance, + MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE, + parseInt("0664", 8), + 0 + ); + + // XXX Do the right thing with unicode and stuff. Or, again, should the + // exporter handle that? + exporter.exportToStream(outputStream, calendarEventArray, null); + outputStream.close(); + } catch (ex) { + cal.showError(cal.l10n.getCalString("unableToWrite") + filePath, window); + } + }); +} + +/** + * Exports all the events and tasks in a calendar. If aCalendar is not specified, + * the user will be prompted with a list of calendars to choose which one to export. + * + * @param aCalendar (optional) A specific calendar to export + */ +function exportEntireCalendar(aCalendar) { + let getItemsFromCal = async function (aCal) { + let items = await aCal.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null); + saveEventsToFile(items, aCal.name); + }; + + if (aCalendar) { + getItemsFromCal(aCalendar); + } else { + let calendars = cal.manager.getCalendars(); + + if (calendars.length == 1) { + // There's only one calendar, so it's silly to ask what calendar + // the user wants to import into. + getItemsFromCal(calendars[0]); + } else { + // Ask what calendar to import into + let args = {}; + args.onOk = getItemsFromCal; + args.promptText = cal.l10n.getCalString("exportPrompt"); + openDialog( + "chrome://calendar/content/chooseCalendarDialog.xhtml", + "_blank", + "chrome,titlebar,modal,resizable", + args + ); + } + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-editing.js b/comm/calendar/base/content/item-editing/calendar-item-editing.js new file mode 100644 index 0000000000..a280e62f48 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-editing.js @@ -0,0 +1,849 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../calendar-management.js */ +/* import-globals-from ../calendar-views-utils.js */ + +/* globals goUpdateCommand */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); +var { CalTransactionManager } = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAddTransaction: "resource:///modules/CalTransactionManager.jsm", + CalDeleteTransaction: "resource:///modules/CalTransactionManager.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalModifyTransaction: "resource:///modules/CalTransactionManager.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +/* exported modifyEventWithDialog, undo, redo, setContextPartstat */ + +/** + * The global calendar transaction manager. + * + * @type {CalTransactionManager} + */ +var gCalTransactionMgr = CalTransactionManager.getInstance(); + +/** + * If a batch transaction is active, it is stored here. + * + * @type {CalBatchTransaction?} + */ +var gCalBatchTransaction = null; + +/** + * Sets the default values for new items, taking values from either the passed + * parameters or the preferences. + * + * @param {calIItemBase} aItem - The item to set up. + * @param {?calICalendar} aCalendar - The calendar to apply. + * @param {?calIDateTime} aStartDate - The start date to set. + * @param {?calIDateTime} aEndDate - The end date/due date to set. + * @param {?calIDateTime} aInitialDate - The reference date for the date pickers. + * @param {boolean} [aForceAllday=false] - Force the event/task to be an all-day item. + * @param {calIAttendee[]} aAttendees - Attendees to add, if `aItem` is an event. + */ +function setDefaultItemValues( + aItem, + aCalendar = null, + aStartDate = null, + aEndDate = null, + aInitialDate = null, + aForceAllday = false, + aAttendees = [] +) { + function endOfDay(aDate) { + let eod = aDate ? aDate.clone() : cal.dtz.now(); + eod.hour = Services.prefs.getIntPref("calendar.view.dayendhour", 19); + eod.minute = 0; + eod.second = 0; + return eod; + } + function startOfDay(aDate) { + let sod = aDate ? aDate.clone() : cal.dtz.now(); + sod.hour = Services.prefs.getIntPref("calendar.view.daystarthour", 8); + sod.minute = 0; + sod.second = 0; + return sod; + } + + let initialDate = aInitialDate ? aInitialDate.clone() : cal.dtz.now(); + initialDate.isDate = true; + + if (aItem.isEvent()) { + if (aStartDate) { + aItem.startDate = aStartDate.clone(); + if (aStartDate.isDate && !aForceAllday) { + // This is a special case where the date is specified, but the + // time is not. To take care, we setup up the time to our + // default event start time. + aItem.startDate = cal.dtz.getDefaultStartDate(aItem.startDate); + } else if (aForceAllday) { + // If the event should be forced to be allday, then don't set up + // any default hours and directly make it allday. + aItem.startDate.isDate = true; + aItem.startDate.timezone = cal.dtz.floating; + } + } else { + // If no start date was passed, then default to the next full hour + // of today, but with the date of the selected day + aItem.startDate = cal.dtz.getDefaultStartDate(initialDate); + } + + if (aEndDate) { + aItem.endDate = aEndDate.clone(); + if (aForceAllday) { + // XXX it is currently not specified, how callers that force all + // day should pass the end date. Right now, they should make + // sure that the end date is 00:00:00 of the day after. + aItem.endDate.isDate = true; + aItem.endDate.timezone = cal.dtz.floating; + } + } else { + aItem.endDate = aItem.startDate.clone(); + if (aForceAllday) { + // All day events need to go to the beginning of the next day. + aItem.endDate.day++; + } else { + // If the event is not all day, then add the default event + // length. + aItem.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60); + } + } + + // Free/busy status is only valid for events, must not be set for tasks. + aItem.setProperty("TRANSP", cal.item.getEventDefaultTransparency(aForceAllday)); + + for (let attendee of aAttendees) { + aItem.addAttendee(attendee); + } + } else if (aItem.isTodo()) { + let now = cal.dtz.now(); + let initDate = initialDate ? initialDate.clone() : now; + initDate.isDate = false; + initDate.hour = now.hour; + initDate.minute = now.minute; + initDate.second = now.second; + + if (aStartDate) { + aItem.entryDate = aStartDate.clone(); + } else { + let defaultStart = Services.prefs.getStringPref("calendar.task.defaultstart", "none"); + if ( + Services.prefs.getIntPref("calendar.alarms.onfortodos", 0) == 1 && + defaultStart == "none" + ) { + // start date is required if we want to set an alarm + defaultStart = "offsetcurrent"; + } + + let units = Services.prefs.getStringPref("calendar.task.defaultstartoffsetunits", "minutes"); + if (!["days", "hours", "minutes"].includes(units)) { + units = "minutes"; + } + let startOffset = cal.createDuration(); + startOffset[units] = Services.prefs.getIntPref("calendar.task.defaultstartoffset", 0); + let start; + + switch (defaultStart) { + case "none": + break; + case "startofday": + start = startOfDay(initDate); + break; + case "tomorrow": + start = startOfDay(initDate); + start.day++; + break; + case "nextweek": + start = startOfDay(initDate); + start.day += 7; + break; + case "offsetcurrent": + start = initDate.clone(); + start.addDuration(startOffset); + break; + case "offsetnexthour": + start = initDate.clone(); + start.second = 0; + start.minute = 0; + start.hour++; + start.addDuration(startOffset); + break; + } + + if (start) { + aItem.entryDate = start; + } + } + + if (aEndDate) { + aItem.dueDate = aEndDate.clone(); + } else { + let defaultDue = Services.prefs.getStringPref("calendar.task.defaultdue", "none"); + + let units = Services.prefs.getStringPref("calendar.task.defaultdueoffsetunits", "minutes"); + if (!["days", "hours", "minutes"].includes(units)) { + units = "minutes"; + } + let dueOffset = cal.createDuration(); + dueOffset[units] = Services.prefs.getIntPref("calendar.task.defaultdueoffset", 0); + + let start = aItem.entryDate ? aItem.entryDate.clone() : initDate.clone(); + let due; + + switch (defaultDue) { + case "none": + break; + case "endofday": + due = endOfDay(start); + // go to tomorrow if we're past the end of today + if (start.compare(due) > 0) { + due.day++; + } + break; + case "tomorrow": + due = endOfDay(start); + due.day++; + break; + case "nextweek": + due = endOfDay(start); + due.day += 7; + break; + case "offsetcurrent": + due = start.clone(); + due.addDuration(dueOffset); + break; + case "offsetnexthour": + due = start.clone(); + due.second = 0; + due.minute = 0; + due.hour++; + due.addDuration(dueOffset); + break; + } + + if (aItem.entryDate && due && aItem.entryDate.compare(due) > 0) { + // due can't be earlier than start date. + due = aItem.entryDate; + } + + if (due) { + aItem.dueDate = due; + } + } + } + + // Calendar + aItem.calendar = aCalendar || getSelectedCalendar(); + + // Alarms + cal.alarms.setDefaultValues(aItem); +} + +/** + * Creates an event with the calendar event dialog. + * + * @param {?calICalendar} calendar - The calendar to create the event in + * @param {?calIDateTime} startDate - The event's start date. + * @param {?calIDateTime} endDate - The event's end date. + * @param {?string} summary - The event's title. + * @param {?calIEvent} event - A template event to show in the dialog + * @param {?boolean} forceAllDay - Make sure the event shown in the dialog is an all-day event. + * @param {?calIAttendee} attendees - Attendees to add to the event. + */ +function createEventWithDialog( + calendar, + startDate, + endDate, + summary, + event, + forceAllDay, + attendees +) { + let onNewEvent = function (item, opcalendar, originalItem, listener, extresponse = null) { + if (item.id) { + // If the item already has an id, then this is the result of + // saving the item without closing, and then saving again. + doTransaction("modify", item, opcalendar, originalItem, listener, extresponse); + } else { + // Otherwise, this is an addition + doTransaction("add", item, opcalendar, null, listener, extresponse); + } + }; + + if (event) { + if (!event.isMutable) { + event = event.clone(); + } + // If the event should be created from a template, then make sure to + // remove the id so that the item obtains a new id when doing the + // transaction + event.id = null; + + if (forceAllDay) { + event.startDate.isDate = true; + event.endDate.isDate = true; + if (event.startDate.compare(event.endDate) == 0) { + // For a one day all day event, the end date must be 00:00:00 of + // the next day. + event.endDate.day++; + } + } + + if (!event.calendar) { + event.calendar = calendar || getSelectedCalendar(); + } + } else { + event = new CalEvent(); + + let refDate = currentView().selectedDay?.clone(); + setDefaultItemValues(event, calendar, startDate, endDate, refDate, forceAllDay, attendees); + if (summary) { + event.title = summary; + } + } + openEventDialog(event, event.calendar, "new", onNewEvent); +} + +/** + * Creates a task with the calendar event dialog. + * + * @param calendar (optional) The calendar to create the task in + * @param dueDate (optional) The task's due date. + * @param summary (optional) The task's title. + * @param todo (optional) A template task to show in the dialog. + * @param initialDate (optional) The initial date for new task datepickers + */ +function createTodoWithDialog(calendar, dueDate, summary, todo, initialDate) { + let onNewItem = function (item, opcalendar, originalItem, listener, extresponse = null) { + if (item.id) { + // If the item already has an id, then this is the result of + // saving the item without closing, and then saving again. + doTransaction("modify", item, opcalendar, originalItem, listener, extresponse); + } else { + // Otherwise, this is an addition + doTransaction("add", item, opcalendar, null, listener, extresponse); + } + }; + + if (todo) { + // If the todo should be created from a template, then make sure to + // remove the id so that the item obtains a new id when doing the + // transaction + if (todo.id) { + todo = todo.clone(); + todo.id = null; + } + + if (!todo.calendar) { + todo.calendar = calendar || getSelectedCalendar(); + } + } else { + todo = new CalTodo(); + setDefaultItemValues(todo, calendar, null, dueDate, initialDate); + + if (summary) { + todo.title = summary; + } + } + + openEventDialog(todo, calendar, "new", onNewItem, initialDate); +} + +/** + * Opens the passed event item for viewing. This enables the modify callback in + * openEventDialog so invitation responses can be edited. + * + * @param {calIItemBase} item - The calendar item to view. + */ +function openEventDialogForViewing(item) { + function onDialogComplete(newItem, calendar, originalItem, listener, extresponse) { + doTransaction("modify", newItem, calendar, originalItem, listener, extresponse); + } + openEventDialog(item, item.calendar, "view", onDialogComplete); +} + +/** + * Modifies the passed event in the event dialog. + * + * @param aItem The item to modify. + * @param aPromptOccurrence If the user should be prompted to select if the + * parent item or occurrence should be modified. + * @param initialDate (optional) The initial date for new task datepickers + * @param aCounterProposal (optional) An object representing the counterproposal + * { + * {JsObject} result: { + * type: {String} "OK"|"OUTDATED"|"NOTLATESTUPDATE"|"ERROR"|"NODIFF" + * descr: {String} a technical description of the problem if type is ERROR or NODIFF, + * otherwise an empty string + * }, + * (empty if result.type = "ERROR"|"NODIFF"){Array} differences: [{ + * property: {String} a property that is subject to the proposal + * proposed: {String} the proposed value + * original: {String} the original value + * }] + * } + */ +function modifyEventWithDialog(aItem, aPromptOccurrence, initialDate = null, aCounterProposal) { + let dlg = cal.item.findWindow(aItem); + if (dlg) { + dlg.focus(); + return; + } + + let onModifyItem = function (item, calendar, originalItem, listener, extresponse = null) { + doTransaction("modify", item, calendar, originalItem, listener, extresponse); + }; + + let item = aItem; + let response; + if (aPromptOccurrence !== false) { + [item, , response] = promptOccurrenceModification(aItem, true, "edit"); + } + + if (item && (response || response === undefined)) { + openEventDialog(item, item.calendar, "modify", onModifyItem, initialDate, aCounterProposal); + } +} + +/** + * @callback onDialogComplete + * + * @param {calIItemBase} newItem + * @param {calICalendar} calendar + * @param {calIItemBase} originalItem + * @param {?calIOperationListener} listener + * @param {?object} extresponse + */ + +/** + * Opens the event dialog with the given item (task OR event). + * + * @param {calIItemBase} calendarItem - The item to open the dialog with. + * @param {calICalendar} calendar - The calendar to open the dialog with. + * @param {string} mode - The operation the dialog should do + * ("new", "view", "modify"). + * @param {onDialogComplete} callback - The callback to call when the dialog + * has completed. + * @param {?calIDateTime} initialDate - The initial date for new task + * datepickers. + * @param {?object} counterProposal - An object representing the + * counterproposal - see description + * for modifyEventWithDialog(). + */ +function openEventDialog( + calendarItem, + calendar, + mode, + callback, + initialDate = null, + counterProposal +) { + let dlg = cal.item.findWindow(calendarItem); + if (dlg) { + dlg.focus(); + return; + } + + // Set up some defaults + mode = mode || "new"; + calendar = calendar || getSelectedCalendar(); + let calendars = cal.manager.getCalendars(); + calendars = calendars.filter(cal.acl.isCalendarWritable); + + let isItemSupported; + if (calendarItem.isTodo()) { + isItemSupported = function (aCalendar) { + return aCalendar.getProperty("capabilities.tasks.supported") !== false; + }; + } else if (calendarItem.isEvent()) { + isItemSupported = function (aCalendar) { + return aCalendar.getProperty("capabilities.events.supported") !== false; + }; + } + + // Filter out calendars that don't support the given calendar item + calendars = calendars.filter(isItemSupported); + + // Filter out calendar/items that we cannot write to/modify + if (mode == "new") { + calendars = calendars.filter(cal.acl.userCanAddItemsToCalendar); + } else if (mode == "modify") { + calendars = calendars.filter(aCalendar => { + /* If the calendar is the item calendar, we check that the item + * can be modified. If the calendar is NOT the item calendar, we + * check that the user can remove items from that calendar and + * add items to the current one. + */ + let isSameCalendar = calendarItem.calendar == aCalendar; + let canModify = cal.acl.userCanModifyItem(calendarItem); + let canMoveItems = + cal.acl.userCanDeleteItemsFromCalendar(calendarItem.calendar) && + cal.acl.userCanAddItemsToCalendar(aCalendar); + + return isSameCalendar ? canModify : canMoveItems; + }); + } + + if ( + mode == "new" && + (!cal.acl.isCalendarWritable(calendar) || + !cal.acl.userCanAddItemsToCalendar(calendar) || + !isItemSupported(calendar)) + ) { + if (calendars.length < 1) { + // There are no writable calendars or no calendar supports the given + // item. Don't show the dialog. + return; + } + // Pick the first calendar that supports the item and is writable + calendar = calendars[0]; + if (calendarItem) { + // XXX The dialog currently uses the items calendar as a first + // choice. Since we are shortly before a release to keep + // regression risk low, explicitly set the item's calendar here. + calendarItem.calendar = calendars[0]; + } + } + + // Setup the window arguments + let args = {}; + args.calendarEvent = calendarItem; + args.calendar = calendar; + args.mode = mode; + args.onOk = callback; + args.initialStartDateValue = initialDate || cal.dtz.getDefaultStartDate(); + args.counterProposal = counterProposal; + args.inTab = Services.prefs.getBoolPref("calendar.item.editInTab", false); + // this will be called if file->new has been selected from within the dialog + args.onNewEvent = function (opcalendar) { + createEventWithDialog(opcalendar, null, null); + }; + args.onNewTodo = function (opcalendar) { + createTodoWithDialog(opcalendar); + }; + + // the dialog will reset this to auto when it is done loading. + window.setCursor("wait"); + + // Ask the provider if this item is an invitation. If this is the case, + // we'll open the summary dialog since the user is not allowed to change + // the details of the item. + let isInvitation = + calendar.supportsScheduling && calendar.getSchedulingSupport().isInvitation(calendarItem); + + // open the dialog modeless + let url; + let isEditable = mode == "modify" && !isInvitation && cal.acl.userCanModifyItem(calendarItem); + + if (cal.acl.isCalendarWritable(calendar) && (mode == "new" || isEditable)) { + // Currently the read-only summary dialog is never opened in a tab. + if (args.inTab) { + url = "chrome://calendar/content/calendar-item-iframe.xhtml"; + } else { + url = "chrome://calendar/content/calendar-event-dialog.xhtml"; + } + } else { + url = "chrome://calendar/content/calendar-summary-dialog.xhtml"; + args.inTab = false; + args.isInvitation = isInvitation; + } + + if (args.inTab) { + args.url = url; + let tabmail = document.getElementById("tabmail"); + let tabtype = args.calendarEvent.isEvent() ? "calendarEvent" : "calendarTask"; + tabmail.openTab(tabtype, args); + } else { + // open in a window + openDialog(url, "_blank", "chrome,titlebar,toolbar,resizable", args); + } +} + +/** + * Prompts the user how the passed item should be modified. If the item is an + * exception or already a parent item, the item is returned without prompting. + * If "all occurrences" is specified, the parent item is returned. If "this + * occurrence only" is specified, then aItem is returned. If "this and following + * occurrences" is selected, aItem's parentItem is modified so that the + * recurrence rules end (UNTIL) just before the given occurrence. If + * aNeedsFuture is specified, a new item is made from the part that was stripped + * off the passed item. + * + * EXDATEs and RDATEs that do not fit into the items recurrence are removed. If + * the modified item or the future item only consist of a single occurrence, + * they are changed to be single items. + * + * @param aItem The item or array of items to check. + * @param aNeedsFuture If true, the future item is parsed. + * This parameter can for example be + * false if a deletion is being made. + * @param aAction Either "edit" or "delete". Sets up + * the labels in the occurrence prompt + * @returns [modifiedItem, futureItem, promptResponse] + * modifiedItem is a single item or array + * of items depending on the past aItem + * + * If "this and all following" was chosen, + * an array containing the item *until* + * the given occurrence (modifiedItem), + * and the item *after* the given + * occurrence (futureItem). + * + * If any other option was chosen, + * futureItem is null and the + * modifiedItem is either the parent item + * or the passed occurrence, or null if + * the dialog was canceled. + * + * The promptResponse parameter gives the + * response of the dialog as a constant. + */ +function promptOccurrenceModification(aItem, aNeedsFuture, aAction) { + const CANCEL = 0; + const MODIFY_OCCURRENCE = 1; + const MODIFY_FOLLOWING = 2; + const MODIFY_PARENT = 3; + + let futureItems = false; + let pastItems = []; + let returnItem = null; + let type = CANCEL; + let items = Array.isArray(aItem) ? aItem : [aItem]; + + // Check if this actually is an instance of a recurring event + if (items.every(item => item == item.parentItem)) { + type = MODIFY_PARENT; + } else if (aItem && items.length) { + // Prompt the user. Setting modal blocks the dialog until it is closed. We + // use rv to pass our return value. + let rv = { value: CANCEL, items, action: aAction }; + window.openDialog( + "chrome://calendar/content/calendar-occurrence-prompt.xhtml", + "PromptOccurrenceModification", + "centerscreen,chrome,modal,titlebar", + rv + ); + type = rv.value; + } + + switch (type) { + case MODIFY_PARENT: + pastItems = items.map(item => item.parentItem); + break; + case MODIFY_FOLLOWING: + // TODO tbd in a different bug + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + case MODIFY_OCCURRENCE: + pastItems = items; + break; + case CANCEL: + // Since we have not set past or futureItem, the return below will + // take care. + break; + } + if (aItem) { + returnItem = Array.isArray(aItem) ? pastItems : pastItems[0]; + } + return [returnItem, futureItems, type]; +} + +// Undo/Redo code + +/** + * Create and commit a transaction with the given arguments to the transaction + * manager. Also updates the undo/redo menu. + * + * @param action The action to do. + * @param item The new item to add/modify/delete + * @param calendar The calendar to do the transaction on + * @param oldItem (optional) some actions require an old item + * @param observer (optional) the observer to call when complete. + * @param extResponse (optional) JS object with additional parameters for sending itip messages + * (see also description of checkAndSend in calItipUtils.jsm) + */ +async function doTransaction(action, item, calendar, oldItem, observer, extResponse = null) { + // This is usually a user-initiated transaction, so make sure the calendar + // this transaction is happening on is visible. + top.ensureCalendarVisible(calendar); + + let manager = gCalBatchTransaction || gCalTransactionMgr; + let trn; + switch (action) { + case "add": + trn = new CalAddTransaction(item, calendar, oldItem, extResponse); + break; + case "modify": + trn = new CalModifyTransaction(item, calendar, oldItem, extResponse); + break; + case "delete": + trn = new CalDeleteTransaction(item, calendar, oldItem, extResponse); + break; + default: + throw new Components.Exception( + `Invalid action specified "${action}"`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + await manager.commit(trn); + + // If a batch transaction is active, do not update the menu as + // endBatchTransaction() will take care of that. + if (gCalBatchTransaction) { + return; + } + + observer?.onTransactionComplete(trn.item, trn.oldItem); + updateUndoRedoMenu(); +} + +/** + * Undo the last operation done through the transaction manager. + */ +function undo() { + if (canUndo()) { + gCalTransactionMgr.undo(); + updateUndoRedoMenu(); + } +} + +/** + * Redo the last undone operation in the transaction manager. + */ +function redo() { + if (canRedo()) { + gCalTransactionMgr.redo(); + updateUndoRedoMenu(); + } +} + +/** + * Start a batch transaction on the transaction manager. + */ +function startBatchTransaction() { + gCalBatchTransaction = gCalTransactionMgr.beginBatch(); +} + +/** + * End a previously started batch transaction. NOTE: be sure to call this in a + * try-catch-finally-block in case you have code that could fail between + * startBatchTransaction and this call. + */ +function endBatchTransaction() { + gCalBatchTransaction = null; + updateUndoRedoMenu(); +} + +/** + * Checks if the last operation can be undone (or if there is a last operation + * at all). + */ +function canUndo() { + return gCalTransactionMgr.canUndo(); +} + +/** + * Checks if the last undone operation can be redone. + */ +function canRedo() { + return gCalTransactionMgr.canRedo(); +} + +/** + * Update the undo and redo commands. + */ +function updateUndoRedoMenu() { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); +} + +/** + * Updates the partstat of the calendar owner for specified items triggered by a + * context menu operation + * + * For a documentation of the expected bahaviours for different use cases of + * dealing with context menu partstat actions, see also setupAttendanceMenu(...) + * in calendar-ui-utils.js + * + * @param {EventTarget} aTarget the target of the triggering event + * @param {Array} aItems an array of calEvent or calIToDo items + */ +function setContextPartstat(aTarget, aItems) { + /** + * Provides the participation representing the user for a provided item + * + * @param {calEvent|calTodo} aItem The calendar item to inspect + * @returns {?calIAttendee} An calIAttendee object or null if no + * participant was detected + */ + function getParticipant(aItem) { + let party = null; + if (cal.itip.isInvitation(aItem)) { + party = cal.itip.getInvitedAttendee(aItem); + } else if (aItem.organizer && aItem.getAttendees().length) { + let calOrgId = aItem.calendar.getProperty("organizerId"); + if (calOrgId.toLowerCase() == aItem.organizer.id.toLowerCase()) { + party = aItem.organizer; + } + } + return party; + } + + startBatchTransaction(); + try { + // TODO: make sure we overwrite the partstat of all occurrences in + // the selection, if the partstat of the respective master item is + // changed - see matrix in the doc block of setupAttendanceMenu(...) + // in calendar-ui-utils.js + + for (let oldItem of aItems) { + // Skip this item if its calendar is read only. + if (oldItem.calendar.readOnly) { + continue; + } + if (aTarget.getAttribute("scope") == "all-occurrences") { + oldItem = oldItem.parentItem; + } + let attendee = getParticipant(oldItem); + if (attendee) { + // skip this item if the partstat for the participant hasn't + // changed. otherwise we would always perform update operations + // for recurring events on both, the master and the occurrence + // item + let partStat = aTarget.getAttribute("respvalue"); + if (attendee.participationStatus == partStat) { + continue; + } + + let newItem = oldItem.clone(); + let newAttendee = attendee.clone(); + newAttendee.participationStatus = partStat; + if (newAttendee.isOrganizer) { + newItem.organizer = newAttendee; + } else { + newItem.removeAttendee(attendee); + newItem.addAttendee(newAttendee); + } + + let extResponse = null; + if (aTarget.hasAttribute("respmode")) { + let mode = aTarget.getAttribute("respmode"); + let itipMode = Ci.calIItipItem[mode]; + extResponse = { responseMode: itipMode }; + } + + doTransaction("modify", newItem, newItem.calendar, oldItem, null, extResponse); + } + } + } catch (e) { + cal.ERROR("Error setting partstat: " + e + "\r\n"); + } finally { + endBatchTransaction(); + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-iframe.js b/comm/calendar/base/content/item-editing/calendar-item-iframe.js new file mode 100644 index 0000000000..bdabd21356 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-iframe.js @@ -0,0 +1,4302 @@ +/* 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 onEventDialogUnload, changeUndiscloseCheckboxStatus, + * categoryPopupHiding, categoryTextboxKeypress, + * toggleKeepDuration, dateTimeControls2State, onUpdateAllDay, + * openNewEvent, openNewTask, openNewMessage, + * deleteAllAttachments, copyAttachment, attachmentLinkKeyPress, + * attachmentDblClick, attachmentClick, notifyUser, + * removeNotification, chooseRecentTimezone, showTimezonePopup, + * attendeeDblClick, setAttendeeContext, removeAttendee, + * removeAllAttendees, sendMailToUndecidedAttendees, checkUntilDate, + * applyValues + */ + +/* global MozElements */ + +/* import-globals-from ../../../../mail/components/compose/content/editor.js */ +/* import-globals-from ../../../../mail/components/compose/content/editorUtilities.js */ +/* import-globals-from ../calendar-ui-utils.js */ +/* import-globals-from ../dialogs/calendar-dialog-utils.js */ +/* globals gTimezonesEnabled */ // Set by calendar-item-panel.js. + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { + recurrenceRule2String, + splitRecurrenceRules, + checkRecurrenceRule, + countOccurrences, + hasUnsupported, +} = ChromeUtils.import("resource:///modules/calendar/calRecurrenceUtils.jsm"); +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +window.addEventListener("load", onLoad); +window.addEventListener("unload", onEventDialogUnload); + +var cloudFileAccounts; +try { + ({ cloudFileAccounts } = ChromeUtils.import("resource:///modules/cloudFileAccounts.jsm")); +} catch (e) { + // This will fail on Seamonkey, but that's ok since the pref for cloudfiles + // is false, which means the UI will not be shown +} + +// the following variables are constructed if the jsContext this file +// belongs to gets constructed. all those variables are meant to be accessed +// from within this file only. +var gStartTime = null; +var gEndTime = null; +var gItemDuration = null; +var gStartTimezone = null; +var gEndTimezone = null; +var gUntilDate = null; +var gIsReadOnly = false; +var gAttachMap = {}; +var gConfirmCancel = true; +var gLastRepeatSelection = 0; +var gIgnoreUpdate = false; +var gWarning = false; +var gPreviousCalendarId = null; +var gTabInfoObject; +var gLastAlarmSelection = 0; +var gConfig = { + priority: 0, + privacy: null, + status: "NONE", + showTimeAs: null, + percentComplete: 0, +}; +// The following variables are set by the load handler function of the +// parent context, so that they are already set before iframe content load: +// - gTimezoneEnabled + +XPCOMUtils.defineLazyGetter(this, "gEventNotification", () => { + return new MozElements.NotificationBox(element => { + document.getElementById("event-dialog-notifications").append(element); + }); +}); + +var eventDialogRequestObserver = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(aSubject, aTopic, aData) { + if ( + aTopic == "http-on-modify-request" && + aSubject instanceof Ci.nsIChannel && + aSubject.loadInfo && + aSubject.loadInfo.loadingDocument && + aSubject.loadInfo.loadingDocument == + document.getElementById("item-description").contentDocument + ) { + aSubject.cancel(Cr.NS_ERROR_ABORT); + } + }, +}; + +var eventDialogQuitObserver = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(aSubject, aTopic, aData) { + // Check whether or not we want to veto the quit request (unless another + // observer already did. + if ( + aTopic == "quit-application-requested" && + aSubject instanceof Ci.nsISupportsPRBool && + !aSubject.data + ) { + aSubject.data = !onCancel(); + } + }, +}; + +var eventDialogCalendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + + target: null, + isObserving: false, + + onModifyItem(aNewItem, aOldItem) { + if ( + this.isObserving && + "calendarItem" in window && + window.calendarItem && + window.calendarItem.id == aOldItem.id + ) { + let doUpdate = true; + + // The item has been modified outside the dialog. We only need to + // prompt if there have been local changes also. + if (isItemChanged()) { + let promptTitle = cal.l10n.getCalString("modifyConflictPromptTitle"); + let promptMessage = cal.l10n.getCalString("modifyConflictPromptMessage"); + let promptButton1 = cal.l10n.getCalString("modifyConflictPromptButton1"); + let promptButton2 = cal.l10n.getCalString("modifyConflictPromptButton2"); + let flags = + Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_0 + + Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_1; + + let choice = Services.prompt.confirmEx( + window, + promptTitle, + promptMessage, + flags, + promptButton1, + promptButton2, + null, + null, + {} + ); + if (!choice) { + doUpdate = false; + } + } + + let item = aNewItem; + if (window.calendarItem.recurrenceId && aNewItem.recurrenceInfo) { + item = aNewItem.recurrenceInfo.getOccurrenceFor(window.calendarItem.recurrenceId) || item; + } + window.calendarItem = item; + + if (doUpdate) { + loadDialog(window.calendarItem); + } + } + }, + + onDeleteItem(aDeletedItem) { + if ( + this.isObserving && + "calendarItem" in window && + window.calendarItem && + window.calendarItem.id == aDeletedItem.id + ) { + cancelItem(); + } + }, + + onStartBatch() {}, + onEndBatch() {}, + onLoad() {}, + onAddItem() {}, + onError() {}, + onPropertyChanged() {}, + onPropertyDeleting() {}, + + observe(aCalendar) { + // use the new calendar if one was passed, otherwise use the last one + this.target = aCalendar || this.target; + if (this.target) { + this.cancel(); + this.target.addObserver(this); + this.isObserving = true; + } + }, + + cancel() { + if (this.isObserving && this.target) { + this.target.removeObserver(this); + this.isObserving = false; + } + }, +}; + +/** + * Checks if the given calendar supports notifying attendees. The item is needed + * since calendars may support notifications for only some types of items. + * + * @param {calICalendar} aCalendar - The calendar to check + * @param {calIItemBase} aItem - The item to check support for + */ +function canNotifyAttendees(aCalendar, aItem) { + try { + let calendar = aCalendar.QueryInterface(Ci.calISchedulingSupport); + return calendar.canNotify("REQUEST", aItem) && calendar.canNotify("CANCEL", aItem); + } catch (exc) { + return false; + } +} + +/** + * Sends an asynchronous message to the parent context that contains the + * iframe. Additional properties of aMessage are generally arguments + * that will be passed to the function named in aMessage.command. + * + * @param {object} aMessage - The message to pass to the parent context + * @param {string} aMessage.command - The name of a function to call + */ +function sendMessage(aMessage) { + parent.postMessage(aMessage, "*"); +} + +/** + * Receives asynchronous messages from the parent context that contains the iframe. + * + * @param {MessageEvent} aEvent - Contains the message being received + */ +function receiveMessage(aEvent) { + let validOrigin = gTabmail ? "chrome://messenger" : "chrome://calendar"; + if (aEvent.origin !== validOrigin) { + return; + } + switch (aEvent.data.command) { + case "editAttendees": + editAttendees(); + break; + case "attachURL": + attachURL(); + break; + case "onCommandDeleteItem": + onCommandDeleteItem(); + break; + case "onCommandSave": + onCommandSave(aEvent.data.isClosing); + break; + case "onAccept": + onAccept(); + break; + case "onCancel": + onCancel(aEvent.data.iframeId); + break; + case "openNewEvent": + openNewEvent(); + break; + case "openNewTask": + openNewTask(); + break; + case "editConfigState": { + Object.assign(gConfig, aEvent.data.argument); + updateConfigState(aEvent.data.argument); + break; + } + case "editToDoStatus": { + let textbox = document.getElementById("percent-complete-textbox"); + textbox.value = aEvent.data.value; + updateToDoStatus("percent-changed"); + break; + } + case "postponeTask": + postponeTask(aEvent.data.value); + break; + case "toggleTimezoneLinks": + gTimezonesEnabled = aEvent.data.checked; // eslint-disable-line + updateDateTime(); + break; + case "closingWindowWithTabs": { + let response = onCancel(aEvent.data.id, true); + sendMessage({ + command: "replyToClosingWindowWithTabs", + response, + }); + break; + } + case "attachFileByAccountKey": + attachFileByAccountKey(aEvent.data.accountKey); + break; + case "triggerUpdateSaveControls": + updateParentSaveControls(); + break; + } +} + +/** + * Sets up the event dialog from the window arguments, also setting up all + * dialog controls from the window's item. + */ +function onLoad() { + window.addEventListener("message", receiveMessage); + + // first of all retrieve the array of + // arguments this window has been called with. + let args = window.arguments[0]; + + intializeTabOrWindowVariables(); + + // Needed so we can call switchToTab for the prompt about saving + // unsaved changes, to show the tab that the prompt is for. + if (gInTab) { + gTabInfoObject = gTabmail.currentTabInfo; + } + + // the most important attribute we expect from the + // arguments is the item we'll edit in the dialog. + let item = args.calendarEvent; + + // set the iframe's top level id for event vs task + if (item.isTodo()) { + setDialogId(document.documentElement, "calendar-task-dialog-inner"); + } + + document.getElementById("item-title").placeholder = cal.l10n.getString( + "calendar-event-dialog", + item.isEvent() ? "newEvent" : "newTask" + ); + + window.onAcceptCallback = args.onOk; + window.mode = args.mode; + + // we store the item in the window to be able + // to access this from any location. please note + // that the item is either an occurrence [proxy] + // or the stand-alone item [single occurrence item]. + window.calendarItem = item; + // store the initial date value for datepickers in New Task dialog + window.initialStartDateValue = args.initialStartDateValue; + + window.attendeeTabLabel = document.getElementById("event-grid-tab-attendees").label; + window.attachmentTabLabel = document.getElementById("event-grid-tab-attachments").label; + + // Store the array of attendees on the window for later retrieval. Clone each + // existing attendee to prevent modifying objects referenced elsewhere. + const attendees = item.getAttendees() ?? []; + window.attendees = attendees.map(attendee => attendee.clone()); + + window.organizer = null; + if (item.organizer) { + window.organizer = item.organizer.clone(); + } else if (attendees.length > 0) { + // Previous versions of calendar may not have set the organizer correctly. + let organizerId = item.calendar.getProperty("organizerId"); + if (organizerId) { + let organizer = new CalAttendee(); + organizer.id = cal.email.removeMailTo(organizerId); + organizer.commonName = item.calendar.getProperty("organizerCN"); + organizer.isOrganizer = true; + window.organizer = organizer; + } + } + + // we store the recurrence info in the window so it + // can be accessed from any location. since the recurrence + // info is a property of the parent item we need to check + // whether or not this item is a proxy or a parent. + let parentItem = item; + if (parentItem.parentItem != parentItem) { + parentItem = parentItem.parentItem; + } + + window.recurrenceInfo = null; + if (parentItem.recurrenceInfo) { + window.recurrenceInfo = parentItem.recurrenceInfo.clone(); + } + + // Set initial values for datepickers in New Tasks dialog + if (item.isTodo()) { + let initialDatesValue = cal.dtz.dateTimeToJsDate(args.initialStartDateValue); + document.getElementById("completed-date-picker").value = initialDatesValue; + document.getElementById("todo-entrydate").value = initialDatesValue; + document.getElementById("todo-duedate").value = initialDatesValue; + } + loadDialog(window.calendarItem); + + if (args.counterProposal) { + window.counterProposal = args.counterProposal; + displayCounterProposal(); + } + + gMainWindow.setCursor("auto"); + + document.getElementById("item-title").select(); + + // This causes the app to ask if the window should be closed when the + // application is closed. + Services.obs.addObserver(eventDialogQuitObserver, "quit-application-requested"); + + // This stops the editor from loading remote HTTP(S) content. + Services.obs.addObserver(eventDialogRequestObserver, "http-on-modify-request"); + + // Normally, Enter closes a <dialog>. We want this to rather on Ctrl+Enter. + // Stopping event propagation doesn't seem to work, so just overwrite the + // function that does this. + if (!gInTab) { + document.documentElement._hitEnter = function () {}; + } + + // set up our calendar event observer + eventDialogCalendarObserver.observe(item.calendar); + + // Disable save and save close buttons and menuitems if the item + // title is empty. + updateTitle(); + + cal.view.colorTracker.registerWindow(window); + + top.document.commandDispatcher.addCommandUpdater( + document.getElementById("styleMenuItems"), + "style", + "*" + ); + EditorSharedStartup(); + + // We want to keep HTML output as simple as possible, so don't try to use divs + // as separators. As a bonus, this avoids a bug in the editor which sometimes + // causes the user to have to hit enter twice for it to take effect. + const editor = GetCurrentEditor(); + editor.document.execCommand("defaultparagraphseparator", false, "br"); + + onLoad.hasLoaded = true; +} +// Set a variable to allow or prevent actions before the dialog is done loading. +onLoad.hasLoaded = false; + +function onEventDialogUnload() { + Services.obs.removeObserver(eventDialogRequestObserver, "http-on-modify-request"); + Services.obs.removeObserver(eventDialogQuitObserver, "quit-application-requested"); + eventDialogCalendarObserver.cancel(); +} + +/** + * Handler function to be called when the accept button is pressed. + * + * @returns Returns true if the window should be closed + */ +function onAccept() { + dispose(); + onCommandSave(true); + if (!gWarning) { + sendMessage({ command: "closeWindowOrTab" }); + } + return !gWarning; +} + +/** + * Asks the user if the item should be saved and does so if requested. If the + * user cancels, the window should stay open. + * + * XXX Could possibly be consolidated into onCancel() + * + * @returns Returns true if the window should be closed. + */ +function onCommandCancel() { + // Allow closing if the item has not changed and no warning dialog has to be showed. + if (!isItemChanged() && !gWarning) { + return true; + } + + if (gInTab && gTabInfoObject) { + // Switch to the tab that the prompt refers to. + gTabmail.switchToTab(gTabInfoObject); + } + + let promptTitle = cal.l10n.getCalString( + window.calendarItem.isEvent() ? "askSaveTitleEvent" : "askSaveTitleTask" + ); + let promptMessage = cal.l10n.getCalString( + window.calendarItem.isEvent() ? "askSaveMessageEvent" : "askSaveMessageTask" + ); + + let flags = + Ci.nsIPromptService.BUTTON_TITLE_SAVE * Ci.nsIPromptService.BUTTON_POS_0 + + Ci.nsIPromptService.BUTTON_TITLE_CANCEL * Ci.nsIPromptService.BUTTON_POS_1 + + Ci.nsIPromptService.BUTTON_TITLE_DONT_SAVE * Ci.nsIPromptService.BUTTON_POS_2; + + let choice = Services.prompt.confirmEx( + null, + promptTitle, + promptMessage, + flags, + null, + null, + null, + null, + {} + ); + switch (choice) { + case 0: // Save + let itemTitle = document.getElementById("item-title"); + if (!itemTitle.value) { + itemTitle.value = cal.l10n.getCalString("eventUntitled"); + } + onCommandSave(true); + return true; + case 2: // Don't save + // Don't show any warning dialog when closing without saving. + gWarning = false; + return true; + default: + // Cancel + return false; + } +} + +/** + * Handler function to be called when the cancel button is pressed. + * aPreventClose is true when closing the main window but leaving the tab open. + * + * @param {string} aIframeId (optional) iframe id of the tab to be closed + * @param {boolean} aPreventClose (optional) True means don't close, just ask about saving + * @returns {boolean} True if the tab or window should be closed + */ +function onCancel(aIframeId, aPreventClose) { + // The datepickers need to remove the focus in order to trigger the + // validation of the values just edited, with the keyboard, but not yet + // confirmed (i.e. not followed by a click, a tab or enter keys pressure). + document.documentElement.focus(); + + if (!gConfirmCancel || (gConfirmCancel && onCommandCancel())) { + dispose(); + // Don't allow closing the dialog when the user inputs a wrong + // date then closes the dialog and answers with "Save" in + // the "Save Event" dialog. Don't allow closing the dialog if + // the main window is being closed but the tabs in it are not. + + if (!gWarning && !aPreventClose) { + sendMessage({ command: "closeWindowOrTab", iframeId: aIframeId }); + } + return !gWarning; + } + return false; +} + +/** + * Cancels (closes) either the window or the tab, for example when the + * item is being deleted. + */ +function cancelItem() { + gConfirmCancel = false; + if (gInTab) { + onCancel(); + } else { + sendMessage({ command: "cancelDialog" }); + } +} + +/** + * Get the currently selected calendar from the menulist of calendars. + * + * @returns The currently selected calendar. + */ +function getCurrentCalendar() { + return document.getElementById("item-calendar").selectedItem.calendar; +} + +/** + * Sets up all dialog controls from the information of the passed item. + * + * @param aItem The item to parse information out of. + */ +function loadDialog(aItem) { + loadDateTime(aItem); + + document.getElementById("item-title").value = aItem.title; + document.getElementById("item-location").value = aItem.getProperty("LOCATION"); + + // add calendars to the calendar menulist + let calendarList = document.getElementById("item-calendar"); + let indexToSelect = appendCalendarItems( + aItem, + calendarList, + aItem.calendar || window.arguments[0].calendar + ); + if (indexToSelect > -1) { + calendarList.selectedIndex = indexToSelect; + } + + // Categories + loadCategories(aItem); + + // Attachment + loadCloudProviders(); + + let hasAttachments = capSupported("attachments"); + let attachments = aItem.getAttachments(); + if (hasAttachments && attachments && attachments.length > 0) { + for (let attachment of attachments) { + addAttachment(attachment); + } + } else { + updateAttachment(); + } + + // URL link + let itemUrl = window.calendarItem.getProperty("URL")?.trim() || ""; + let showLink = showOrHideItemURL(itemUrl); + updateItemURL(showLink, itemUrl); + + // Description + let editorElement = document.getElementById("item-description"); + let editor = editorElement.getHTMLEditor(editorElement.contentWindow); + + let link = editorElement.contentDocument.createElement("link"); + link.rel = "stylesheet"; + link.href = "chrome://messenger/skin/shared/editorContent.css"; + editorElement.contentDocument.head.appendChild(link); + + try { + let checker = editor.getInlineSpellChecker(true); + checker.enableRealTimeSpell = Services.prefs.getBoolPref("mail.spellcheck.inline", true); + } catch (ex) { + // No dictionaries. + } + + if (aItem.descriptionText) { + let docFragment = cal.view.textToHtmlDocumentFragment( + aItem.descriptionText, + editorElement.contentDocument, + aItem.descriptionHTML + ); + editor.flags = + editor.eEditorMailMask | editor.eEditorNoCSSMask | editor.eEditorAllowInteraction; + editor.enableUndo(false); + editor.forceCompositionEnd(); + editor.rootElement.replaceChildren(docFragment); + // This reinitialises the editor after we replaced its contents. + editor.insertText(""); + editor.enableUndo(true); + } + + editor.resetModificationCount(); + + if (aItem.isTodo()) { + // Task completed date + if (aItem.completedDate) { + updateToDoStatus(aItem.status, cal.dtz.dateTimeToJsDate(aItem.completedDate)); + } else { + updateToDoStatus(aItem.status); + } + + // Task percent complete + let percentCompleteInteger = 0; + let percentCompleteProperty = aItem.getProperty("PERCENT-COMPLETE"); + if (percentCompleteProperty != null) { + percentCompleteInteger = parseInt(percentCompleteProperty, 10); + } + if (percentCompleteInteger < 0) { + percentCompleteInteger = 0; + } else if (percentCompleteInteger > 100) { + percentCompleteInteger = 100; + } + gConfig.percentComplete = percentCompleteInteger; + document.getElementById("percent-complete-textbox").value = percentCompleteInteger; + } + + // When in a window, set Item-Menu label to Event or Task + if (!gInTab) { + let isEvent = aItem.isEvent(); + + let labelString = isEvent ? "itemMenuLabelEvent" : "itemMenuLabelTask"; + let label = cal.l10n.getString("calendar-event-dialog", labelString); + + let accessKeyString = isEvent ? "itemMenuAccesskeyEvent2" : "itemMenuAccesskeyTask2"; + let accessKey = cal.l10n.getString("calendar-event-dialog", accessKeyString); + sendMessage({ + command: "initializeItemMenu", + label, + accessKey, + }); + } + + // Repeat details + let [repeatType, untilDate] = getRepeatTypeAndUntilDate(aItem); + loadRepeat(repeatType, untilDate, aItem); + + // load reminders details + let alarmsMenu = document.querySelector(".item-alarm"); + window.gLastAlarmSelection = loadReminders(aItem.getAlarms(), alarmsMenu, getCurrentCalendar()); + + // Synchronize link-top-image with keep-duration-button status + let keepAttribute = document.getElementById("keepduration-button").getAttribute("keep") == "true"; + document.getElementById("link-image-top").setAttribute("keep", keepAttribute); + + updateDateTime(); + + updateCalendar(); + + let notifyCheckbox = document.getElementById("notify-attendees-checkbox"); + let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox"); + let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox"); + if (canNotifyAttendees(aItem.calendar, aItem)) { + // visualize that the server will send out mail: + notifyCheckbox.checked = true; + // hide these controls as this a client only feature + undiscloseCheckbox.disabled = true; + } else { + let itemProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS"); + notifyCheckbox.checked = + aItem.calendar.getProperty("imip.identity") && + (itemProp === null + ? Services.prefs.getBoolPref("calendar.itip.notify", true) + : itemProp == "TRUE"); + let undiscloseProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED"); + undiscloseCheckbox.checked = + undiscloseProp === null + ? Services.prefs.getBoolPref("calendar.itip.separateInvitationPerAttendee") + : undiscloseProp == "TRUE"; + // disable checkbox, if notifyCheckbox is not checked + undiscloseCheckbox.disabled = !notifyCheckbox.checked; + } + // this may also be a server exposed calendar property from exchange servers - if so, this + // probably should overrule the client-side config option + let disallowCounterProp = aItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER"); + disallowcounterCheckbox.checked = disallowCounterProp == "TRUE"; + // if we're in reschedule mode, it's pointless to enable the control + disallowcounterCheckbox.disabled = !!window.counterProposal; + + updateAttendeeInterface(); + updateRepeat(true); + updateReminder(true); + + // Status + if (aItem.isEvent()) { + gConfig.status = aItem.hasProperty("STATUS") ? aItem.getProperty("STATUS") : "NONE"; + if (gConfig.status == "NONE") { + sendMessage({ command: "showCmdStatusNone" }); + } + updateConfigState({ status: gConfig.status }); + } else { + let itemStatus = aItem.getProperty("STATUS"); + let todoStatus = document.getElementById("todo-status"); + todoStatus.value = itemStatus; + if (!todoStatus.selectedItem) { + // No selected item means there was no <menuitem> that matches the + // value given. Select the "NONE" item by default. + todoStatus.value = "NONE"; + } + } + + // Priority, Privacy, Transparency + gConfig.priority = parseInt(aItem.priority, 10); + gConfig.privacy = aItem.privacy; + gConfig.showTimeAs = aItem.getProperty("TRANSP"); + + // update in outer parent context + updateConfigState(gConfig); + + if (aItem.getAttendees().length && !aItem.descriptionText) { + let tabs = document.getElementById("event-grid-tabs"); + let attendeeTab = document.getElementById("event-grid-tab-attendees"); + tabs.selectedItem = attendeeTab; + } +} + +/** + * Enables/disables undiscloseCheckbox on (un)checking notifyCheckbox + */ +function changeUndiscloseCheckboxStatus() { + let notifyCheckbox = document.getElementById("notify-attendees-checkbox"); + let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox"); + undiscloseCheckbox.disabled = !notifyCheckbox.checked; + updateParentSaveControls(); +} + +/** + * Loads the item's categories into the category panel + * + * @param aItem The item to load into the category panel + */ +function loadCategories(aItem) { + let itemCategories = aItem.getCategories(); + let categoryList = cal.category.fromPrefs(); + for (let cat of itemCategories) { + if (!categoryList.includes(cat)) { + categoryList.push(cat); + } + } + cal.l10n.sortArrayByLocaleCollator(categoryList); + + // Make sure the maximum number of categories is applied to the listbox + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + + let categoryPopup = document.getElementById("item-categories-popup"); + if (maxCount == 1) { + let item = document.createXULElement("menuitem"); + item.setAttribute("class", "menuitem-iconic"); + item.setAttribute("label", cal.l10n.getCalString("None")); + item.setAttribute("type", "radio"); + if (itemCategories.length === 0) { + item.setAttribute("checked", "true"); + } + categoryPopup.appendChild(item); + } + for (let cat of categoryList) { + let item = document.createXULElement("menuitem"); + item.setAttribute("class", "menuitem-iconic calendar-category"); + item.setAttribute("label", cat); + item.setAttribute("value", cat); + item.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio"); + if (itemCategories.includes(cat)) { + item.setAttribute("checked", "true"); + } + let cssSafeId = cal.view.formatStringForCSSRule(cat); + item.style.setProperty("--item-color", `var(--category-${cssSafeId}-color)`); + categoryPopup.appendChild(item); + } + + updateCategoryMenulist(); +} + +/** + * Updates the category menulist to show the correct label, depending on the + * selected categories in the category panel + */ +function updateCategoryMenulist() { + let categoryMenulist = document.getElementById("item-categories"); + let categoryPopup = document.getElementById("item-categories-popup"); + + // Make sure the maximum number of categories is applied to the listbox + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + + // Hide the categories listbox and label in case categories are not + // supported + document.getElementById("event-grid-category-row").toggleAttribute("hidden", maxCount === 0); + + let label; + let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"); + if (categoryList.length > 1) { + label = cal.l10n.getCalString("multipleCategories"); + } else if (categoryList.length == 1) { + label = categoryList[0].getAttribute("label"); + } else { + label = cal.l10n.getCalString("None"); + } + categoryMenulist.setAttribute("label", label); + + let labelBox = categoryMenulist.shadowRoot.querySelector("#label-box"); + let labelLabel = labelBox.querySelector("#label"); + for (let box of labelBox.querySelectorAll("box")) { + box.remove(); + } + for (let i = 0; i < categoryList.length; i++) { + let box = labelBox.insertBefore(document.createXULElement("box"), labelLabel); + // Normal CSS selectors like :first-child don't work on shadow DOM items, + // so we have to set up something they do work on. + let parts = ["color"]; + if (i == 0) { + parts.push("first"); + } + if (i == categoryList.length - 1) { + parts.push("last"); + } + box.setAttribute("part", parts.join(" ")); + box.style.setProperty("--item-color", categoryList[i].style.getPropertyValue("--item-color")); + } +} + +/** + * Updates the categories menulist label and decides if the popup should close + * + * @param aItem The popuphiding event + * @returns Whether the popup should close + */ +function categoryPopupHiding(event) { + updateCategoryMenulist(); + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + if (maxCount === null || maxCount > 1) { + return event.target.localName != "menuitem"; + } + return true; +} + +/** + * Prompts for a new category name, then adds it to the list + */ +function categoryTextboxKeypress(event) { + let category = event.target.value; + let categoryPopup = document.getElementById("item-categories-popup"); + switch (event.key) { + case "Tab": + case "ArrowDown": + case "ArrowUp": { + event.target.blur(); + event.preventDefault(); + + let keyCode = event.key == "ArrowUp" ? KeyboardEvent.DOM_VK_UP : KeyboardEvent.DOM_VK_DOWN; + categoryPopup.dispatchEvent(new KeyboardEvent("keydown", { keyCode })); + categoryPopup.dispatchEvent(new KeyboardEvent("keyup", { keyCode })); + return; + } + case "Escape": + if (category) { + event.target.value = ""; + } else { + categoryPopup.hidePopup(); + } + event.preventDefault(); + return; + case "Enter": + category = category.trim(); + if (category != "") { + break; + } + return; + default: + return; + } + event.preventDefault(); + + let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category"); + let categories = Array.from(categoryList, cat => cat.getAttribute("value")); + + let newIndex = categories.indexOf(category); + if (newIndex > -1) { + categoryList[newIndex].setAttribute("checked", true); + } else { + const localeCollator = new Intl.Collator(); + let compare = localeCollator.compare; + newIndex = cal.data.binaryInsert(categories, category, compare, true); + + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + + let item = document.createXULElement("menuitem"); + item.setAttribute("class", "menuitem-iconic calendar-category"); + item.setAttribute("label", category); + item.setAttribute("value", category); + item.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio"); + item.setAttribute("checked", true); + categoryPopup.insertBefore(item, categoryList[newIndex]); + } + + event.target.value = ""; + // By pushing this to the end of the event loop, the other checked items in the list + // are cleared, where only one category is allowed. + setTimeout(updateCategoryMenulist, 0); +} + +/** + * Saves the selected categories into the passed item + * + * @param aItem The item to set the categories on + */ +function saveCategories(aItem) { + let categoryPopup = document.getElementById("item-categories-popup"); + let categoryList = Array.from( + categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"), + cat => cat.getAttribute("label") + ); + aItem.setCategories(categoryList); +} + +/** + * Sets up all date related controls from the passed item + * + * @param item The item to parse information out of. + */ +function loadDateTime(item) { + let kDefaultTimezone = cal.dtz.defaultTimezone; + if (item.isEvent()) { + let startTime = item.startDate; + let endTime = item.endDate; + let duration = endTime.subtractDate(startTime); + + // Check if an all-day event has been passed in (to adapt endDate). + if (startTime.isDate) { + startTime = startTime.clone(); + endTime = endTime.clone(); + + endTime.day--; + duration.days--; + } + + // store the start/end-times as calIDateTime-objects + // converted to the default timezone. store the timezones + // separately. + gStartTimezone = startTime.timezone; + gEndTimezone = endTime.timezone; + gStartTime = startTime.getInTimezone(kDefaultTimezone); + gEndTime = endTime.getInTimezone(kDefaultTimezone); + gItemDuration = duration; + } + + if (item.isTodo()) { + let startTime = null; + let endTime = null; + let duration = null; + + let hasEntryDate = item.entryDate != null; + if (hasEntryDate) { + startTime = item.entryDate; + gStartTimezone = startTime.timezone; + startTime = startTime.getInTimezone(kDefaultTimezone); + } else { + gStartTimezone = kDefaultTimezone; + } + let hasDueDate = item.dueDate != null; + if (hasDueDate) { + endTime = item.dueDate; + gEndTimezone = endTime.timezone; + endTime = endTime.getInTimezone(kDefaultTimezone); + } else { + gEndTimezone = kDefaultTimezone; + } + if (hasEntryDate && hasDueDate) { + duration = endTime.subtractDate(startTime); + } + document.getElementById("cmd_attendees").setAttribute("disabled", true); + document.getElementById("keepduration-button").disabled = !(hasEntryDate && hasDueDate); + sendMessage({ + command: "updateConfigState", + argument: { attendeesCommand: false }, + }); + gStartTime = startTime; + gEndTime = endTime; + gItemDuration = duration; + } else { + sendMessage({ + command: "updateConfigState", + argument: { attendeesCommand: true }, + }); + } +} + +/** + * Toggles the "keep" attribute every time the keepduration-button is pressed. + */ +function toggleKeepDuration() { + let kdb = document.getElementById("keepduration-button"); + let keepAttribute = kdb.getAttribute("keep") == "true"; + // To make the "keep" attribute persistent, it mustn't be removed when in + // false state (bug 15232). + kdb.setAttribute("keep", keepAttribute ? "false" : "true"); + document.getElementById("link-image-top").setAttribute("keep", !keepAttribute); +} + +/** + * Handler function to be used when the Start time or End time of the event have + * changed. + * When changing the Start date, the End date changes automatically so the + * event/task's duration stays the same. Instead the End date is not linked + * to the Start date unless the the keepDurationButton has the "keep" attribute + * set to true. In this case modifying the End date changes the Start date in + * order to keep the same duration. + * + * @param aStartDatepicker If true the Start or Entry datepicker has changed, + * otherwise the End or Due datepicker has changed. + */ +function dateTimeControls2State(aStartDatepicker) { + if (gIgnoreUpdate) { + return; + } + let keepAttribute = document.getElementById("keepduration-button").getAttribute("keep") == "true"; + let allDay = document.getElementById("event-all-day").checked; + let startWidgetId; + let endWidgetId; + if (window.calendarItem.isEvent()) { + startWidgetId = "event-starttime"; + endWidgetId = "event-endtime"; + } else { + if (!document.getElementById("todo-has-entrydate").checked) { + gItemDuration = null; + } + if (!document.getElementById("todo-has-duedate").checked) { + gItemDuration = null; + } + startWidgetId = "todo-entrydate"; + endWidgetId = "todo-duedate"; + } + + let saveStartTime = gStartTime; + let saveEndTime = gEndTime; + let kDefaultTimezone = cal.dtz.defaultTimezone; + + if (gStartTime) { + // jsDate is always in OS timezone, thus we create a calIDateTime + // object from the jsDate representation then we convert the timezone + // in order to keep gStartTime in default timezone. + if (gTimezonesEnabled || allDay) { + gStartTime = cal.dtz.jsDateToDateTime( + document.getElementById(startWidgetId).value, + gStartTimezone + ); + gStartTime = gStartTime.getInTimezone(kDefaultTimezone); + } else { + gStartTime = cal.dtz.jsDateToDateTime( + document.getElementById(startWidgetId).value, + kDefaultTimezone + ); + } + gStartTime.isDate = allDay; + } + if (gEndTime) { + if (aStartDatepicker) { + // Change the End date in order to keep the duration. + gEndTime = gStartTime.clone(); + if (gItemDuration) { + gEndTime.addDuration(gItemDuration); + } + } else { + let timezone = gEndTimezone; + if (timezone.isUTC) { + if (gStartTime && !cal.data.compareObjects(gStartTimezone, gEndTimezone)) { + timezone = gStartTimezone; + } + } + if (gTimezonesEnabled || allDay) { + gEndTime = cal.dtz.jsDateToDateTime(document.getElementById(endWidgetId).value, timezone); + gEndTime = gEndTime.getInTimezone(kDefaultTimezone); + } else { + gEndTime = cal.dtz.jsDateToDateTime( + document.getElementById(endWidgetId).value, + kDefaultTimezone + ); + } + gEndTime.isDate = allDay; + if (keepAttribute && gItemDuration) { + // Keepduration-button links the the Start to the End date. We + // have to change the Start date in order to keep the duration. + let fduration = gItemDuration.clone(); + fduration.isNegative = true; + gStartTime = gEndTime.clone(); + gStartTime.addDuration(fduration); + } + } + } + + if (allDay) { + gStartTime.isDate = true; + gEndTime.isDate = true; + gItemDuration = gEndTime.subtractDate(gStartTime); + } + + // calculate the new duration of start/end-time. + // don't allow for negative durations. + let warning = false; + let stringWarning = ""; + if (!aStartDatepicker && gStartTime && gEndTime) { + if (gEndTime.compare(gStartTime) >= 0) { + gItemDuration = gEndTime.subtractDate(gStartTime); + } else { + gStartTime = saveStartTime; + gEndTime = saveEndTime; + warning = true; + stringWarning = cal.l10n.getCalString("warningEndBeforeStart"); + } + } + + let startChanged = false; + if (gStartTime && saveStartTime) { + startChanged = gStartTime.compare(saveStartTime) != 0; + } + // Preset the date in the until-datepicker's minimonth to the new start + // date if it has changed. + if (startChanged) { + let startDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating)); + document.getElementById("repeat-until-datepicker").extraDate = startDate; + } + + // Sort out and verify the until date if the start date has changed. + if (gUntilDate && startChanged) { + // Make the time part of the until date equal to the time of start date. + updateUntildateRecRule(); + + // Don't allow for until date earlier than the start date. + if (gUntilDate.compare(gStartTime) < 0) { + // We have to restore valid dates. Since the user has intentionally + // changed the start date, it looks reasonable to restore a valid + // until date equal to the start date. + gUntilDate = gStartTime.clone(); + // Update the until-date-picker. In case of "custom" rule, the + // recurrence string is going to be changed by updateDateTime() below. + if ( + !document.getElementById("repeat-untilDate").hidden && + document.getElementById("repeat-details").hidden + ) { + document.getElementById("repeat-until-datepicker").value = cal.dtz.dateTimeToJsDate( + gUntilDate.getInTimezone(cal.dtz.floating) + ); + } + + warning = true; + stringWarning = cal.l10n.getCalString("warningUntilDateBeforeStart"); + } + } + + updateDateTime(); + updateTimezone(); + updateAccept(); + + if (warning) { + // Disable the "Save" and "Save and Close" commands as long as the + // warning dialog is showed. + enableAcceptCommand(false); + gWarning = true; + let callback = function () { + Services.prompt.alert(null, document.title, stringWarning); + gWarning = false; + updateAccept(); + }; + setTimeout(callback, 1); + } +} + +/** + * Updates the entry date checkboxes, used for example when choosing an alarm: + * the entry date needs to be checked in that case. + */ +function updateEntryDate() { + updateDateCheckboxes("todo-entrydate", "todo-has-entrydate", { + isValid() { + return gStartTime != null; + }, + setDateTime(date) { + gStartTime = date; + }, + }); +} + +/** + * Updates the due date checkboxes. + */ +function updateDueDate() { + updateDateCheckboxes("todo-duedate", "todo-has-duedate", { + isValid() { + return gEndTime != null; + }, + setDateTime(date) { + gEndTime = date; + }, + }); +} + +/** + * Common function used by updateEntryDate and updateDueDate to set up the + * checkboxes correctly. + * + * @param aDatePickerId The XUL id of the datepicker to update. + * @param aCheckboxId The XUL id of the corresponding checkbox. + * @param aDateTime An object implementing the isValid and setDateTime + * methods. XXX explain. + */ +function updateDateCheckboxes(aDatePickerId, aCheckboxId, aDateTime) { + if (gIgnoreUpdate) { + return; + } + + if (!window.calendarItem.isTodo()) { + return; + } + + // force something to get set if there was nothing there before + aDatePickerId.value = document.getElementById(aDatePickerId).value; + + // first of all disable the datetime picker if we don't have a date + let hasDate = document.getElementById(aCheckboxId).checked; + aDatePickerId.disabled = !hasDate; + + // create a new datetime object if date is now checked for the first time + if (hasDate && !aDateTime.isValid()) { + let date = cal.dtz.jsDateToDateTime( + document.getElementById(aDatePickerId).value, + cal.dtz.defaultTimezone + ); + aDateTime.setDateTime(date); + } else if (!hasDate && aDateTime.isValid()) { + aDateTime.setDateTime(null); + } + + // calculate the duration if possible + let hasEntryDate = document.getElementById("todo-has-entrydate").checked; + let hasDueDate = document.getElementById("todo-has-duedate").checked; + if (hasEntryDate && hasDueDate) { + let start = cal.dtz.jsDateToDateTime(document.getElementById("todo-entrydate").value); + let end = cal.dtz.jsDateToDateTime(document.getElementById("todo-duedate").value); + gItemDuration = end.subtractDate(start); + } else { + gItemDuration = null; + } + document.getElementById("keepduration-button").disabled = !(hasEntryDate && hasDueDate); + updateDateTime(); + updateTimezone(); +} + +/** + * Get the item's recurrence information for displaying in dialog controls. + * + * @param {object} aItem - The calendar item + * @returns {string[]} An array of two strings: [repeatType, untilDate] + */ +function getRepeatTypeAndUntilDate(aItem) { + let recurrenceInfo = window.recurrenceInfo; + let repeatType = "none"; + let untilDate = "forever"; + + /** + * Updates the until date (locally and globally). + * + * @param aRule The recurrence rule + */ + let updateUntilDate = aRule => { + if (!aRule.isByCount) { + if (aRule.isFinite) { + gUntilDate = aRule.untilDate.clone().getInTimezone(cal.dtz.defaultTimezone); + untilDate = cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating)); + } else { + gUntilDate = null; + } + } + }; + + if (recurrenceInfo) { + repeatType = "custom"; + let ritems = recurrenceInfo.getRecurrenceItems(); + let rules = []; + let exceptions = []; + for (let ritem of ritems) { + if (ritem.isNegative) { + exceptions.push(ritem); + } else { + rules.push(ritem); + } + } + if (rules.length == 1) { + let rule = cal.wrapInstance(rules[0], Ci.calIRecurrenceRule); + if (rule) { + switch (rule.type) { + case "DAILY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + let ruleComp = rule.getComponent("BYDAY"); + if (rule.interval == 1) { + if (ruleComp.length > 0) { + if (ruleComp.length == 5) { + let found = false; + for (let i = 0; i < 5; i++) { + if (ruleComp[i] != i + 2) { + found = true; + break; + } + } + if (!found && (!rule.isFinite || !rule.isByCount)) { + repeatType = "every.weekday"; + updateUntilDate(rule); + } + } + } else if (!rule.isFinite || !rule.isByCount) { + repeatType = "daily"; + updateUntilDate(rule); + } + } + } + break; + } + case "WEEKLY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYDAY", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + let weekType = ["weekly", "bi.weekly"]; + if ( + (rule.interval == 1 || rule.interval == 2) && + (!rule.isFinite || !rule.isByCount) + ) { + repeatType = weekType[rule.interval - 1]; + updateUntilDate(rule); + } + } + break; + } + case "MONTHLY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYDAY", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + if (rule.interval == 1 && (!rule.isFinite || !rule.isByCount)) { + repeatType = "monthly"; + updateUntilDate(rule); + } + } + break; + } + case "YEARLY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYDAY", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + if (rule.interval == 1 && (!rule.isFinite || !rule.isByCount)) { + repeatType = "yearly"; + updateUntilDate(rule); + } + } + break; + } + } + } + } + } + return [repeatType, untilDate]; +} + +/** + * Updates the XUL UI with the repeat type and the until date. + * + * @param {string} aRepeatType - The type of repeat + * @param {string} aUntilDate - The until date + * @param {object} aItem - The calendar item + */ +function loadRepeat(aRepeatType, aUntilDate, aItem) { + document.getElementById("item-repeat").value = aRepeatType; + let repeatMenu = document.getElementById("item-repeat"); + gLastRepeatSelection = repeatMenu.selectedIndex; + + if (aItem.parentItem != aItem) { + document.getElementById("item-repeat").setAttribute("disabled", "true"); + document.getElementById("repeat-until-datepicker").setAttribute("disabled", "true"); + } + // Show the repeat-until-datepicker and set its date + document.getElementById("repeat-untilDate").hidden = false; + document.getElementById("repeat-details").hidden = true; + document.getElementById("repeat-until-datepicker").value = aUntilDate; +} + +/** + * Update reminder related elements on the dialog. + * + * @param aSuppressDialogs If true, controls are updated without prompting + * for changes with the custom dialog + */ +function updateReminder(aSuppressDialogs) { + window.gLastAlarmSelection = commonUpdateReminder( + document.querySelector(".item-alarm"), + window.calendarItem, + window.gLastAlarmSelection, + getCurrentCalendar(), + document.querySelector(".reminder-details"), + window.gStartTimezone || window.gEndTimezone, + aSuppressDialogs + ); + updateAccept(); +} + +/** + * Saves all values the user chose on the dialog to the passed item + * + * @param item The item to save to. + */ +function saveDialog(item) { + // Calendar + item.calendar = getCurrentCalendar(); + + cal.item.setItemProperty(item, "title", document.getElementById("item-title").value); + cal.item.setItemProperty(item, "LOCATION", document.getElementById("item-location").value); + + saveDateTime(item); + + if (item.isTodo()) { + let percentCompleteInteger = 0; + if (document.getElementById("percent-complete-textbox").value != "") { + percentCompleteInteger = parseInt( + document.getElementById("percent-complete-textbox").value, + 10 + ); + } + if (percentCompleteInteger < 0) { + percentCompleteInteger = 0; + } else if (percentCompleteInteger > 100) { + percentCompleteInteger = 100; + } + cal.item.setItemProperty(item, "PERCENT-COMPLETE", percentCompleteInteger); + } + + // Categories + saveCategories(item); + + // Attachment + // We want the attachments to be up to date, remove all first. + item.removeAllAttachments(); + + // Now add back the new ones + for (let hashId in gAttachMap) { + let att = gAttachMap[hashId]; + item.addAttachment(att); + } + + // Description + let editorElement = document.getElementById("item-description"); + let editor = editorElement.getHTMLEditor(editorElement.contentWindow); + if (editor.documentModified) { + // Get editor output as HTML. We request raw output to avoid any + // pretty-printing which may cause issues with Google Calendar (see comments + // in calViewUtils.fixGoogleCalendarDescription() for more information). + let mode = + Ci.nsIDocumentEncoder.OutputRaw | + Ci.nsIDocumentEncoder.OutputDropInvisibleBreak | + Ci.nsIDocumentEncoder.OutputBodyOnly; + + const editorOutput = editor.outputToString("text/html", mode); + + // The editor gives us output wrapped in a body tag. We don't really want + // that, so strip it. (Yes, it's a regex with HTML, but a _very_ specific + // one.) We use the `s` flag to match across newlines in case there's a + // <pre/> tag, in which case <br/> will not be inserted. + item.descriptionHTML = editorOutput.replace(/^<body>(.+)<\/body>$/s, "$1"); + } + + // Event Status + if (item.isEvent()) { + if (gConfig.status && gConfig.status != "NONE") { + item.setProperty("STATUS", gConfig.status); + } else { + item.deleteProperty("STATUS"); + } + } else { + let status = document.getElementById("todo-status").value; + if (status != "COMPLETED") { + item.completedDate = null; + } + cal.item.setItemProperty(item, "STATUS", status == "NONE" ? null : status); + } + + // set the "PRIORITY" property if a valid priority has been + // specified (any integer value except *null*) OR the item + // already specifies a priority. in any other case we don't + // need this property and can safely delete it. we need this special + // handling since the WCAP provider always includes the priority + // with value *null* and we don't detect changes to this item if + // we delete this property. + if (capSupported("priority") && (gConfig.priority || item.hasProperty("PRIORITY"))) { + item.setProperty("PRIORITY", gConfig.priority); + } else { + item.deleteProperty("PRIORITY"); + } + + // Transparency + if (gConfig.showTimeAs) { + item.setProperty("TRANSP", gConfig.showTimeAs); + } else { + item.deleteProperty("TRANSP"); + } + + // Privacy + cal.item.setItemProperty(item, "CLASS", gConfig.privacy, "privacy"); + + if (item.status == "COMPLETED" && item.isTodo()) { + let elementValue = document.getElementById("completed-date-picker").value; + item.completedDate = cal.dtz.jsDateToDateTime(elementValue); + } + + saveReminder(item, getCurrentCalendar(), document.querySelector(".item-alarm")); +} + +/** + * Save date and time related values from the dialog to the passed item. + * + * @param item The item to save to. + */ +function saveDateTime(item) { + // Changes to the start date don't have to change the until date. + untilDateCompensation(item); + + if (item.isEvent()) { + let startTime = gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime.getInTimezone(gEndTimezone); + let isAllDay = document.getElementById("event-all-day").checked; + if (isAllDay) { + startTime = startTime.clone(); + endTime = endTime.clone(); + startTime.isDate = true; + endTime.isDate = true; + endTime.day += 1; + } else { + startTime = startTime.clone(); + startTime.isDate = false; + endTime = endTime.clone(); + endTime.isDate = false; + } + cal.item.setItemProperty(item, "startDate", startTime); + cal.item.setItemProperty(item, "endDate", endTime); + } + if (item.isTodo()) { + let startTime = gStartTime && gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime && gEndTime.getInTimezone(gEndTimezone); + cal.item.setItemProperty(item, "entryDate", startTime); + cal.item.setItemProperty(item, "dueDate", endTime); + } +} + +/** + * Changes the until date in the rule in order to compensate the automatic + * correction caused by the function onStartDateChange() when saving the + * item. + * It allows to keep the until date set in the dialog irrespective of the + * changes that the user has done to the start date. + */ +function untilDateCompensation(aItem) { + // The current start date in the item is always the date that we get + // when opening the dialog or after the last save. + let startDate = aItem[cal.dtz.startDateProp(aItem)]; + + if (aItem.recurrenceInfo) { + let rrules = splitRecurrenceRules(aItem.recurrenceInfo); + let rule = rrules[0][0]; + if (!rule.isByCount && rule.isFinite && startDate) { + let compensation = startDate.subtractDate(gStartTime); + if (compensation != "PT0S") { + let untilDate = rule.untilDate.clone(); + untilDate.addDuration(compensation); + rule.untilDate = untilDate; + } + } + } +} + +/** + * Updates the dialog title based on item type and if the item is new or to be + * modified. + */ +function updateTitle() { + let strName; + if (window.calendarItem.isEvent()) { + strName = window.mode == "new" ? "newEventDialog" : "editEventDialog"; + } else if (window.calendarItem.isTodo()) { + strName = window.mode == "new" ? "newTaskDialog" : "editTaskDialog"; + } else { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + sendMessage({ + command: "updateTitle", + prefix: cal.l10n.getCalString(strName), + title: document.getElementById("item-title").value, + }); +} + +/** + * Update the disabled status of the accept button. The button is enabled if all + * parts of the dialog have options selected that make sense. + * constraining factors like + */ +function updateAccept() { + let enableAccept = true; + let kDefaultTimezone = cal.dtz.defaultTimezone; + let startDate; + let endDate; + let isEvent = window.calendarItem.isEvent(); + + // don't allow for end dates to be before start dates + if (isEvent) { + startDate = cal.dtz.jsDateToDateTime(document.getElementById("event-starttime").value); + endDate = cal.dtz.jsDateToDateTime(document.getElementById("event-endtime").value); + } else { + startDate = document.getElementById("todo-has-entrydate").checked + ? cal.dtz.jsDateToDateTime(document.getElementById("todo-entrydate").value) + : null; + endDate = document.getElementById("todo-has-duedate").checked + ? cal.dtz.jsDateToDateTime(document.getElementById("todo-duedate").value) + : null; + } + + if (startDate && endDate) { + if (gTimezonesEnabled) { + let startTimezone = gStartTimezone; + let endTimezone = gEndTimezone; + if (endTimezone.isUTC) { + if (!cal.data.compareObjects(gStartTimezone, gEndTimezone)) { + endTimezone = gStartTimezone; + } + } + + startDate = startDate.getInTimezone(kDefaultTimezone); + endDate = endDate.getInTimezone(kDefaultTimezone); + + startDate.timezone = startTimezone; + endDate.timezone = endTimezone; + } + + startDate = startDate.getInTimezone(kDefaultTimezone); + endDate = endDate.getInTimezone(kDefaultTimezone); + + // For all-day events we are not interested in times and compare only + // dates. + if (isEvent && document.getElementById("event-all-day").checked) { + // jsDateToDateTime returns the values in UTC. Depending on the + // local timezone and the values selected in datetimepicker the date + // in UTC might be shifted to the previous or next day. + // For example: The user (with local timezone GMT+05) selected + // Feb 10 2006 00:00:00. The corresponding value in UTC is + // Feb 09 2006 19:00:00. If we now set isDate to true we end up with + // a date of Feb 09 2006 instead of Feb 10 2006 resulting in errors + // during the following comparison. + // Calling getInTimezone() ensures that we use the same dates as + // displayed to the user in datetimepicker for comparison. + startDate.isDate = true; + endDate.isDate = true; + } + } + + if (endDate && startDate && endDate.compare(startDate) == -1) { + enableAccept = false; + } + + enableAcceptCommand(enableAccept); + + return enableAccept; +} + +/** + * Enables/disables the commands cmd_accept and cmd_save related to the + * save operation. + * + * @param aEnable true: enables the command + */ +function enableAcceptCommand(aEnable) { + sendMessage({ command: "enableAcceptCommand", argument: aEnable }); +} + +// Global variables used to restore start and end date-time when changing the +// "all day" status in the onUpdateAllday() function. +var gOldStartTime = null; +var gOldEndTime = null; +var gOldStartTimezone = null; +var gOldEndTimezone = null; + +/** + * Handler function to update controls and state in consequence of the "all + * day" checkbox being clicked. + */ +function onUpdateAllDay() { + if (!window.calendarItem.isEvent()) { + return; + } + let allDay = document.getElementById("event-all-day").checked; + let kDefaultTimezone = cal.dtz.defaultTimezone; + + if (allDay) { + // Store date-times and related timezones so we can restore + // if the user unchecks the "all day" checkbox. + gOldStartTime = gStartTime.clone(); + gOldEndTime = gEndTime.clone(); + gOldStartTimezone = gStartTimezone; + gOldEndTimezone = gEndTimezone; + // 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 (gEndTime.hour == 0 && gEndTime.minute == 0) { + let tempStartTime = gStartTime.clone(); + let tempEndTime = gEndTime.clone(); + tempStartTime.isDate = true; + tempEndTime.isDate = true; + tempStartTime.day++; + if (tempEndTime.compare(tempStartTime) >= 0) { + gEndTime.day--; + } + } + } else { + gStartTime.isDate = false; + gEndTime.isDate = false; + if (!gOldStartTime && !gOldEndTime) { + // The checkbox has been unchecked for the first time, the event + // was an "All day" type, so we have to set default values. + gStartTime.hour = cal.dtz.getDefaultStartDate(window.initialStartDateValue).hour; + gEndTime.hour = gStartTime.hour; + gEndTime.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60); + gOldStartTimezone = kDefaultTimezone; + gOldEndTimezone = kDefaultTimezone; + } else { + // Restore date-times previously stored. + gStartTime.hour = gOldStartTime.hour; + gStartTime.minute = gOldStartTime.minute; + gEndTime.hour = gOldEndTime.hour; + gEndTime.minute = gOldEndTime.minute; + // When we restore 0:00 as end time, we need to add one day to + // the end date in order to include the last day until midnight. + if (gEndTime.hour == 0 && gEndTime.minute == 0) { + gEndTime.day++; + } + } + } + gStartTimezone = allDay ? cal.dtz.floating : gOldStartTimezone; + gEndTimezone = allDay ? cal.dtz.floating : gOldEndTimezone; + setShowTimeAs(allDay); + + updateAllDay(); +} + +/** + * This function sets the enabled/disabled state of the following controls: + * - 'event-starttime' + * - 'event-endtime' + * - 'timezone-starttime' + * - 'timezone-endtime' + * the state depends on whether or not the event is configured as 'all-day' or not. + */ +function updateAllDay() { + if (gIgnoreUpdate) { + return; + } + + if (!window.calendarItem.isEvent()) { + return; + } + + let allDay = document.getElementById("event-all-day").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"); + } + + gStartTime.isDate = allDay; + gEndTime.isDate = allDay; + gItemDuration = gEndTime.subtractDate(gStartTime); + + updateDateTime(); + updateUntildateRecRule(); + updateRepeatDetails(); + updateAccept(); +} + +/** + * Use the window arguments to cause the opener to create a new event on the + * item's calendar + */ +function openNewEvent() { + let item = window.calendarItem; + let args = window.arguments[0]; + args.onNewEvent(item.calendar); +} + +/** + * Use the window arguments to cause the opener to create a new event on the + * item's calendar + */ +function openNewTask() { + let item = window.calendarItem; + let args = window.arguments[0]; + args.onNewTodo(item.calendar); +} + +/** + * Update the transparency status of this dialog, depending on if the event + * is all-day or not. + * + * @param allDay If true, the event is all-day + */ +function setShowTimeAs(allDay) { + gConfig.showTimeAs = cal.item.getEventDefaultTransparency(allDay); + updateConfigState({ showTimeAs: gConfig.showTimeAs }); +} + +function editAttendees() { + let savedWindow = window; + let calendar = getCurrentCalendar(); + + let callback = function (attendees, organizer, startTime, endTime) { + savedWindow.attendees = attendees; + savedWindow.organizer = organizer; + + // if a participant was added or removed we switch to the attendee + // tab, so the user can see the change directly + let tabs = document.getElementById("event-grid-tabs"); + let attendeeTab = document.getElementById("event-grid-tab-attendees"); + tabs.selectedItem = attendeeTab; + + let duration = endTime.subtractDate(startTime); + startTime = startTime.clone(); + endTime = endTime.clone(); + let kDefaultTimezone = cal.dtz.defaultTimezone; + gStartTimezone = startTime.timezone; + gEndTimezone = endTime.timezone; + gStartTime = startTime.getInTimezone(kDefaultTimezone); + gEndTime = endTime.getInTimezone(kDefaultTimezone); + gItemDuration = duration; + updateAttendeeInterface(); + updateDateTime(); + updateAllDay(); + + if (isAllDay != gStartTime.isDate) { + setShowTimeAs(gStartTime.isDate); + } + }; + + let startTime = gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime.getInTimezone(gEndTimezone); + + let isAllDay = document.getElementById("event-all-day").checked; + if (isAllDay) { + startTime.isDate = true; + endTime.isDate = true; + endTime.day += 1; + } else { + startTime.isDate = false; + endTime.isDate = false; + } + let args = {}; + args.startTime = startTime; + args.endTime = endTime; + args.displayTimezone = gTimezonesEnabled; + args.attendees = window.attendees; + args.organizer = window.organizer && window.organizer.clone(); + args.calendar = calendar; + args.item = window.calendarItem; + args.onOk = callback; + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-attendees.xhtml", + "_blank", + "chrome,titlebar,modal,resizable", + args + ); +} + +/** + * Updates the UI outside of the iframe (toolbar, menu, statusbar, etc.) + * for changes in priority, privacy, status, showTimeAs/transparency, + * and/or other properties. This function should be called any time that + * gConfig.privacy, gConfig.priority, etc. are updated. + * + * Privacy and priority updates depend on the selected calendar. If the + * selected calendar does not support them, or only supports certain + * values, these are removed from the UI. + * + * @param {object} aArg - Container + * @param {string} aArg.privacy - (optional) The new privacy value + * @param {short} aArg.priority - (optional) The new priority value + * @param {string} aArg.status - (optional) The new status value + * @param {string} aArg.showTimeAs - (optional) The new transparency value + */ +function updateConfigState(aArg) { + // We include additional info for priority and privacy. + if (aArg.hasOwnProperty("priority")) { + aArg.hasPriority = capSupported("priority"); + } + if (aArg.hasOwnProperty("privacy")) { + Object.assign(aArg, { + hasPrivacy: capSupported("privacy"), + calendarType: getCurrentCalendar().type, + privacyValues: capValues("privacy", ["PUBLIC", "CONFIDENTIAL", "PRIVATE"]), + }); + } + + // For tasks, do not include showTimeAs + if (aArg.hasOwnProperty("showTimeAs") && window.calendarItem.isTodo()) { + delete aArg.showTimeAs; + if (Object.keys(aArg).length == 0) { + return; + } + } + + sendMessage({ command: "updateConfigState", argument: aArg }); +} + +/** + * Add menu items to the UI for attaching files using cloud providers. + */ +function loadCloudProviders() { + let cloudFileEnabled = Services.prefs.getBoolPref("mail.cloud_files.enabled", false); + let cmd = document.getElementById("cmd_attach_cloud"); + let message = { + command: "setElementAttribute", + argument: { id: "cmd_attach_cloud", attribute: "hidden", value: null }, + }; + + if (!cloudFileEnabled) { + // If cloud file support is disabled, just hide the attach item + cmd.hidden = true; + message.argument.value = true; + sendMessage(message); + return; + } + + let isHidden = cloudFileAccounts.configuredAccounts.length == 0; + cmd.hidden = isHidden; + message.argument.value = isHidden; + sendMessage(message); + + let itemObjects = []; + + for (let cloudProvider of cloudFileAccounts.configuredAccounts) { + // Create a serializable object to pass in a message outside the iframe + let itemObject = {}; + itemObject.displayName = cloudFileAccounts.getDisplayName(cloudProvider); + itemObject.label = cal.l10n.getString("calendar-event-dialog", "attachViaFilelink", [ + itemObject.displayName, + ]); + itemObject.cloudProviderAccountKey = cloudProvider.accountKey; + if (cloudProvider.iconURL) { + itemObject.class = "menuitem-iconic"; + itemObject.image = cloudProvider.iconURL; + } + + itemObjects.push(itemObject); + + // Create a menu item from the serializable object + let item = document.createXULElement("menuitem"); + item.setAttribute("label", itemObject.label); + item.setAttribute("observes", "cmd_attach_cloud"); + item.setAttribute( + "oncommand", + "attachFile(event.target.cloudProvider); event.stopPropagation();" + ); + + if (itemObject.class) { + item.setAttribute("class", itemObject.class); + item.setAttribute("image", itemObject.image); + } + + // Add the menu item to places inside the iframe where we advertise cloud providers + let attachmentPopup = document.getElementById("attachment-popup"); + attachmentPopup.appendChild(item).cloudProvider = cloudProvider; + } + + // Add the items to places outside the iframe where we advertise cloud providers + sendMessage({ command: "loadCloudProviders", items: itemObjects }); +} + +/** + * Prompts the user to attach an url to this item. + */ +function attachURL() { + if (Services.prompt) { + // ghost in an example... + let result = { value: "http://" }; + let confirm = Services.prompt.prompt( + window, + cal.l10n.getString("calendar-event-dialog", "specifyLinkLocation"), + cal.l10n.getString("calendar-event-dialog", "enterLinkLocation"), + result, + null, + { value: 0 } + ); + + if (confirm) { + try { + // If something bogus was entered, Services.io.newURI may fail. + let attachment = new CalAttachment(); + attachment.uri = Services.io.newURI(result.value); + addAttachment(attachment); + // we switch to the attachment tab if it is not already displayed + // to allow the user to see the attachment was added + let tabs = document.getElementById("event-grid-tabs"); + let attachTab = document.getElementById("event-grid-tab-attachments"); + tabs.selectedItem = attachTab; + } catch (e) { + // TODO We might want to show a warning instead of just not + // adding the file + } + } + } +} + +/** + * Attach a file using a cloud provider, identified by its accountKey. + * + * @param {string} aAccountKey - The accountKey for a cloud provider + */ +function attachFileByAccountKey(aAccountKey) { + for (let cloudProvider of cloudFileAccounts.configuredAccounts) { + if (aAccountKey == cloudProvider.accountKey) { + attachFile(cloudProvider); + return; + } + } +} + +/** + * Attach a file to the item. Not passing a cloud provider is currently unsupported. + * + * @param cloudProvider If set, the cloud provider will be used for attaching + */ +function attachFile(cloudProvider) { + if (!cloudProvider) { + cal.ERROR( + "[calendar-event-dialog] Could not attach file without cloud provider" + cal.STACK(10) + ); + } + + let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + filePicker.init( + window, + cal.l10n.getString("calendar-event-dialog", "selectAFile"), + Ci.nsIFilePicker.modeOpenMultiple + ); + + // Check for the last directory + let lastDir = lastDirectory(); + if (lastDir) { + filePicker.displayDirectory = lastDir; + } + + filePicker.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !filePicker.files) { + return; + } + + // Create the attachment + for (let file of filePicker.files) { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + let uriSpec = fileHandler.getURLSpecFromActualFile(file); + + if (!(uriSpec in gAttachMap)) { + // If the attachment hasn't been added, then set the last display + // directory. + lastDirectory(uriSpec); + + // ... and add the attachment. + let attachment = new CalAttachment(); + if (cloudProvider) { + attachment.uri = Services.io.newURI(uriSpec); + } else { + // TODO read file into attachment + } + addAttachment(attachment, cloudProvider); + } + } + }); +} + +/** + * Helper function to remember the last directory chosen when attaching files. + * + * @param aFileUri (optional) If passed, the last directory will be set and + * returned. If null, the last chosen directory + * will be returned. + * @returns The last directory that was set with this function. + */ +function lastDirectory(aFileUri) { + if (aFileUri) { + // Act similar to a setter, save the passed uri. + let uri = Services.io.newURI(aFileUri); + let file = uri.QueryInterface(Ci.nsIFileURL).file; + lastDirectory.mValue = file.parent.QueryInterface(Ci.nsIFile); + } + + // In any case, return the value + return lastDirectory.mValue === undefined ? null : lastDirectory.mValue; +} + +/** + * Turns an url into a string that can be used in UI. + * - For a file:// url, shows the filename. + * - For a http:// url, removes protocol and trailing slash + * + * @param aUri The uri to parse. + * @returns A string that can be used in UI. + */ +function makePrettyName(aUri) { + let name = aUri.spec; + + if (aUri.schemeIs("file")) { + name = aUri.spec.split("/").pop(); + } else if (aUri.schemeIs("http")) { + name = aUri.spec.replace(/\/$/, "").replace(/^http:\/\//, ""); + } + return name; +} + +/** + * Asynchronously uploads the given attachment to the cloud provider, updating + * the passed listItem as things progress. + * + * @param attachment A calIAttachment to upload. + * @param cloudFileAccount The cloud file account used for uploading. + * @param listItem The listitem in attachment-link listbox to update. + */ +function uploadCloudAttachment(attachment, cloudFileAccount, listItem) { + let file = attachment.uri.QueryInterface(Ci.nsIFileURL).file; + let image = listItem.querySelector("img"); + listItem.attachCloudFileAccount = cloudFileAccount; + image.setAttribute("src", "chrome://global/skin/icons/loading.png"); + // WebExtension APIs do not support calendar tabs. + cloudFileAccount.uploadFile(null, file, attachment.name).then( + upload => { + delete gAttachMap[attachment.hashId]; + attachment.uri = Services.io.newURI(upload.url); + attachment.setParameter("FILENAME", file.leafName); + attachment.setParameter("X-SERVICE-ICONURL", upload.serviceIcon); + listItem.setAttribute("label", file.leafName); + gAttachMap[attachment.hashId] = attachment; + image.setAttribute("src", upload.serviceIcon); + listItem.attachCloudFileUpload = upload; + updateAttachment(); + }, + statusCode => { + cal.ERROR( + "[calendar-event-dialog] Uploading cloud attachment failed. Status code: " + + statusCode.result + ); + + // Uploading failed. First of all, show an error icon. Also, + // delete it from the attach map now, this will make sure it is + // not serialized if the user saves. + image.setAttribute("src", "chrome://messenger/skin/icons/error.png"); + delete gAttachMap[attachment.hashId]; + + // Keep the item for a while so the user can see something failed. + // When we have a nice notification bar, we can show more info + // about the failure. + setTimeout(() => { + listItem.remove(); + updateAttachment(); + }, 5000); + } + ); +} + +/** + * Adds the given attachment to dialog controls. + * + * @param attachment The calIAttachment object to add + * @param cloudFileAccount (optional) If set, the given cloud file account will be used. + */ +function addAttachment(attachment, cloudFileAccount) { + if (!attachment || !attachment.hashId || attachment.hashId in gAttachMap) { + return; + } + + // We currently only support uri attachments + if (attachment.uri) { + let documentLink = document.getElementById("attachment-link"); + let listItem = document.createXULElement("richlistitem"); + let image = document.createElement("img"); + image.setAttribute("alt", ""); + image.width = "24"; + image.height = "24"; + // Allow the moz-icon src to be invalid. + image.classList.add("invisible-on-broken"); + listItem.appendChild(image); + let label = document.createXULElement("label"); + label.setAttribute("value", makePrettyName(attachment.uri)); + label.setAttribute("crop", "end"); + listItem.appendChild(label); + listItem.setAttribute("tooltiptext", attachment.uri.spec); + if (cloudFileAccount) { + if (attachment.uri.schemeIs("file")) { + // Its still a local url, needs to be uploaded + image.setAttribute("src", "chrome://messenger/skin/icons/connecting.png"); + uploadCloudAttachment(attachment, cloudFileAccount, listItem); + } else { + let cloudFileIconURL = attachment.getParameter("X-SERVICE-ICONURL"); + image.setAttribute("src", cloudFileIconURL); + let leafName = attachment.getParameter("FILENAME"); + if (leafName) { + listItem.setAttribute("label", leafName); + } + } + } else if (attachment.uri.schemeIs("file")) { + image.setAttribute("src", "moz-icon://" + attachment.uri.spec); + } else { + let leafName = attachment.getParameter("FILENAME"); + let cloudFileIconURL = attachment.getParameter("X-SERVICE-ICONURL"); + let cloudFileEnabled = Services.prefs.getBoolPref("mail.cloud_files.enabled", false); + + if (leafName) { + // TODO security issues? + listItem.setAttribute("label", leafName); + } + if (cloudFileIconURL && cloudFileEnabled) { + image.setAttribute("src", cloudFileIconURL); + } else { + let iconSrc = attachment.uri.spec.length ? attachment.uri.spec : "dummy.html"; + if (attachment.formatType) { + iconSrc = "goat?contentType=" + attachment.formatType; + } else { + // let's try to auto-detect + let parts = iconSrc.substr(attachment.uri.scheme.length + 2).split("/"); + if (parts.length) { + iconSrc = parts[parts.length - 1]; + } + } + image.setAttribute("src", "moz-icon://" + iconSrc); + } + } + + // Now that everything is set up, add it to the attachment box. + documentLink.appendChild(listItem); + + // full attachment object is stored here + listItem.attachment = attachment; + + // Update the number of rows and save our attachment globally + documentLink.rows = documentLink.getRowCount(); + } + + gAttachMap[attachment.hashId] = attachment; + updateAttachment(); +} + +/** + * Removes the currently selected attachment from the dialog controls. + * + * XXX This could use a dialog maybe? + */ +function deleteAttachment() { + let documentLink = document.getElementById("attachment-link"); + let item = documentLink.selectedItem; + delete gAttachMap[item.attachment.hashId]; + + if (item.attachCloudFileAccount && item.attachCloudFileUpload) { + try { + // WebExtension APIs do not support calendar tabs. + item.attachCloudFileAccount + .deleteFile(null, item.attachCloudFileUpload.id) + .catch(statusCode => { + // TODO With a notification bar, we could actually show this error. + cal.ERROR( + "[calendar-event-dialog] Deleting cloud attachment " + + "failed, file will remain on server. " + + " Status code: " + + statusCode + ); + }); + } catch (e) { + cal.ERROR( + "[calendar-event-dialog] Deleting cloud attachment " + + "failed, file will remain on server. " + + "Exception: " + + e + ); + } + } + item.remove(); + + updateAttachment(); +} + +/** + * Removes all attachments from the dialog controls. + */ +function deleteAllAttachments() { + let documentLink = document.getElementById("attachment-link"); + let itemCount = documentLink.getRowCount(); + let canRemove = itemCount < 2; + + if (itemCount > 1) { + let removeText = PluralForm.get( + itemCount, + cal.l10n.getString("calendar-event-dialog", "removeAttachmentsText") + ); + let removeTitle = cal.l10n.getString("calendar-event-dialog", "removeCalendarsTitle"); + canRemove = Services.prompt.confirm( + window, + removeTitle, + removeText.replace("#1", itemCount), + {} + ); + } + + if (canRemove) { + while (documentLink.lastChild) { + documentLink.lastChild.attachment = null; + documentLink.lastChild.remove(); + } + gAttachMap = {}; + } + updateAttachment(); +} + +/** + * Opens the selected attachment using the external protocol service. + * + * @see nsIExternalProtocolService + */ +function openAttachment() { + // Only one file has to be selected and we don't handle base64 files at all + let documentLink = document.getElementById("attachment-link"); + if (documentLink.selectedItem) { + let attURI = documentLink.selectedItem.attachment.uri; + let externalLoader = Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService( + Ci.nsIExternalProtocolService + ); + // TODO There should be a nicer dialog + externalLoader.loadURI(attURI); + } +} + +/** + * Copies the link location of the first selected attachment to the clipboard + */ +function copyAttachment() { + let documentLink = document.getElementById("attachment-link"); + if (documentLink.selectedItem) { + let attURI = documentLink.selectedItem.attachment.uri.spec; + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); + clipboard.copyString(attURI); + } +} + +/** + * Handler function to handle pressing keys in the attachment listbox. + * + * @param aEvent The DOM event caused by the key press. + */ +function attachmentLinkKeyPress(aEvent) { + switch (aEvent.key) { + case "Backspace": + case "Delete": + deleteAttachment(); + break; + case "Enter": + openAttachment(); + aEvent.preventDefault(); + break; + } +} + +/** + * Handler function to take care of double clicking on an attachment + * + * @param aEvent The DOM event caused by the clicking. + */ +function attachmentDblClick(aEvent) { + let item = aEvent.target; + while (item && item.localName != "richlistbox" && item.localName != "richlistitem") { + item = item.parentNode; + } + + // left double click on a list item + if (item.localName == "richlistitem" && aEvent.button == 0) { + openAttachment(); + } +} + +/** + * Handler function to take care of right clicking on an attachment or the attachment list + * + * @param aEvent The DOM event caused by the clicking. + */ +function attachmentClick(aEvent) { + let item = aEvent.target.triggerNode; + while (item && item.localName != "richlistbox" && item.localName != "richlistitem") { + item = item.parentNode; + } + + for (let node of aEvent.target.children) { + if (item.localName == "richlistitem" || node.id == "attachment-popup-attachPage") { + node.removeAttribute("hidden"); + } else { + node.setAttribute("hidden", "true"); + } + } +} + +/** + * Helper function to show a notification in the event-dialog's notificationbox + * + * @param aMessage the message text to show + * @param aValue string identifying the notification + * @param aPriority (optional) the priority of the warning (info, critical), default is 'warn' + * @param aImage (optional) URL of image to appear on the notification + * @param aButtonset (optional) array of button descriptions to appear on the notification + * @param aCallback (optional) a function to handle events from the notificationbox + */ +function notifyUser(aMessage, aValue, aPriority, aImage, aButtonset, aCallback) { + // only append, if the notification does not already exist + if (gEventNotification.getNotificationWithValue(aValue) == null) { + const prioMap = { + info: gEventNotification.PRIORITY_INFO_MEDIUM, + critical: gEventNotification.PRIORITY_CRITICAL_MEDIUM, + }; + let prio = prioMap[aPriority] || gEventNotification.PRIORITY_WARNING_MEDIUM; + gEventNotification.appendNotification( + aValue, + { + label: aMessage, + image: aImage, + priority: prio, + eventCallback: aCallback, + }, + aButtonset + ); + } +} + +/** + * Remove a notification from the notifiactionBox + * + * @param {string} aValue - string identifying the notification to remove + */ +function removeNotification(aValue) { + let notification = gEventNotification.getNotificationWithValue(aValue); + if (notification) { + gEventNotification.removeNotification(notification); + } +} + +/** + * Update the dialog controls related to the item's calendar. + */ +function updateCalendar() { + let item = window.calendarItem; + let calendar = getCurrentCalendar(); + + let cssSafeId = cal.view.formatStringForCSSRule(calendar.id); + document + .getElementById("item-calendar") + .style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`); + + gIsReadOnly = calendar.readOnly; + + if (!gPreviousCalendarId) { + gPreviousCalendarId = item.calendar.id; + } + + // We might have to change the organizer, let's see + let calendarOrgId = calendar.getProperty("organizerId"); + if (window.organizer && calendarOrgId && calendar.id != gPreviousCalendarId) { + window.organizer.id = calendarOrgId; + window.organizer.commonName = calendar.getProperty("organizerCN"); + gPreviousCalendarId = calendar.id; + updateAttendeeInterface(); + } + + if (!canNotifyAttendees(calendar, item) && calendar.getProperty("imip.identity")) { + document.getElementById("notify-attendees-checkbox").removeAttribute("disabled"); + document.getElementById("undisclose-attendees-checkbox").removeAttribute("disabled"); + } else { + document.getElementById("notify-attendees-checkbox").setAttribute("disabled", "true"); + document.getElementById("undisclose-attendees-checkbox").setAttribute("disabled", "true"); + } + + // update the accept button + updateAccept(); + + // TODO: the code above decided about whether or not the item is readonly. + // below we enable/disable all controls based on this decision. + // unfortunately some controls need to be disabled based on some other + // criteria. this is why we enable all controls in case the item is *not* + // readonly and run through all those updateXXX() functions to disable + // them again based on the specific logic build into those function. is this + // really a good idea? + if (gIsReadOnly) { + let disableElements = document.getElementsByAttribute("disable-on-readonly", "true"); + for (let element of disableElements) { + if (element.namespaceURI == "http://www.w3.org/1999/xhtml") { + element.setAttribute("disabled", "disabled"); + } else { + element.setAttribute("disabled", "true"); + } + + // we mark link-labels with the hyperlink attribute, since we need + // to remove their class in case they get disabled. TODO: it would + // be better to create a small binding for those link-labels + // instead of adding those special stuff. + if (element.hasAttribute("hyperlink")) { + element.removeAttribute("class"); + element.removeAttribute("onclick"); + } + } + + let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true"); + for (let element of collapseElements) { + element.setAttribute("collapsed", "true"); + } + } else { + sendMessage({ command: "removeDisableAndCollapseOnReadonly" }); + + let enableElements = document.getElementsByAttribute("disable-on-readonly", "true"); + for (let element of enableElements) { + element.removeAttribute("disabled"); + if (element.hasAttribute("hyperlink")) { + element.classList.add("text-link"); + } + } + + let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true"); + for (let element of collapseElements) { + element.removeAttribute("collapsed"); + } + + if (item.isTodo()) { + // Task completed date + if (item.completedDate) { + updateToDoStatus(item.status, cal.dtz.dateTimeToJsDate(item.completedDate)); + } else { + updateToDoStatus(item.status); + } + } + + // disable repeat menupopup if this is an occurrence + item = window.calendarItem; + if (item.parentItem != item) { + document.getElementById("item-repeat").setAttribute("disabled", "true"); + document.getElementById("repeat-until-datepicker").setAttribute("disabled", "true"); + let repeatDetails = document.getElementById("repeat-details"); + let numChilds = repeatDetails.children.length; + for (let i = 0; i < numChilds; i++) { + let node = repeatDetails.children[i]; + node.setAttribute("disabled", "true"); + node.removeAttribute("class"); + node.removeAttribute("onclick"); + } + } + + // If the item is a proxy occurrence/instance, a few things aren't + // valid. + if (item.parentItem != item) { + document.getElementById("item-calendar").setAttribute("disabled", "true"); + + // don't allow to revoke the entrydate of recurring todo's. + disableElementWithLock("todo-has-entrydate", "permanent-lock"); + } + + // update datetime pickers, disable checkboxes if dates are required by + // recurrence or reminders. + updateRepeat(true); + updateReminder(true); + updateAllDay(); + } + + // Make sure capabilities are reflected correctly + updateCapabilities(); +} + +/** + * Opens the recurrence dialog modally to allow the user to edit the recurrence + * rules. + */ +function editRepeat() { + let args = {}; + args.calendarEvent = window.calendarItem; + args.recurrenceInfo = window.recurrenceInfo; + args.startTime = gStartTime; + args.endTime = gEndTime; + + let savedWindow = window; + args.onOk = function (recurrenceInfo) { + savedWindow.recurrenceInfo = recurrenceInfo; + }; + + window.setCursor("wait"); + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml", + "_blank", + "chrome,titlebar,modal,resizable,centerscreen", + args + ); +} + +/** + * This function is responsible for propagating UI state to controls + * depending on the repeat setting of an item. This functionality is used + * after the dialog has been loaded as well as if the repeat pattern has + * been changed. + * + * @param aSuppressDialogs If true, controls are updated without prompting + * for changes with the recurrence dialog + * @param aItemRepeatCall True when the function is being called from + * the item-repeat menu list. It allows to detect + * a change from the "custom" option. + */ +function updateRepeat(aSuppressDialogs, aItemRepeatCall) { + function setUpEntrydateForTask(item) { + // if this item is a task, we need to make sure that it has + // an entry-date, otherwise we can't create a recurrence. + if (item.isTodo()) { + // automatically check 'has entrydate' if needed. + 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. the 'disabled' state will be + // revoked if the user turns off the repeat pattern. + disableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + } + + let repeatMenu = document.getElementById("item-repeat"); + let repeatValue = repeatMenu.selectedItem.getAttribute("value"); + let repeatUntilDate = document.getElementById("repeat-untilDate"); + let repeatDetails = document.getElementById("repeat-details"); + + if (repeatValue == "none") { + repeatUntilDate.hidden = true; + repeatDetails.hidden = true; + window.recurrenceInfo = null; + let item = window.calendarItem; + if (item.isTodo()) { + enableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + } else if (repeatValue == "custom") { + // the user selected custom repeat pattern. we now need to bring + // up the appropriate dialog in order to let the user specify the + // new rule. First of all, retrieve the item we want to specify + // the custom repeat pattern for. + let item = window.calendarItem; + + setUpEntrydateForTask(item); + + // retrieve the current recurrence info, we need this + // to find out whether or not the user really created + // a new repeat pattern. + let recurrenceInfo = window.recurrenceInfo; + + // now bring up the recurrence dialog. + // don't pop up the dialog if aSuppressDialogs was specified or if + // called during initialization of the dialog. + if (!aSuppressDialogs && repeatMenu.hasAttribute("last-value")) { + editRepeat(); + } + + // Assign gUntilDate on the first run or when returning from the + // edit recurrence dialog. + if (window.recurrenceInfo) { + let rrules = splitRecurrenceRules(window.recurrenceInfo); + let rule = rrules[0][0]; + gUntilDate = null; + if (!rule.isByCount && rule.isFinite && rule.untilDate) { + gUntilDate = rule.untilDate.clone().getInTimezone(cal.dtz.defaultTimezone); + } + } + + // we need to address two separate cases here. + // 1)- We need to revoke the selection of the repeat + // drop down list in case the user didn't specify + // a new repeat pattern (i.e. canceled the dialog); + // - re-enable the 'has entrydate' option in case + // we didn't end up with a recurrence rule. + // 2) Check whether the new recurrence rule needs the + // recurrence details text or it can be displayed + // only with the repeat-until-datepicker. + if (recurrenceInfo == window.recurrenceInfo) { + repeatMenu.selectedIndex = gLastRepeatSelection; + if (item.isTodo()) { + if (!window.recurrenceInfo) { + enableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + } + } else { + repeatUntilDate.hidden = true; + repeatDetails.hidden = false; + // From the Edit Recurrence dialog, the rules "every day" and + // "every weekday" don't need the recurrence details text when they + // have only the until date. The getRepeatTypeAndUntilDate() + // function verifies whether this is the case. + let [repeatType, untilDate] = getRepeatTypeAndUntilDate(item); + loadRepeat(repeatType, untilDate, window.calendarItem); + } + } else { + let item = window.calendarItem; + let recurrenceInfo = window.recurrenceInfo || item.recurrenceInfo; + let proposedUntilDate = (gStartTime || window.initialStartDateValue).clone(); + + if (recurrenceInfo) { + recurrenceInfo = recurrenceInfo.clone(); + let rrules = splitRecurrenceRules(recurrenceInfo); + let rule = rrules[0][0]; + + // If the previous rule was "custom" we have to recover the until + // date, or the last occurrence's date in order to set the + // repeat-until-datepicker with the same date. + if (aItemRepeatCall && repeatUntilDate.hidden && !repeatDetails.hidden) { + let repeatDate; + if (!rule.isByCount || !rule.isFinite) { + if (rule.isFinite) { + repeatDate = rule.untilDate.getInTimezone(cal.dtz.floating); + repeatDate = cal.dtz.dateTimeToJsDate(repeatDate); + } else { + repeatDate = "forever"; + } + } else { + // Try to recover the last occurrence in 10(?) years. + let endDate = gStartTime.clone(); + endDate.year += 10; + let lastOccurrenceDate = null; + let dates = recurrenceInfo.getOccurrenceDates(gStartTime, endDate, 0); + if (dates) { + lastOccurrenceDate = dates[dates.length - 1]; + } + repeatDate = (lastOccurrenceDate || proposedUntilDate).getInTimezone(cal.dtz.floating); + repeatDate = cal.dtz.dateTimeToJsDate(repeatDate); + } + document.getElementById("repeat-until-datepicker").value = repeatDate; + } + if (rrules[0].length > 0) { + recurrenceInfo.deleteRecurrenceItem(rule); + } + } else { + // New event proposes "forever" as default until date. + recurrenceInfo = new CalRecurrenceInfo(item); + document.getElementById("repeat-until-datepicker").value = "forever"; + } + + repeatUntilDate.hidden = false; + repeatDetails.hidden = true; + + let recRule = cal.createRecurrenceRule(); + recRule.interval = 1; + switch (repeatValue) { + case "daily": + recRule.type = "DAILY"; + break; + case "weekly": + recRule.type = "WEEKLY"; + break; + case "every.weekday": + recRule.type = "DAILY"; + recRule.setComponent("BYDAY", [2, 3, 4, 5, 6]); + break; + case "bi.weekly": + recRule.type = "WEEKLY"; + recRule.interval = 2; + break; + case "monthly": + recRule.type = "MONTHLY"; + break; + case "yearly": + recRule.type = "YEARLY"; + break; + } + + setUpEntrydateForTask(item); + updateUntildateRecRule(recRule); + + recurrenceInfo.insertRecurrenceItemAt(recRule, 0); + window.recurrenceInfo = recurrenceInfo; + + if (item.isTodo()) { + if (!document.getElementById("todo-has-entrydate").checked) { + document.getElementById("todo-has-entrydate").checked = true; + } + disableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + + // Preset the until-datepicker's minimonth to the start date. + let startDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating)); + document.getElementById("repeat-until-datepicker").extraDate = startDate; + } + + gLastRepeatSelection = repeatMenu.selectedIndex; + repeatMenu.setAttribute("last-value", repeatValue); + + updateRepeatDetails(); + updateEntryDate(); + updateDueDate(); + updateAccept(); +} + +/** + * Update the until date in the recurrence rule in order to set + * the same time of the start date. + * + * @param recRule (optional) The recurrence rule + */ +function updateUntildateRecRule(recRule) { + if (!recRule) { + let recurrenceInfo = window.recurrenceInfo; + if (!recurrenceInfo) { + return; + } + let rrules = splitRecurrenceRules(recurrenceInfo); + recRule = rrules[0][0]; + } + let defaultTimezone = cal.dtz.defaultTimezone; + let repeatUntilDate = null; + + let itemRepeat = document.getElementById("item-repeat").selectedItem.value; + if (itemRepeat == "none") { + return; + } else if (itemRepeat == "custom") { + repeatUntilDate = gUntilDate; + } else { + let untilDatepickerDate = document.getElementById("repeat-until-datepicker").value; + if (untilDatepickerDate != "forever") { + repeatUntilDate = cal.dtz.jsDateToDateTime(untilDatepickerDate, defaultTimezone); + } + } + + if (repeatUntilDate) { + if (onLoad.hasLoaded) { + repeatUntilDate.isDate = gStartTime.isDate; // Enforce same value type as DTSTART + if (!gStartTime.isDate) { + repeatUntilDate.hour = gStartTime.hour; + repeatUntilDate.minute = gStartTime.minute; + repeatUntilDate.second = gStartTime.second; + } + } + recRule.untilDate = repeatUntilDate.clone(); + gUntilDate = repeatUntilDate.clone().getInTimezone(defaultTimezone); + } else { + // Rule that recurs forever or with a "count" number of recurrences. + gUntilDate = null; + } +} + +/** + * Updates the UI controls related to a task's completion status. + * + * @param {string} aStatus - The item's completion status or a string + * that allows to identify a change in the + * percent-complete's textbox. + * @param {Date} aCompletedDate - The item's completed date (as a JSDate). + */ +function updateToDoStatus(aStatus, aCompletedDate = null) { + // RFC2445 doesn't support completedDates without the todo's status + // being "COMPLETED", however twiddling the status menulist shouldn't + // destroy that information at this point (in case you change status + // back to COMPLETED). When we go to store this VTODO as .ics the + // date will get lost. + + // remember the original values + let oldPercentComplete = parseInt(document.getElementById("percent-complete-textbox").value, 10); + let oldCompletedDate = document.getElementById("completed-date-picker").value; + + // If the percent completed has changed to 100 or from 100 to another + // value, the status must change. + if (aStatus == "percent-changed") { + let selectedIndex = document.getElementById("todo-status").selectedIndex; + let menuItemCompleted = selectedIndex == 3; + let menuItemNotSpecified = selectedIndex == 0; + if (oldPercentComplete == 100) { + aStatus = "COMPLETED"; + } else if (menuItemCompleted || menuItemNotSpecified) { + aStatus = "IN-PROCESS"; + } + } + + switch (aStatus) { + case null: + case "": + case "NONE": + oldPercentComplete = 0; + document.getElementById("todo-status").selectedIndex = 0; + document.getElementById("percent-complete-textbox").setAttribute("disabled", "true"); + document.getElementById("percent-complete-label").setAttribute("disabled", "true"); + break; + case "CANCELLED": + document.getElementById("todo-status").selectedIndex = 4; + document.getElementById("percent-complete-textbox").setAttribute("disabled", "true"); + document.getElementById("percent-complete-label").setAttribute("disabled", "true"); + break; + case "COMPLETED": + document.getElementById("todo-status").selectedIndex = 3; + document.getElementById("percent-complete-textbox").removeAttribute("disabled"); + document.getElementById("percent-complete-label").removeAttribute("disabled"); + // if there is no aCompletedDate, set it to the previous value + if (!aCompletedDate) { + aCompletedDate = oldCompletedDate; + } + break; + case "IN-PROCESS": + document.getElementById("todo-status").selectedIndex = 2; + document.getElementById("completed-date-picker").setAttribute("disabled", "true"); + document.getElementById("percent-complete-textbox").removeAttribute("disabled"); + document.getElementById("percent-complete-label").removeAttribute("disabled"); + break; + case "NEEDS-ACTION": + document.getElementById("todo-status").selectedIndex = 1; + document.getElementById("percent-complete-textbox").removeAttribute("disabled"); + document.getElementById("percent-complete-label").removeAttribute("disabled"); + break; + } + + let newPercentComplete; + if ((aStatus == "IN-PROCESS" || aStatus == "NEEDS-ACTION") && oldPercentComplete == 100) { + newPercentComplete = 0; + document.getElementById("completed-date-picker").value = oldCompletedDate; + document.getElementById("completed-date-picker").setAttribute("disabled", "true"); + } else if (aStatus == "COMPLETED") { + newPercentComplete = 100; + document.getElementById("completed-date-picker").value = aCompletedDate; + document.getElementById("completed-date-picker").removeAttribute("disabled"); + } else { + newPercentComplete = oldPercentComplete; + document.getElementById("completed-date-picker").value = oldCompletedDate; + document.getElementById("completed-date-picker").setAttribute("disabled", "true"); + } + + gConfig.percentComplete = newPercentComplete; + document.getElementById("percent-complete-textbox").value = newPercentComplete; + if (gInTab) { + sendMessage({ + command: "updateConfigState", + argument: { percentComplete: newPercentComplete }, + }); + } +} + +/** + * Saves all dialog controls back to the item. + * + * @returns a copy of the original item with changes made. + */ +function saveItem() { + // we need to clone the item in order to apply the changes. + // it is important to not apply the changes to the original item + // (even if it happens to be mutable) in order to guarantee + // that providers see a proper oldItem/newItem pair in case + // they rely on this fact (e.g. WCAP does). + let originalItem = window.calendarItem; + let item = originalItem.clone(); + + // override item's recurrenceInfo *before* serializing date/time-objects. + if (!item.recurrenceId) { + item.recurrenceInfo = window.recurrenceInfo; + } + + // serialize the item + saveDialog(item); + + item.organizer = window.organizer; + + item.removeAllAttendees(); + if (window.attendees && window.attendees.length > 0) { + for (let attendee of window.attendees) { + item.addAttendee(attendee); + } + + let notifyCheckbox = document.getElementById("notify-attendees-checkbox"); + if (notifyCheckbox.disabled) { + item.deleteProperty("X-MOZ-SEND-INVITATIONS"); + } else { + item.setProperty("X-MOZ-SEND-INVITATIONS", notifyCheckbox.checked ? "TRUE" : "FALSE"); + } + let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox"); + if (undiscloseCheckbox.disabled) { + item.deleteProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED"); + } else { + item.setProperty( + "X-MOZ-SEND-INVITATIONS-UNDISCLOSED", + undiscloseCheckbox.checked ? "TRUE" : "FALSE" + ); + } + let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox"); + let xProp = window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER"); + // we want to leave an existing x-prop in case the checkbox is disabled as we need to + // roundtrip x-props that are not exclusively under our control + if (!disallowcounterCheckbox.disabled) { + // we only set the prop if we need to + if (disallowcounterCheckbox.checked) { + item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "TRUE"); + } else if (xProp) { + item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "FALSE"); + } + } + } + + // We check if the organizerID is different from our + // calendar-user-address-set. The organzerID is the owner of the calendar. + // If it's different, that is because someone is acting on behalf of + // the organizer. + if (item.organizer && item.calendar.aclEntry) { + let userAddresses = item.calendar.aclEntry.getUserAddresses(); + if ( + userAddresses.length > 0 && + !cal.email.attendeeMatchesAddresses(item.organizer, userAddresses) + ) { + let organizer = item.organizer.clone(); + organizer.setProperty("SENT-BY", "mailto:" + userAddresses[0]); + item.organizer = organizer; + } + } + return item; +} + +/** + * Action to take when the user chooses to save. This can happen either by + * saving directly or the user selecting to save after being prompted when + * closing the dialog. + * + * This function also takes care of notifying this dialog's caller that the item + * is saved. + * + * @param aIsClosing If true, the save action originates from the + * save prompt just before the window is closing. + */ +function onCommandSave(aIsClosing) { + // The datepickers need to remove the focus in order to trigger the + // validation of the values just edited, with the keyboard, but not yet + // confirmed (i.e. not followed by a click, a tab or enter keys pressure). + document.documentElement.focus(); + + // Don't save if a warning dialog about a wrong input date must be showed. + if (gWarning) { + return; + } + + eventDialogCalendarObserver.cancel(); + + let originalItem = window.calendarItem; + let item = saveItem(); + let calendar = getCurrentCalendar(); + adaptScheduleAgent(item); + + item.makeImmutable(); + // Set the item for now, the callback below will set the full item when the + // call succeeded + window.calendarItem = item; + + // When the call is complete, we need to set the new item, so that the + // dialog is up to date. + + // XXX Do we want to disable the dialog or at least the save button until + // the call is complete? This might help when the user tries to save twice + // before the call is complete. In that case, we do need a progress bar and + // the ability to cancel the operation though. + let listener = { + onTransactionComplete(aItem) { + let aId = aItem.id; + let aCalendar = aItem.calendar; + // Check if the current window has a calendarItem first, because in case of undo + // window refers to the main window and we would get a 'calendarItem is undefined' warning. + if (!aIsClosing && "calendarItem" in window) { + // If we changed the calendar of the item, onOperationComplete will be called multiple + // times. We need to make sure we're receiving the update on the right calendar. + if ( + (!window.calendarItem.id || aId == window.calendarItem.id) && + aCalendar.id == window.calendarItem.calendar.id + ) { + if (window.calendarItem.recurrenceId) { + // TODO This workaround needs to be removed in bug 396182 + // We are editing an occurrence. Make sure that the returned + // item is the same occurrence, not its parent item. + let occ = aItem.recurrenceInfo.getOccurrenceFor(window.calendarItem.recurrenceId); + window.calendarItem = occ; + } else { + // We are editing the parent item, no workarounds needed + window.calendarItem = aItem; + } + + // We now have an item, so we must change to an edit. + window.mode = "modify"; + updateTitle(); + eventDialogCalendarObserver.observe(window.calendarItem.calendar); + } + } + // this triggers the update of the imipbar in case this is a rescheduling case + if (window.counterProposal && window.counterProposal.onReschedule) { + window.counterProposal.onReschedule(); + } + }, + onGetResult(calendarItem, status, itemType, detail, items) {}, + }; + let resp = document.getElementById("notify-attendees-checkbox").checked + ? Ci.calIItipItem.AUTO + : Ci.calIItipItem.NONE; + let extResponse = { responseMode: resp }; + window.onAcceptCallback(item, calendar, originalItem, listener, extResponse); +} + +/** + * This function is called when the user chooses to delete an Item + * from the Event/Task dialog + * + */ +function onCommandDeleteItem() { + // only ask for confirmation, if the User changed anything on a new item or we modify an existing item + if (isItemChanged() || window.mode != "new") { + if (!cal.window.promptDeleteItems(window.calendarItem, true)) { + return; + } + } + + if (window.mode == "new") { + cancelItem(); + } else { + let deleteListener = { + // when deletion of item is complete, close the dialog + onTransactionComplete(item) { + // Check if the current window has a calendarItem first, because in case of undo + // window refers to the main window and we would get a 'calendarItem is undefined' warning. + if ("calendarItem" in window) { + if (item.id == window.calendarItem.id) { + cancelItem(); + } else { + eventDialogCalendarObserver.observe(window.calendarItem.calendar); + } + } + }, + }; + + eventDialogCalendarObserver.cancel(); + if (window.calendarItem.parentItem.recurrenceInfo && window.calendarItem.recurrenceId) { + // if this is a single occurrence of a recurring item + if (countOccurrences(window.calendarItem) == 1) { + // this is the last occurrence, hence we delete the parent item + // to not leave a parent item without children in the calendar + gMainWindow.doTransaction( + "delete", + window.calendarItem.parentItem, + window.calendarItem.calendar, + null, + deleteListener + ); + } else { + // we just need to remove the occurrence + let newItem = window.calendarItem.parentItem.clone(); + newItem.recurrenceInfo.removeOccurrenceAt(window.calendarItem.recurrenceId); + gMainWindow.doTransaction( + "modify", + newItem, + newItem.calendar, + window.calendarItem.parentItem, + deleteListener + ); + } + } else { + gMainWindow.doTransaction( + "delete", + window.calendarItem, + window.calendarItem.calendar, + null, + deleteListener + ); + } + } +} + +/** + * Postpone the task's start date/time and due date/time. ISO 8601 + * format: "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We + * use this format intentionally instead of a calIDuration object because + * those objects cannot be serialized for message passing with iframes.) + * + * @param {string} aDuration - A duration in ISO 8601 format + */ +function postponeTask(aDuration) { + let duration = cal.createDuration(aDuration); + if (gStartTime != null) { + gStartTime.addDuration(duration); + } + if (gEndTime != null) { + gEndTime.addDuration(duration); + } + updateDateTime(); +} + +/** + * Prompts the user to change the start timezone. + */ +function editStartTimezone() { + editTimezone( + "timezone-starttime", + gStartTime.getInTimezone(gStartTimezone), + editStartTimezone.complete + ); +} +editStartTimezone.complete = function (datetime) { + let equalTimezones = false; + if (gStartTimezone && gEndTimezone) { + if (gStartTimezone == gEndTimezone) { + equalTimezones = true; + } + } + gStartTimezone = datetime.timezone; + if (equalTimezones) { + gEndTimezone = datetime.timezone; + } + updateDateTime(); +}; + +/** + * Prompts the user to change the end timezone. + */ +function editEndTimezone() { + editTimezone("timezone-endtime", gEndTime.getInTimezone(gEndTimezone), editEndTimezone.complete); +} +editEndTimezone.complete = function (datetime) { + gEndTimezone = datetime.timezone; + updateDateTime(); +}; + +/** + * Called to choose a recent timezone from the timezone popup. + * + * @param event The event with a target that holds the timezone id value. + */ +function chooseRecentTimezone(event) { + let tzid = event.target.value; + let timezonePopup = document.getElementById("timezone-popup"); + + if (tzid != "custom") { + let zone = cal.timezoneService.getTimezone(tzid); + let datetime = timezonePopup.dateTime.getInTimezone(zone); + timezonePopup.editTimezone.complete(datetime); + } +} + +/** + * Opens the timezone popup on the node the event target points at. + * + * @param event The event causing the popup to open + * @param dateTime The datetime for which the timezone should be modified + * @param editFunc The function to be called when the custom menuitem is clicked. + */ +function showTimezonePopup(event, dateTime, editFunc) { + // Don't do anything for right/middle-clicks. Also, don't show the popup if + // the opening node is disabled. + if (event.button != 0 || event.target.disabled) { + return; + } + + let timezonePopup = document.getElementById("timezone-popup"); + let timezoneDefaultItem = document.getElementById("timezone-popup-defaulttz"); + let timezoneSeparator = document.getElementById("timezone-popup-menuseparator"); + let defaultTimezone = cal.dtz.defaultTimezone; + let recentTimezones = cal.dtz.getRecentTimezones(true); + + // Set up the right editTimezone function, so the custom item can use it. + timezonePopup.editTimezone = editFunc; + timezonePopup.dateTime = dateTime; + + // Set up the default timezone item + timezoneDefaultItem.value = defaultTimezone.tzid; + timezoneDefaultItem.label = defaultTimezone.displayName; + + // Clear out any old recent timezones + while (timezoneDefaultItem.nextElementSibling != timezoneSeparator) { + timezoneDefaultItem.nextElementSibling.remove(); + } + + // Fill in the new recent timezones + for (let timezone of recentTimezones) { + let menuItem = document.createXULElement("menuitem"); + menuItem.setAttribute("value", timezone.tzid); + menuItem.setAttribute("label", timezone.displayName); + timezonePopup.insertBefore(menuItem, timezoneDefaultItem.nextElementSibling); + } + + // Show the popup + timezonePopup.openPopup(event.target, "after_start", 0, 0, true); +} + +/** + * Common function of edit(Start|End)Timezone() to prompt the user for a + * timezone change. + * + * @param aElementId The XUL element id of the timezone label. + * @param aDateTime The Date/Time of the time to change zone on. + * @param aCallback What to do when the user has chosen a zone. + */ +function editTimezone(aElementId, aDateTime, aCallback) { + if (document.getElementById(aElementId).hasAttribute("disabled")) { + return; + } + + // prepare the arguments that will be passed to the dialog + let args = {}; + args.time = aDateTime; + args.calendar = getCurrentCalendar(); + args.onOk = function (datetime) { + cal.dtz.saveRecentTimezone(datetime.timezone.tzid); + return aCallback(datetime); + }; + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-timezone.xhtml", + "_blank", + "chrome,titlebar,modal,resizable,centerscreen", + args + ); +} + +/** + * This function initializes the following controls: + * - 'event-starttime' + * - 'event-endtime' + * - 'event-all-day' + * - 'todo-has-entrydate' + * - 'todo-entrydate' + * - 'todo-has-duedate' + * - 'todo-duedate' + * The date/time-objects are either displayed in their respective + * timezone or in the default timezone. This decision is based + * on whether or not 'cmd_timezone' is checked. + * the necessary information is taken from the following variables: + * - 'gStartTime' + * - 'gEndTime' + * - 'window.calendarItem' (used to decide about event/task) + */ +function updateDateTime() { + gIgnoreUpdate = true; + + let item = window.calendarItem; + // Convert to default timezone if the timezone option + // is *not* checked, otherwise keep the specific timezone + // and display the labels in order to modify the timezone. + if (gTimezonesEnabled) { + if (item.isEvent()) { + let startTime = gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime.getInTimezone(gEndTimezone); + + document.getElementById("event-all-day").checked = startTime.isDate; + + // In the case where the timezones are different but + // the timezone of the endtime is "UTC", we convert + // the endtime into the timezone of the starttime. + if (startTime && endTime) { + if (!cal.data.compareObjects(startTime.timezone, endTime.timezone)) { + if (endTime.timezone.isUTC) { + endTime = endTime.getInTimezone(startTime.timezone); + } + } + } + + // 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. + startTime.timezone = cal.dtz.floating; + endTime.timezone = cal.dtz.floating; + + document.getElementById("event-starttime").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("event-endtime").value = cal.dtz.dateTimeToJsDate(endTime); + } + + if (item.isTodo()) { + let startTime = gStartTime && gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime && gEndTime.getInTimezone(gEndTimezone); + let hasEntryDate = startTime != null; + let hasDueDate = endTime != null; + + if (hasEntryDate && hasDueDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else if (hasEntryDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(startTime); + } else if (hasDueDate) { + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(endTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else { + startTime = window.initialStartDateValue; + startTime.timezone = cal.dtz.floating; + endTime = startTime.clone(); + + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } + } + } else { + let kDefaultTimezone = cal.dtz.defaultTimezone; + + if (item.isEvent()) { + let startTime = gStartTime.getInTimezone(kDefaultTimezone); + let endTime = gEndTime.getInTimezone(kDefaultTimezone); + document.getElementById("event-all-day").checked = startTime.isDate; + + // 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. + startTime.timezone = cal.dtz.floating; + endTime.timezone = cal.dtz.floating; + document.getElementById("event-starttime").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("event-endtime").value = cal.dtz.dateTimeToJsDate(endTime); + } + + if (item.isTodo()) { + let startTime = gStartTime && gStartTime.getInTimezone(kDefaultTimezone); + let endTime = gEndTime && gEndTime.getInTimezone(kDefaultTimezone); + let hasEntryDate = startTime != null; + let hasDueDate = endTime != null; + + if (hasEntryDate && hasDueDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else if (hasEntryDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(startTime); + } else if (hasDueDate) { + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(endTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else { + startTime = window.initialStartDateValue; + startTime.timezone = cal.dtz.floating; + endTime = startTime.clone(); + + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } + } + } + + updateTimezone(); + updateAllDay(); + updateRepeatDetails(); + + gIgnoreUpdate = false; +} + +/** + * This function initializes the following controls: + * - 'timezone-starttime' + * - 'timezone-endtime' + * the timezone-links show the corrosponding names of the + * start/end times. If 'cmd_timezone' is not checked + * the links will be collapsed. + */ +function updateTimezone() { + function updateTimezoneElement(aTimezone, aId, aDateTime) { + let element = document.getElementById(aId); + if (!element) { + return; + } + + if (aTimezone) { + element.removeAttribute("collapsed"); + element.value = aTimezone.displayName || aTimezone.tzid; + if (!aDateTime || !aDateTime.isValid || gIsReadOnly || aDateTime.isDate) { + if (element.hasAttribute("class")) { + element.setAttribute("class-on-enabled", element.getAttribute("class")); + element.removeAttribute("class"); + } + if (element.hasAttribute("onclick")) { + element.setAttribute("onclick-on-enabled", element.getAttribute("onclick")); + element.removeAttribute("onclick"); + } + element.setAttribute("disabled", "true"); + } else { + if (element.hasAttribute("class-on-enabled")) { + element.setAttribute("class", element.getAttribute("class-on-enabled")); + element.removeAttribute("class-on-enabled"); + } + if (element.hasAttribute("onclick-on-enabled")) { + element.setAttribute("onclick", element.getAttribute("onclick-on-enabled")); + element.removeAttribute("onclick-on-enabled"); + } + element.removeAttribute("disabled"); + } + } else { + element.setAttribute("collapsed", "true"); + } + } + + // convert to default timezone if the timezone option + // is *not* checked, otherwise keep the specific timezone + // and display the labels in order to modify the timezone. + if (gTimezonesEnabled) { + updateTimezoneElement(gStartTimezone, "timezone-starttime", gStartTime); + updateTimezoneElement(gEndTimezone, "timezone-endtime", gEndTime); + } else { + document.getElementById("timezone-starttime").setAttribute("collapsed", "true"); + document.getElementById("timezone-endtime").setAttribute("collapsed", "true"); + } +} + +/** + * Updates dialog controls related to item attachments + */ +function updateAttachment() { + let hasAttachments = capSupported("attachments"); + document.getElementById("cmd_attach_url").setAttribute("disabled", !hasAttachments); + + // update the attachment tab label to make the number of (uri) attachments visible + // even if another tab is displayed + let attachments = Object.values(gAttachMap).filter(aAtt => aAtt.uri); + let attachmentTab = document.getElementById("event-grid-tab-attachments"); + if (attachments.length) { + attachmentTab.label = cal.l10n.getString("calendar-event-dialog", "attachmentsTabLabel", [ + attachments.length, + ]); + } else { + attachmentTab.label = window.attachmentTabLabel; + } + + sendMessage({ + command: "updateConfigState", + argument: { attachUrlCommand: hasAttachments }, + }); +} + +/** + * Returns whether to show or hide the related link on the dialog + * (rfc2445 URL property). + * + * @param {string} aUrl - The url in question. + * @returns {boolean} true for show and false for hide + */ +function showOrHideItemURL(url) { + if (!url) { + return false; + } + let handler; + let uri; + try { + uri = Services.io.newURI(url); + handler = Services.io.getProtocolHandler(uri.scheme); + } catch (e) { + // No protocol handler for the given protocol, or invalid uri + // hideOrShow(false); + return false; + } + // 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); + return !handler || handler.externalAppExistsForScheme(uri.scheme); +} + +/** + * Updates the related link on the dialog (rfc2445 URL property). + * + * @param {boolean} aShow - Show the link (true) or not (false) + * @param {string} aUrl - The url + */ +function updateItemURL(aShow, aUrl) { + // Hide or show the link + document.getElementById("event-grid-link-separator").toggleAttribute("hidden", !aShow); + document.getElementById("event-grid-link-row").toggleAttribute("hidden", !aShow); + + // Set the url for the link + if (aShow && aUrl.length) { + setTimeout(() => { + // HACK the url-link doesn't crop when setting the value in onLoad + let label = document.getElementById("url-link"); + label.setAttribute("value", aUrl); + label.setAttribute("href", aUrl); + }, 0); + } +} + +/** + * This function updates dialog controls related to attendees. + */ +function updateAttendeeInterface() { + // sending email invitations currently only supported for events + let attendeeTab = document.getElementById("event-grid-tab-attendees"); + let attendeePanel = document.getElementById("event-grid-tabpanel-attendees"); + let notifyOptions = document.getElementById("notify-options"); + if (window.calendarItem.isEvent()) { + attendeeTab.removeAttribute("collapsed"); + attendeePanel.removeAttribute("collapsed"); + notifyOptions.removeAttribute("collapsed"); + + let organizerRow = document.getElementById("item-organizer-row"); + if (window.organizer && window.organizer.id) { + let existingLabel = organizerRow.querySelector(":scope > .attendee-label"); + if (existingLabel) { + organizerRow.removeChild(existingLabel); + } + organizerRow.appendChild( + cal.invitation.createAttendeeLabel(document, window.organizer, window.attendees) + ); + organizerRow.hidden = false; + } else { + organizerRow.hidden = true; + } + + let attendeeContainer = document.querySelector(".item-attendees-list-container"); + if (attendeeContainer.firstChild) { + attendeeContainer.firstChild.remove(); + } + attendeeContainer.appendChild(cal.invitation.createAttendeesList(document, window.attendees)); + for (let label of attendeeContainer.querySelectorAll(".attendee-label")) { + label.addEventListener("dblclick", attendeeDblClick); + label.setAttribute("tabindex", "0"); + } + + // update the attendee tab label to make the number of attendees + // visible even if another tab is displayed + if (window.attendees.length) { + attendeeTab.label = cal.l10n.getString("calendar-event-dialog", "attendeesTabLabel", [ + window.attendees.length, + ]); + } else { + attendeeTab.label = window.attendeeTabLabel; + } + } else { + attendeeTab.setAttribute("collapsed", "true"); + attendeePanel.setAttribute("collapsed", "true"); + } + updateParentSaveControls(); +} + +/** + * Update the save controls in parent context depending on the whether attendees + * exist for this event and notifying is enabled + */ +function updateParentSaveControls() { + let mode = + window.calendarItem.isEvent() && + window.organizer && + window.organizer.id && + window.attendees && + window.attendees.length > 0 && + document.getElementById("notify-attendees-checkbox").checked; + + sendMessage({ + command: "updateSaveControls", + argument: { sendNotSave: mode }, + }); +} + +/** + * This function updates dialog controls related to recurrence, in this case the + * text describing the recurrence rule. + */ +function updateRepeatDetails() { + // Don't try to show the details text for + // anything but a custom recurrence rule. + let recurrenceInfo = window.recurrenceInfo; + let itemRepeat = document.getElementById("item-repeat"); + let repeatDetails = document.getElementById("repeat-details"); + if (itemRepeat.value == "custom" && recurrenceInfo && !hasUnsupported(recurrenceInfo)) { + let item = window.calendarItem; + document.getElementById("repeat-untilDate").hidden = true; + // Try to create a descriptive string from the rule(s). + let kDefaultTimezone = cal.dtz.defaultTimezone; + let event = item.isEvent(); + + let startDate = document.getElementById(event ? "event-starttime" : "todo-entrydate").value; + let endDate = document.getElementById(event ? "event-endtime" : "todo-duedate").value; + startDate = cal.dtz.jsDateToDateTime(startDate, kDefaultTimezone); + endDate = cal.dtz.jsDateToDateTime(endDate, kDefaultTimezone); + + let allDay = document.getElementById("event-all-day").checked; + let detailsString = recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay); + + if (!detailsString) { + detailsString = cal.l10n.getString("calendar-event-dialog", "ruleTooComplex"); + } + repeatDetails.hidden = false; + + // Now display the string. + let lines = detailsString.split("\n"); + while (repeatDetails.children.length > lines.length) { + repeatDetails.lastChild.remove(); + } + let numChilds = repeatDetails.children.length; + for (let i = 0; i < lines.length; i++) { + if (i >= numChilds) { + let newNode = repeatDetails.children[0].cloneNode(true); + repeatDetails.appendChild(newNode); + } + repeatDetails.children[i].value = lines[i]; + repeatDetails.children[i].setAttribute("tooltiptext", detailsString); + } + } else { + repeatDetails.hidden = true; + } +} + +/** + * This function does not strictly check if the given attendee has the status + * TENTATIVE, but also if he hasn't responded. + * + * @param aAttendee The attendee to check. + * @returns True, if the attendee hasn't responded. + */ +function isAttendeeUndecided(aAttendee) { + return ( + aAttendee.participationStatus != "ACCEPTED" && + aAttendee.participationStatus != "DECLINED" && + aAttendee.participationStatus != "DELEGATED" + ); +} + +/** + * Event handler for dblclick on attendee items. + * + * @param aEvent The popupshowing event + */ +function attendeeDblClick(aEvent) { + // left mouse button + if (aEvent.button == 0) { + editAttendees(); + } +} + +/** + * Event handler to set up the attendee-popup. This builds the popup menuitems. + * + * @param aEvent The popupshowing event + */ +function setAttendeeContext(aEvent) { + if (window.attendees.length == 0) { + // we just need the option to open the attendee dialog in this case + let popup = document.getElementById("attendee-popup"); + let invite = document.getElementById("attendee-popup-invite-menuitem"); + for (let node of popup.children) { + if (node == invite) { + node.removeAttribute("hidden"); + } else { + node.setAttribute("hidden", "true"); + } + } + } else { + if (window.attendees.length > 1) { + let removeall = document.getElementById("attendee-popup-removeallattendees-menuitem"); + removeall.removeAttribute("hidden"); + } + document.getElementById("attendee-popup-sendemail-menuitem").removeAttribute("hidden"); + document.getElementById("attendee-popup-sendtentativeemail-menuitem").removeAttribute("hidden"); + document.getElementById("attendee-popup-first-separator").removeAttribute("hidden"); + + // setup attendee specific menu items if appropriate otherwise hide respective menu items + let mailto = document.getElementById("attendee-popup-emailattendee-menuitem"); + let remove = document.getElementById("attendee-popup-removeattendee-menuitem"); + let secondSeparator = document.getElementById("attendee-popup-second-separator"); + let attId = + aEvent.target.getAttribute("attendeeid") || + aEvent.target.parentNode.getAttribute("attendeeid"); + let attendee = window.attendees.find(aAtt => aAtt.id == attId); + if (attendee) { + mailto.removeAttribute("hidden"); + remove.removeAttribute("hidden"); + secondSeparator.removeAttribute("hidden"); + + mailto.setAttribute("label", attendee.toString()); + mailto.attendee = attendee; + remove.attendee = attendee; + } else { + mailto.setAttribute("hidden", "true"); + remove.setAttribute("hidden", "true"); + secondSeparator.setAttribute("hidden", "true"); + } + + if (window.attendees.some(isAttendeeUndecided)) { + document.getElementById("cmd_email_undecided").removeAttribute("disabled"); + } else { + document.getElementById("cmd_email_undecided").setAttribute("disabled", "true"); + } + } +} + +/** + * Removes the selected attendee from the window + * + * @param aAttendee + */ +function removeAttendee(aAttendee) { + if (aAttendee) { + window.attendees = window.attendees.filter(aAtt => aAtt != aAttendee); + updateAttendeeInterface(); + } +} + +/** + * Removes all attendees from the window + */ +function removeAllAttendees() { + window.attendees = []; + window.organizer = null; + updateAttendeeInterface(); +} + +/** + * Send Email to all attendees that haven't responded or are tentative. + * + * @param aAttendees The attendees to check. + */ +function sendMailToUndecidedAttendees(aAttendees) { + let targetAttendees = aAttendees.filter(isAttendeeUndecided); + sendMailToAttendees(targetAttendees); +} + +/** + * Send Email to all given attendees. + * + * @param aAttendees The attendees to send mail to. + */ +function sendMailToAttendees(aAttendees) { + let toList = cal.email.createRecipientList(aAttendees); + let item = saveItem(); + let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]); + let identity = window.calendarItem.calendar.getProperty("imip.identity"); + cal.email.sendTo(toList, emailSubject, null, identity); +} + +/** + * Make sure all fields that may have calendar specific capabilities are updated + */ +function updateCapabilities() { + updateAttachment(); + updateConfigState({ + priority: gConfig.priority, + privacy: gConfig.privacy, + }); + updateReminderDetails( + document.querySelector(".reminder-details"), + document.querySelector(".item-alarm"), + getCurrentCalendar() + ); + updateCategoryMenulist(); +} + +/** + * find out if the User already changed values in the Dialog + * + * @return: true if the values in the Dialog have changed. False otherwise. + */ +function isItemChanged() { + let newItem = saveItem(); + let oldItem = window.calendarItem; + + if (newItem.calendar.id == oldItem.calendar.id && cal.item.compareContent(newItem, oldItem)) { + return false; + } + return true; +} + +/** + * Test if a specific capability is supported + * + * @param aCap The capability from "capabilities.<aCap>.supported" + */ +function capSupported(aCap) { + let calendar = getCurrentCalendar(); + return calendar.getProperty("capabilities." + aCap + ".supported") !== false; +} + +/** + * Return the values for a certain capability. + * + * @param aCap The capability from "capabilities.<aCap>.values" + * @returns The values for this capability + */ +function capValues(aCap, aDefault) { + let calendar = getCurrentCalendar(); + let vals = calendar.getProperty("capabilities." + aCap + ".values"); + return vals === null ? aDefault : vals; +} + +/** + * 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; sets the warning flag to prevent closing + * the dialog when the user enters a wrong until date. + */ +function checkUntilDate() { + let repeatUntilDate = document.getElementById("repeat-until-datepicker").value; + if (repeatUntilDate == "forever") { + updateRepeat(); + // "forever" is never earlier than another date. + return; + } + + // Check whether the date is valid. Set the correct time just in this case. + let untilDate = cal.dtz.jsDateToDateTime(repeatUntilDate, gStartTime.timezone); + let startDate = gStartTime.clone(); + startDate.isDate = true; + if (untilDate.compare(startDate) < 0) { + // Invalid date: restore the previous date. Since we are checking an + // until date, a null value for gUntilDate means repeat "forever". + document.getElementById("repeat-until-datepicker").value = gUntilDate + ? cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating)) + : "forever"; + gWarning = true; + let callback = function () { + // Disable the "Save" and "Save and Close" commands as long as the + // warning dialog is showed. + enableAcceptCommand(false); + + Services.prompt.alert( + null, + document.title, + cal.l10n.getCalString("warningUntilDateBeforeStart") + ); + enableAcceptCommand(true); + gWarning = false; + }; + setTimeout(callback, 1); + } else { + // Valid date: set the time equal to start date time. + gUntilDate = untilDate; + updateUntildateRecRule(); + } +} + +/** + * Displays a counterproposal if any + */ +function displayCounterProposal() { + if ( + !window.counterProposal || + !window.counterProposal.attendee || + !window.counterProposal.proposal + ) { + return; + } + + let propLabels = document.getElementById("counter-proposal-property-labels"); + let propValues = document.getElementById("counter-proposal-property-values"); + let idCounter = 0; + let comment; + + for (let proposal of window.counterProposal.proposal) { + if (proposal.property == "COMMENT") { + if (proposal.proposed && !proposal.original) { + comment = proposal.proposed; + } + } else { + let label = lookupCounterLabel(proposal); + let value = formatCounterValue(proposal); + if (label && value) { + // setup label node + let propLabel = propLabels.firstElementChild.cloneNode(false); + propLabel.id = propLabel.id + "-" + idCounter; + propLabel.control = propLabel.control + "-" + idCounter; + propLabel.removeAttribute("collapsed"); + propLabel.value = label; + // setup value node + let propValue = propValues.firstElementChild.cloneNode(false); + propValue.id = propLabel.control; + propValue.removeAttribute("collapsed"); + propValue.value = value; + // append nodes + propLabels.appendChild(propLabel); + propValues.appendChild(propValue); + idCounter++; + } + } + } + + let attendeeId = + window.counterProposal.attendee.CN || + cal.email.removeMailTo(window.counterProposal.attendee.id || ""); + let partStat = window.counterProposal.attendee.participationStatus; + if (partStat == "DECLINED") { + partStat = "counterSummaryDeclined"; + } else if (partStat == "TENTATIVE") { + partStat = "counterSummaryTentative"; + } else if (partStat == "ACCEPTED") { + partStat = "counterSummaryAccepted"; + } else if (partStat == "DELEGATED") { + partStat = "counterSummaryDelegated"; + } else if (partStat == "NEEDS-ACTION") { + partStat = "counterSummaryNeedsAction"; + } else { + cal.LOG("Unexpected partstat " + partStat + " detected."); + // we simply reset partStat not display the summary text of the counter box + // to avoid the window of death + partStat = null; + } + + if (idCounter > 0) { + if (partStat && attendeeId.length) { + document.getElementById("counter-proposal-summary").value = cal.l10n.getString( + "calendar-event-dialog", + partStat, + [attendeeId] + ); + document.getElementById("counter-proposal-summary").removeAttribute("collapsed"); + } + if (comment) { + document.getElementById("counter-proposal-comment").value = comment; + document.getElementById("counter-proposal-box").removeAttribute("collapsed"); + } + document.getElementById("counter-proposal-box").removeAttribute("collapsed"); + + if (window.counterProposal.oldVersion) { + // this is a counterproposal to a previous version of the event - we should notify the + // user accordingly + notifyUser( + "counterProposalOnPreviousVersion", + cal.l10n.getString("calendar-event-dialog", "counterOnPreviousVersionNotification"), + "warn" + ); + } + if (window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER") == "TRUE") { + // this is a counterproposal although the user disallowed countering when sending the + // invitation, so we notify the user accordingly + notifyUser( + "counterProposalOnCounteringDisallowed", + cal.l10n.getString("calendar-event-dialog", "counterOnCounterDisallowedNotification"), + "warn" + ); + } + } +} + +/** + * Get the property label to display for a counterproposal based on the respective label used in + * the dialog + * + * @param {JSObject} aProperty The property to check for a label + * @returns {string | null} The label to display or null if no such label + */ +function lookupCounterLabel(aProperty) { + let nodeIds = getPropertyMap(); + let labels = + nodeIds.has(aProperty.property) && + document.getElementsByAttribute("control", nodeIds.get(aProperty.property)); + let labelValue; + if (labels && labels.length) { + // as label control assignment should be unique, we can just take the first result + labelValue = labels[0].value; + } else { + cal.LOG( + "Unsupported property " + + aProperty.property + + " detected when setting up counter " + + "box labels." + ); + } + return labelValue; +} + +/** + * Get the property value to display for a counterproposal as currently supported + * + * @param {JSObject} aProperty The property to check for a label + * @returns {string | null} The value to display or null if the property is not supported + */ +function formatCounterValue(aProperty) { + const dateProps = ["DTSTART", "DTEND"]; + const stringProps = ["SUMMARY", "LOCATION"]; + + let val; + if (dateProps.includes(aProperty.property)) { + let localTime = aProperty.proposed.getInTimezone(cal.dtz.defaultTimezone); + val = cal.dtz.formatter.formatDateTime(localTime); + if (gTimezonesEnabled) { + let tzone = localTime.timezone.displayName || localTime.timezone.tzid; + val += " " + tzone; + } + } else if (stringProps.includes(aProperty.property)) { + val = aProperty.proposed; + } else { + cal.LOG( + "Unsupported property " + aProperty.property + " detected when setting up counter box values." + ); + } + return val; +} + +/** + * Get a map of property names and labels of currently supported properties + * + * @returns {Map} + */ +function getPropertyMap() { + let map = new Map(); + map.set("SUMMARY", "item-title"); + map.set("LOCATION", "item-location"); + map.set("DTSTART", "event-starttime"); + map.set("DTEND", "event-endtime"); + return map; +} + +/** + * Applies the proposal or original data to the respective dialog fields + * + * @param {string} aType Either 'proposed' or 'original' + */ +function applyValues(aType) { + if (!window.counterProposal || (aType != "proposed" && aType != "original")) { + return; + } + let originalBtn = document.getElementById("counter-original-btn"); + if (originalBtn.disabled) { + // The button is disabled when opening the dialog/tab, which makes it more obvious to the + // user that he/she needs to apply the proposal values prior to saving & sending. + // Once that happened, we leave both options to the user without toggling the button states + // to avoid needing to listen to manual changes to do that correctly + originalBtn.removeAttribute("disabled"); + } + let nodeIds = getPropertyMap(); + window.counterProposal.proposal.forEach(aProperty => { + if (aProperty.property != "COMMENT") { + let valueNode = + nodeIds.has(aProperty.property) && document.getElementById(nodeIds.get(aProperty.property)); + if (valueNode) { + if (["DTSTART", "DTEND"].includes(aProperty.property)) { + valueNode.value = cal.dtz.dateTimeToJsDate(aProperty[aType]); + } else { + valueNode.value = aProperty[aType]; + } + } + } + }); +} + +/** + * Opens the context menu for the editor element. + * + * Since its content is, well, content, its contextmenu event is + * eaten by the context menu actor before the element's default + * context menu processing. Since we know that the editor runs + * in the parent process, we can just listen directly to the event. + */ +function openEditorContextMenu(event) { + let popup = document.getElementById("editorContext"); + popup.openPopupAtScreen(event.screenX, event.screenY, true, event); + event.preventDefault(); +} + +// Thunderbird's dialog is mail-centric, but we just want a lightweight prompt. +function insertLink() { + let href = { value: "" }; + let editor = GetCurrentEditor(); + let existingLink = editor.getSelectedElement("href"); + if (existingLink) { + editor.selectElement(existingLink); + href.value = existingLink.getAttribute("href"); + } + let text = GetSelectionAsText().trim() || href.value || GetString("EmptyHREFError"); + let title = GetString("Link"); + if (Services.prompt.prompt(window, title, text, href, null, {})) { + if (!href.value) { + // Remove the link + EditorRemoveTextProperty("href", ""); + } else if (editor.selection.isCollapsed) { + // Insert a link with its href as the text + let link = editor.createElementWithDefaults("a"); + link.setAttribute("href", href.value); + link.textContent = href.value; + editor.insertElementAtSelection(link, false); + } else { + // Change the href of the selection + let link = editor.createElementWithDefaults("a"); + link.setAttribute("href", href.value); + editor.insertLinkAroundSelection(link); + } + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml b/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml new file mode 100644 index 0000000000..60180a0e4b --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml @@ -0,0 +1,1225 @@ +<?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/. --> + +<!-- XXX some of these css files may not be needed here. --> +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.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/shared/calendar-attendees.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/primaryToolbar.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.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/themeableDialog.css"?> + +<!DOCTYPE html [ <!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"> +<!ENTITY % messengercomposeDTD SYSTEM "chrome://messenger/locale/messengercompose/messengercompose.dtd" > +<!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd"> +%brandDTD; %globalDTD; %calendarDTD; %eventDialogDTD; %messengercomposeDTD; %editorOverlayDTD; ]> +<html + id="calendar-event-dialog-inner" + 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" + scrolling="false" +> + <head> + <title></title> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="calendar/calendar-editable-item.ftl" /> + <link rel="localization" href="calendar/calendar-widgets.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/calApplicationUtils.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://messenger/content/messengercompose/editor.js"></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/editorUtilities.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/ComposerCommands.js" + ></script> + <script defer="defer" src="chrome://calendar/content/calendar-item-iframe.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <commandset id=""> + <command id="cmd_recurrence" oncommand="editRepeat();" /> + <command id="cmd_attendees" oncommand="editAttendees();" /> + <command id="cmd_email" oncommand="sendMailToAttendees(window.attendees);" /> + <command + id="cmd_email_undecided" + oncommand="sendMailToUndecidedAttendees(window.attendees);" + /> + <command id="cmd_attach_url" disable-on-readonly="true" oncommand="attachURL()" /> + <command id="cmd_attach_cloud" disable-on-readonly="true" /> + <command id="cmd_openAttachment" oncommand="openAttachment()" /> + <command id="cmd_copyAttachment" oncommand="copyAttachment()" /> + <command + id="cmd_deleteAttachment" + disable-on-readonly="true" + oncommand="deleteAttachment()" + /> + <command + id="cmd_deleteAllAttachments" + disable-on-readonly="true" + oncommand="deleteAllAttachments()" + /> + <command + id="cmd_applyProposal" + disable-on-readonly="true" + oncommand="applyValues('proposed')" + /> + <command + id="cmd_applyOriginal" + disable-on-readonly="true" + oncommand="applyValues('original')" + /> + </commandset> + + <!-- style related commands that update on creation, and on selection change --> + <!-- not using commandupdater directly, as it has to listen to the parent --> + <commandset id="styleMenuItems" oncommandupdate="goUpdateComposerMenuItems(this)"> + <command id="cmd_renderedHTMLEnabler" disabled="true" /> + <command + id="cmd_bold" + state="false" + oncommand="doStyleUICommand('cmd_bold')" + disabled="true" + /> + <command + id="cmd_italic" + state="false" + oncommand="doStyleUICommand('cmd_italic')" + disabled="true" + /> + <command + id="cmd_underline" + state="false" + oncommand="doStyleUICommand('cmd_underline')" + disabled="true" + /> + + <command id="cmd_ul" state="false" oncommand="doStyleUICommand('cmd_ul')" disabled="true" /> + <command id="cmd_ol" state="false" oncommand="doStyleUICommand('cmd_ol')" disabled="true" /> + + <command id="cmd_indent" oncommand="goDoCommand('cmd_indent')" disabled="true" /> + <command id="cmd_outdent" oncommand="goDoCommand('cmd_outdent')" disabled="true" /> + + <command id="cmd_align" state="" disabled="true" /> + </commandset> + + <keyset id="editorKeys"> + <key id="boldkb" key="&styleBoldCmd.key;" observes="cmd_bold" modifiers="accel" /> + <key id="italickb" key="&styleItalicCmd.key;" observes="cmd_italic" modifiers="accel" /> + <key + id="underlinekb" + key="&styleUnderlineCmd.key;" + observes="cmd_underline" + modifiers="accel" + /> + <key + id="increaseindentkb" + key="&increaseIndent.key;" + observes="cmd_indent" + modifiers="accel" + /> + <key + id="decreaseindentkb" + key="&decreaseIndent.key;" + observes="cmd_outdent" + modifiers="accel" + /> + </keyset> + + <menupopup id="editorContext" onpopupshowing="goUpdateGlobalEditMenuItems(true);"> + <menuitem data-l10n-id="text-action-undo" command="cmd_undo" /> + <menuseparator /> + <menuitem data-l10n-id="text-action-cut" command="cmd_cut" /> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy" /> + <menuitem data-l10n-id="text-action-paste" command="cmd_paste" /> + <menuitem data-l10n-id="text-action-delete" command="cmd_item_delete" /> + <menuseparator /> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll" /> + </menupopup> + + <!-- Counter information section --> + <hbox id="counter-proposal-box" collapsed="true"> + <vbox> + <description id="counter-proposal-summary" collapsed="true" crop="end" /> + <hbox id="counter-proposal"> + <vbox id="counter-proposal-property-labels"> + <label + id="counter-proposal-property-label" + control="counter-proposal-property-value" + collapsed="true" + value="" + /> + </vbox> + <vbox id="counter-proposal-property-values"> + <description + id="counter-proposal-property-value" + crop="end" + collapsed="true" + value="" + /> + </vbox> + </hbox> + <description id="counter-proposal-comment" collapsed="true" crop="end" /> + </vbox> + <spacer flex="1" /> + <vbox id="counter-buttons"> + <button + id="counter-proposal-btn" + label="&counter.button.proposal.label;" + crop="end" + command="cmd_applyProposal" + orient="horizontal" + class="counter-buttons" + accesskey="&counter.button.proposal.accesskey;" + tooltip="&counter.button.proposal.tooltip2;" + /> + <button + id="counter-original-btn" + label="&counter.button.original.label;" + crop="end" + command="cmd_applyOriginal" + orient="horizontal" + disabled="true" + class="counter-buttons" + accesskey="&counter.button.original.accesskey;" + tooltip="&counter.button.original.tooltip2;" + /> + </vbox> + </hbox> + + <vbox id="event-dialog-notifications"> + <!-- notificationbox will be added here lazily. --> + </vbox> + + <html:table id="event-grid"> + <!-- Calendar --> + <html:tr> + <html:th> + <label + id="item-calendar-label" + value="&event.calendar.label;" + accesskey="&event.calendar.accesskey;" + control="item-calendar" + disable-on-readonly="true" + /> + </html:th> + <html:td id="event-grid-item-calendar-td"> + <menulist id="item-calendar" disable-on-readonly="true" oncommand="updateCalendar();" /> + </html:td> + </html:tr> + + <!-- Title --> + <html:tr id="event-grid-title-row"> + <html:th> + <label + id="item-title-label" + value="&event.title.textbox.label;" + accesskey="&event.title.textbox.accesskey;" + control="item-title" + disable-on-readonly="true" + /> + </html:th> + <html:td class="event-input-td"> + <html:input + id="item-title" + disable-on-readonly="true" + oninput="updateTitle()" + aria-labelledby="item-title-label" + /> + </html:td> + </html:tr> + + <!-- Location --> + <html:tr id="event-grid-location-row"> + <html:th> + <label + id="item-location-label" + value="&event.location.label;" + accesskey="&event.location.accesskey;" + control="item-location" + disable-on-readonly="true" + /> + </html:th> + <html:td class="event-input-td"> + <html:input + id="item-location" + disable-on-readonly="true" + aria-labelledby="item-location-label" + /> + </html:td> + </html:tr> + + <!-- Category --> + <html:tr id="event-grid-category-row"> + <html:th> + <hbox id="event-grid-category-labels-box"> + <label + id="item-categories-label" + value="&event.categories.label;" + accesskey="&event.categories.accesskey;" + control="item-categories" + disable-on-readonly="true" + /> + </hbox> + </html:th> + <html:td id="event-grid-category-td"> + <menulist id="item-categories" type="panel-menulist" disable-on-readonly="true"> + <menupopup + id="item-categories-popup" + onpopuphiding="return categoryPopupHiding(event);" + > + <html:input + id="item-categories-textbox" + placeholder="&event.categories.textbox.label;" + onblur="this.parentNode.removeAttribute('ignorekeys');" + onfocus="this.parentNode.setAttribute('ignorekeys', 'true');" + onkeypress="categoryTextboxKeypress(event);" + /> + <menuseparator /> + </menupopup> + </menulist> + </html:td> + </html:tr> + + <html:tr class="separator"> + <html:td colspan="2"></html:td> + </html:tr> + + <!-- All-Day --> + <html:tr id="event-grid-allday-row" class="event-only"> + <html:th> </html:th> + <html:td> + <checkbox + id="event-all-day" + disable-on-readonly="true" + label="&event.alldayevent.label;" + accesskey="&event.alldayevent.accesskey;" + oncommand="onUpdateAllDay();" + /> + </html:td> + </html:tr> + + <!-- StartDate --> + <html:tr id="event-grid-startdate-row"> + <html:th id="event-grid-startdate-th"> + <hbox id="event-grid-startdate-label-box" align="center"> + <label + value="&event.from.label;" + accesskey="&event.from.accesskey;" + control="event-starttime" + class="event-only" + disable-on-readonly="true" + /> + <label + value="&task.from.label;" + accesskey="&task.from.accesskey;" + control="todo-has-entrydate" + class="todo-only" + disable-on-readonly="true" + /> + </hbox> + </html:th> + <html:td id="event-grid-startdate-td"> + <hbox id="event-grid-startdate-picker-box"> + <datetimepicker + id="event-starttime" + class="event-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(true);" + /> + <checkbox + id="todo-has-entrydate" + class="todo-only checkbox-no-label" + disable-on-readonly="true" + oncommand="updateEntryDate();" + /> + <datetimepicker + id="todo-entrydate" + class="todo-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(true);" + /> + <vbox> + <hbox> + <html:img + id="link-image-top" + src="chrome://calendar/skin/shared/link-image-top.svg" + alt="" + class="keepduration-link-image" + keep="true" + /> + </hbox> + <spacer flex="1" /> + <toolbarbutton + id="keepduration-button" + accesskey="&event.dialog.keepDurationButton.accesskey;" + oncommand="toggleKeepDuration();" + persist="keep" + keep="false" + tooltiptext="&event.dialog.keepDurationButton.tooltip;" + /> + </vbox> + <hbox align="center"> + <label + id="timezone-starttime" + class="text-link" + collapsed="true" + crop="end" + disable-on-readonly="true" + hyperlink="true" + onclick="showTimezonePopup(event, gStartTime.getInTimezone(gStartTimezone), editStartTimezone)" + /> + </hbox> + </hbox> + </html:td> + </html:tr> + + <!-- EndDate --> + <html:tr id="event-grid-enddate-row"> + <html:th> + <hbox id="event-grid-enddate-label-box" align="center"> + <label + value="&event.to.label;" + accesskey="&event.to.accesskey;" + control="event-endtime" + class="event-only" + disable-on-readonly="true" + /> + <label + value="&task.to.label;" + accesskey="&task.to.accesskey;" + control="todo-has-duedate" + class="todo-only" + disable-on-readonly="true" + /> + </hbox> + </html:th> + <html:td id="event-grid-enddate-td"> + <vbox id="event-grid-enddate-vbox"> + <hbox id="event-grid-enddate-picker-box"> + <datetimepicker + id="event-endtime" + class="event-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(false);" + /> + <checkbox + id="todo-has-duedate" + class="todo-only checkbox-no-label" + disable-on-readonly="true" + oncommand="updateDueDate();" + /> + <datetimepicker + id="todo-duedate" + class="todo-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(false);" + /> + <vbox pack="end"> + <html:img + id="link-image-bottom" + alt="" + src="chrome://calendar/skin/shared/link-image-bottom.svg" + class="keepduration-link-image" + /> + </vbox> + <hbox align="center"> + <label + id="timezone-endtime" + class="text-link" + collapsed="true" + crop="end" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="showTimezonePopup(event, gEndTime.getInTimezone(gEndTimezone), editEndTimezone)" + /> + </hbox> + </hbox> + </vbox> + </html:td> + </html:tr> + + <html:tr id="event-grid-todo-status-row" class="todo-only"> + <html:th> + <label + id="todo-status-label" + value="&task.status.label;" + accesskey="&task.status.accesskey;" + control="todo-status" + disable-on-readonly="true" + /> + </html:th> + <html:td id="event-grid-todo-status-td"> + <hbox id="event-grid-todo-status-picker-box" align="center"> + <menulist + id="todo-status" + class="todo-only" + disable-on-readonly="true" + oncommand="updateToDoStatus(this.value);" + > + <menupopup id="todo-status-menupopup"> + <menuitem + id="todo-status-none-menuitem" + label="&newevent.todoStatus.none.label;" + value="NONE" + /> + <menuitem + id="todo-status-needsaction-menuitem" + label="&newevent.status.needsaction.label;" + value="NEEDS-ACTION" + /> + <menuitem + id="todo-status-inprogress-menuitem" + label="&newevent.status.inprogress.label;" + value="IN-PROCESS" + /> + <menuitem + id="todo-status-completed-menuitem" + label="&newevent.status.completed.label;" + value="COMPLETED" + /> + <menuitem + id="todo-status-canceled-menuitem" + label="&newevent.todoStatus.cancelled.label;" + value="CANCELLED" + /> + </menupopup> + </menulist> + <datepicker + id="completed-date-picker" + class="todo-only" + disable-on-readonly="true" + disabled="true" + value="" + /> + <html:input + id="percent-complete-textbox" + type="number" + class="size3 input-inline" + min="0" + max="100" + disable-on-readonly="true" + oninput="updateToDoStatus('percent-changed')" + onselect="updateToDoStatus('percent-changed')" + /> + <label + id="percent-complete-label" + class="todo-only" + disable-on-readonly="true" + value="&newtodo.percentcomplete.label;" + /> + </hbox> + </html:td> + </html:tr> + + <!-- Recurrence --> + <html:tr id="event-grid-recurrence-row"> + <html:th> + <label + value="&event.repeat.label;" + accesskey="&event.repeat.accesskey;" + control="item-repeat" + disable-on-readonly="true" + /> + </html:th> + <html:td id="event-grid-recurrence-td"> + <hbox id="event-grid-recurrence-picker-box" align="center" flex="1"> + <menulist + id="item-repeat" + disable-on-readonly="true" + oncommand="updateRepeat(null, true)" + > + <menupopup id="item-repeat-menupopup"> + <menuitem + id="repeat-none-menuitem" + label="&event.repeat.does.not.repeat.label;" + selected="true" + value="none" + /> + <menuitem + id="repeat-daily-menuitem" + label="&event.repeat.daily.label;" + value="daily" + /> + <menuitem + id="repeat-weekly-menuitem" + label="&event.repeat.weekly.label;" + value="weekly" + /> + <menuitem + id="repeat-weekday-menuitem" + label="&event.repeat.every.weekday.label;" + value="every.weekday" + /> + <menuitem + id="repeat-biweekly-menuitem" + label="&event.repeat.bi.weekly.label;" + value="bi.weekly" + /> + <menuitem + id="repeat-monthly-menuitem" + label="&event.repeat.monthly.label;" + value="monthly" + /> + <menuitem + id="repeat-yearly-menuitem" + label="&event.repeat.yearly.label;" + value="yearly" + /> + <menuseparator id="item-repeat-separator" /> + <menuitem + id="repeat-custom-menuitem" + label="&event.repeat.custom.label;" + value="custom" + /> + </menupopup> + </menulist> + <hbox id="repeat-untilDate" align="center" hidden="true"> + <label + value="&event.until.label;" + accesskey="&event.until.accesskey;" + control="repeat-until-datepicker" + disable-on-readonly="true" + /> + <datepicker + id="repeat-until-datepicker" + flex="1" + type="forever" + disable-on-readonly="true" + onchange="if (onLoad.hasLoaded) { checkUntilDate(); }" + value="" + /> + </hbox> + <vbox id="repeat-details" flex="1" hidden="true"> + <label + id="repeat-details-label" + class="text-link" + crop="end" + disable-on-readonly="true" + hyperlink="true" + flex="1" + onclick="if (onLoad.hasLoaded) { updateRepeat(); }" + /> + </vbox> + </hbox> + </html:td> + </html:tr> + + <html:tr class="separator"> + <html:td colspan="2"></html:td> + </html:tr> + + <!-- Reminder (Alarm) --> + <html:tr id="event-grid-alarm-row"> + <html:th> + <label + value="&event.reminder.label;" + accesskey="&event.reminder.accesskey;" + control="item-alarm" + disable-on-readonly="true" + /> + </html:th> + <html:td> + <hbox id="event-grid-alarm-picker-box" align="center"> + <menulist + id="item-alarm" + class="item-alarm" + disable-on-readonly="true" + oncommand="updateReminder()" + > + <menupopup id="item-alarm-menupopup"> + <menuitem + id="reminder-none-menuitem" + label="&event.reminder.none.label;" + selected="true" + value="none" + /> + <menuseparator id="reminder-none-separator" /> + <menuitem + id="reminder-0minutes-menuitem" + label="&event.reminder.0minutes.before.label;" + length="0" + origin="before" + relation="START" + unit="minutes" + /> + <menuitem + id="reminder-5minutes-menuitem" + label="&event.reminder.5minutes.before.label;" + length="5" + origin="before" + relation="START" + unit="minutes" + /> + <menuitem + id="reminder-15minutes-menuitem" + label="&event.reminder.15minutes.before.label;" + length="15" + origin="before" + relation="START" + unit="minutes" + /> + <menuitem + id="reminder-30minutes-menuitem" + label="&event.reminder.30minutes.before.label;" + length="30" + origin="before" + relation="START" + unit="minutes" + /> + <menuseparator id="reminder-minutes-separator" /> + <menuitem + id="reminder-1hour-menuitem" + label="&event.reminder.1hour.before.label;" + length="1" + origin="before" + relation="START" + unit="hours" + /> + <menuitem + id="reminder-2hours-menuitem" + label="&event.reminder.2hours.before.label;" + length="2" + origin="before" + relation="START" + unit="hours" + /> + <menuitem + id="reminder-12hours-menuitem" + label="&event.reminder.12hours.before.label;" + length="12" + origin="before" + relation="START" + unit="hours" + /> + <menuseparator id="reminder-hours-separator" /> + <menuitem + id="reminder-1day-menuitem" + label="&event.reminder.1day.before.label;" + length="1" + origin="before" + relation="START" + unit="days" + /> + <menuitem + id="reminder-2days-menuitem" + label="&event.reminder.2days.before.label;" + length="2" + origin="before" + relation="START" + unit="days" + /> + <menuitem + id="reminder-1week-menuitem" + label="&event.reminder.1week.before.label;" + length="7" + origin="before" + relation="START" + unit="days" + /> + <menuseparator id="reminder-custom-separator" /> + <menuitem + class="reminder-custom-menuitem" + label="&event.reminder.custom.label;" + value="custom" + /> + </menupopup> + </menulist> + <hbox class="reminder-details"> + <hbox class="alarm-icons-box" align="center" /> + <!-- TODO oncommand? onkeypress? --> + <label + class="reminder-multiple-alarms-label text-link" + hidden="true" + value="&event.reminder.multiple.label;" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="updateReminder()" + /> + <label + class="reminder-single-alarms-label text-link" + hidden="true" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="updateReminder()" + /> + </hbox> + </hbox> + </html:td> + </html:tr> + + <html:tr id="event-grid-link-separator" class="separator" hidden="hidden"> + <html:td colspan="2"></html:td> + </html:tr> + + <html:tr id="event-grid-link-row" hidden="hidden"> + <html:th> + <label value="&event.url.label;" control="url-link" /> + </html:th> + <html:td> + <label + id="url-link" + class="text-link" + onclick="launchBrowser(this.getAttribute('href'), event)" + oncommand="launchBrowser(this.getAttribute('href'), event)" + crop="end" + /> + </html:td> + </html:tr> + + <html:tr class="separator"> + <html:td colspan="2"></html:td> + </html:tr> + </html:table> + + <vbox id="event-grid-tab-vbox" flex="1"> + <!-- Multi purpose tab box --> + <hbox id="event-grid-tab-box-row"> + <tabbox id="event-grid-tabbox" selectedIndex="0" flex="1"> + <tabs id="event-grid-tabs"> + <tab + id="event-grid-tab-description" + label="&event.description.label;" + accesskey="&event.description.accesskey;" + /> + <tab + id="event-grid-tab-attachments" + label="&event.attachments.label;" + accesskey="&event.attachments.accesskey;" + /> + <tab + id="event-grid-tab-attendees" + label="&event.attendees.label;" + accesskey="&event.attendees.accesskey;" + collapsed="true" + /> + </tabs> + <tabpanels id="event-grid-tabpanels" flex="1"> + <tabpanel id="event-grid-tabpanel-description" orient="vertical"> + <toolbox id="FormatToolbox" mode="icons"> + <toolbar + id="FormatToolbar" + class="inline-toolbar chromeclass-toolbar themeable-full" + nowindowdrag="true" + > + <toolbarbutton + id="paragraphButton" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&ParagraphSelect.tooltip;" + oncommand="goDoCommandParams('cmd_paragraphState', event.target.value);" + observes="cmd_renderedHTMLEnabler" + > + <menupopup id="paragraphPopup"> + <menuitem id="toolbarmenu_bodyText" label="&bodyTextCmd.label;" value="" /> + <menuitem id="toolbarmenu_h1" label="&heading1Cmd.label;" value="h1" /> + <menuitem id="toolbarmenu_h2" label="&heading2Cmd.label;" value="h2" /> + <menuitem id="toolbarmenu_h3" label="&heading3Cmd.label;" value="h3" /> + <menuitem id="toolbarmenu_h4" label="&heading4Cmd.label;" value="h4" /> + <menuitem id="toolbarmenu_h5" label="&heading5Cmd.label;" value="h5" /> + <menuitem id="toolbarmenu_h6" label="&heading6Cmd.label;" value="h6" /> + <menuitem + id="toolbarmenu_pre" + label="¶graphPreformatCmd.label;" + value="pre" + /> + </menupopup> + </toolbarbutton> + <toolbarseparator class="toolbarseparator-standard" /> + <toolbarbutton + id="boldButton" + class="formatting-button" + tooltiptext="&boldToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_bold" + /> + <toolbarbutton + id="italicButton" + class="formatting-button" + tooltiptext="&italicToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_italic" + /> + <toolbarbutton + id="underlineButton" + class="formatting-button" + tooltiptext="&underlineToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_underline" + /> + <toolbarseparator class="toolbarseparator-standard" /> + <toolbarbutton + id="ulButton" + class="formatting-button" + tooltiptext="&bulletListToolbarCmd.tooltip;" + type="radio" + group="lists" + autoCheck="false" + observes="cmd_ul" + /> + <toolbarbutton + id="olButton" + class="formatting-button" + tooltiptext="&numberListToolbarCmd.tooltip;" + type="radio" + group="lists" + autoCheck="false" + observes="cmd_ol" + /> + <toolbarbutton + id="outdentButton" + class="formatting-button" + tooltiptext="&outdentToolbarCmd.tooltip;" + observes="cmd_outdent" + /> + <toolbarbutton + id="indentButton" + class="formatting-button" + tooltiptext="&indentToolbarCmd.tooltip;" + observes="cmd_indent" + /> + <toolbarseparator class="toolbarseparator-standard" /> + <toolbarbutton + id="AlignPopupButton" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&AlignPopupButton.tooltip;" + observes="cmd_align" + > + <menupopup id="AlignPopup"> + <menuitem + id="AlignLeftItem" + class="menuitem-iconic" + label="&alignLeft.label;" + oncommand="doStatefulCommand('cmd_align', 'left')" + tooltiptext="&alignLeftButton.tooltip;" + /> + <menuitem + id="AlignCenterItem" + class="menuitem-iconic" + label="&alignCenter.label;" + oncommand="doStatefulCommand('cmd_align', 'center')" + tooltiptext="&alignCenterButton.tooltip;" + /> + <menuitem + id="AlignRightItem" + class="menuitem-iconic" + label="&alignRight.label;" + oncommand="doStatefulCommand('cmd_align', 'right')" + tooltiptext="&alignRightButton.tooltip;" + /> + <menuitem + id="AlignJustifyItem" + class="menuitem-iconic" + label="&alignJustify.label;" + oncommand="doStatefulCommand('cmd_align', 'justify')" + tooltiptext="&alignJustifyButton.tooltip;" + /> + </menupopup> + </toolbarbutton> + <toolbarbutton + id="linkButton" + class="formatting-button" + tooltiptext="&linkToolbarCmd.label;" + oncommand="insertLink();" + observes="cmd_renderedHTMLEnabler" + /> + <toolbarbutton + id="smileButtonMenu" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&SmileButton.tooltip;" + observes="cmd_renderedHTMLEnabler" + > + <menupopup id="smileyPopup" class="no-icon-menupopup"> + <menuitem + id="smileySmile" + class="menuitem-iconic" + label="🙂 &smiley1Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🙂')" + /> + <menuitem + id="smileyFrown" + class="menuitem-iconic" + label="🙁 &smiley2Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🙁')" + /> + <menuitem + id="smileyWink" + class="menuitem-iconic" + label="😉 &smiley3Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😉')" + /> + <menuitem + id="smileyTongue" + class="menuitem-iconic" + label="😛 &smiley4Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😛')" + /> + <menuitem + id="smileyLaughing" + class="menuitem-iconic" + label="😂 &smiley5Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😂')" + /> + <menuitem + id="smileyEmbarassed" + class="menuitem-iconic" + label="😳 &smiley6Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😳')" + /> + <menuitem + id="smileyUndecided" + class="menuitem-iconic" + label="😕 &smiley7Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😕')" + /> + <menuitem + id="smileySurprise" + class="menuitem-iconic" + label="😮 &smiley8Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😮')" + /> + <menuitem + id="smileyKiss" + class="menuitem-iconic" + label="😘 &smiley9Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😘')" + /> + <menuitem + id="smileyYell" + class="menuitem-iconic" + label="😠 &smiley10Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😠')" + /> + <menuitem + id="smileyCool" + class="menuitem-iconic" + label="😎 &smiley11Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😎')" + /> + <menuitem + id="smileyMoney" + class="menuitem-iconic" + label="🤑 &smiley12Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🤑')" + /> + <menuitem + id="smileyFoot" + class="menuitem-iconic" + label="😬 &smiley13Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😬')" + /> + <menuitem + id="smileyInnocent" + class="menuitem-iconic" + label="😇 &smiley14Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😇')" + /> + <menuitem + id="smileyCry" + class="menuitem-iconic" + label="😭 &smiley15Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😭')" + /> + <menuitem + id="smileySealed" + class="menuitem-iconic" + label="🤐 &smiley16Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🤐')" + /> + </menupopup> + </toolbarbutton> + </toolbar> + </toolbox> + <editor + id="item-description" + type="content" + primary="true" + editortype="html" + oncontextmenu="openEditorContextMenu(event);" + disable-on-readonly="true" + flex="1" + /> + </tabpanel> + <tabpanel id="event-grid-tabpanel-attachments"> + <vbox flex="1"> + <richlistbox + id="attachment-link" + seltype="single" + context="attachment-popup" + rows="3" + flex="1" + disable-on-readonly="true" + onkeypress="attachmentLinkKeyPress(event)" + ondblclick="attachmentDblClick(event);" + /> + </vbox> + </tabpanel> + <tabpanel id="event-grid-tabpanel-attendees" collapsed="true"> + <vbox flex="1"> + <hbox id="item-organizer-row" hidden="true" align="start"> + <label value="&read.only.organizer.label;" /> + </hbox> + <vbox + class="item-attendees-list-container" + dialog-type="event" + flex="1" + context="attendee-popup" + oncontextmenu="setAttendeeContext(event)" + disable-on-readonly="true" + /> + </vbox> + </tabpanel> + </tabpanels> + <hbox + id="notify-options" + dialog-type="event" + align="center" + collapsed="true" + disable-on-readonly="true" + > + <checkbox + id="notify-attendees-checkbox" + label="&event.attendees.notify.label;" + accesskey="&event.attendees.notify.accesskey;" + oncommand="changeUndiscloseCheckboxStatus();" + pack="start" + /> + <checkbox + id="undisclose-attendees-checkbox" + label="&event.attendees.notifyundisclosed.label;" + accesskey="&event.attendees.notifyundisclosed.accesskey;" + tooltiptext="&event.attendees.notifyundisclosed.tooltip;" + pack="start" + /> + <checkbox + id="disallow-counter-checkbox" + label="&event.attendees.disallowcounter.label;" + accesskey="&event.attendees.disallowcounter.accesskey;" + tooltiptext="&event.attendees.disallowcounter.tooltip;" + pack="start" + /> + </hbox> + </tabbox> + </hbox> + </vbox> + + <popupset id="event-dialog-popupset"> + <menupopup id="attendee-popup"> + <menuitem + id="attendee-popup-invite-menuitem" + label="&event.invite.attendees.label;" + accesskey="&event.invite.attendees.accesskey;" + command="cmd_attendees" + disable-on-readonly="true" + /> + <menuitem + id="attendee-popup-removeallattendees-menuitem" + label="&event.remove.attendees.label2;" + accesskey="&event.remove.attendees.accesskey;" + oncommand="removeAllAttendees()" + disable-on-readonly="true" + crop="end" + /> + <menuitem + id="attendee-popup-removeattendee-menuitem" + label="&event.remove.attendee.label;" + accesskey="&event.remove.attendee.accesskey;" + oncommand="removeAttendee(event.target.attendee)" + crop="end" + /> + <menuseparator id="attendee-popup-first-separator" /> + <menuitem + id="attendee-popup-sendemail-menuitem" + label="&event.email.attendees.label;" + accesskey="&event.email.attendees.accesskey;" + command="cmd_email" + /> + <menuitem + id="attendee-popup-sendtentativeemail-menuitem" + label="&event.email.tentative.attendees.label;" + accesskey="&event.email.tentative.attendees.accesskey;" + command="cmd_email_undecided" + /> + <menuseparator id="attendee-popup-second-separator" /> + <menuitem + id="attendee-popup-emailattendee-menuitem" + oncommand="sendMailToAttendees([event.target.attendee])" + crop="end" + /> + </menupopup> + <menupopup id="attachment-popup" onpopupshowing="attachmentClick(event)"> + <menuitem + id="attachment-popup-open" + label="&event.attachments.popup.open.label;" + accesskey="&event.attachments.popup.open.accesskey;" + command="cmd_openAttachment" + /> + <menuitem + id="attachment-popup-copy" + label="&calendar.copylink.label;" + accesskey="&calendar.copylink.accesskey;" + command="cmd_copyAttachment" + /> + <menuitem + id="attachment-popup-delete" + label="&event.attachments.popup.remove.label;" + accesskey="&event.attachments.popup.remove.accesskey;" + command="cmd_deleteAttachment" + /> + <menuitem + id="attachment-popup-deleteAll" + label="&event.attachments.popup.removeAll.label;" + accesskey="&event.attachments.popup.removeAll.accesskey;" + command="cmd_deleteAllAttachments" + /> + <menuseparator /> + <menuitem + id="attachment-popup-attachPage" + label="&event.attachments.popup.attachPage.label;" + accesskey="&event.attachments.popup.attachPage.accesskey;" + command="cmd_attach_url" + /> + </menupopup> + <menupopup id="timezone-popup" position="after_start" oncommand="chooseRecentTimezone(event)"> + <menuitem id="timezone-popup-defaulttz" /> + <menuseparator id="timezone-popup-menuseparator" /> + <menuitem + id="timezone-custom-menuitem" + label="&event.timezone.custom.label;" + value="custom" + oncommand="this.parentNode.editTimezone()" + /> + </menupopup> + </popupset> + </html:body> +</html> diff --git a/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml b/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml new file mode 100644 index 0000000000..c567053ee6 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml @@ -0,0 +1,130 @@ +# 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/. + +# This file requires the following localization files: +# chrome://calendar/locale/global.dtd +# chrome://calendar/locale/calendar.dtd +# chrome://calendar/locale/calendar-event-dialog.dtd +# chrome://lightning/locale/lightning-toolbar.dtd + + <vbox id="calendarItemPanel" collapsed="true"> + + <!-- The id of the inner vbox and the iframe are set dynamically + when a tab is created. --> + <vbox flex="1" + id="dummy-calendar-event-dialog-tab" + class="calendar-event-dialog-tab"> + + <!-- Commands --> + <commandset id="itemCommands"> + <command id="cmd_save" + disable-on-readonly="true" + oncommand="onCommandSave()"/> + <command id="cmd_item_delete" + disable-on-readonly="true" + oncommand="onCommandDeleteItem()"/> + + <!-- View menu --> + <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();"/> + + <!-- accept, attachments, timezone --> + <command id="cmd_accept" + disable-on-readonly="true" + oncommand="sendMessage({ command: 'onAccept' });"/> + <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="save-key" + modifiers="accel, shift" + key="&event.dialog.save.key;" + command="cmd_save"/> + <key id="saveandclose-key" + modifiers="accel" + key="&event.dialog.saveandclose.key;" + command="cmd_accept"/> + <key id="saveandclose-key2" + modifiers="accel" + keycode="VK_RETURN" + command="cmd_accept"/> + </keyset> + + <toolbox id="event-toolbox" + class="mail-toolbox" + mode="full" + defaultmode="full" + iconsize="small" + defaulticonsize="small" + labelalign="end" + defaultlabelalign="end"> + <toolbarpalette id="event-toolbarpalette"> +#include calendar-item-toolbar.inc.xhtml + </toolbarpalette> + <!-- toolboxid is set here since we move the toolbar around for tabs --> + <toolbar is="customizable-toolbar" id="event-tab-toolbar" + toolbarname="&event.menu.view.toolbars.event.label;" + accesskey="&event.menu.view.toolbars.event.accesskey;" + toolboxid="event-toolbox" + class="chromeclass-toolbar inline-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-priority,button-status,button-freebusy,button-delete,spring"/> + </toolbox> + + <iframe id="calendar-item-panel-iframe" flex="1"/> + + </vbox> + </vbox> diff --git a/comm/calendar/base/content/item-editing/calendar-item-panel.js b/comm/calendar/base/content/item-editing/calendar-item-panel.js new file mode 100644 index 0000000000..f2550d509a --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-panel.js @@ -0,0 +1,1143 @@ +/* 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 onLoadCalendarItemPanel, onCancel, onCommandSave, + * onCommandDeleteItem, editAttendees, editPrivacy, editPriority, + * editStatus, editShowTimeAs, updateShowTimeAs, editToDoStatus, + * postponeTask, toggleTimezoneLinks, attachURL, + * onCommandViewToolbar, onCommandCustomize, attachFileByAccountKey, + * onUnloadCalendarItemPanel, openNewEvent, openNewTask, + * openNewMessage + */ + +/* import-globals-from ../../../../mail/base/content/globalOverlay.js */ +/* import-globals-from ../dialogs/calendar-dialog-utils.js */ +/* import-globals-from ../calendar-ui-utils.js */ + +// XXX Need to determine which of these we really need here. +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var gTabmail; +window.addEventListener( + "DOMContentLoaded", + () => { + // gTabmail is null if we are in a dialog window and not in a tab. + gTabmail = document.getElementById("tabmail") || null; + + if (!gTabmail) { + // In a dialog window the following menu item functions need to be + // defined. In a tab they are defined elsewhere. To prevent errors in + // the log they are defined here (before the onLoad function is called). + /** + * Update menu items that rely on focus. + */ + window.goUpdateGlobalEditMenuItems = () => { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_selectAll"); + }; + /** + * Update menu items that rely on the current selection. + */ + window.goUpdateSelectEditMenuItems = () => { + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_selectAll"); + }; + /** + * Update menu items that relate to undo/redo. + */ + window.goUpdateUndoEditMenuItems = () => { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); + }; + /** + * Update menu items that depend on clipboard contents. + */ + window.goUpdatePasteMenuItems = () => { + goUpdateCommand("cmd_paste"); + }; + } + }, + { once: true } +); + +// Stores the ids of the iframes of currently open event/task tabs, used +// when window is closed to prompt for saving changes. +var gItemTabIds = []; +var gItemTabIdsCopy; + +// gConfig is used when switching tabs to restore the state of +// toolbar, statusbar, and menubar for the current tab. +var gConfig = { + isEvent: null, + privacy: null, + hasPrivacy: null, + calendarType: null, + privacyValues: null, + priority: null, + hasPriority: null, + status: null, + percentComplete: null, + showTimeAs: null, + // whether commands are enabled or disabled + attendeesCommand: null, // cmd_attendees + attachUrlCommand: null, // cmd_attach_url + timezonesEnabled: false, // cmd_timezone +}; + +/** + * Receive an asynchronous message from the iframe. + * + * @param {MessageEvent} aEvent - Contains the message being received + */ +function receiveMessage(aEvent) { + if (aEvent.origin !== "chrome://calendar") { + return; + } + switch (aEvent.data.command) { + case "initializeItemMenu": + initializeItemMenu(aEvent.data.label, aEvent.data.accessKey); + break; + case "cancelDialog": + document.querySelector("dialog").cancelDialog(); + break; + case "closeWindowOrTab": + closeWindowOrTab(aEvent.data.iframeId); + break; + case "showCmdStatusNone": + document.getElementById("cmd_status_none").removeAttribute("hidden"); + break; + case "updateTitle": + updateTitle(aEvent.data.prefix, aEvent.data.title); + break; + case "updateConfigState": + updateItemTabState(aEvent.data.argument); + Object.assign(gConfig, aEvent.data.argument); + break; + case "enableAcceptCommand": + enableAcceptCommand(aEvent.data.argument); + break; + case "replyToClosingWindowWithTabs": + handleWindowClose(aEvent.data.response); + break; + case "removeDisableAndCollapseOnReadonly": + removeDisableAndCollapseOnReadonly(); + break; + case "setElementAttribute": { + let arg = aEvent.data.argument; + document.getElementById(arg.id)[arg.attribute] = arg.value; + break; + } + case "loadCloudProviders": { + loadCloudProviders(aEvent.data.items); + break; + } + case "updateSaveControls": { + updateSaveControls(aEvent.data.argument.sendNotSave); + break; + } + } +} + +window.addEventListener("message", receiveMessage); + +/** + * Send an asynchronous message to an iframe. Additional properties of + * aMessage are generally arguments that will be passed to the function + * named in aMessage.command. If aIframeId is omitted, the message will + * be sent to the iframe of the current tab. + * + * @param {object} aMessage - Contains the message being sent + * @param {string} aMessage.command - The name of a function to call + * @param {string} aIframeId - (optional) id of an iframe to send the message to + */ +function sendMessage(aMessage, aIframeId) { + let iframeId = gTabmail + ? aIframeId || gTabmail.currentTabInfo.iframe.id + : "calendar-item-panel-iframe"; + let iframe = document.getElementById(iframeId); + iframe.contentWindow.postMessage(aMessage, "*"); +} + +/** + * When the user closes the window, this function handles prompting them + * to save any unsaved changes for any open item tabs, before closing the + * window, or not if 'cancel' was clicked. Requires sending and receiving + * async messages from the iframes of all open item tabs. + * + * @param {boolean} aResponse - The response from the tab's iframe + */ +function handleWindowClose(aResponse) { + if (!aResponse) { + // Cancel was clicked, just leave the window open. We're done. + } else if (gItemTabIdsCopy.length > 0) { + // There are more unsaved changes in tabs to prompt the user about. + let nextId = gItemTabIdsCopy.shift(); + sendMessage({ command: "closingWindowWithTabs", id: nextId }, nextId); + } else { + // Close the window, there are no more unsaved changes in tabs. + window.removeEventListener("close", windowCloseListener); + window.close(); + } +} + +/** + * Listener function for window close. We prevent the window from + * closing, then for each open tab we prompt the user to save any + * unsaved changes with handleWindowClose. + * + * @param {object} aEvent - The window close event + */ +function windowCloseListener(aEvent) { + aEvent.preventDefault(); + gItemTabIdsCopy = gItemTabIds.slice(); + handleWindowClose(true); +} + +/** + * Load handler for the outer parent context that contains the iframe. + * + * @param {string} aIframeId - (optional) Id of the iframe in this tab + * @param {string} aUrl - (optional) The url to load in the iframe + */ +function onLoadCalendarItemPanel(aIframeId, aUrl) { + let iframe; + let iframeSrc; + let dialog = document.querySelector("dialog"); + + if (!gTabmail) { + gTabmail = document.getElementById("tabmail") || null; + // This should not happen. + if (gTabmail) { + console.warn( + "gTabmail was undefined on document load and is defined now, that should not happen." + ); + } + } + if (gTabmail) { + // tab case + let iframeId = aIframeId || gTabmail.currentTabInfo.iframe.id; + iframe = document.getElementById(iframeId); + iframeSrc = aUrl; + + // Add a listener to detect close events, prompt user about saving changes. + window.addEventListener("close", windowCloseListener); + } else { + // window dialog case + iframe = document.createXULElement("iframe"); + iframeSrc = "chrome://calendar/content/calendar-item-iframe.xhtml"; + + iframe.setAttribute("id", "calendar-item-panel-iframe"); + iframe.setAttribute("flex", "1"); + + // Note: iframe.contentWindow is undefined before the iframe is inserted here. + dialog.insertBefore(iframe, document.getElementById("status-bar")); + + iframe.contentWindow.addEventListener( + "load", + () => { + // Push setting dimensions to the end of the event queue. + setTimeout(() => { + let body = iframe.contentDocument.body; + // Make sure the body does not exceed its content's size. + body.style.width = "fit-content"; + body.style.height = "fit-content"; + let { scrollHeight, scrollWidth } = body; + iframe.style.minHeight = `${scrollHeight}px`; + iframe.style.minWidth = `${scrollWidth}px`; + // Reset the body. + body.style.width = null; + body.style.height = null; + }); + }, + { once: true } + ); + + // Move the args so they are positioned relative to the iframe, + // for the window dialog just as they are for the tab. + // XXX Should we delete the arguments here in the parent context + // so they are only accessible in one place? + iframe.contentWindow.arguments = [window.arguments[0]]; + + // hide the ok and cancel dialog buttons + let accept = dialog.getButton("accept"); + let cancel = dialog.getButton("cancel"); + accept.setAttribute("collapsed", "true"); + cancel.setAttribute("collapsed", "true"); + cancel.parentNode.setAttribute("collapsed", "true"); + + document.addEventListener("dialogaccept", event => { + let itemTitle = iframe.contentDocument.documentElement.querySelector("#item-title"); + // Prevent dialog from saving if title is empty. + if (!itemTitle.value) { + event.preventDefault(); + return; + } + sendMessage({ command: "onAccept" }); + event.preventDefault(); + }); + + document.addEventListener("dialogcancel", event => { + sendMessage({ command: "onCancel" }); + event.preventDefault(); + }); + + // set toolbar icon color for light or dark themes + if (typeof window.ToolbarIconColor !== "undefined") { + window.ToolbarIconColor.init(); + } + } + + // event or task + let calendarItem = iframe.contentWindow.arguments[0].calendarEvent; + gConfig.isEvent = calendarItem.isEvent(); + + // for tasks in a window dialog, set the dialog id for CSS selection. + if (!gTabmail) { + if (gConfig.isEvent) { + setDialogId(dialog, "calendar-event-dialog"); + } else { + setDialogId(dialog, "calendar-task-dialog"); + } + } + + // timezones enabled + gConfig.timezonesEnabled = getTimezoneCommandState(); + iframe.contentWindow.gTimezonesEnabled = gConfig.timezonesEnabled; + + // set the iframe src, which loads the iframe's contents + iframe.setAttribute("src", iframeSrc); +} + +/** + * Unload handler for the outer parent context that contains the iframe. + * Currently only called for windows and not tabs. + */ +function onUnloadCalendarItemPanel() { + if (!gTabmail) { + // window dialog case + if (typeof window.ToolbarIconColor !== "undefined") { + window.ToolbarIconColor.uninit(); + } + } +} + +/** + * Updates the UI. Called when a user makes a change and when an + * event/task tab is shown. When a tab is shown aArg contains the gConfig + * data for that event/task. We pass the full tab state object to the + * update functions and they just use the properties they need from it. + * + * @param {object} aArg - Its properties hold data about the event/task + */ +function updateItemTabState(aArg) { + const lookup = { + privacy: updatePrivacy, + priority: updatePriority, + status: updateStatus, + showTimeAs: updateShowTimeAs, + percentComplete: updateMarkCompletedMenuItem, + attendeesCommand: updateAttendeesCommand, + attachUrlCommand: updateAttachment, + timezonesEnabled: updateTimezoneCommand, + }; + for (let key of Object.keys(aArg)) { + let procedure = lookup[key]; + if (procedure) { + procedure(aArg); + } + } +} + +/** + * When in a window, set Item-Menu label to Event or Task. + * + * @param {string} aLabel - The new name for the menu + * @param {string} aAccessKey - The access key for the menu + */ +function initializeItemMenu(aLabel, aAccessKey) { + let menuItem = document.getElementById("item-menu"); + menuItem.setAttribute("label", aLabel); + menuItem.setAttribute("accesskey", aAccessKey); +} + +/** + * Handler for when tab is cancelled. (calendar.item.editInTab = true) + * + * @param {string} aIframeId - The id of the iframe + */ +function onCancel(aIframeId) { + sendMessage({ command: "onCancel", iframeId: aIframeId }, aIframeId); + // We return false to prevent closing of a window until we + // can ask the user about saving any unsaved changes. + return false; +} + +/** + * Closes tab or window. Called after prompting to save any unsaved changes. + * + * @param {string} aIframeId - The id of the iframe + */ +function closeWindowOrTab(iframeId) { + if (gTabmail) { + if (iframeId) { + // Find the tab associated with this iframeId, and close it. + let myTabInfo = gTabmail.tabInfo.filter(x => "iframe" in x && x.iframe.id == iframeId)[0]; + myTabInfo.allowTabClose = true; + gTabmail.closeTab(myTabInfo); + } else { + gTabmail.currentTabInfo.allowTabClose = true; + gTabmail.removeCurrentTab(); + } + } else { + window.close(); + } +} + +/** + * Handler for saving the event or task. + * + * @param {boolean} aIsClosing - Is the tab or window closing + */ +function onCommandSave(aIsClosing) { + sendMessage({ command: "onCommandSave", isClosing: aIsClosing }); +} + +/** + * Handler for deleting the event or task. + */ +function onCommandDeleteItem() { + sendMessage({ command: "onCommandDeleteItem" }); +} + +/** + * Disable the saving options according to the item title. + * + * @param {boolean} disabled - True if the save options needs to be disabled else false. + */ +function disableSaving(disabled) { + let cmdSave = document.getElementById("cmd_save"); + if (cmdSave) { + cmdSave.setAttribute("disabled", disabled); + } + let cmdAccept = document.getElementById("cmd_accept"); + if (cmdAccept) { + cmdAccept.setAttribute("disabled", disabled); + } +} + +/** + * Update the title of the tab or window. + * + * @param {string} prefix - The prefix string according to the item. + * @param {string} title - The item title. + */ +function updateTitle(prefix, title) { + disableSaving(!title); + let newTitle = prefix + ": " + title; + if (gTabmail) { + gTabmail.currentTabInfo.title = newTitle; + gTabmail.setTabTitle(gTabmail.currentTabInfo); + } else { + document.title = newTitle; + } +} + +/** + * Open a new event. + */ +function openNewEvent() { + sendMessage({ command: "openNewEvent" }); +} + +/** + * Open a new task. + */ +function openNewTask() { + sendMessage({ command: "openNewTask" }); +} + +/** + * Open a new Thunderbird compose window. + */ +function openNewMessage() { + MailServices.compose.OpenComposeWindow( + null, + null, + null, + Ci.nsIMsgCompType.New, + Ci.nsIMsgCompFormat.Default, + null, + null, + null + ); +} + +/** + * Handler for edit attendees command. + */ +function editAttendees() { + sendMessage({ command: "editAttendees" }); +} + +/** + * Sends a message to set the gConfig values in the iframe. + * + * @param {object} aArg - Container + * @param {string} aArg.privacy - (optional) New privacy value + * @param {short} aArg.priority - (optional) New priority value + * @param {string} aArg.status - (optional) New status value + * @param {string} aArg.showTimeAs - (optional) New showTimeAs / transparency value + */ +function editConfigState(aArg) { + sendMessage({ command: "editConfigState", argument: aArg }); +} + +/** + * Handler for changing privacy. aEvent is used for the popup menu + * event-privacy-menupopup in the Privacy toolbar button. + * + * @param {Node} aTarget Has the new privacy in its "value" attribute + * @param {XULCommandEvent} aEvent - (optional) the UI element selection event + */ +function editPrivacy(aTarget, aEvent) { + if (aEvent) { + aEvent.stopPropagation(); + } + // "privacy" is indeed the correct attribute to use here + let newPrivacy = aTarget.getAttribute("privacy"); + editConfigState({ privacy: newPrivacy }); +} + +/** + * Updates the UI according to the privacy setting and the selected + * calendar. If the selected calendar does not support privacy or only + * certain values, these are removed from the UI. This function should + * be called any time that privacy setting is updated. + * + * @param {object} aArg Contains privacy properties + * @param {string} aArg.privacy The new privacy value + * @param {boolean} aArg.hasPrivacy Whether privacy is supported + * @param {string} aArg.calendarType The type of calendar + * @param {string[]} aArg.privacyValues The possible privacy values + */ +function updatePrivacy(aArg) { + if (aArg.hasPrivacy) { + // Update privacy capabilities (toolbar) + let menupopup = document.getElementById("event-privacy-menupopup"); + if (menupopup) { + // Only update the toolbar if the button is actually there + for (let node of menupopup.children) { + let currentProvider = node.getAttribute("provider"); + if (node.hasAttribute("privacy")) { + let currentPrivacyValue = node.getAttribute("privacy"); + // Collapsed state + + // Hide the toolbar if the value is unsupported or is for a + // specific provider and doesn't belong to the current provider. + if ( + !aArg.privacyValues.includes(currentPrivacyValue) || + (currentProvider && currentProvider != aArg.calendarType) + ) { + node.setAttribute("collapsed", "true"); + } else { + node.removeAttribute("collapsed"); + } + + // Checked state + if (aArg.privacy == currentPrivacyValue) { + node.setAttribute("checked", "true"); + } else { + node.removeAttribute("checked"); + } + } + } + } + + // Update privacy capabilities (menu) but only if we are not in a tab. + if (!gTabmail) { + menupopup = document.getElementById("options-privacy-menupopup"); + for (let node of menupopup.children) { + let currentProvider = node.getAttribute("provider"); + if (node.hasAttribute("privacy")) { + let currentPrivacyValue = node.getAttribute("privacy"); + // Collapsed state + + // Hide the menu if the value is unsupported or is for a + // specific provider and doesn't belong to the current provider. + if ( + !aArg.privacyValues.includes(currentPrivacyValue) || + (currentProvider && currentProvider != aArg.calendarType) + ) { + node.setAttribute("collapsed", "true"); + } else { + node.removeAttribute("collapsed"); + } + + // Checked state + if (aArg.privacy == currentPrivacyValue) { + node.setAttribute("checked", "true"); + } else { + node.removeAttribute("checked"); + } + } + } + } + + // Update privacy capabilities (statusbar) + let privacyPanel = document.getElementById("status-privacy"); + let hasAnyPrivacyValue = false; + for (let node of privacyPanel.children) { + let currentProvider = node.getAttribute("provider"); + if (node.hasAttribute("privacy")) { + let currentPrivacyValue = node.getAttribute("privacy"); + + // Hide the panel if the value is unsupported or is for a + // specific provider and doesn't belong to the current provider, + // or is not the items privacy value + if ( + !aArg.privacyValues.includes(currentPrivacyValue) || + (currentProvider && currentProvider != aArg.calendarType) || + aArg.privacy != currentPrivacyValue + ) { + node.setAttribute("collapsed", "true"); + } else { + node.removeAttribute("collapsed"); + hasAnyPrivacyValue = true; + } + } + } + + // Don't show the status panel if no valid privacy value is selected + if (hasAnyPrivacyValue) { + privacyPanel.removeAttribute("collapsed"); + } else { + privacyPanel.setAttribute("collapsed", "true"); + } + } else { + // aArg.hasPrivacy is false + document.getElementById("button-privacy").disabled = true; + document.getElementById("status-privacy").collapsed = true; + // in the tab case the menu item does not exist + let privacyMenuItem = document.getElementById("options-privacy-menu"); + if (privacyMenuItem) { + document.getElementById("options-privacy-menu").disabled = true; + } + } +} + +/** + * Handler to change the priority. + * + * @param {Node} aTarget - Has the new priority in its "value" attribute + */ +function editPriority(aTarget) { + let newPriority = parseInt(aTarget.getAttribute("value"), 10); + editConfigState({ priority: newPriority }); +} + +/** + * Updates the dialog controls related to priority. + * + * @param {object} aArg Contains priority properties + * @param {string} aArg.priority The new priority value + * @param {boolean} aArg.hasPriority - Whether priority is supported + */ +function updatePriority(aArg) { + // Set up capabilities + if (document.getElementById("button-priority")) { + document.getElementById("button-priority").disabled = !aArg.hasPriority; + } + if (!gTabmail && document.getElementById("options-priority-menu")) { + document.getElementById("options-priority-menu").disabled = !aArg.hasPriority; + } + document.getElementById("status-priority").collapsed = !aArg.hasPriority; + + if (aArg.hasPriority) { + let priorityLevel = "none"; + if (aArg.priority >= 1 && aArg.priority <= 4) { + priorityLevel = "high"; + } else if (aArg.priority == 5) { + priorityLevel = "normal"; + } else if (aArg.priority >= 6 && aArg.priority <= 9) { + priorityLevel = "low"; + } + + let priorityNone = document.getElementById("cmd_priority_none"); + let priorityLow = document.getElementById("cmd_priority_low"); + let priorityNormal = document.getElementById("cmd_priority_normal"); + let priorityHigh = document.getElementById("cmd_priority_high"); + + priorityNone.setAttribute("checked", priorityLevel == "none" ? "true" : "false"); + priorityLow.setAttribute("checked", priorityLevel == "low" ? "true" : "false"); + priorityNormal.setAttribute("checked", priorityLevel == "normal" ? "true" : "false"); + priorityHigh.setAttribute("checked", priorityLevel == "high" ? "true" : "false"); + + // Status bar panel + let priorityPanel = document.getElementById("status-priority"); + let image = priorityPanel.querySelector("img"); + if (priorityLevel === "none") { + // If the priority is none, don't show the status bar panel + priorityPanel.setAttribute("collapsed", "true"); + image.removeAttribute("data-l10n-id"); + image.setAttribute("alt", ""); + image.removeAttribute("src"); + } else { + priorityPanel.removeAttribute("collapsed"); + image.setAttribute("alt", cal.l10n.getString("calendar", `${priorityLevel}Priority`)); + image.setAttribute( + "src", + `chrome://calendar/skin/shared/statusbar-priority-${priorityLevel}.svg` + ); + } + } +} + +/** + * Handler for changing the status. + * + * @param {Node} aTarget - Has the new status in its "value" attribute + */ +function editStatus(aTarget) { + let newStatus = aTarget.getAttribute("value"); + editConfigState({ status: newStatus }); +} + +/** + * Update the dialog controls related to status. + * + * @param {object} aArg - Contains the new status value + * @param {string} aArg.status - The new status value + */ +function updateStatus(aArg) { + const statusLabels = [ + "status-status-tentative-label", + "status-status-confirmed-label", + "status-status-cancelled-label", + ]; + const commands = [ + "cmd_status_none", + "cmd_status_tentative", + "cmd_status_confirmed", + "cmd_status_cancelled", + ]; + let found = false; + document.getElementById("status-status").collapsed = true; + commands.forEach((aElement, aIndex, aArray) => { + let node = document.getElementById(aElement); + let matches = node.getAttribute("value") == aArg.status; + found = found || matches; + + node.setAttribute("checked", matches ? "true" : "false"); + + if (aIndex > 0) { + statusLabels[aIndex - 1].hidden = !matches; + if (matches) { + document.getElementById("status-status").collapsed = false; + } + } + }); + if (!found) { + // The current Status value is invalid. Change the status to + // "not specified" and update the status again. + sendMessage({ command: "editStatus", value: "NONE" }); + } +} + +/** + * Handler for changing the transparency. + * + * @param {Node} aTarget - Has the new transparency in its "value" attribute + */ +function editShowTimeAs(aTarget) { + let newValue = aTarget.getAttribute("value"); + editConfigState({ showTimeAs: newValue }); +} + +/** + * Update the dialog controls related to transparency. + * + * @param {object} aArg - Contains the new transparency value + * @param {string} aArg.showTimeAs - The new transparency value + */ +function updateShowTimeAs(aArg) { + let showAsBusy = document.getElementById("cmd_showtimeas_busy"); + let showAsFree = document.getElementById("cmd_showtimeas_free"); + + showAsBusy.setAttribute("checked", aArg.showTimeAs == "OPAQUE" ? "true" : "false"); + showAsFree.setAttribute("checked", aArg.showTimeAs == "TRANSPARENT" ? "true" : "false"); + + document.getElementById("status-freebusy").collapsed = + aArg.showTimeAs != "OPAQUE" && aArg.showTimeAs != "TRANSPARENT"; + document.getElementById("status-freebusy-free-label").hidden = aArg.showTimeAs == "OPAQUE"; + document.getElementById("status-freebusy-busy-label").hidden = aArg.showTimeAs == "TRANSPARENT"; +} + +/** + * Change the task percent complete (and thus task status). + * + * @param {short} aPercentComplete - The new percent complete value + */ +function editToDoStatus(aPercentComplete) { + sendMessage({ command: "editToDoStatus", value: aPercentComplete }); +} + +/** + * Check or uncheck the "Mark updated" menu item in "Events and Tasks" + * menu based on the percent complete value. + * + * @param {object} aArg - Container + * @param {short} aArg.percentComplete - The percent complete value + */ +function updateMarkCompletedMenuItem(aArg) { + // Command only for tab case, function only to be executed in dialog windows. + if (gTabmail) { + let completedCommand = document.getElementById("calendar_toggle_completed_command"); + let isCompleted = aArg.percentComplete == 100; + completedCommand.setAttribute("checked", isCompleted); + } +} + +/** + * Postpone the task's start date/time and due date/time. ISO 8601 + * format: "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We + * use this format intentionally instead of a calIDuration object because + * those objects cannot be serialized for message passing with iframes.) + * + * @param {string} aDuration - A duration in ISO 8601 format + */ +function postponeTask(aDuration) { + sendMessage({ command: "postponeTask", value: aDuration }); +} + +/** + * Get the timezone button state. + * + * @returns {boolean} True is active/checked and false is inactive/unchecked + */ +function getTimezoneCommandState() { + let cmdTimezone = document.getElementById("cmd_timezone"); + return cmdTimezone.getAttribute("checked") == "true"; +} + +/** + * Set the timezone button state. Used to keep the toolbar button in + * sync when switching tabs. + * + * @param {object} aArg - Contains timezones property + * @param {boolean} aArg.timezonesEnabled - Are timezones enabled? + */ +function updateTimezoneCommand(aArg) { + let cmdTimezone = document.getElementById("cmd_timezone"); + cmdTimezone.setAttribute("checked", aArg.timezonesEnabled); + gConfig.timezonesEnabled = aArg.timezonesEnabled; +} + +/** + * Toggles the command that allows enabling the timezone links in the dialog. + */ +function toggleTimezoneLinks() { + let cmdTimezone = document.getElementById("cmd_timezone"); + let currentState = getTimezoneCommandState(); + cmdTimezone.setAttribute("checked", currentState ? "false" : "true"); + gConfig.timezonesEnabled = !currentState; + sendMessage({ command: "toggleTimezoneLinks", checked: !currentState }); +} + +/** + * Prompts the user to attach an url to this item. + */ +function attachURL() { + sendMessage({ command: "attachURL" }); +} + +/** + * Updates dialog controls related to item attachments. + * + * @param {object} aArg Container + * @param {boolean} aArg.attachUrlCommand - Enable the attach url command? + */ +function updateAttachment(aArg) { + document.getElementById("cmd_attach_url").setAttribute("disabled", !aArg.attachUrlCommand); +} + +/** + * Updates attendees command enabled/disabled state. + * + * @param {object} aArg Container + * @param {boolean} aArg.attendeesCommand - Enable the attendees command? + */ +function updateAttendeesCommand(aArg) { + document.getElementById("cmd_attendees").setAttribute("disabled", !aArg.attendeesCommand); +} + +/** + * Enables/disables the commands cmd_accept and cmd_save related to the + * save operation. + * + * @param {boolean} aEnable - Enable the commands? + */ +function enableAcceptCommand(aEnable) { + document.getElementById("cmd_accept").setAttribute("disabled", !aEnable); + document.getElementById("cmd_save").setAttribute("disabled", !aEnable); +} + +/** + * Enable and un-collapse all elements that are disable-on-readonly and + * collapse-on-readonly. + */ +function removeDisableAndCollapseOnReadonly() { + let enableElements = document.getElementsByAttribute("disable-on-readonly", "true"); + for (let element of enableElements) { + element.removeAttribute("disabled"); + } + let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true"); + for (let element of collapseElements) { + element.removeAttribute("collapsed"); + } +} + +/** + * Handler to toggle toolbar visibility. + * + * @param {string} aToolbarId - The id of the toolbar node to toggle + * @param {string} aMenuitemId - The corresponding menuitem in the view menu + */ +function onCommandViewToolbar(aToolbarId, aMenuItemId) { + let toolbar = document.getElementById(aToolbarId); + let menuItem = document.getElementById(aMenuItemId); + + if (!toolbar || !menuItem) { + return; + } + + let toolbarCollapsed = toolbar.collapsed; + + // toggle the checkbox + menuItem.setAttribute("checked", toolbarCollapsed); + + // toggle visibility of the toolbar + toolbar.collapsed = !toolbarCollapsed; + + Services.xulStore.persist(toolbar, "collapsed"); + Services.xulStore.persist(menuItem, "checked"); +} + +/** + * Called after the customize toolbar dialog has been closed by the + * user. We need to restore the state of all buttons and commands of + * all customizable toolbars. + * + * @param {boolean} aToolboxChanged - When true the toolbox has changed + */ +function dialogToolboxCustomizeDone(aToolboxChanged) { + // Re-enable menu items (disabled during toolbar customization). + let menubarId = gTabmail ? "mail-menubar" : "event-menubar"; + let menubar = document.getElementById(menubarId); + for (let menuitem of menubar.children) { + menuitem.removeAttribute("disabled"); + } + + // make sure our toolbar buttons have the correct enabled state restored to them... + document.commandDispatcher.updateCommands("itemCommands"); + + // Enable the toolbar context menu items + document.getElementById("cmd_customize").removeAttribute("disabled"); + + // Update privacy items to make sure the toolbarbutton's menupopup is set + // correctly + updatePrivacy(gConfig); +} + +/** + * Handler to start the customize toolbar dialog for the event dialog's toolbar. + */ +function onCommandCustomize() { + // install the callback that handles what needs to be + // done after a toolbar has been customized. + let toolboxId = "event-toolbox"; + + let toolbox = document.getElementById(toolboxId); + toolbox.customizeDone = dialogToolboxCustomizeDone; + + // Disable menu items during toolbar customization. + let menubarId = gTabmail ? "mail-menubar" : "event-menubar"; + let menubar = document.getElementById(menubarId); + for (let menuitem of menubar.children) { + menuitem.setAttribute("disabled", true); + } + + // Disable the toolbar context menu items + document.getElementById("cmd_customize").setAttribute("disabled", "true"); + + let wintype = document.documentElement.getAttribute("windowtype"); + wintype = wintype.replace(/:/g, ""); + + window.openDialog( + "chrome://messenger/content/customizeToolbar.xhtml", + "CustomizeToolbar" + wintype, + "chrome,all,dependent", + document.getElementById(toolboxId), // toolbox dom node + false, // is mode toolbar yes/no? + null, // callback function + "dialog" + ); // name of this mode +} + +/** + * Add menu items to the UI for attaching files using a cloud provider. + * + * @param {object[]} aItemObjects - Array of objects that each contain + * data to create a menuitem + */ +function loadCloudProviders(aItemObjects) { + /** + * Deletes any existing menu items in aParentNode that have a + * cloudProviderAccountKey attribute. + * + * @param {Node} aParentNode - A menupopup containing menu items + */ + function deleteAlreadyExisting(aParentNode) { + for (let node of aParentNode.children) { + if (node.cloudProviderAccountKey) { + aParentNode.removeChild(node); + } + } + } + + // Delete any existing menu items with a cloudProviderAccountKey, + // needed for the tab case to prevent duplicate menu items, and + // helps keep the menu items current. + let toolbarPopup = document.getElementById("button-attach-menupopup"); + if (toolbarPopup) { + deleteAlreadyExisting(toolbarPopup); + } + let optionsPopup = document.getElementById("options-attachments-menupopup"); + if (optionsPopup) { + deleteAlreadyExisting(optionsPopup); + } + + for (let itemObject of aItemObjects) { + // Create a menu item. + let item = document.createXULElement("menuitem"); + item.setAttribute("label", itemObject.label); + item.setAttribute("observes", "cmd_attach_cloud"); + item.setAttribute( + "oncommand", + "attachFileByAccountKey(event.target.cloudProviderAccountKey); event.stopPropagation();" + ); + + if (itemObject.class) { + item.setAttribute("class", itemObject.class); + item.setAttribute("image", itemObject.image); + } + + // Add the menu item to the UI. + if (toolbarPopup) { + toolbarPopup.appendChild(item.cloneNode(true)).cloudProviderAccountKey = + itemObject.cloudProviderAccountKey; + } + if (optionsPopup) { + // This one doesn't need to clone, just use the item itself. + optionsPopup.appendChild(item).cloudProviderAccountKey = itemObject.cloudProviderAccountKey; + } + } +} + +/** + * Send a message to attach a file using a given cloud provider, + * to be identified by the cloud provider's accountKey. + * + * @param {string} aAccountKey - The accountKey for a cloud provider + */ +function attachFileByAccountKey(aAccountKey) { + sendMessage({ command: "attachFileByAccountKey", accountKey: aAccountKey }); +} + +/** + * Updates the save controls depending on whether the event has attendees + * + * @param {boolean} aSendNotSave + */ +function updateSaveControls(aSendNotSave) { + if (window.calItemSaveControls && window.calItemSaveControls.state == aSendNotSave) { + return; + } + + let saveBtn = document.getElementById("button-save"); + let saveandcloseBtn = document.getElementById("button-saveandclose"); + let saveMenu = + document.getElementById("item-save-menuitem") || + document.getElementById("calendar-save-menuitem"); + let saveandcloseMenu = + document.getElementById("item-saveandclose-menuitem") || + document.getElementById("calendar-save-and-close-menuitem"); + + // we store the initial label and tooltip values to be able to reset later + if (!window.calItemSaveControls) { + window.calItemSaveControls = { + state: false, + saveMenu: { label: saveMenu.label }, + saveandcloseMenu: { label: saveandcloseMenu.label }, + saveBtn: null, + saveandcloseBtn: null, + }; + // we need to check for each button whether it exists since toolbarbuttons + // can be removed by customizing + if (saveBtn) { + window.window.calItemSaveControls.saveBtn = { + label: saveBtn.label, + tooltiptext: saveBtn.tooltip, + }; + } + if (saveandcloseBtn) { + window.window.calItemSaveControls.saveandcloseBtn = { + label: saveandcloseBtn.label, + tooltiptext: saveandcloseBtn.tooltip, + }; + } + } + + // we update labels and tooltips but leave accesskeys as they are + window.calItemSaveControls.state = aSendNotSave; + if (aSendNotSave) { + if (saveBtn) { + saveBtn.label = cal.l10n.getString("calendar-event-dialog", "saveandsendButtonLabel"); + saveBtn.tooltiptext = cal.l10n.getString("calendar-event-dialog", "saveandsendButtonTooltip"); + saveBtn.setAttribute("mode", "send"); + } + if (saveandcloseBtn) { + saveandcloseBtn.label = cal.l10n.getString( + "calendar-event-dialog", + "sendandcloseButtonLabel" + ); + saveandcloseBtn.tooltiptext = cal.l10n.getString( + "calendar-event-dialog", + "sendandcloseButtonTooltip" + ); + saveandcloseBtn.setAttribute("mode", "send"); + } + saveMenu.label = cal.l10n.getString("calendar-event-dialog", "saveandsendMenuLabel"); + saveandcloseMenu.label = cal.l10n.getString("calendar-event-dialog", "sendandcloseMenuLabel"); + } else { + if (saveBtn) { + saveBtn.label = window.calItemSaveControls.saveBtn.label; + saveBtn.tooltiptext = window.calItemSaveControls.saveBtn.tooltip; + saveBtn.removeAttribute("mode"); + } + if (saveandcloseBtn) { + saveandcloseBtn.label = window.calItemSaveControls.saveandcloseBtn.label; + saveandcloseBtn.tooltiptext = window.calItemSaveControls.saveandcloseBtn.tooltip; + saveandcloseBtn.removeAttribute("mode"); + } + saveMenu.label = window.calItemSaveControls.saveMenu.label; + saveandcloseMenu.label = window.calItemSaveControls.saveandcloseMenu.label; + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml b/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml new file mode 100644 index 0000000000..5d0c744086 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml @@ -0,0 +1,164 @@ +# 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/. + + <toolbarbutton id="button-save" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.toolbar.save.label2;" + tooltiptext="&event.toolbar.save.tooltip2;" + command="cmd_save"/> + <toolbarbutton id="button-saveandclose" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.toolbar.saveandclose.label;" + tooltiptext="&event.toolbar.saveandclose.tooltip;" + command="cmd_accept"/> + <toolbarbutton id="button-attendees" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1 event-only" + disable-on-readonly="true" + label="&event.toolbar.attendees.label;" + tooltiptext="&event.toolbar.attendees.tooltip;" + command="cmd_attendees"/> + <toolbarbutton id="button-privacy" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&event.toolbar.privacy.label;" + tooltiptext="&event.toolbar.privacy.tooltip;"> + <menupopup id="event-privacy-menupopup"> + <menuitem id="event-privacy-public-menuitem" + name="event-privacy-group" + label="&event.menu.options.privacy.public.label;" + type="radio" + privacy="PUBLIC" + oncommand="editPrivacy(this, event)"/> + <menuitem id="event-privacy-confidential-menuitem" + name="event-privacy-group" + label="&event.menu.options.privacy.confidential.label;" + type="radio" + privacy="CONFIDENTIAL" + oncommand="editPrivacy(this, event)"/> + <menuitem id="event-privacy-private-menuitem" + name="event-privacy-group" + label="&event.menu.options.privacy.private.label;" + type="radio" + privacy="PRIVATE" + oncommand="editPrivacy(this, event)"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-url" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + type="menu" + wantdropmarker="true" + label="&event.attachments.menubutton.label;" + tooltiptext="&event.toolbar.attachments.tooltip;" + disable-on-readonly="true"> + <menupopup id="button-attach-menupopup"> + <menuitem id="button-attach-url" + label="&event.attachments.url.label;" + command="cmd_attach_url"/> + <!-- Additional items are added here in loadCloudProviders(). --> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-delete" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.toolbar.delete.label;" + tooltiptext="&event.toolbar.delete.tooltip;" + command="cmd_item_delete" + disable-on-readonly="true"/> + <toolbarbutton id="button-priority" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&event.menu.options.priority2.label;" + tooltiptext="&event.toolbar.priority.tooltip;"> + <menupopup id="event-priority-menupopup"> + <menuitem id="event-priority-none-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.notspecified.label;" + type="radio" + command="cmd_priority_none"/> + <menuitem id="event-priority-low-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.low.label;" + type="radio" + command="cmd_priority_low"/> + <menuitem id="event-priority-normal-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.normal.label;" + type="radio" + command="cmd_priority_normal"/> + <menuitem id="event-priority-high-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.high.label;" + type="radio" + command="cmd_priority_high"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-status" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1 event-only" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&newevent.status.label;" + tooltiptext="&event.toolbar.status.tooltip;"> + <menupopup id="event-status-menupopup"> + <menuitem id="event-status-none-menuitem" + name="event-status-group" + label="&newevent.eventStatus.none.label;" + type="radio" + command="cmd_status_none"/> + <menuitem id="event-status-tentative-menuitem" + name="event-status-group" + label="&newevent.status.tentative.label;" + type="radio" + command="cmd_status_tentative"/> + <menuitem id="event-status-confirmed-menuitem" + name="event-status-group" + label="&newevent.status.confirmed.label;" + type="radio" + command="cmd_status_confirmed"/> + <menuitem id="event-status-cancelled-menuitem" + name="event-status-group" + label="&newevent.eventStatus.cancelled.label;" + type="radio" + command="cmd_status_cancelled"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-freebusy" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1 event-only" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&event.menu.options.show.time.label;" + tooltiptext="&event.toolbar.freebusy.tooltip;"> + <menupopup id="event-freebusy-menupopup"> + <menuitem id="event-freebusy-busy-menuitem" + name="event-freebusy-group" + label="&event.menu.options.show.time.busy.label;" + type="radio" + command="cmd_showtimeas_busy"/> + <menuitem id="event-freebusy-free-menuitem" + name="event-freebusy-group" + label="&event.menu.options.show.time.free.label;" + type="radio" + command="cmd_showtimeas_free"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-timezones" + mode="dialog" + type="checkbox" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.menu.options.timezone2.label;" + tooltiptext="&event.menu.options.timezone2.label;" + command="cmd_timezone"/> diff --git a/comm/calendar/base/content/item-editing/calendar-task-editing.js b/comm/calendar/base/content/item-editing/calendar-task-editing.js new file mode 100644 index 0000000000..33f67f81d7 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-task-editing.js @@ -0,0 +1,181 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../calendar-management.js */ +/* import-globals-from ../calendar-ui-utils.js */ +/* import-globals-from calendar-item-editing.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +/** + * Used by the "quick add" feature for tasks, for example in the task view or + * the uniinder-todo. + * + * NOTE: many of the following methods are called without taskEdit being the + * |this| object. + */ + +var taskEdit = { + /** + * Helper function to set readonly and aria-disabled states and the value + * for a given target. + * + * @param aTarget The ID or XUL node to set the value + * @param aDisable A boolean if the target should be disabled. + * @param aValue The value that should be set on the target. + */ + setupTaskField(aTarget, aDisable, aValue) { + aTarget.value = aValue; + aTarget.readOnly = aDisable; + aTarget.ariaDisabled = aDisable; + }, + + /** + * Handler function to call when the quick-add input gains focus. + * + * @param aEvent The DOM focus event + */ + onFocus(aEvent) { + let edit = aEvent.target; + let calendar = getSelectedCalendar(); + edit.showsInstructions = true; + + if (calendar.getProperty("capabilities.tasks.supported") === false) { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsCapability")); + } else if (cal.acl.isCalendarWritable(calendar)) { + edit.showsInstructions = false; + taskEdit.setupTaskField(edit, false, edit.savedValue || ""); + } else { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsReadonly")); + } + }, + + /** + * Handler function to call when the quick-add input loses focus. + * + * @param aEvent The DOM blur event + */ + onBlur(aEvent) { + let edit = aEvent.target; + let calendar = getSelectedCalendar(); + if (!calendar) { + // this must be a first run, we don't have a calendar yet + return; + } + + if (calendar.getProperty("capabilities.tasks.supported") === false) { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsCapability")); + } else if (cal.acl.isCalendarWritable(calendar)) { + if (!edit.showsInstructions) { + edit.savedValue = edit.value || ""; + } + taskEdit.setupTaskField(edit, false, cal.l10n.getCalString("taskEditInstructions")); + } else { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsReadonly")); + } + + edit.showsInstructions = true; + }, + + /** + * Handler function to call on keypress for the quick-add input. + * + * @param aEvent The DOM keypress event + */ + onKeyPress(aEvent) { + if (aEvent.key == "Enter") { + let edit = aEvent.target; + if (edit.value && edit.value.length > 0) { + let item = new CalTodo(); + setDefaultItemValues(item); + item.title = edit.value; + + edit.value = ""; + doTransaction("add", item, item.calendar, null, null); + } + } + }, + + /** + * Helper function to call onBlur for all fields with class name + * "task-edit-field". + */ + callOnBlurForAllTaskFields() { + let taskEditFields = document.getElementsByClassName("task-edit-field"); + for (let i = 0; i < taskEditFields.length; i++) { + taskEdit.onBlur({ target: taskEditFields[i] }); + } + }, + + /** + * Load function to set up all quick-add inputs. The input must + * have the class "task-edit-field". + */ + onLoad(aEvent) { + cal.view.getCompositeCalendar(window).addObserver(taskEdit.compositeObserver); + taskEdit.callOnBlurForAllTaskFields(); + }, + + /** + * Window load function to clean up all quick-add fields. + */ + onUnload() { + cal.view.getCompositeCalendar(window).removeObserver(taskEdit.compositeObserver); + }, + + /** + * Observer to watch for changes to the selected calendar. + * + * @see calIObserver + * @see calICompositeObserver + */ + compositeObserver: { + QueryInterface: ChromeUtils.generateQI(["calIObserver", "calICompositeObserver"]), + + // calIObserver: + onStartBatch() {}, + onEndBatch() {}, + onLoad(aCalendar) {}, + onAddItem(aItem) {}, + onModifyItem(aNewItem, aOldItem) {}, + onDeleteItem(aDeletedItem) {}, + onError(aCalendar, aErrNo, aMessage) {}, + + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + if (aCalendar.id != getSelectedCalendar().id) { + // Optimization: if the given calendar isn't the selected calendar, + // then we don't need to change any readonly/disabled states. + return; + } + + switch (aName) { + case "readOnly": + case "disabled": { + taskEdit.callOnBlurForAllTaskFields(); + break; + } + } + }, + + onPropertyDeleting(aCalendar, aName) { + // Since the old value is not used directly in onPropertyChanged, + // but should not be the same as the value, set it to a different + // value. + this.onPropertyChanged(aCalendar, aName, null, null); + }, + + // calICompositeObserver: + onCalendarAdded(aCalendar) {}, + onCalendarRemoved(aCalendar) {}, + onDefaultCalendarChanged(aNewDefault) { + taskEdit.callOnBlurForAllTaskFields(); + }, + }, +}; diff --git a/comm/calendar/base/content/preferences/alarms.inc.xhtml b/comm/calendar/base/content/preferences/alarms.inc.xhtml new file mode 100644 index 0000000000..a2524983d0 --- /dev/null +++ b/comm/calendar/base/content/preferences/alarms.inc.xhtml @@ -0,0 +1,188 @@ +# 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/. + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <html:legend data-l10n-id="reminder-legend"></html:legend> + <vbox id="alarm-sound-box"> + <hbox align="center"> + <checkbox id="alarmSoundCheckbox" + preference="calendar.alarms.playsound" + data-l10n-id="reminder-play-checkbox"/> + <spacer flex="1"/> + <button is="highlightable-button" id="calendar.prefs.alarm.sound.play" + data-l10n-id="reminder-play-alarm-button" + oncommand="gAlarmsPane.previewAlarm()"/> + </hbox> + <radiogroup id="alarmSoundType" + class="indent" + orient="vertical" + preference="calendar.alarms.soundType" + aria-labelledby="alarmSoundCheckbox"> + <hbox> + <radio id="alarmSoundSystem" + value="0" + data-l10n-id="reminder-default-sound-label"/> + </hbox> + <hbox> + <radio id="alarmSoundCustom" + value="1" + data-l10n-id="reminder-custom-sound-label"/> + </hbox> + </radiogroup> + <hbox align="center" class="input-container"> + <html:input id="alarmSoundFileField" + type="text" + class="input-filefield indent" + readonly="readonly" + preference="calendar.alarms.soundURL" + preference-editable="true" + aria-labelledby="alarmSoundCustom"/> + <button is="highlightable-button" id="calendar.prefs.alarm.sound.browse" + data-l10n-id="reminder-browse-sound-label" + oncommand="gAlarmsPane.browseAlarm()"/> + </hbox> + </vbox> + <hbox align="center" flex="1"> + <checkbox id="alarmshow" + preference="calendar.alarms.show" + data-l10n-id="reminder-dialog-label"/> + </hbox> + <hbox align="center" flex="1"> + <checkbox id="missedalarms" + preference="calendar.alarms.showmissed" + data-l10n-id="missed-reminder-label"/> + </hbox> + </html:fieldset> + </html:div> + + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <html:legend data-l10n-id="reminder-default-legend"></html:legend> + <hbox align="center"> + <label data-l10n-id="default-snooze-label" + control="defaultsnoozelength"/> + <html:input id="defaultsnoozelength" type="number" class="size3" + min="0" + preference="calendar.alarms.defaultsnoozelength" + onselect="updateUnitLabelPlural('defaultsnoozelength', 'defaultsnoozelengthunit', 'minutes')" + oninput="updateUnitLabelPlural('defaultsnoozelength', 'defaultsnoozelengthunit', 'minutes')"/> + <label id="defaultsnoozelengthunit"/> + </hbox> + <hbox> + <html:table id="alarm-defaults-table"> + <html:tr> + <html:td> + <label data-l10n-id="event-alarm-label" + control="eventdefalarm"/> + </html:td> + <html:td> + <hbox> + <menulist id="eventdefalarm" + crop="none" + preference="calendar.alarms.onforevents"> + <menupopup id="eventdefalarmpopup"> + <menuitem id="eventdefalarmon" + data-l10n-id="alarm-on-label" + value="1"/> + <menuitem id="eventdefalarmoff" + data-l10n-id="alarm-off-label" + value="0" + selected="true"/> + </menupopup> + </menulist> + </hbox> + </html:td> + </html:tr> + <html:tr> + <html:td> + <label data-l10n-id="task-alarm-label" + control="tododefalarm"/> + </html:td> + <html:td> + <hbox> + <menulist id="tododefalarm" + crop="none" + preference="calendar.alarms.onfortodos"> + <menupopup id="tododefalarmpopup"> + <menuitem id="tododefalarmon" + data-l10n-id="alarm-on-label" + value="1"/> + <menuitem id="tododefalarmoff" + data-l10n-id="alarm-off-label" + value="0" + selected="true"/> + </menupopup> + </menulist> + </hbox> + </html:td> + </html:tr> + <html:tr> + <html:td> + <label data-l10n-id="event-alarm-time-label" + control="eventdefalarmlen"/> + </html:td> + <html:td> + <hbox class="defaultTimeBox" + align="center" + flex="1"> + <html:input id="eventdefalarmlen" type="number" class="size3" min="0" + preference="calendar.alarms.eventalarmlen" + onselect="updateMenuLabelsPlural('eventdefalarmlen', 'eventdefalarmunit')" + oninput="updateMenuLabelsPlural('eventdefalarmlen', 'eventdefalarmunit')"/> + <hbox> + <menulist id="eventdefalarmunit" + flex="1" + crop="none" + preference="calendar.alarms.eventalarmunit"> + <menupopup id="eventdefalarmunitpopup"> + <menuitem id="eventdefalarmunitmin" + value="minutes" + selected="true"/> + <menuitem id="eventdefalarmunithour" + value="hours"/> + <menuitem id="eventdefalarmunitday" + value="days"/> + </menupopup> + </menulist> + </hbox> + </hbox> + </html:td> + </html:tr> + <html:tr> + <html:td> + <label data-l10n-id="task-alarm-time-label" + control="tododefalarmlen"/> + </html:td> + <html:td> + <hbox class="defaultTimeBox" + align="center" + flex="1"> + <html:input id="tododefalarmlen" type="number" class="size3" min="0" + preference="calendar.alarms.todoalarmlen" + onselect="updateMenuLabelsPlural('tododefalarmlen', 'tododefalarmunit')" + oninput="updateMenuLabelsPlural('tododefalarmlen', 'tododefalarmunit')"/> + <hbox> + <menulist id="tododefalarmunit" + flex="1" + crop="none" + preference="calendar.alarms.todoalarmunit"> + <menupopup id="tododefalarmunitpopup"> + <menuitem id="tododefalarmunitmin" + value="minutes" + selected="true"/> + <menuitem id="tododefalarmunithour" + value="hours"/> + <menuitem id="tododefalarmunitday" + value="days"/> + </menupopup> + </menulist> + </hbox> + </hbox> + </html:td> + </html:tr> + </html:table> + <spacer flex="1"/> + </hbox> + </html:fieldset> + </html:div> diff --git a/comm/calendar/base/content/preferences/alarms.js b/comm/calendar/base/content/preferences/alarms.js new file mode 100644 index 0000000000..29b5df30ab --- /dev/null +++ b/comm/calendar/base/content/preferences/alarms.js @@ -0,0 +1,165 @@ +/* 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 gAlarmsPane */ + +/* import-globals-from ../calendar-ui-utils.js */ +/* globals Preferences */ + +Preferences.addAll([ + { id: "calendar.alarms.playsound", type: "bool" }, + { id: "calendar.alarms.soundURL", type: "string" }, + { id: "calendar.alarms.soundType", type: "int" }, + { id: "calendar.alarms.show", type: "bool" }, + { id: "calendar.alarms.showmissed", type: "bool" }, + { id: "calendar.alarms.onforevents", type: "int" }, + { id: "calendar.alarms.onfortodos", type: "int" }, + { id: "calendar.alarms.eventalarmlen", type: "int" }, + { id: "calendar.alarms.eventalarmunit", type: "string" }, + { id: "calendar.alarms.todoalarmlen", type: "int" }, + { id: "calendar.alarms.todoalarmunit", type: "string" }, + { id: "calendar.alarms.defaultsnoozelength", type: "int" }, +]); + +/** + * Global Object to hold methods for the alarms pref pane + */ +var gAlarmsPane = { + /** + * Initialize the alarms pref pane. Sets up dialog controls to match the + * values set in prefs. + */ + init() { + // Enable/disable the alarm sound URL box and buttons + this.alarmsPlaySoundPrefChanged(); + + // Set the correct singular/plural for the time units + updateMenuLabelsPlural("eventdefalarmlen", "eventdefalarmunit"); + updateMenuLabelsPlural("tododefalarmlen", "tododefalarmunit"); + updateUnitLabelPlural("defaultsnoozelength", "defaultsnoozelengthunit", "minutes"); + + Preferences.addSyncFromPrefListener(document.getElementById("alarmSoundFileField"), () => + this.readSoundLocation() + ); + }, + + /** + * Converts the given file url to a nsIFile + * + * @param aFileURL A string with a file:// url. + * @returns The corresponding nsIFile. + */ + convertURLToLocalFile(aFileURL) { + // Convert the file url into a nsIFile + if (aFileURL) { + let fph = Services.io.getProtocolHandler("file").QueryInterface(Ci.nsIFileProtocolHandler); + return fph.getFileFromURLSpec(aFileURL); + } + return null; + }, + + /** + * Handler function to be called when the calendar.alarms.soundURL pref has + * changed. Updates the label in the dialog. + */ + readSoundLocation() { + let soundUrl = document.getElementById("alarmSoundFileField"); + soundUrl.value = Preferences.get("calendar.alarms.soundURL").value; + if (soundUrl.value.startsWith("file://")) { + soundUrl.label = gAlarmsPane.convertURLToLocalFile(soundUrl.value).leafName; + } else { + soundUrl.label = soundUrl.value; + } + soundUrl.style.backgroundImage = "url(moz-icon://" + soundUrl.label + "?size=16)"; + return undefined; + }, + + /** + * Causes the default sound to be selected in the dialog controls + */ + useDefaultSound() { + let defaultSoundUrl = "chrome://calendar/content/sound.wav"; + Preferences.get("calendar.alarms.soundURL").value = defaultSoundUrl; + document.getElementById("alarmSoundCheckbox").checked = true; + this.readSoundLocation(); + }, + + /** + * Opens a filepicker to open a local sound for the alarm. + */ + browseAlarm() { + let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + // If we already have a sound file, then use the path for that sound file + // as the initial path in the dialog. + let currentValue = Preferences.get("calendar.alarms.soundURL").value; + if (currentValue && currentValue.startsWith("file://")) { + let localFile = Services.io.newURI(currentValue).QueryInterface(Ci.nsIFileURL).file; + picker.displayDirectory = localFile.parent; + } + + let title = document.getElementById("bundlePreferences").getString("soundFilePickerTitle"); + + picker.init(window, title, Ci.nsIFilePicker.modeOpen); + picker.appendFilters(Ci.nsIFilePicker.filterAudio); + picker.appendFilters(Ci.nsIFilePicker.filterAll); + + picker.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !picker.file) { + return; + } + Preferences.get("calendar.alarms.soundURL").value = picker.fileURL.spec; + document.getElementById("alarmSoundCheckbox").checked = true; + this.readSoundLocation(); + }); + }, + + /** + * Plays the alarm sound currently selected. + */ + previewAlarm() { + let soundUrl; + if (Preferences.get("calendar.alarms.soundType").value == 0) { + soundUrl = "chrome://calendar/content/sound.wav"; + } else { + soundUrl = Preferences.get("calendar.alarms.soundURL").value; + } + let soundIfc = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound); + let url; + try { + soundIfc.init(); + if (soundUrl && soundUrl.length && soundUrl.length > 0) { + url = Services.io.newURI(soundUrl); + soundIfc.play(url); + } else { + soundIfc.beep(); + } + } catch (ex) { + dump("alarms.js previewAlarm Exception caught! " + ex + "\n"); + } + }, + + /** + * Handler function to call when the calendar.alarms.playsound preference + * has been changed. Updates the disabled state of fields that depend on + * playing a sound. + */ + alarmsPlaySoundPrefChanged() { + let alarmsPlaySoundPref = Preferences.get("calendar.alarms.playsound"); + let alarmsSoundType = Preferences.get("calendar.alarms.soundType"); + + for (let item of ["alarmSoundType", "calendar.prefs.alarm.sound.play"]) { + document.getElementById(item).disabled = !alarmsPlaySoundPref.value; + } + + for (let item of ["alarmSoundFileField", "calendar.prefs.alarm.sound.browse"]) { + document.getElementById(item).disabled = + alarmsSoundType.value != 1 || !alarmsPlaySoundPref.value; + } + }, +}; + +Preferences.get("calendar.alarms.playsound").on("change", gAlarmsPane.alarmsPlaySoundPrefChanged); +Preferences.get("calendar.alarms.soundType").on("change", gAlarmsPane.alarmsPlaySoundPrefChanged); +Preferences.get("calendar.alarms.soundURL").on("change", gAlarmsPane.readSoundLocation); diff --git a/comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml b/comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml new file mode 100644 index 0000000000..d07f2b812b --- /dev/null +++ b/comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml @@ -0,0 +1,55 @@ +# 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/. + +# This file requires the following localization files: +# chrome://lightning/locale/lightning.dtd +# chrome://calendar/locale/global.dtd +# chrome://calendar/locale/calendar-event-dialog.dtd + <linkset> + <html:link rel="localization" href="calendar/preferences.ftl"/> + <html:link rel="localization" href="calendar/category-dialog.ftl"/> + </linkset> + <html:template id="paneCalendar" + flex="1" + insertbefore="paneChat"> + + <hbox id="calendarPaneCategory" + class="subcategory" + data-category="paneCalendar"> + <html:h1 data-l10n-id="calendar-title"></html:h1> + </hbox> + +#include views.inc.xhtml +#include general.inc.xhtml + + <hbox id="calendarReminderCategory" + class="subcategory" + data-category="paneCalendar"> + <html:h1 data-l10n-id="calendar-title-reminder"></html:h1> + </hbox> + +#include alarms.inc.xhtml + + <hbox id="calendarNotificationCategory" + class="subcategory" + data-category="paneCalendar"> + <html:h1 data-l10n-id="calendar-title-notification"></html:h1> + </hbox> + + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <calendar-notifications-setting id="calendar-notifications-setting"/> + <label data-l10n-id="calendar-notifications-customize-label" + class="indent tip-caption"></label> + </html:fieldset> + </html:div> + + <hbox id="calendarCategoriesCategory" + class="subcategory" + data-category="paneCalendar"> + <html:h1 data-l10n-id="calendar-title-category"></html:h1> + </hbox> + +#include categories.inc.xhtml + </html:template> diff --git a/comm/calendar/base/content/preferences/calendar-preferences.js b/comm/calendar/base/content/preferences/calendar-preferences.js new file mode 100644 index 0000000000..4b42fa2300 --- /dev/null +++ b/comm/calendar/base/content/preferences/calendar-preferences.js @@ -0,0 +1,28 @@ +/* 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 gCalendarPane */ + +/* import-globals-from alarms.js */ +/* import-globals-from categories.js */ +/* import-globals-from general.js */ +/* import-globals-from notifications.js */ +/* import-globals-from views.js */ +/* globals Preferences */ + +Preferences.add({ id: "calendar.preferences.lightning.selectedTabIndex", type: "int" }); + +var gCalendarPane = { + init() { + let elements = document.querySelectorAll("#paneCalendar preference"); + for (let element of elements) { + element.updateElements(); + } + gCalendarGeneralPane.init(); + gAlarmsPane.init(); + gNotificationsPane.init(); + gCategoriesPane.init(); + gViewsPane.init(); + }, +}; diff --git a/comm/calendar/base/content/preferences/categories.inc.xhtml b/comm/calendar/base/content/preferences/categories.inc.xhtml new file mode 100644 index 0000000000..d6f37be9df --- /dev/null +++ b/comm/calendar/base/content/preferences/categories.inc.xhtml @@ -0,0 +1,32 @@ +# 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/. + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <separator class="thin"/> + <hbox> + <richlistbox id="categorieslist" + flex="1" + seltype="multiple" + onselect="gCategoriesPane.updateButtons()" + ondblclick="gCategoriesPane.listOnDblClick(event)"/> + <vbox id="categoriesButtons"> + <hbox> + <button is="highlightable-button" id="newCButton" + data-l10n-id="new-tag-button" + oncommand="gCategoriesPane.addCategory()" + search-l10n-ids="category-name-label,category-color-label.label"/> + </hbox> + <hbox> + <button is="highlightable-button" id="editCButton" + data-l10n-id="edit-tag-button" + oncommand="gCategoriesPane.editCategory()" + search-l10n-ids="category-name-label,category-color-label.label"/> + </hbox> + <button is="highlightable-button" id="deleteCButton" + data-l10n-id="delete-tag-button" + oncommand="gCategoriesPane.deleteCategory()"/> + </vbox> + </hbox> + </html:fieldset> + </html:div> diff --git a/comm/calendar/base/content/preferences/categories.js b/comm/calendar/base/content/preferences/categories.js new file mode 100644 index 0000000000..ba7453b447 --- /dev/null +++ b/comm/calendar/base/content/preferences/categories.js @@ -0,0 +1,297 @@ +/* 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 gCategoriesPane */ + +/* globals gSubDialog, Preferences */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); + +Preferences.add({ id: "calendar.categories.names", type: "string" }); + +var gCategoryList; +var categoryPrefBranch = Services.prefs.getBranch("calendar.category.color."); + +/** + * Global Object to hold methods for the categories pref pane + */ +var gCategoriesPane = { + mCategoryDialog: null, + mWinProp: null, + + /** + * Initialize the categories pref pane. Sets up dialog controls to show the + * categories saved in preferences. + */ + init() { + // On non-instant-apply platforms, once this pane has been loaded, + // attach our "revert all changes" function to the parent prefwindow's + // "ondialogcancel" event. + let parentPrefWindow = document.documentElement; + if (!parentPrefWindow.instantApply) { + let existingOnDialogCancel = parentPrefWindow.getAttribute("ondialogcancel"); + parentPrefWindow.setAttribute( + "ondialogcancel", + "gCategoriesPane.panelOnCancel(); " + existingOnDialogCancel + ); + } + + // A list of preferences to be reverted when the dialog is cancelled. + // It needs to be a property of the parent to be visible onCancel + if (!("backupPrefList" in parent)) { + parent.backupPrefList = []; + } + + gCategoryList = cal.category.fromPrefs(); + + this.updateCategoryList(); + + this.mCategoryDialog = "chrome://calendar/content/preferences/editCategory.xhtml"; + + // Workaround for Bug 1151440 - the HTML color picker won't work + // in linux when opened from modal dialog + this.mWinProp = "centerscreen, chrome, resizable=no"; + if (AppConstants.platform != "linux") { + this.mWinProp += ", modal"; + } + }, + + /** + * Updates the listbox containing the categories from the categories saved + * in preferences. + */ + + updatePrefs() { + cal.l10n.sortArrayByLocaleCollator(gCategoryList); + Preferences.get("calendar.categories.names").value = cal.category.arrayToString(gCategoryList); + }, + + updateCategoryList() { + this.updatePrefs(); + let listbox = document.getElementById("categorieslist"); + + listbox.clearSelection(); + this.updateButtons(); + + while (listbox.lastElementChild) { + listbox.lastChild.remove(); + } + + for (let i = 0; i < gCategoryList.length; i++) { + let newListItem = document.createXULElement("richlistitem"); + let categoryName = document.createXULElement("label"); + categoryName.setAttribute("id", gCategoryList[i]); + categoryName.setAttribute("flex", "1"); + categoryName.setAttribute("value", gCategoryList[i]); + let categoryNameFix = cal.view.formatStringForCSSRule(gCategoryList[i]); + + let categoryColor = document.createXULElement("box"); + categoryColor.style.width = "150px"; + let colorCode = categoryPrefBranch.getCharPref(categoryNameFix, ""); + if (colorCode) { + categoryColor.style.backgroundColor = colorCode; + } + + newListItem.appendChild(categoryName); + newListItem.appendChild(categoryColor); + listbox.appendChild(newListItem); + } + }, + + /** + * Adds a category, opening the edit category dialog to prompt the user to + * set up the category. + */ + async addCategory() { + let listbox = document.getElementById("categorieslist"); + listbox.clearSelection(); + this.updateButtons(); + let params = { + title: await document.l10n.formatValue("category-new-label"), + category: "", + color: null, + }; + gSubDialog.open(this.mCategoryDialog, { features: "resizable=no" }, params); + }, + + /** + * Edits the currently selected category using the edit category dialog. + */ + async editCategory() { + let list = document.getElementById("categorieslist"); + let categoryNameFix = cal.view.formatStringForCSSRule(gCategoryList[list.selectedIndex]); + let currentColor = categoryPrefBranch.getCharPref(categoryNameFix, ""); + + let params = { + title: await document.l10n.formatValue("category-edit-label"), + category: gCategoryList[list.selectedIndex], + color: currentColor, + }; + if (list.selectedItem) { + gSubDialog.open(this.mCategoryDialog, { features: "resizable=no" }, params); + } + }, + + /** + * Removes the selected category. + */ + deleteCategory() { + let list = document.getElementById("categorieslist"); + if (list.selectedCount < 1) { + return; + } + + let categoryNameFix = cal.view.formatStringForCSSRule(gCategoryList[list.selectedIndex]); + this.backupData(categoryNameFix); + try { + categoryPrefBranch.clearUserPref(categoryNameFix); + } catch (ex) { + // If the pref doesn't exist, don't bail out here. + } + + // Remove category entry from listbox and gCategoryList. + let newSelection = + list.selectedItem.nextElementSibling || list.selectedItem.previousElementSibling; + let selectedItems = Array.from(list.selectedItems); + for (let i = list.selectedCount - 1; i >= 0; i--) { + let item = selectedItems[i]; + if (item == newSelection) { + newSelection = newSelection.nextElementSibling || newSelection.previousElementSibling; + } + gCategoryList.splice(list.getIndexOfItem(item), 1); + item.remove(); + } + list.selectedItem = newSelection; + this.updateButtons(); + + // Update the prefs from gCategoryList + this.updatePrefs(); + }, + + /** + * Saves the given category to the preferences. + * + * @param categoryName The name of the category. + * @param categoryColor The color of the category + */ + async saveCategory(categoryName, categoryColor) { + let list = document.getElementById("categorieslist"); + // Check to make sure another category doesn't have the same name + let toBeDeleted = -1; + for (let i = 0; i < gCategoryList.length; i++) { + if (i == list.selectedIndex) { + continue; + } + + if (categoryName.toLowerCase() == gCategoryList[i].toLowerCase()) { + let [title, description] = await document.l10n.formatValues([ + { id: "category-overwrite-title" }, + { id: "category-overwrite" }, + ]); + + if (Services.prompt.confirm(null, title, description)) { + if (list.selectedIndex != -1) { + // Don't delete the old category yet. It will mess up indices. + toBeDeleted = list.selectedIndex; + } + list.selectedIndex = i; + } else { + return; + } + } + } + + if (categoryName.length == 0) { + let warning = await document.l10n.formatValue("category-blank-warning"); + Services.prompt.alert(null, null, warning); + return; + } + + let categoryNameFix = cal.view.formatStringForCSSRule(categoryName); + if (list.selectedIndex == -1) { + this.backupData(categoryNameFix); + gCategoryList.push(categoryName); + if (categoryColor) { + categoryPrefBranch.setCharPref(categoryNameFix, categoryColor); + } + } else { + this.backupData(categoryNameFix); + gCategoryList.splice(list.selectedIndex, 1, categoryName); + categoryPrefBranch.setCharPref(categoryNameFix, categoryColor || ""); + } + + // If 'Overwrite' was chosen, delete category that was being edited + if (toBeDeleted != -1) { + list.selectedIndex = toBeDeleted; + this.deleteCategory(); + } + + this.updateCategoryList(); + + let updatedCategory = gCategoryList.indexOf(categoryName); + list.ensureIndexIsVisible(updatedCategory); + list.selectedIndex = updatedCategory; + }, + + /** + * Enable the edit and delete category buttons. + */ + updateButtons() { + let categoriesList = document.getElementById("categorieslist"); + document.getElementById("deleteCButton").disabled = categoriesList.selectedCount <= 0; + document.getElementById("editCButton").disabled = categoriesList.selectedCount != 1; + }, + + /** + * Backs up the category name in case the dialog is canceled. + * + * @see formatStringForCSSRule + * @param categoryNameFix The formatted category name. + */ + backupData(categoryNameFix) { + let currentColor = categoryPrefBranch.getCharPref(categoryNameFix, "##NEW"); + + for (let i = 0; i < parent.backupPrefList.length; i++) { + if (categoryNameFix == parent.backupPrefList[i].name) { + return; + } + } + parent.backupPrefList[parent.backupPrefList.length] = { + name: categoryNameFix, + color: currentColor, + }; + }, + + /** + * Event Handler function to be called on doubleclick of the categories + * list. If the edit function is enabled and the user doubleclicked on a + * list item, then edit the selected category. + */ + listOnDblClick(event) { + if (event.target.localName == "listitem" && !document.getElementById("editCButton").disabled) { + this.editCategory(); + } + }, + + /** + * Reverts category preferences in case the cancel button is pressed. + */ + panelOnCancel() { + for (let i = 0; i < parent.backupPrefList.length; i++) { + if (parent.backupPrefList[i].color == "##NEW") { + try { + categoryPrefBranch.clearUserPref(parent.backupPrefList[i].name); + } catch (ex) { + dump("Exception caught in 'panelOnCancel': " + ex + "\n"); + } + } else { + categoryPrefBranch.setCharPref( + parent.backupPrefList[i].name, + parent.backupPrefList[i].color + ); + } + } + }, +}; diff --git a/comm/calendar/base/content/preferences/editCategory.js b/comm/calendar/base/content/preferences/editCategory.js new file mode 100644 index 0000000000..c3479b88c0 --- /dev/null +++ b/comm/calendar/base/content/preferences/editCategory.js @@ -0,0 +1,112 @@ +/* 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 editCategoryLoad, categoryNameChanged, clickColor, delay */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +window.addEventListener("DOMContentLoaded", editCategoryLoad); + +// Global variable, set to true if the user has picked a custom color. +var customColorSelected = false; + +/** + * Load Handler, called when the edit category dialog is loaded + */ +function editCategoryLoad() { + let winArg = window.arguments[0]; + let color = winArg.color || cal.view.hashColor(winArg.category); + let hasColor = !!winArg.color; + document.getElementById("categoryName").value = winArg.category; + document.getElementById("categoryColor").value = color; + document.getElementById("useColor").checked = hasColor; + customColorSelected = hasColor; + document.title = winArg.title; + + toggleColor(); +} + +/** + * Handler function to be called when the category dialog is accepted and + * the opener should further process the selected name and color + */ +document.addEventListener("dialogaccept", () => { + let color = document.getElementById("useColor").checked + ? document.getElementById("categoryColor").value + : null; + + let categoryName = document.getElementById("categoryName").value; + window.opener.gCategoriesPane.saveCategory(categoryName, color); +}); + +/** + * Handler function to be called when the category name changed + */ +function categoryNameChanged() { + let newValue = document.getElementById("categoryName").value; + + // The user removed the category name, assign the color automatically again. + if (newValue == "") { + customColorSelected = false; + } + + if (!customColorSelected && document.getElementById("useColor").checked) { + // Color is wanted, choose the color based on the category name's hash. + document.getElementById("categoryColor").value = cal.view.hashColor(newValue); + } +} + +/** + * Handler function to be called when the color picker's color has been changed. + */ +function colorPickerChanged() { + document.getElementById("useColor").checked = true; + customColorSelected = true; +} + +/** + * Handler called when the use color checkbox is toggled. + */ +function toggleColor() { + let useColor = document.getElementById("useColor").checked; + let categoryColor = document.getElementById("categoryColor"); + + if (useColor) { + categoryColor.removeAttribute("disabled"); + if (toggleColor.lastColor) { + categoryColor.value = toggleColor.lastColor; + } + } else { + categoryColor.setAttribute("disabled", "disabled"); + toggleColor.lastColor = categoryColor.value; + categoryColor.value = ""; + } +} + +/** + * Click handler for the color picker. Turns the button back into a colorpicker + * when clicked. + */ +function clickColor() { + let categoryColor = document.getElementById("categoryColor"); + if (categoryColor.hasAttribute("disabled")) { + colorPickerChanged(); + toggleColor(); + categoryColor.click(); + } +} + +/** + * Call the function after the given timeout, resetting the timer if delay is + * called again with the same function. + * + * @param timeout The timeout interval. + * @param func The function to call after the timeout. + */ +function delay(timeout, func) { + if (func.timer) { + clearTimeout(func.timer); + } + func.timer = setTimeout(func, timeout); +} diff --git a/comm/calendar/base/content/preferences/editCategory.xhtml b/comm/calendar/base/content/preferences/editCategory.xhtml new file mode 100644 index 0000000000..5128a03249 --- /dev/null +++ b/comm/calendar/base/content/preferences/editCategory.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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!DOCTYPE html> +<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" + scrolling="false" +> + <head> + <title><!-- category-new-label / category-edit-label --></title> + <link rel="localization" href="calendar/category-dialog.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/preferences/editCategory.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog id="editCategory" buttons="accept,cancel" style="width: 100vw; height: 100vh"> + <label id="categoryNameLabel" data-l10n-id="category-name-label" control="categoryName" /> + <html:input + id="categoryName" + type="text" + onchange="categoryNameChanged()" + oninput="delay(500, categoryNameChanged)" + aria-labelledby="categoryNameLabel" + /> + <hbox id="colorSelectRow"> + <checkbox + id="useColor" + data-l10n-id="category-color-label" + oncommand="toggleColor(); categoryNameChanged()" + /> + <html:input + id="categoryColor" + type="color" + style="width: 64px; height: 23px" + onclick="clickColor()" + onchange="colorPickerChanged()" + aria-labelledby="useColor" + /> + </hbox> + </dialog> + </html:body> +</html> diff --git a/comm/calendar/base/content/preferences/general.inc.xhtml b/comm/calendar/base/content/preferences/general.inc.xhtml new file mode 100644 index 0000000000..f6b00ad29c --- /dev/null +++ b/comm/calendar/base/content/preferences/general.inc.xhtml @@ -0,0 +1,185 @@ +# 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/. + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <html:legend data-l10n-id="todaypane-legend"></html:legend> + <hbox align="center"> + <label data-l10n-id="agenda-days" + control="agenda-days-menulist"/> + <hbox> + <menulist id="agenda-days-menulist" + preference="calendar.agenda.days"> + <menupopup id="agenda-days-popup"> + <menuitem value="1"/> + <menuitem value="2"/> + <menuitem value="3"/> + <menuitem value="4"/> + <menuitem value="5"/> + <menuitem value="6"/> + <menuitem value="7"/> + <menuitem value="14"/> + <menuitem value="21"/> + <menuitem value="28"/> + </menupopup> + </menulist> + </hbox> + </hbox> + </html:fieldset> + </html:div> + + <html:div data-category="paneCalendar"> + <html:fieldset id="defaults-itemtype-groupbox" data-category="paneCalendar"> + <html:legend data-l10n-id="event-task-legend"></html:legend> + <vbox id="defaults-itemtype-box"> + <hbox id="defaults-event-grid-box" align="center"> + <label id="default-event-length-label" + data-l10n-id="default-length-label" + control="defaultlength"/> + <html:input id="defaultlength" type="number" + class="size3" + min="0" + preference="calendar.event.defaultlength" + onselect="updateUnitLabelPlural('defaultlength', 'defaultlengthunit', 'minutes')" + oninput="updateUnitLabelPlural('defaultlength', 'defaultlengthunit', 'minutes')"/> + <label id="defaultlengthunit"/> + </hbox> + <html:table id="defaults-task-table"> + <html:tr id="defaults-task-start-row"> + <html:td> + <label id="default-task-start-label" + data-l10n-id="task-start-label" + control="default_task_start"/> + </html:td> + <html:td> + <hbox> + <menulist id="default_task_start" + crop="none" + oncommand="gCalendarGeneralPane.updateDefaultTodoDates()" + preference="calendar.task.defaultstart"> + <menupopup id="default_task_start_popup"> + <menuitem id="default_task_start_none" + data-l10n-id="task-start-1-label" + value="none" + selected="true"/> + <menuitem id="default_task_start_start_of_day" + data-l10n-id="task-start-2-label" + value="startofday"/> + <menuitem id="default_task_start_tomorrow" + data-l10n-id="task-start-4-label" + value="tomorrow"/> + <menuitem id="default_task_start_next_week" + data-l10n-id="task-start-5-label" + value="nextweek"/> + <menuitem id="default_task_start_offset_current" + data-l10n-id="task-start-6-label" + value="offsetcurrent"/> + <menuitem id="default_task_start_offset_next_hour" + data-l10n-id="task-start-8-label" + value="offsetnexthour"/> + </menupopup> + </menulist> + </hbox> + </html:td> + <html:td> + <hbox id="default_task_start_offset" align="center"> + <html:input id="default_task_start_offset_text" type="number" + class="size3" + min="0" + preference="calendar.task.defaultstartoffset" + onselect="updateMenuLabelsPlural('default_task_start_offset_text', 'default_task_start_offset_units')" + oninput="updateMenuLabelsPlural('default_task_start_offset_text', 'default_task_start_offset_units')"/> + <hbox> + <menulist id="default_task_start_offset_units" + crop="none" + preference="calendar.task.defaultstartoffsetunits"> + <menupopup id="default_task_start_offset_units_popup"> + <menuitem id="default_task_start_offset_units_minutes" + value="minutes" + selected="true"/> + <menuitem id="default_task_start_offset_units_hours" + value="hours"/> + <menuitem id="default_task_start_offset_units_days" + value="days"/> + </menupopup> + </menulist> + </hbox> + </hbox> + </html:td> + </html:tr> + <html:tr id="defaults-task-due-row"> + <html:td> + <label id="default-task-due-label" + data-l10n-id="task-due-label" + control="default_task_due"/> + </html:td> + <html:td> + <hbox> + <menulist id="default_task_due" + crop="none" + oncommand="gCalendarGeneralPane.updateDefaultTodoDates()" + preference="calendar.task.defaultdue"> + <menupopup id="default_task_due_popup"> + <menuitem id="default_task_due_none" + data-l10n-id="task-start-1-label" + value="none" + selected="true"/> + <menuitem id="default_task_due_end_of_day" + data-l10n-id="task-start-3-label" + value="endofday"/> + <menuitem id="default_task_due_tomorrow" + data-l10n-id="task-start-4-label" + value="tomorrow"/> + <menuitem id="default_task_due_next_week" + data-l10n-id="task-start-5-label" + value="nextweek"/> + <menuitem id="default_task_due_offset_current" + data-l10n-id="task-start-7-label" + value="offsetcurrent"/> + <menuitem id="default_task_due_offset_next_hour" + data-l10n-id="task-start-8-label" + value="offsetnexthour"/> + </menupopup> + </menulist> + </hbox> + </html:td> + <html:td> + <hbox id="default_task_due_offset" align="center"> + <html:input id="default_task_due_offset_text" type="number" + class="size3" + min="0" + preference="calendar.task.defaultdueoffset" + onselect="updateMenuLabelsPlural('default_task_due_offset_text', 'default_task_due_offset_units')" + oninput="updateMenuLabelsPlural('default_task_due_offset_text', 'default_task_due_offset_units')"/> + <hbox> + <menulist id="default_task_due_offset_units" + crop="none" + preference="calendar.task.defaultdueoffsetunits"> + <menupopup id="default_task_due_offset_units_popup"> + <menuitem id="default_task_due_offset_units_minutes" + value="minutes" + selected="true"/> + <menuitem id="default_task_due_offset_units_hours" + value="hours"/> + <menuitem id="default_task_due_offset_units_days" + value="days"/> + </menupopup> + </menulist> + </hbox> + </hbox> + </html:td> + </html:tr> + </html:table> + </vbox> + <hbox align="center"> + <checkbox id="tabedit" pack="end" + data-l10n-id="edit-intab-label" + preference="calendar.item.editInTab"/> + </hbox> + <hbox align="center"> + <checkbox id="promptDelete" pack="end" + data-l10n-id="prompt-delete-label" + preference="calendar.item.promptDelete"/> + </hbox> + </html:fieldset> + </html:div> diff --git a/comm/calendar/base/content/preferences/general.js b/comm/calendar/base/content/preferences/general.js new file mode 100644 index 0000000000..8991193e05 --- /dev/null +++ b/comm/calendar/base/content/preferences/general.js @@ -0,0 +1,148 @@ +/* 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 gCalendarGeneralPane */ + +/* import-globals-from ../calendar-ui-utils.js */ +/* globals Preferences */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +Preferences.addAll([ + { id: "calendar.date.format", type: "int" }, + { id: "calendar.event.defaultlength", type: "int" }, + { id: "calendar.timezone.local", type: "string" }, + { id: "calendar.timezone.useSystemTimezone", type: "bool" }, + { id: "calendar.task.defaultstart", type: "string" }, + { id: "calendar.task.defaultstartoffset", type: "int" }, + { id: "calendar.task.defaultstartoffsetunits", type: "string" }, + { id: "calendar.task.defaultdue", type: "string" }, + { id: "calendar.task.defaultdueoffset", type: "int" }, + { id: "calendar.task.defaultdueoffsetunits", type: "string" }, + { id: "calendar.agenda.days", type: "int" }, + { id: "calendar.item.editInTab", type: "bool" }, + { id: "calendar.item.promptDelete", type: "bool" }, +]); + +/** + * Global Object to hold methods for the general pref pane + */ +var gCalendarGeneralPane = { + /** + * Initialize the general pref pane. Sets up dialog controls to match the + * values set in prefs. + */ + init() { + this.onChangedUseSystemTimezonePref(); + + let formatter = cal.dtz.formatter; + let dateFormattedLong = formatter.formatDateLong(cal.dtz.now()); + let dateFormattedShort = formatter.formatDateShort(cal.dtz.now()); + + // menu items include examples of current date formats. + document.l10n.setAttributes( + document.getElementById("dateformat-long-menuitem"), + "dateformat-long", + { date: dateFormattedLong } + ); + + document.l10n.setAttributes( + document.getElementById("dateformat-short-menuitem"), + "dateformat-short", + { date: dateFormattedShort } + ); + + // deselect and reselect to update visible item title + updateUnitLabelPlural("defaultlength", "defaultlengthunit", "minutes"); + this.updateDefaultTodoDates(); + + let tzMenuList = document.getElementById("calendar-timezone-menulist"); + let tzMenuPopup = document.getElementById("calendar-timezone-menupopup"); + + let tzids = {}; + let displayNames = []; + // don't rely on what order the timezone-service gives you + 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 displayName of displayNames) { + addMenuItem(tzMenuPopup, displayName, tzids[displayName]); + } + + let prefValue = Preferences.get("calendar.timezone.local").value; + if (!prefValue) { + prefValue = cal.dtz.defaultTimezone.tzid; + } + tzMenuList.value = prefValue; + + // Set the agenda length menulist. + this.initializeTodaypaneMenu(); + }, + + updateDefaultTodoDates() { + let defaultDue = document.getElementById("default_task_due").value; + let defaultStart = document.getElementById("default_task_start").value; + let offsetValues = ["offsetcurrent", "offsetnexthour"]; + + document.getElementById("default_task_due_offset").style.visibility = offsetValues.includes( + defaultDue + ) + ? "" + : "hidden"; + document.getElementById("default_task_start_offset").style.visibility = offsetValues.includes( + defaultStart + ) + ? "" + : "hidden"; + + updateMenuLabelsPlural("default_task_start_offset_text", "default_task_start_offset_units"); + updateMenuLabelsPlural("default_task_due_offset_text", "default_task_due_offset_units"); + }, + + initializeTodaypaneMenu() { + // Assign the labels for the menuitem + let menulist = document.getElementById("agenda-days-menulist"); + let items = menulist.getElementsByTagName("menuitem"); + for (let menuItem of items) { + let menuitemValue = Number(menuItem.value); + if (menuitemValue > 7) { + menuItem.label = unitPluralForm(menuitemValue / 7, "weeks"); + } else { + menuItem.label = unitPluralForm(menuitemValue, "days"); + } + } + + let pref = Preferences.get("calendar.agenda.days"); + let value = pref.value; + + // Check if the preference has been edited with a wrong value. + if (value > 0 && value <= 28) { + if (value % 7 != 0) { + let intValue = Math.floor(value / 7) * 7; + value = intValue == 0 ? value : intValue; + pref.value = value; + } + } else { + pref.value = 14; + } + }, + + onChangedUseSystemTimezonePref() { + let useSystemTimezonePref = Preferences.get("calendar.timezone.useSystemTimezone"); + + document.getElementById("calendar-timezone-menulist").disabled = useSystemTimezonePref.value; + }, +}; + +Preferences.get("calendar.timezone.useSystemTimezone").on( + "change", + gCalendarGeneralPane.onChangedUseSystemTimezonePref +); diff --git a/comm/calendar/base/content/preferences/notifications.js b/comm/calendar/base/content/preferences/notifications.js new file mode 100644 index 0000000000..11725b8309 --- /dev/null +++ b/comm/calendar/base/content/preferences/notifications.js @@ -0,0 +1,24 @@ +/* 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 gNotificationsPane */ +/* globals Preferences */ + +Preferences.add({ id: "calendar.notifications.times", type: "string" }); + +/** + * Global Object to hold methods for the notifications pref pane. + */ +var gNotificationsPane = { + /** + * Initialize <calendar-notifications-setting> and listen to the change event. + */ + init() { + var calendarNotificationsSetting = document.getElementById("calendar-notifications-setting"); + calendarNotificationsSetting.value = Preferences.get("calendar.notifications.times").value; + calendarNotificationsSetting.addEventListener("change", () => { + Preferences.get("calendar.notifications.times").value = calendarNotificationsSetting.value; + }); + }, +}; diff --git a/comm/calendar/base/content/preferences/views.inc.xhtml b/comm/calendar/base/content/preferences/views.inc.xhtml new file mode 100644 index 0000000000..d37d183d8b --- /dev/null +++ b/comm/calendar/base/content/preferences/views.inc.xhtml @@ -0,0 +1,311 @@ +# 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/. + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <separator/> + <box id="datePrefsBox"> + <hbox align="center"> + <label data-l10n-id="dateformat-label" + control="dateformat"/> + </hbox> + <hbox align="center"> + <menulist id="dateformat" crop="none" + flex="1" + preference="calendar.date.format"> + <menupopup id="dateformatpopup"> + <menuitem id="dateformat-long-menuitem" + value="0"/> + <menuitem id="dateformat-short-menuitem" + value="1"/> + </menupopup> + </menulist> + </hbox> + <spacer/> +#if defined(XP_WIN) || defined(XP_MACOSX) || defined(MOZ_WIDGET_GTK) && defined(MOZ_ENABLE_DBUS) + <radiogroup id="timezone-source-radio-group" + orient="vertical" + preference="calendar.timezone.useSystemTimezone"> + <hbox align="center"> + <radio id="use-system-timezone-radio-button" + value="true" + data-l10n-id="use-system-timezone-radio-button"/> + </hbox> + <hbox> + <radio id="set-timezone-manually-radio-button" + value="false" + data-l10n-id="set-timezone-manually-radio-button"/> + </hbox> + </radiogroup> + <spacer/><spacer/> + <hbox align="center" class="indent"> + <label data-l10n-id="timezone-label" + control="calendar-timezone-menulist"/> + </hbox> + <hbox align="center"> + <menulist id="calendar-timezone-menulist" + flex="1" + preference="calendar.timezone.local"> + <menupopup id="calendar-timezone-menupopup"/> + </menulist> + </hbox> +#else + <hbox align="center" class="indent"> + <label data-l10n-id="timezone-label" + control="calendar-timezone-menulist"/> + </hbox> + <hbox align="center"> + <menulist id="calendar-timezone-menulist" + flex="1" + preference="calendar.timezone.local"> + <menupopup id="calendar-timezone-menupopup"/> + </menulist> + </hbox> +#endif + <spacer/> + <hbox align="center" flex="1"> + <label data-l10n-id="weekstart-label" + control="weekstarts"/> + </hbox> + <hbox align="center"> + <menulist id="weekstarts" + flex="1" + preference="calendar.week.start" + oncommand="gViewsPane.updateViewWorkDayCheckboxes(this.value)"> + <menupopup id="weekstartspopup"> + <menuitem data-l10n-id="day-1-name" value="0"/> + <menuitem data-l10n-id="day-2-name" value="1"/> + <menuitem data-l10n-id="day-3-name" value="2"/> + <menuitem data-l10n-id="day-4-name" value="3"/> + <menuitem data-l10n-id="day-5-name" value="4"/> + <menuitem data-l10n-id="day-6-name" value="5"/> + <menuitem data-l10n-id="day-7-name" value="6"/> + </menupopup> + </menulist> + </hbox> + <spacer/> + </box> + <hbox align="center" flex="1"> + <checkbox id="weekNumber" + crop="end" + data-l10n-id="show-weeknumber-label" + preference="calendar.view-minimonth.showWeekNumber"/> + </hbox> + <separator/> + <hbox> + <vbox> + <label data-l10n-id="workdays-label"/> + </vbox> + <vbox> + <hbox> + <checkbox id="dayoff0" + class="dayOffCheckbox" + data-l10n-id="day-1-checkbox" + orient="vertical" + preference="calendar.week.d0sundaysoff"/> + <checkbox id="dayoff1" + class="dayOffCheckbox" + data-l10n-id="day-2-checkbox" + orient="vertical" + preference="calendar.week.d1mondaysoff"/> + <checkbox id="dayoff2" + class="dayOffCheckbox" + data-l10n-id="day-3-checkbox" + orient="vertical" + preference="calendar.week.d2tuesdaysoff"/> + <checkbox id="dayoff3" + class="dayOffCheckbox" + data-l10n-id="day-4-checkbox" + orient="vertical" + preference="calendar.week.d3wednesdaysoff"/> + <checkbox id="dayoff4" + class="dayOffCheckbox" + data-l10n-id="day-5-checkbox" + orient="vertical" + preference="calendar.week.d4thursdaysoff"/> + <checkbox id="dayoff5" + class="dayOffCheckbox" + data-l10n-id="day-6-checkbox" + orient="vertical" + preference="calendar.week.d5fridaysoff"/> + <checkbox id="dayoff6" + class="dayOffCheckbox" + data-l10n-id="day-7-checkbox" + orient="vertical" + preference="calendar.week.d6saturdaysoff"/> + </hbox> + </vbox> + </hbox> + </html:fieldset> + </html:div> + + <html:div data-category="paneCalendar"> + <html:fieldset data-category="paneCalendar"> + <html:legend data-l10n-id="dayweek-legend"></html:legend> + <html:table id="dayAndWeekViewsTable"> + <html:tr> + <html:th> + <label data-l10n-id="visible-hours-label" + control="visiblehours"/> + </html:th> + <html:td> + <hbox align="center"> + <hbox> + <menulist id="visiblehours" + preference="calendar.view.visiblehours"> + <menupopup id="visiblehourspopup"> + <menuitem label="1" value="1"/> + <menuitem label="2" value="2"/> + <menuitem label="3" value="3"/> + <menuitem label="4" value="4"/> + <menuitem label="5" value="5"/> + <menuitem label="6" value="6"/> + <menuitem label="7" value="7"/> + <menuitem label="8" value="8"/> + <menuitem label="9" value="9"/> + <menuitem label="10" value="10"/> + <menuitem label="11" value="11"/> + <menuitem label="12" value="12"/> + <menuitem label="13" value="13"/> + <menuitem label="14" value="14"/> + <menuitem label="15" value="15"/> + <menuitem label="16" value="16"/> + <menuitem label="17" value="17"/> + <menuitem label="18" value="18"/> + <menuitem label="19" value="19"/> + <menuitem label="20" value="20"/> + <menuitem label="21" value="21"/> + <menuitem label="22" value="22"/> + <menuitem label="23" value="23"/> + <menuitem label="24" value="24"/> + </menupopup> + </menulist> + </hbox> + <label data-l10n-id="visible-hours-end-label"/> + </hbox> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label data-l10n-id="day-start-label" + control="daystarthour"/> + </html:th> + <html:td> + <hbox> + <menulist id="daystarthour" + oncommand="gViewsPane.updateViewEndMenu(this.value);" + preference="calendar.view.daystarthour"> + <menupopup id="daystarthourpopup"> + <menuitem id="timeStart0" value="0" data-l10n-id="midnight-label"/> + <menuitem id="timeStart1" value="1"/> + <menuitem id="timeStart2" value="2"/> + <menuitem id="timeStart3" value="3"/> + <menuitem id="timeStart4" value="4"/> + <menuitem id="timeStart5" value="5"/> + <menuitem id="timeStart6" value="6"/> + <menuitem id="timeStart7" value="7"/> + <menuitem id="timeStart8" value="8"/> + <menuitem id="timeStart9" value="9"/> + <menuitem id="timeStart10" value="10"/> + <menuitem id="timeStart11" value="11"/> + <menuitem id="timeStart12" value="12" data-l10n-id="noon-label"/> + <menuitem id="timeStart13" value="13"/> + <menuitem id="timeStart14" value="14"/> + <menuitem id="timeStart15" value="15"/> + <menuitem id="timeStart16" value="16"/> + <menuitem id="timeStart17" value="17"/> + <menuitem id="timeStart18" value="18"/> + <menuitem id="timeStart19" value="19"/> + <menuitem id="timeStart20" value="20"/> + <menuitem id="timeStart21" value="21"/> + <menuitem id="timeStart22" value="22"/> + <menuitem id="timeStart23" value="23"/> + </menupopup> + </menulist> + </hbox> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label data-l10n-id="day-end-label" + control="dayendhour"/> + </html:th> + <html:td> + <hbox> + <menulist id="dayendhour" + oncommand="gViewsPane.updateViewStartMenu(this.value);" + preference="calendar.view.dayendhour"> + <menupopup id="dayendhourpopup"> + <menuitem id="timeEnd1" value="1"/> + <menuitem id="timeEnd2" value="2"/> + <menuitem id="timeEnd3" value="3"/> + <menuitem id="timeEnd4" value="4"/> + <menuitem id="timeEnd5" value="5"/> + <menuitem id="timeEnd6" value="6"/> + <menuitem id="timeEnd7" value="7"/> + <menuitem id="timeEnd8" value="8"/> + <menuitem id="timeEnd9" value="9"/> + <menuitem id="timeEnd10" value="10"/> + <menuitem id="timeEnd11" value="11"/> + <menuitem id="timeEnd12" value="12" data-l10n-id="noon-label"/> + <menuitem id="timeEnd13" value="13"/> + <menuitem id="timeEnd14" value="14"/> + <menuitem id="timeEnd15" value="15"/> + <menuitem id="timeEnd16" value="16"/> + <menuitem id="timeEnd17" value="17"/> + <menuitem id="timeEnd18" value="18"/> + <menuitem id="timeEnd19" value="19"/> + <menuitem id="timeEnd20" value="20"/> + <menuitem id="timeEnd21" value="21"/> + <menuitem id="timeEnd22" value="22"/> + <menuitem id="timeEnd23" value="23"/> + <menuitem id="timeEnd24" value="24" data-l10n-id="midnight-label"/> + </menupopup> + </menulist> + </hbox> + </html:td> + </html:tr> + </html:table> + <checkbox id="showLocation" pack="end" + data-l10n-id="location-checkbox" + preference="calendar.view.showLocation"/> + <spacer/> + </html:fieldset> + </html:div> + + <html:div data-category="paneCalendar"> + <html:fieldset id="viewsMultiweekGroupbox" data-category="paneCalendar"> + <html:legend data-l10n-id="multiweek-legend"></html:legend> + <hbox align="center"> + <label data-l10n-id="number-of-weeks-label" + control="viewsMultiweekTotalWeeks"/> + <hbox> + <menulist id="viewsMultiweekTotalWeeks" + preference="calendar.weeks.inview"> + <menupopup> + <menuitem data-l10n-id="week-1-label" value="1"/> + <menuitem data-l10n-id="week-2-label" value="2"/> + <menuitem data-l10n-id="week-3-label" value="3"/> + <menuitem data-l10n-id="week-4-label" value="4"/> + <menuitem data-l10n-id="week-5-label" value="5"/> + <menuitem data-l10n-id="week-6-label" value="6"/> + </menupopup> + </menulist> + </hbox> + </hbox> + <hbox align="center" id="previousWeeksBox"> + <label data-l10n-id="previous-weeks-label" + control="viewsMultiweekPreviousWeeks"/> + <hbox> + <menulist id="viewsMultiweekPreviousWeeks" + preference="calendar.previousweeks.inview"> + <menupopup> + <menuitem data-l10n-id="week-0-label" value="0"/> + <menuitem data-l10n-id="week-1-label" value="1"/> + <menuitem data-l10n-id="week-2-label" value="2"/> + </menupopup> + </menulist> + </hbox> + </hbox> + </html:fieldset> + </html:div> diff --git a/comm/calendar/base/content/preferences/views.js b/comm/calendar/base/content/preferences/views.js new file mode 100644 index 0000000000..7c1a7384a1 --- /dev/null +++ b/comm/calendar/base/content/preferences/views.js @@ -0,0 +1,115 @@ +/* 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 gViewsPane */ + +/* globals Preferences */ + +Preferences.addAll([ + { id: "calendar.week.start", type: "int" }, + { id: "calendar.view-minimonth.showWeekNumber", type: "bool" }, + { id: "calendar.week.d0sundaysoff", type: "bool", inverted: "true" }, + { id: "calendar.week.d1mondaysoff", type: "bool", inverted: "true" }, + { id: "calendar.week.d2tuesdaysoff", type: "bool", inverted: "true" }, + { id: "calendar.week.d3wednesdaysoff", type: "bool", inverted: "true" }, + { id: "calendar.week.d4thursdaysoff", type: "bool", inverted: "true" }, + { id: "calendar.week.d5fridaysoff", type: "bool", inverted: "true" }, + { id: "calendar.week.d6saturdaysoff", type: "bool", inverted: "true" }, + { id: "calendar.view.daystarthour", type: "int" }, + { id: "calendar.view.dayendhour", type: "int" }, + { id: "calendar.view.visiblehours", type: "int" }, + { id: "calendar.weeks.inview", type: "int" }, + { id: "calendar.previousweeks.inview", type: "int" }, + { id: "calendar.view.showLocation", type: "bool" }, +]); + +/** + * Global Object to hold methods for the views pref pane + */ +var gViewsPane = { + /** + * Initialize the views pref pane. Sets up dialog controls to match the + * values set in prefs. + */ + init() { + this.updateViewEndMenu(Preferences.get("calendar.view.daystarthour").value); + this.updateViewStartMenu(Preferences.get("calendar.view.dayendhour").value); + this.updateViewWorkDayCheckboxes(Preferences.get("calendar.week.start").value); + this.initializeViewStartEndMenus(); + }, + + /** + * Initialize the strings for the "day starts at" and "day ends at" + * menulists. This is needed to respect locales that use AM/PM. + */ + initializeViewStartEndMenus() { + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + let formatter = cal.dtz.formatter; + let calTime = cal.createDateTime(); + calTime.minute = 0; + + // 1 to 23 instead of 0 to 24 to keep midnight & noon as the localized strings + for (let theHour = 1; theHour <= 23; theHour++) { + calTime.hour = theHour; + let time = formatter.formatTime(calTime); + + let labelIdStart = "timeStart" + theHour; + let labelIdEnd = "timeEnd" + theHour; + // This if block to keep Noon as the localized string, instead of as a number. + if (theHour != 12) { + document.getElementById(labelIdStart).setAttribute("label", time); + document.getElementById(labelIdEnd).setAttribute("label", time); + } + } + }, + + /** + * Updates the view end menu to only display hours after the selected view + * start. + * + * @param aStartValue The value selected for view start. + */ + updateViewEndMenu(aStartValue) { + let endMenuKids = document.getElementById("dayendhourpopup").children; + for (let i = 0; i < endMenuKids.length; i++) { + if (Number(endMenuKids[i].value) <= Number(aStartValue)) { + endMenuKids[i].setAttribute("hidden", true); + } else { + endMenuKids[i].removeAttribute("hidden"); + } + } + }, + + /** + * Updates the view start menu to only display hours before the selected view + * end. + * + * @param aEndValue The value selected for view end. + */ + updateViewStartMenu(aEndValue) { + let startMenuKids = document.getElementById("daystarthourpopup").children; + for (let i = 0; i < startMenuKids.length; i++) { + if (Number(startMenuKids[i].value) >= Number(aEndValue)) { + startMenuKids[i].setAttribute("hidden", true); + } else { + startMenuKids[i].removeAttribute("hidden"); + } + } + }, + + /** + * Update the workday checkboxes based on the start of the week. + * + * @Param weekStart The (0-based) index of the weekday the week + * should start at. + */ + updateViewWorkDayCheckboxes(weekStart) { + weekStart = Number(weekStart); + for (let i = weekStart; i < weekStart + 7; i++) { + let checkbox = document.getElementById("dayoff" + (i % 7)); + checkbox.parentNode.appendChild(checkbox); + } + }, +}; diff --git a/comm/calendar/base/content/printing-template.html b/comm/calendar/base/content/printing-template.html new file mode 100644 index 0000000000..f7ed5fdbca --- /dev/null +++ b/comm/calendar/base/content/printing-template.html @@ -0,0 +1,285 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta charset="utf-8" /> + <title id="title"></title> + <style> + table { + width: 100%; + border: 1px black outset; + border-spacing: 0; + page-break-inside: avoid; + display: grid; + } + + tbody, + tr, + th, + td { + display: contents; + } + + th > div, + td > div { + border: 1px black inset; + padding: 2px; + overflow: hidden; + } + + td > div { + min-height: 100px; + } + + .day-title { + text-align: end; + font-size: 13px; + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li { + margin-block-start: 2px; + padding: 2px; + font-size: 11px; + } + + #list-container .vevent { + border: 1px solid black; + padding: 0; + margin-bottom: 10px; + } + + #list-container .key { + font-style: italic; + margin-inline-start: 3px; + } + + #list-container .value { + margin-inline-start: 20px; + } + + #list-container .summarykey { + display: none; + } + + #list-container .summary { + font-weight: bold; + margin: 0; + padding: 3px; + } + + #list-container .description { + white-space: pre-wrap; + } + + #month-container table { + grid-template-columns: repeat(7, 1fr); + } + + #month-container .month-title { + grid-column: 1 / 8; + } + + #week-container table { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: min-content 2fr 2fr 1fr 1fr; + } + + #week-container .week-title { + grid-column: 1 / 3; + } + + #week-container .monday-box > div { + grid-column: 1; + grid-row: 2; + } + + #week-container .tuesday-box > div { + grid-column: 1; + grid-row: 3; + } + + #week-container .wednesday-box > div { + grid-column: 1; + grid-row: 4 / 6; + } + </style> + </head> + <body> + <!-- This is what is printed when printing the calendar. + It is filled dynamically by calPrintUtils.jsm. --> + <div id="list-container"></div> + <div id="month-container"></div> + <div id="week-container"></div> + <div id="tasks-list-box" hidden="true"> + <h3 id="tasks-title"></h3> + <ul id="task-container" class="taskList"></ul> + </div> + + <!-- List item template for the "list" layout. --> + <template id="list-item-template"> + <div class="vevent"> + <div class="row summaryrow"> + <div class="key summarykey"></div> + <div class="value summary"></div> + </div> + <div class="row intervalrow"> + <div class="key intervalkey"></div> + <div class="value dtstart"></div> + </div> + <div class="row locationrow"> + <div class="key locationkey"></div> + <div class="value location"></div> + </div> + <div class="row descriptionrow"> + <div class="key descriptionkey"></div> + <div class="value description"></div> + </div> + </div> + </template> + + <!-- Month template for the "monthly grid" layout. --> + <template id="month-template"> + <table> + <tr> + <th><div class="month-title"></div></th> + </tr> + <tr> + <th><div></div></th> + <th><div></div></th> + <th><div></div></th> + <th><div></div></th> + <th><div></div></th> + <th><div></div></th> + <th><div></div></th> + </tr> + </table> + </template> + + <!-- Week template for the "monthly grid" layout. --> + <template id="month-week-template"> + <tr> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + </tr> + </template> + + <!-- Week template for the "weekly planner" layout. --> + <template id="week-template"> + <table> + <tr> + <th> + <div class="week-title"></div> + </th> + </tr> + <tr> + <td class="monday-box"> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td class="tuesday-box"> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td class="wednesday-box"> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td class="thursday-box"> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td class="friday-box"> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td class="saturday-box"> + <div> + <div class="day-title"></div> + <ul class="items"></ul> + </div> + </td> + <td class="sunday-box"> + <div> + <div class="day-title sunday-title"></div> + <ul class="items"></ul> + </div> + </td> + </tr> + </table> + </template> + + <!-- List item template for the "monthly grid" and "weekly planner" layouts. --> + <template id="item-template"> + <li class="category-color-box calendar-color-box"> + <span class="item-interval"></span> + <span class="item-title"></span> + </li> + </template> + + <!-- Template for tasks with no due date. --> + <template id="task-template"> + <li> + <input type="checkbox" class="task-checkbox" disabled="disabled" /> + <span class="task-title"></span> + </li> + </template> + </body> +</html> diff --git a/comm/calendar/base/content/publish.js b/comm/calendar/base/content/publish.js new file mode 100644 index 0000000000..cad9123843 --- /dev/null +++ b/comm/calendar/base/content/publish.js @@ -0,0 +1,239 @@ +/* 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 publishCalendarData, publishCalendarDataDialogResponse, + * publishEntireCalendar, publishEntireCalendarDialogResponse + */ + +/* import-globals-from ../../base/content/calendar-views-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Show publish dialog, ask for URL and publish all selected items. + */ +function publishCalendarData() { + let args = {}; + + args.onOk = self.publishCalendarDataDialogResponse; + + openDialog( + "chrome://calendar/content/publishDialog.xhtml", + "caPublishEvents", + "chrome,titlebar,modal,resizable", + args + ); +} + +/** + * Callback method for publishCalendarData() that is called when the user + * presses the OK button in the publish dialog. + */ +function publishCalendarDataDialogResponse(CalendarPublishObject, aProgressDialog) { + publishItemArray( + currentView().getSelectedItems(), + CalendarPublishObject.remotePath, + aProgressDialog + ); +} + +/** + * Show publish dialog, ask for URL and publish all items from the calendar. + * + * @param {?calICalendar} aCalendar - The calendar that will be published. + * If not specified, the user will be prompted to select a calendar. + */ +function publishEntireCalendar(aCalendar) { + if (!aCalendar) { + let calendars = cal.manager.getCalendars(); + + if (calendars.length == 1) { + // Do not ask user for calendar if only one calendar exists + aCalendar = calendars[0]; + } else { + // Ask user to select the calendar that should be published. + // publishEntireCalendar() will be called again if OK is pressed + // in the dialog and the selected calendar will be passed in. + // Therefore return after openDialog(). + let args = {}; + args.onOk = publishEntireCalendar; + args.promptText = cal.l10n.getCalString("publishPrompt"); + openDialog( + "chrome://calendar/content/chooseCalendarDialog.xhtml", + "_blank", + "chrome,titlebar,modal,resizable", + args + ); + return; + } + } + + let args = {}; + let publishObject = {}; + + args.onOk = self.publishEntireCalendarDialogResponse; + + publishObject.calendar = aCalendar; + + // restore the remote ics path preference from the calendar passed in + let remotePath = aCalendar.getProperty("remote-ics-path"); + if (remotePath) { + publishObject.remotePath = remotePath; + } + + args.publishObject = publishObject; + openDialog( + "chrome://calendar/content/publishDialog.xhtml", + "caPublishEvents", + "chrome,titlebar,modal,resizable", + args + ); +} + +/** + * Callback method for publishEntireCalendar() that is called when the user + * presses the OK button in the publish dialog. + */ +async function publishEntireCalendarDialogResponse(CalendarPublishObject, aProgressDialog) { + // store the selected remote ics path as a calendar preference + CalendarPublishObject.calendar.setProperty("remote-ics-path", CalendarPublishObject.remotePath); + + aProgressDialog.onStartUpload(); + let oldCalendar = CalendarPublishObject.calendar; + let items = await oldCalendar.getItemsAsArray( + Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, + null, + null + ); + publishItemArray(items, CalendarPublishObject.remotePath, aProgressDialog); +} + +function publishItemArray(aItemArray, aPath, aProgressDialog) { + let outputStream; + let inputStream; + let storageStream; + + let icsURL = Services.io.newURI(aPath); + + let channel = Services.io.newChannelFromURI( + icsURL, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + if (icsURL.schemeIs("webcal")) { + icsURL.scheme = "http"; + } + if (icsURL.schemeIs("webcals")) { + icsURL.scheme = "https"; + } + + switch (icsURL.scheme) { + case "http": + case "https": + channel = channel.QueryInterface(Ci.nsIHttpChannel); + break; + case "file": + channel = channel.QueryInterface(Ci.nsIFileChannel); + break; + default: + dump("No such scheme\n"); + return; + } + + let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel); + uploadChannel.notificationCallbacks = notificationCallbacks; + + storageStream = Cc["@mozilla.org/storagestream;1"].createInstance(Ci.nsIStorageStream); + storageStream.init(32768, 0xffffffff, null); + outputStream = storageStream.getOutputStream(0); + + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems(aItemArray); + // Outlook requires METHOD:PUBLISH property: + let methodProp = cal.icsService.createIcalProperty("METHOD"); + methodProp.value = "PUBLISH"; + serializer.addProperty(methodProp); + serializer.serializeToStream(outputStream); + outputStream.close(); + + inputStream = storageStream.newInputStream(0); + + uploadChannel.setUploadStream(inputStream, "text/calendar", -1); + try { + channel.asyncOpen(new PublishingListener(aProgressDialog)); + } catch (e) { + Services.prompt.alert( + null, + cal.l10n.getCalString("genericErrorTitle"), + cal.l10n.getCalString("otherPutError", [e.message]) + ); + } +} + +/** @implements {nsIInterfaceRequestor} */ +var notificationCallbacks = { + getInterface(iid, instance) { + if (iid.equals(Ci.nsIAuthPrompt2)) { + if (!this.calAuthPrompt) { + return new cal.auth.Prompt(); + } + } + if (iid.equals(Ci.nsIAuthPrompt)) { + // use the window watcher service to get a nsIAuthPrompt impl + return Services.ww.getNewAuthPrompter(null); + } + + throw Components.Exception(`${iid} not implemented`, Cr.NS_ERROR_NO_INTERFACE); + }, +}; + +/** + * Listener object to pass to `channel.asyncOpen()`. A reference to the current dialog window + * passed to the constructor provides access to the dialog once the request is done. + * + * @implements {nsIStreamListener} + */ +class PublishingListener { + QueryInterface = ChromeUtils.generateQI(["nsIStreamListener"]); + + constructor(progressDialog) { + this.progressDialog = progressDialog; + } + + onStartRequest(request) {} + onStopRequest(request, status) { + let channel; + let requestSucceeded; + try { + channel = request.QueryInterface(Ci.nsIHttpChannel); + requestSucceeded = channel.requestSucceeded; + } catch (e) { + // Don't fail if it is not an http channel, will be handled below. + } + + if (channel && !requestSucceeded) { + this.progressDialog.wrappedJSObject.onStopUpload(0); + let body = cal.l10n.getCalString("httpPutError", [ + channel.responseStatus, + channel.responseStatusText, + ]); + Services.prompt.alert(null, cal.l10n.getCalString("genericErrorTitle"), body); + } else if (!channel && !Components.isSuccessCode(request.status)) { + this.progressDialog.wrappedJSObject.onStopUpload(0); + // XXX this should be made human-readable. + let body = cal.l10n.getCalString("otherPutError", [request.status.toString(16)]); + Services.prompt.alert(null, cal.l10n.getCalString("genericErrorTitle"), body); + } else { + this.progressDialog.wrappedJSObject.onStopUpload(100); + } + } + + onDataAvailable(request, inStream, sourceOffset, count) {} +} diff --git a/comm/calendar/base/content/sound.wav b/comm/calendar/base/content/sound.wav Binary files differnew file mode 100644 index 0000000000..1bd5683f8c --- /dev/null +++ b/comm/calendar/base/content/sound.wav diff --git a/comm/calendar/base/content/today-pane-agenda.js b/comm/calendar/base/content/today-pane-agenda.js new file mode 100644 index 0000000000..7eb74a574e --- /dev/null +++ b/comm/calendar/base/content/today-pane-agenda.js @@ -0,0 +1,668 @@ +/* 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 CalendarFilteredViewMixin, calendarCalendarButtonDNDObserver, setupAttendanceMenu, + openEventDialogForViewing, modifyEventWithDialog, calendarViewController, showToolTip, + TodayPane */ + +{ + const { CalMetronome } = ChromeUtils.import("resource:///modules/CalMetronome.jsm"); + const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + class Agenda extends CalendarFilteredViewMixin(customElements.get("tree-listbox")) { + _showsToday = false; + + constructor() { + super(); + + this.addEventListener("contextmenu", event => this._showContextMenu(event)); + this.addEventListener("keypress", event => { + if (this.selectedIndex < 0) { + return; + } + + switch (event.key) { + case "Enter": + this.editSelectedItem(); + break; + case "Delete": + case "Backspace": + // Fall through to "Backspace" to avoid deleting messages if the + // preferred deletion button is not "Delete". + this.deleteSelectedItem(); + event.stopPropagation(); + event.preventDefault(); + break; + } + }); + this.addEventListener("dragover", event => + calendarCalendarButtonDNDObserver.onDragOver(event) + ); + this.addEventListener("drop", event => calendarCalendarButtonDNDObserver.onDrop(event)); + document + .getElementById("itemTooltip") + .addEventListener("popupshowing", event => this._fillTooltip(event)); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "numberOfDays", + "calendar.agenda.days", + 14, + () => this.update(this.startDate), + value => { + // Invalid values, return the default. + if (value < 1 || value > 28) { + return 14; + } + return value; + } + ); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + super.connectedCallback(); + + let metronomeCallback = () => { + if (!this.showsToday) { + return; + } + + for (let item of this.children) { + item.setRelativeTime(); + } + }; + CalMetronome.on("minute", metronomeCallback); + window.addEventListener("unload", () => CalMetronome.off("minute", metronomeCallback)); + } + + /** + * Implementation as required by CalendarFilteredViewMixin. + */ + clearItems() { + while (this.lastChild) { + this.lastChild.remove(); + } + } + + /** + * Implementation as required by CalendarFilteredViewMixin. + * + * @param {calIItemBase[]} items + */ + addItems(items) { + for (let item of items) { + if (document.getElementById(`agenda-listitem-${item.hashId}`)) { + // Item already added. + continue; + } + + let startItem = document.createElement("li", { is: "agenda-listitem" }); + startItem.item = item; + this.insertListItem(startItem); + + // Try to maintain selection across item edits. + if (this._lastRemovedID == startItem.id) { + setTimeout(() => (this.selectedIndex = this.rows.indexOf(startItem))); + } + } + } + + /** + * Implementation as required by CalendarFilteredViewMixin. + * + * @param {calIItemBase[]} items + */ + removeItems(items) { + for (let item of items) { + let startItem = document.getElementById(`agenda-listitem-${item.hashId}`); + if (!startItem) { + // Item not found. + continue; + } + + this.removeListItem(startItem); + this._lastRemovedID = startItem.id; + } + } + + /** + * Implementation as required by CalendarFilteredViewMixin. + * + * @param {string} calendarId + */ + removeItemsFromCalendar(calendarId) { + for (let li of [...this.children]) { + if (li.item.calendar.id == calendarId) { + if (li.displayDateHeader && li.nextElementSibling?.dateString == li.dateString) { + li.nextElementSibling.displayDateHeader = true; + } + li.remove(); + } + } + } + + /** + * Set the date displayed in the agenda. If the date is today, display the + * full agenda, otherwise display just the given date. + * + * @param {calIDateTime} date + */ + async update(date) { + let today = cal.dtz.now(); + + this.startDate = date.clone(); + this.startDate.isDate = true; + + this.endDate = this.startDate.clone(); + this._showsToday = + date.year == today.year && date.month == today.month && date.day == today.day; + if (this._showsToday) { + this.endDate.day += this.numberOfDays; + } else { + this.endDate.day++; + } + + this.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + if (this.isActive) { + await this.refreshItems(); + } else { + await this.activate(); + } + this.selectedIndex = 0; + } + + /** + * If the agenda is showing today (true), or any other day (false). + * + * @type {boolean} + */ + get showsToday() { + return this._showsToday; + } + + /** + * Insert the given list item at the appropriate point in the list, and + * shows or hides date headers as appropriate. Use this method rather than + * DOM methods. + * + * @param {AgendaListItem} listItem + */ + insertListItem(listItem) { + cal.data.binaryInsertNode(this, listItem, listItem, this._compareListItems, false, n => n); + + if (listItem.previousElementSibling?.dateString == listItem.dateString) { + listItem.displayDateHeader = false; + } else if (listItem.nextElementSibling?.dateString == listItem.dateString) { + listItem.nextElementSibling.displayDateHeader = false; + } + } + + /** + * Remove the given list item from the list, and shows date headers as + * appropriate. Use this method rather than DOM methods. + * + * @param {AgendaListItem} listItem + */ + removeListItem(listItem) { + if ( + listItem.displayDateHeader && + listItem.nextElementSibling?.dateString == listItem.dateString + ) { + listItem.nextElementSibling.displayDateHeader = true; + } + listItem.remove(); + } + + /** + * Compare two list items for insertion order, using the `sortValue` + * property on each item, deferring to `compareItems` if the same. + * + * @param {AgendaListItem} a + * @param {AgendaListItem} b + * @returns {number} + */ + _compareListItems(a, b) { + let cmp = a.sortValue - b.sortValue; + if (cmp != 0) { + return cmp; + } + + return cal.view.compareItems(a.item, b.item); + } + + /** + * Returns the calendar item of the selected row. + * + * @returns {calIEvent} + */ + get selectedItem() { + return this.getRowAtIndex(this.selectedIndex)?.item; + } + + /** + * Shows the context menu. + * + * @param {MouseEvent} event + */ + _showContextMenu(event) { + let row = event.target.closest("li"); + if (!row) { + return; + } + this.selectedIndex = this.rows.indexOf(row); + + let popup = document.getElementById("agenda-menupopup"); + let menu = document.getElementById("calendar-today-pane-menu-attendance-menu"); + setupAttendanceMenu(menu, [this.selectedItem]); + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } + + /** + * Opens the UI for editing the selected event. + */ + editSelectedItem() { + if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) { + modifyEventWithDialog(this.selectedItem, true); + return; + } + openEventDialogForViewing(this.selectedItem); + } + + /** + * Deletes the selected event. + */ + deleteSelectedItem() { + calendarViewController.deleteOccurrences([this.selectedItem], false, false); + } + + /** + * Called in the 'popupshowing' event of #itemTooltip. + * + * @param {Event} event + */ + _fillTooltip(event) { + let element = document.elementFromPoint(event.clientX, event.clientY); + if (!this.contains(element)) { + // Not on the agenda, ignore. + return; + } + + if (!element.closest(".agenda-listitem-details")) { + // Not on an agenda item, cancel. + event.preventDefault(); + return; + } + + showToolTip(event.target, element.closest(".agenda-listitem").item); + } + } + customElements.define("agenda-list", Agenda, { extends: "ul" }); + + class AgendaListItem extends HTMLLIElement { + /** + * If this element represents an event that starts before the displayed day(s). + * + * @type {boolean} + */ + overlapsDisplayStart = false; + + /** + * If this element represents an event on a day that is not the event's first day. + * + * @type {boolean} + */ + overlapsDayStart = false; + + /** + * If this element represents an event on a day that is not the event's last day. + * + * @type {boolean} + */ + overlapsDayEnd = false; + + /** + * If this element represents an event that ends after the displayed day(s). + * + * @type {boolean} + */ + overlapsDisplayEnd = false; + + constructor() { + super(); + this.setAttribute("is", "agenda-listitem"); + this.classList.add("agenda-listitem"); + + let template = document.getElementById("agenda-listitem"); + for (let element of template.content.children) { + this.appendChild(element.cloneNode(true)); + } + + this.dateHeaderElement = this.querySelector(".agenda-date-header"); + this.detailsElement = this.querySelector(".agenda-listitem-details"); + this.calendarElement = this.querySelector(".agenda-listitem-calendar"); + this.timeElement = this.querySelector(".agenda-listitem-time"); + this.titleElement = this.querySelector(".agenda-listitem-title"); + this.relativeElement = this.querySelector(".agenda-listitem-relative"); + this.overlapElement = this.querySelector(".agenda-listitem-overlap"); + + this.detailsElement.addEventListener("dblclick", () => { + if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) { + modifyEventWithDialog(this.item, true); + return; + } + openEventDialogForViewing(this.item); + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + if (!this.overlapsDayEnd || this.overlapsDisplayEnd) { + return; + } + + // Where the start and end of an event are on different days, both within + // the date range of the agenda, a second item is added representing the + // end of the event. It's owned by this item (representing the start of + // the event), and if this item is removed, it is too. + this._endItem = document.createElement("li", { is: "agenda-listitem" }); + this._endItem.classList.add("agenda-listitem-end"); + this._endItem.item = this.item; + TodayPane.agenda.insertListItem(this._endItem); + } + + disconnectedCallback() { + // When this item is removed, remove the item representing the end of + // the event, if there is one. + if (this._endItem) { + TodayPane.agenda.removeListItem(this._endItem); + delete this._endItem; + } + } + + /** + * The date for this event, in ISO format (YYYYMMDD). This corresponds + * to the date header shown for this event, so only the first event on + * each day needs to show a header. + * + * @type string + */ + get dateString() { + return this._dateString; + } + + set dateString(value) { + this._dateString = value.substring(0, 8); + + let date = cal.createDateTime(value); + let today = cal.dtz.now(); + let tomorrow = cal.dtz.now(); + tomorrow.day++; + + if (date.year == today.year && date.month == today.month && date.day == today.day) { + this.dateHeaderElement.textContent = cal.l10n.getCalString("today"); + } else if ( + date.year == tomorrow.year && + date.month == tomorrow.month && + date.day == tomorrow.day + ) { + this.dateHeaderElement.textContent = cal.l10n.getCalString("tomorrow"); + } else { + this.dateHeaderElement.textContent = cal.dtz.formatter.formatDateLongWithoutYear(date); + } + } + + /** + * Whether or not to show the date header on this list item. If the item + * is preceded by an item with the same `dateString` value, no header + * should be shown. + * + * @type {boolean} + */ + get displayDateHeader() { + return !this.dateHeaderElement.hidden; + } + + set displayDateHeader(value) { + this.dateHeaderElement.hidden = !value; + } + + /** + * The calendar item for this list item. + * + * @type {calIEvent} + */ + get item() { + return this._item; + } + + set item(item) { + this._item = item; + + let isAllDay = item.startDate.isDate; + this.classList.toggle("agenda-listitem-all-day", isAllDay); + + let defaultTimezone = cal.dtz.defaultTimezone; + this._localStartDate = item.startDate; + if (this._localStartDate.timezone.tzid != defaultTimezone.tzid) { + this._localStartDate = this._localStartDate.getInTimezone(defaultTimezone); + } + this._localEndDate = item.endDate; + if (this._localEndDate.timezone.tzid != defaultTimezone.tzid) { + this._localEndDate = this._localEndDate.getInTimezone(defaultTimezone); + } + this.overlapsDisplayStart = this._localStartDate.compare(TodayPane.agenda.startDate) < 0; + + // Work out the date and time to use when sorting events, and the date header. + + if (this.classList.contains("agenda-listitem-end")) { + this.id = `agenda-listitem-end-${item.hashId}`; + this.overlapsDayStart = true; + + let sortDate = this._localEndDate.clone(); + if (isAllDay) { + // Sort all-day events at midnight on the previous day. + sortDate.day--; + this.sortValue = sortDate.getInTimezone(defaultTimezone).nativeTime; + } else { + // Sort at the end time of the event. + this.sortValue = this._localEndDate.nativeTime; + + // If the event ends at midnight, remove a microsecond so that + // it is placed at the end of the previous day's events. + if (sortDate.hour == 0 && sortDate.minute == 0 && sortDate.second == 0) { + sortDate.day--; + this.sortValue--; + } + } + this.dateString = sortDate.icalString; + } else { + this.id = `agenda-listitem-${item.hashId}`; + this.overlapsDayStart = this.overlapsDisplayStart; + + let sortDate; + if (this.overlapsDayStart) { + // Use midnight for sorting. + sortDate = cal.createDateTime(); + sortDate.resetTo( + TodayPane.agenda.startDate.year, + TodayPane.agenda.startDate.month, + TodayPane.agenda.startDate.day, + 0, + 0, + 0, + defaultTimezone + ); + } else { + // Use the real start time for sorting. + sortDate = this._localStartDate.clone(); + } + this.dateString = sortDate.icalString; + + let nextDay = cal.createDateTime(); + nextDay.resetTo(sortDate.year, sortDate.month, sortDate.day + 1, 0, 0, 0, defaultTimezone); + this.overlapsDayEnd = this._localEndDate.compare(nextDay) > 0; + this.overlapsDisplayEnd = + this.overlapsDayEnd && this._localEndDate.compare(TodayPane.agenda.endDate) >= 0; + + if (isAllDay || !this.overlapsDayStart || this.overlapsDayEnd) { + // Sort using the start of the event. + this.sortValue = sortDate.nativeTime; + } else { + // Sort using the end of the event. + this.sortValue = this._localEndDate.nativeTime; + + // If the event ends at midnight, remove a microsecond so that + // it is placed at the end of the previous day's events. + if ( + this._localEndDate.hour == 0 && + this._localEndDate.minute == 0 && + this._localEndDate.second == 0 + ) { + this.sortValue--; + } + } + } + + // Set the element's colours. + + let cssSafeCalendar = cal.view.formatStringForCSSRule(this.item.calendar.id); + this.style.setProperty("--item-backcolor", `var(--calendar-${cssSafeCalendar}-backcolor)`); + this.style.setProperty("--item-forecolor", `var(--calendar-${cssSafeCalendar}-forecolor)`); + + // Set the time label if necessary. + + this.timeElement.removeAttribute("datetime"); + this.timeElement.textContent = ""; + if (!isAllDay) { + if (!this.overlapsDayStart) { + this.timeElement.setAttribute("datetime", cal.dtz.toRFC3339(this.item.startDate)); + this.timeElement.textContent = cal.dtz.formatter.formatTime(this._localStartDate); + } else if (!this.overlapsDayEnd) { + this.timeElement.setAttribute("datetime", cal.dtz.toRFC3339(this.item.endDate)); + this.timeElement.textContent = cal.dtz.formatter.formatTime( + this._localEndDate, + // We prefer to show midnight as 24:00 if possible to indicate + // that the event ends at the end of this day, rather than the + // start of the next day. + true + ); + } + this.setRelativeTime(); + } + + // Set the title. + + this.titleElement.textContent = this.item.title; + + // Display icons indicating if this event starts or ends on another day. + + if (this.overlapsDayStart) { + if (this.overlapsDayEnd) { + this.overlapElement.src = "chrome://messenger/skin/icons/new/event-continue.svg"; + document.l10n.setAttributes( + this.overlapElement, + "calendar-editable-item-multiday-event-icon-continue" + ); + } else { + this.overlapElement.src = "chrome://messenger/skin/icons/new/event-end.svg"; + document.l10n.setAttributes( + this.overlapElement, + "calendar-editable-item-multiday-event-icon-end" + ); + } + } else if (this.overlapsDayEnd) { + this.overlapElement.src = "chrome://messenger/skin/icons/new/event-start.svg"; + document.l10n.setAttributes( + this.overlapElement, + "calendar-editable-item-multiday-event-icon-start" + ); + } else { + this.overlapElement.removeAttribute("src"); + this.overlapElement.removeAttribute("data-l10n-id"); + this.overlapElement.removeAttribute("alt"); + } + + // Set the invitation status. + + if (cal.itip.isInvitation(item)) { + this.setAttribute("status", cal.itip.getInvitedAttendee(item).participationStatus); + } + } + + /** + * Sets class names and a label depending on when the event occurs + * relative to the current time. + * + * If the event happened today but has finished, sets the class + * `agenda-listitem-past`, or if it is happening now, sets + * `agenda-listitem-now`. + * + * For events that are today or within the next 12 hours (i.e. early + * tomorrow) a label is displayed stating the when the start time is, e.g. + * "1 hr ago", "now", "in 23 min". + */ + setRelativeTime() { + // These conditions won't change in the lifetime of an AgendaListItem, + // so let's avoid any further work and return immediately. + if ( + !TodayPane.agenda.showsToday || + this.item.startDate.isDate || + this.classList.contains("agenda-listitem-end") + ) { + return; + } + + this.classList.remove("agenda-listitem-past"); + this.classList.remove("agenda-listitem-now"); + this.relativeElement.textContent = ""; + + let now = cal.dtz.now(); + + // The event has started. + if (this._localStartDate.compare(now) <= 0) { + // The event is happening now. + if (this._localEndDate.compare(now) <= 0) { + this.classList.add("agenda-listitem-past"); + } else { + this.classList.add("agenda-listitem-now"); + this.relativeElement.textContent = AgendaListItem.relativeFormatter.format(0, "second"); + } + return; + } + + let relative = this._localStartDate.subtractDate(now); + + // Should we display a label? Is the event today or less than 12 hours away? + if (this._localStartDate.day == now.day || relative.inSeconds < 12 * 60 * 60) { + let unit = "hour"; + let value = relative.hours; + if (relative.inSeconds <= 5400) { + // 90 minutes. + unit = "minute"; + value = value * 60 + relative.minutes; + if (relative.seconds >= 30) { + value++; + } + } else if (relative.minutes >= 30) { + value++; + } + this.relativeElement.textContent = AgendaListItem.relativeFormatter.format(value, unit); + } + } + } + XPCOMUtils.defineLazyGetter( + AgendaListItem, + "relativeFormatter", + () => new Intl.RelativeTimeFormat(undefined, { numeric: "auto", style: "short" }) + ); + customElements.define("agenda-listitem", AgendaListItem, { extends: "li" }); +} diff --git a/comm/calendar/base/content/today-pane.js b/comm/calendar/base/content/today-pane.js new file mode 100644 index 0000000000..ac615b8ebe --- /dev/null +++ b/comm/calendar/base/content/today-pane.js @@ -0,0 +1,535 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from calendar-modes.js */ +/* import-globals-from calendar-tabs.js */ +/* import-globals-from calendar-views-utils.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Namespace object to hold functions related to the today pane. + */ +var TodayPane = { + isLoaded: false, + paneViews: null, + start: null, + cwlabel: null, + previousMode: null, + switchCounter: 0, + minidayTimer: null, + minidayDrag: { + startX: 0, + startY: 0, + distance: 0, + session: false, + }, + + /** + * Load Handler, sets up the today pane controls. + */ + async onLoad() { + this.isLoaded = true; + + TodayPane.paneViews = [ + cal.l10n.getCalString("eventsandtasks"), + cal.l10n.getCalString("tasksonly"), + cal.l10n.getCalString("eventsonly"), + ]; + + this.agenda = document.getElementById("agenda"); + + TodayPane.updateDisplay(); + TodayPane.updateSplitterState(); + TodayPane.previousMode = gCurrentMode; + TodayPane.showTodayPaneStatusLabel(); + + document.getElementById("today-splitter").addEventListener("command", () => { + window.dispatchEvent(new CustomEvent("viewresize")); + }); + + Services.obs.addObserver(TodayPane, "defaultTimezoneChanged"); + }, + + /** + * Unload handler, cleans up the today pane on window unload. + */ + onUnload() { + Services.obs.removeObserver(TodayPane, "defaultTimezoneChanged"); + }, + + /** + * React if the default timezone changes. + */ + observe() { + if (this.start !== null) { + this.setDay(this.start.getInTimezone(cal.dtz.defaultTimezone)); + } + }, + + /** + * Sets up the label for the switcher that allows switching between today pane + * views. (event+task, task only, event only) + */ + updateDisplay() { + if (!this.isLoaded) { + return; + } + let agendaIsVisible = document.getElementById("agenda-panel").isVisible(gCurrentMode); + let todoIsVisible = document.getElementById("todo-tab-panel").isVisible(gCurrentMode); + let index = 2; + if (agendaIsVisible && todoIsVisible) { + index = 0; + } else if (!agendaIsVisible && todoIsVisible) { + index = 1; + } else if (agendaIsVisible && !todoIsVisible) { + index = 2; + } else { + // agendaIsVisible == false && todoIsVisible == false: + // In this case something must have gone wrong + // - probably in the previous session - and no pane is displayed. + // We set a default by only displaying agenda-pane. + agendaIsVisible = true; + document.getElementById("agenda-panel").setVisible(agendaIsVisible); + index = 2; + } + let todayHeader = document.getElementById("today-pane-header"); + todayHeader.setAttribute("index", index); + todayHeader.setAttribute("value", this.paneViews[index]); + let todayPaneSplitter = document.getElementById("today-pane-splitter"); + todayPaneSplitter.hidden = index != 0; + let todayIsVisible = document.getElementById("today-pane-panel").isVisible(); + + // Disable or enable the today pane menuitems that have an attribute + // name="minidisplay" depending on the visibility of elements. + let menupopup = document.getElementById("calTodayPaneMenuPopup"); + if (menupopup) { + for (let child of menupopup.children) { + if (child.getAttribute("name") == "minidisplay") { + child.disabled = !todayIsVisible || !agendaIsVisible; + } + } + } + + if (todayIsVisible) { + if (agendaIsVisible) { + if (this.start === null) { + this.setDay(cal.dtz.now()); + } + if (document.getElementById("today-minimonth-box").isVisible()) { + document.getElementById("today-minimonth").setAttribute("freebusy", "true"); + } + } + if (todoIsVisible) { + // Add listener to update the date filters. + getViewBox().addEventListener("dayselect", event => { + this.updateCalendarToDoUnifinder(); + }); + this.updateCalendarToDoUnifinder(); + } + } + + window.dispatchEvent(new CustomEvent("viewresize")); + }, + + /** + * Updates the applied filter and show completed view of the unifinder todo. + * + * @param {string} [filter] - The filter name to set. + */ + updateCalendarToDoUnifinder(filter) { + let tree = document.getElementById("unifinder-todo-tree"); + if (!tree.hasBeenVisible) { + tree.hasBeenVisible = true; + tree.refresh(); + } + + // Set up hiding completed tasks for the unifinder-todo tree + filter = filter || tree.getAttribute("filterValue") || "throughcurrent"; + tree.setAttribute("filterValue", filter); + + document + .querySelectorAll("#task-context-menu-filter-todaypane-popup > menuitem") + .forEach(item => { + if (item.getAttribute("value") == filter) { + item.setAttribute("checked", "true"); + } else { + item.removeAttribute("checked"); + } + }); + + let showCompleted = document.getElementById("show-completed-checkbox").checked; + if (!showCompleted) { + let filterProps = tree.mFilter.getDefinedFilterProperties(filter); + if (filterProps) { + filterProps.status = + (filterProps.status || filterProps.FILTER_STATUS_ALL) & + (filterProps.FILTER_STATUS_INCOMPLETE | filterProps.FILTER_STATUS_IN_PROGRESS); + filter = filterProps; + } + } + + // update the filter + tree.showCompleted = showCompleted; + tree.updateFilter(filter); + }, + + /** + * Go to month/week/day views when double-clicking a label inside miniday + */ + onDoubleClick(aEvent) { + if (aEvent.button == 0) { + if (aEvent.target.id == "datevalue-label") { + switchCalendarView("day", true); + } else if (aEvent.target.id == "weekdayNameLabel") { + switchCalendarView("day", true); + } else if (aEvent.target.id == "currentWeek-label") { + switchCalendarView("week", true); + } else if (aEvent.target.parentNode.id == "monthNameContainer") { + switchCalendarView("month", true); + } else { + return; + } + document.getElementById("tabmail").openTab("calendar"); + } + }, + + /** + * Set conditions about start dragging on day-label or start switching + * with time on navigation buttons. + */ + onMousedown(aEvent, aDir) { + if (aEvent.button != 0) { + return; + } + let element = aEvent.target; + if (element.id == "previous-day-button" || element.id == "next-day-button") { + // Start switching days by pressing, without release, the navigation buttons + element.addEventListener("mouseout", TodayPane.stopSwitching); + element.addEventListener("mouseup", TodayPane.stopSwitching); + TodayPane.minidayTimer = setTimeout( + TodayPane.updateAdvanceTimer.bind(TodayPane, Event, aDir), + 500 + ); + } else if (element.id == "datevalue-label") { + // Start switching days by dragging the mouse with a starting point on the day label + window.addEventListener("mousemove", TodayPane.onMousemove); + window.addEventListener("mouseup", TodayPane.stopSwitching); + TodayPane.minidayDrag.startX = aEvent.clientX; + TodayPane.minidayDrag.startY = aEvent.clientY; + } + }, + + /** + * Figure out the mouse distance from the center of the day's label + * to the current position. + * + * NOTE: This function is usually called without the correct this pointer. + */ + onMousemove(aEvent) { + const MIN_DRAG_DISTANCE_SQ = 49; + let x = aEvent.clientX - TodayPane.minidayDrag.startX; + let y = aEvent.clientY - TodayPane.minidayDrag.startY; + if (TodayPane.minidayDrag.session) { + if (x * x + y * y >= MIN_DRAG_DISTANCE_SQ) { + let distance = Math.floor(Math.sqrt(x * x + y * y) - Math.sqrt(MIN_DRAG_DISTANCE_SQ)); + // Dragging on the left/right side, the day date decrease/increase + TodayPane.minidayDrag.distance = x > 0 ? distance : -distance; + } else { + TodayPane.minidayDrag.distance = 0; + } + } else if (x * x + y * y > 9) { + // move the mouse a bit before starting the drag session + window.addEventListener("mouseout", TodayPane.stopSwitching); + TodayPane.minidayDrag.session = true; + let dragCenterImage = document.getElementById("dragCenter-image"); + dragCenterImage.removeAttribute("hidden"); + // Move the starting point in the center so we have a fixed + // point where stopping the day switching while still dragging + let centerObj = dragCenterImage.getBoundingClientRect(); + TodayPane.minidayDrag.startX = Math.floor(centerObj.x + centerObj.width / 2); + TodayPane.minidayDrag.startY = Math.floor(centerObj.y + centerObj.height / 2); + + TodayPane.updateAdvanceTimer(); + } + }, + + /** + * Figure out the days switching speed according to the position (when + * dragging) or time elapsed (when pressing buttons). + */ + updateAdvanceTimer(aEvent, aDir) { + const INITIAL_TIME = 400; + const REL_DISTANCE = 8; + const MINIMUM_TIME = 100; + const ACCELERATE_COUNT_LIMIT = 7; + const SECOND_STEP_TIME = 200; + if (TodayPane.minidayDrag.session) { + // Dragging the day label: days switch with cursor distance and time. + let dir = (TodayPane.minidayDrag.distance > 0) - (TodayPane.minidayDrag.distance < 0); + TodayPane.advance(dir); + let distance = Math.abs(TodayPane.minidayDrag.distance); + // Linear relation between distance and switching speed + let timeInterval = Math.max(Math.ceil(INITIAL_TIME - distance * REL_DISTANCE), MINIMUM_TIME); + TodayPane.minidayTimer = setTimeout( + TodayPane.updateAdvanceTimer.bind(TodayPane, null, null), + timeInterval + ); + } else { + // Keeping pressed next/previous day buttons causes days switching (with + // three levels higher speed after some commutations). + TodayPane.advance(parseInt(aDir, 10)); + TodayPane.switchCounter++; + let timeInterval = INITIAL_TIME; + if (TodayPane.switchCounter > 2 * ACCELERATE_COUNT_LIMIT) { + timeInterval = MINIMUM_TIME; + } else if (TodayPane.switchCounter > ACCELERATE_COUNT_LIMIT) { + timeInterval = SECOND_STEP_TIME; + } + TodayPane.minidayTimer = setTimeout( + TodayPane.updateAdvanceTimer.bind(TodayPane, aEvent, aDir), + timeInterval + ); + } + }, + + /** + * Stop automatic days switching when releasing the mouse button or the + * position is outside the window. + * + * NOTE: This function is usually called without the correct this pointer. + */ + stopSwitching(aEvent) { + let element = aEvent.target; + if ( + TodayPane.minidayDrag.session && + aEvent.type == "mouseout" && + element.id != "messengerWindow" + ) { + return; + } + if (TodayPane.minidayTimer) { + clearTimeout(TodayPane.minidayTimer); + delete TodayPane.minidayTimer; + if (TodayPane.switchCounter == 0 && !TodayPane.minidayDrag.session) { + let dir = element.getAttribute("dir"); + TodayPane.advance(parseInt(dir, 10)); + } + } + if (element.id == "previous-day-button" || element.id == "next-day-button") { + TodayPane.switchCounter = 0; + let button = document.getElementById(element.id); + button.removeEventListener("mouseout", TodayPane.stopSwitching); + } + if (TodayPane.minidayDrag.session) { + window.removeEventListener("mouseout", TodayPane.stopSwitching); + TodayPane.minidayDrag.distance = 0; + document.getElementById("dragCenter-image").setAttribute("hidden", "true"); + TodayPane.minidayDrag.session = false; + } + window.removeEventListener("mousemove", TodayPane.onMousemove); + window.removeEventListener("mouseup", TodayPane.stopSwitching); + }, + + /** + * Cycle the view shown in the today pane (event+task, event, task). + * + * @param aCycleForward If true, the views are cycled in the forward + * direction, otherwise in the opposite direction + */ + cyclePaneView(aCycleForward) { + if (this.paneViews == null) { + return; + } + let index = parseInt(document.getElementById("today-pane-header").getAttribute("index"), 10); + index = index + aCycleForward; + let nViewLen = this.paneViews.length; + if (index >= nViewLen) { + index = 0; + } else if (index == -1) { + index = nViewLen - 1; + } + let agendaPanel = document.getElementById("agenda-panel"); + let todoPanel = document.getElementById("todo-tab-panel"); + let isTodoPanelVisible = index != 2 && todoPanel.isVisibleInMode(gCurrentMode); + let isAgendaPanelVisible = index != 1 && agendaPanel.isVisibleInMode(gCurrentMode); + todoPanel.setVisible(isTodoPanelVisible); + agendaPanel.setVisible(isAgendaPanelVisible); + this.updateDisplay(); + }, + + /** + * Sets the shown date from a JSDate. + * + * @param aNewDate The date to show. + */ + setDaywithjsDate(aNewDate) { + let newdatetime = cal.dtz.jsDateToDateTime(aNewDate, cal.dtz.floating); + newdatetime = newdatetime.getInTimezone(cal.dtz.defaultTimezone); + newdatetime.hour = newdatetime.minute = newdatetime.second = 0; + this.setDay(newdatetime, true); + }, + + /** + * Sets the first day shown in the today pane. + * + * @param aNewDate The calIDateTime to set. + * @param aDontUpdateMinimonth If true, the minimonth will not be + * updated to show the same date. + */ + setDay(aNewDate, aDontUpdateMinimonth) { + if (this.setDay.alreadySettingDay) { + // If we update the mini-month, this function gets called again. + return; + } + if (!document.getElementById("agenda-panel").isVisible()) { + // If the agenda panel isn't visible, there's no need to set the day. + return; + } + this.setDay.alreadySettingDay = true; + this.start = aNewDate.clone(); + + let daylabel = document.getElementById("datevalue-label"); + daylabel.value = this.start.day; + + document + .getElementById("weekdayNameLabel") + .setAttribute("value", cal.l10n.getDateFmtString(`day.${this.start.weekday + 1}.Mmm`)); + + let monthnamelabel = document.getElementById("monthNameContainer"); + monthnamelabel.value = + cal.dtz.formatter.shortMonthName(this.start.month) + " " + this.start.year; + + let currentweeklabel = document.getElementById("currentWeek-label"); + currentweeklabel.value = + cal.l10n.getCalString("shortcalendarweek") + + " " + + cal.weekInfoService.getWeekTitle(this.start); + + if (!aDontUpdateMinimonth) { + try { + // The minimonth code sometimes throws an exception as a result of this call. Bug 1560547. + // As there's no known plausible explanation, just catch the exception and carry on. + document.getElementById("today-minimonth").value = cal.dtz.dateTimeToJsDate(this.start); + } catch (ex) { + console.error(ex); + } + } + this.updatePeriod(); + this.setDay.alreadySettingDay = false; + }, + + /** + * Advance by a given number of days in the today pane. + * + * @param aDir The number of days to advance. Negative numbers advance + * backwards in time. + */ + advance(aDir) { + if (aDir != 0) { + this.start.day += aDir; + this.setDay(this.start); + } + }, + + /** + * Checks if the today pane is showing today's date. + */ + showsToday() { + return cal.dtz.sameDay(cal.dtz.now(), this.start); + }, + + /** + * Update the period headers in the agenda listbox using the today pane's + * start date. + */ + updatePeriod() { + this.agenda.update(this.start); + if (document.getElementById("todo-tab-panel").isVisible()) { + this.updateCalendarToDoUnifinder(); + } + }, + + /** + * Display a certain section in the minday/minimonth part of the todaypane. + * + * @param aSection The section to display + */ + displayMiniSection(aSection) { + document.getElementById("today-minimonth-box").setVisible(aSection == "minimonth"); + document.getElementById("mini-day-box").setVisible(aSection == "miniday"); + document.getElementById("today-none-box").setVisible(aSection == "none"); + document.getElementById("today-minimonth").setAttribute("freebusy", aSection == "minimonth"); + }, + + /** + * Handler function to update the today-pane when the current mode changes. + */ + onModeModified() { + TodayPane.updateDisplay(); + TodayPane.updateSplitterState(); + let todayPanePanel = document.getElementById("today-pane-panel"); + const currentWidth = todayPanePanel.getModeAttribute("modewidths"); + if (currentWidth != 0) { + todayPanePanel.style.width = `${currentWidth}px`; + } + TodayPane.previousMode = gCurrentMode; + }, + + get isVisible() { + return document.getElementById("today-pane-panel").isVisible(); + }, + + /** + * Toggle the today-pane and update its visual appearance. + * + * @param aEvent The DOM event occurring on activated command. + */ + toggleVisibility(aEvent) { + document.getElementById("today-pane-panel").togglePane(aEvent); + TodayPane.updateDisplay(); + TodayPane.updateSplitterState(); + }, + + /** + * Update the today-splitter state. + */ + updateSplitterState() { + let splitter = document.getElementById("today-splitter"); + if (this.isVisible) { + splitter.removeAttribute("hidden"); + splitter.setAttribute("state", "open"); + } else { + splitter.setAttribute("hidden", "true"); + } + }, + + /** + * Generates the todaypane toggle command when the today-splitter + * is being collapsed or uncollapsed. + */ + onCommandTodaySplitter() { + let todaypane = document.getElementById("today-pane-panel"); + let splitter = document.getElementById("today-splitter"); + let splitterCollapsed = splitter.getAttribute("state") == "collapsed"; + + todaypane.setModeAttribute("modewidths", todaypane.getAttribute("width")); + + if (splitterCollapsed == todaypane.isVisible()) { + document.getElementById("calendar_toggle_todaypane_command").doCommand(); + } + }, + + /** + * Checks if the todayPaneStatusLabel should be hidden. + */ + showTodayPaneStatusLabel() { + let hideLabel = !Services.prefs.getBoolPref("calendar.view.showTodayPaneStatusLabel", true); + document + .getElementById("calendar-status-todaypane-button") + .toggleAttribute("hideLabel", hideLabel); + }, +}; + +window.addEventListener("unload", TodayPane.onUnload, { capture: false, once: true }); diff --git a/comm/calendar/base/content/widgets/calendar-alarm-widget.js b/comm/calendar/base/content/widgets/calendar-alarm-widget.js new file mode 100644 index 0000000000..58300255bd --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-alarm-widget.js @@ -0,0 +1,402 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global Cr MozElements MozXULElement PluralForm Services */ + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + /** + * Represents an alarm in the alarms dialog. It appears there when an alarm is fired, and + * allows the alarm to be snoozed, dismissed, etc. + * + * @augments MozElements.MozRichlistitem + */ + class MozCalendarAlarmWidgetRichlistitem extends MozElements.MozRichlistitem { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <vbox pack="start"> + <html:img class="alarm-calendar-image" + src="chrome://calendar/skin/shared/icons/icon32.svg" + alt="" /> + </vbox> + <vbox class="alarm-calendar-event"> + <label class="alarm-title-label" crop="end"/> + <vbox class="additional-information-box"> + <label class="alarm-date-label"/> + <description class="alarm-location-description" + crop="end" + flex="1"/> + <hbox pack="start"> + <label class="text-link alarm-details-label" + value="&calendar.alarm.details.label;" + onclick="showDetails(event)" + onkeypress="showDetails(event)"/> + </hbox> + </vbox> + </vbox> + <spacer flex="1"/> + <label class="alarm-relative-date-label"/> + <vbox class="alarm-action-buttons" pack="center"> + <button class="alarm-snooze-button" + type="menu" + label="&calendar.alarm.snoozefor.label;"> + <menupopup is="calendar-snooze-popup" ignorekeys="true"/> + </button> + <button class="alarm-dismiss-button" + label="&calendar.alarm.dismiss.label;" + oncommand="dismissAlarm()"/> + </vbox> + `, + ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"] + ) + ); + this.mItem = null; + this.mAlarm = null; + this.setAttribute("is", "calendar-alarm-widget-richlistitem"); + } + + set item(val) { + this.mItem = val; + this.updateLabels(); + } + + get item() { + return this.mItem; + } + + set alarm(val) { + this.mAlarm = val; + this.updateLabels(); + } + + get alarm() { + return this.mAlarm; + } + + /** + * Refresh UI text (dates, titles, locations) when the data has changed. + */ + updateLabels() { + if (!this.mItem || !this.mAlarm) { + // Setup not complete, do nothing for now. + return; + } + const formatter = cal.dtz.formatter; + let titleLabel = this.querySelector(".alarm-title-label"); + let locationDescription = this.querySelector(".alarm-location-description"); + let dateLabel = this.querySelector(".alarm-date-label"); + + // Dates + if (this.mItem.isEvent()) { + dateLabel.value = formatter.formatItemInterval(this.mItem); + } else if (this.mItem.isTodo()) { + let startDate = this.mItem.entryDate || this.mItem.dueDate; + if (startDate) { + // A task with a start or due date, show with label. + startDate = startDate.getInTimezone(cal.dtz.defaultTimezone); + dateLabel.value = cal.l10n.getCalString("alarmStarts", [ + formatter.formatDateTime(startDate), + ]); + } else { + // If the task has no start date, then format the alarm date. + dateLabel.value = formatter.formatDateTime(this.mAlarm.alarmDate); + } + } else { + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + + // Relative Date + this.updateRelativeDateLabel(); + + // Title, Location + titleLabel.value = this.mItem.title || ""; + locationDescription.value = this.mItem.getProperty("LOCATION") || ""; + if (locationDescription.value.length) { + let urlMatch = locationDescription.value.match(/(https?:\/\/[^ ]*)/); + let url = urlMatch && urlMatch[1]; + if (url) { + locationDescription.setAttribute("link", url); + locationDescription.setAttribute( + "onclick", + "launchBrowser(this.getAttribute('link'), event)" + ); + locationDescription.setAttribute( + "oncommand", + "launchBrowser(this.getAttribute('link'), event)" + ); + locationDescription.classList.add("text-link", "alarm-details-label"); + } + } else { + locationDescription.hidden = true; + } + // Hide snooze button if read-only. + let snoozeButton = this.querySelector(".alarm-snooze-button"); + if ( + !cal.acl.isCalendarWritable(this.mItem.calendar) || + !cal.acl.userCanModifyItem(this.mItem) + ) { + let tooltip = "reminderDisabledSnoozeButtonTooltip"; + snoozeButton.disabled = true; + snoozeButton.setAttribute("tooltiptext", cal.l10n.getString("calendar-alarms", tooltip)); + } else { + snoozeButton.disabled = false; + snoozeButton.removeAttribute("tooltiptext"); + } + } + + /** + * Refresh UI text for relative date when the data has changed. + */ + updateRelativeDateLabel() { + const formatter = cal.dtz.formatter; + const item = this.mItem; + let relativeDateLabel = this.querySelector(".alarm-relative-date-label"); + let relativeDateString; + let startDate = item[cal.dtz.startDateProp(item)] || item[cal.dtz.endDateProp(item)]; + + if (startDate) { + startDate = startDate.getInTimezone(cal.dtz.defaultTimezone); + let currentDate = cal.dtz.now(); + + const sinceDayStart = currentDate.hour * 3600 + currentDate.minute * 60; + + currentDate.second = 0; + startDate.second = 0; + + const sinceAlarm = currentDate.subtractDate(startDate).inSeconds; + + this.mAlarmToday = sinceAlarm < sinceDayStart && sinceAlarm > sinceDayStart - 86400; + + if (this.mAlarmToday) { + // The alarm is today. + relativeDateString = cal.l10n.getCalString("alarmTodayAt", [ + formatter.formatTime(startDate), + ]); + } else if (sinceAlarm <= sinceDayStart - 86400 && sinceAlarm > sinceDayStart - 172800) { + // The alarm is tomorrow. + relativeDateString = cal.l10n.getCalString("alarmTomorrowAt", [ + formatter.formatTime(startDate), + ]); + } else if (sinceAlarm < sinceDayStart + 86400 && sinceAlarm > sinceDayStart) { + // The alarm is yesterday. + relativeDateString = cal.l10n.getCalString("alarmYesterdayAt", [ + formatter.formatTime(startDate), + ]); + } else { + // The alarm is way back. + relativeDateString = [formatter.formatDateTime(startDate)]; + } + } else { + // No start or end date, therefore the alarm must be absolute + // and have an alarm date. + relativeDateString = [formatter.formatDateTime(this.mAlarm.alarmDate)]; + } + + relativeDateLabel.value = relativeDateString; + } + + /** + * Click/keypress handler for "Details" link. Dispatches an event to open an item dialog. + * + * @param event {Event} The click or keypress event. + */ + showDetails(event) { + if (event.type == "click" || (event.type == "keypress" && event.key == "Enter")) { + const detailsEvent = new Event("itemdetails", { bubbles: true, cancelable: false }); + this.dispatchEvent(detailsEvent); + } + } + + /** + * Click handler for "Dismiss" button. Dispatches an event to dismiss the alarm. + */ + dismissAlarm() { + const dismissEvent = new Event("dismiss", { bubbles: true, cancelable: false }); + this.dispatchEvent(dismissEvent); + } + } + + customElements.define("calendar-alarm-widget-richlistitem", MozCalendarAlarmWidgetRichlistitem, { + extends: "richlistitem", + }); + + /** + * A popup panel for selecting how long to snooze alarms/reminders. + * It appears when a snooze button is clicked. + * + * @augments MozElements.MozMenuPopup + */ + class MozCalendarSnoozePopup extends MozElements.MozMenuPopup { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <menuitem label="&calendar.alarm.snooze.5minutes.label;" + value="5" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.10minutes.label;" + value="10" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.15minutes.label;" + value="15" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.30minutes.label;" + value="30" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.45minutes.label;" + value="45" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.1hour.label;" + value="60" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.2hours.label;" + value="120" + oncommand="snoozeItem(event)"/> + <menuitem label="&calendar.alarm.snooze.1day.label;" + value="1440" + oncommand="snoozeItem(event)"/> + <menuseparator/> + <hbox class="snooze-options-box"> + <html:input type="number" + class="size3 snooze-value-textbox" + oninput="updateUIText()" + onselect="updateUIText()"/> + <menulist class="snooze-unit-menulist" allowevents="true"> + <menupopup class="snooze-unit-menupopup menulist-menupopup" + position="after_start" + ignorekeys="true"> + <menuitem closemenu="single" class="unit-menuitem" value="1"></menuitem> + <menuitem closemenu="single" class="unit-menuitem" value="60"></menuitem> + <menuitem closemenu="single" class="unit-menuitem" value="1440"></menuitem> + </menupopup> + </menulist> + <toolbarbutton class="snooze-popup-button snooze-popup-ok-button" + oncommand="snoozeOk()"/> + <toolbarbutton class="snooze-popup-button snooze-popup-cancel-button" + aria-label="&calendar.alarm.snooze.cancel;" + oncommand="snoozeCancel()"/> + </hbox> + `, + ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"] + ) + ); + const defaultSnoozeLength = Services.prefs.getIntPref( + "calendar.alarms.defaultsnoozelength", + 0 + ); + const snoozeLength = defaultSnoozeLength <= 0 ? 5 : defaultSnoozeLength; + + let unitList = this.querySelector(".snooze-unit-menulist"); + let unitValue = this.querySelector(".snooze-value-textbox"); + + if ((snoozeLength / 60) % 24 == 0) { + // Days + unitValue.value = snoozeLength / 60 / 24; + unitList.selectedIndex = 2; + } else if (snoozeLength % 60 == 0) { + // Hours + unitValue.value = snoozeLength / 60; + unitList.selectedIndex = 1; + } else { + // Minutes + unitValue.value = snoozeLength; + unitList.selectedIndex = 0; + } + + this.updateUIText(); + } + + /** + * Dispatch a snooze event when an alarm is snoozed. + * + * @param minutes {number|string} The number of minutes to snooze for. + */ + snoozeAlarm(minutes) { + let snoozeEvent = new Event("snooze", { bubbles: true, cancelable: false }); + snoozeEvent.detail = minutes; + + // For single alarms the event.target has to be the calendar-alarm-widget element, + // (so call dispatchEvent on that). For snoozing all alarms the event.target is not + // relevant but the snooze all popup is not inside a calendar-alarm-widget (so call + // dispatchEvent on 'this'). + const eventTarget = this.id == "alarm-snooze-all-popup" ? this : this.closest("richlistitem"); + eventTarget.dispatchEvent(snoozeEvent); + } + + /** + * Click handler for snooze popup menu items (like "5 Minutes", "1 Hour", etc.). + * + * @param event {Event} The click event. + */ + snoozeItem(event) { + this.snoozeAlarm(event.target.value); + } + + /** + * Click handler for the "OK" (checkmark) button when snoozing for a custom amount of time. + */ + snoozeOk() { + const unitList = this.querySelector(".snooze-unit-menulist"); + const unitValue = this.querySelector(".snooze-value-textbox"); + const minutes = (unitList.value || 1) * unitValue.value; + this.snoozeAlarm(minutes); + } + + /** + * Click handler for the "cancel" ("X") button for not snoozing a custom amount of time. + */ + snoozeCancel() { + this.hidePopup(); + } + + /** + * Initializes and updates the dynamic UI text. This text can change depending on + * input, like for plurals, when you change from "[1] [minute]" to "[2] [minutes]". + */ + updateUIText() { + const unitList = this.querySelector(".snooze-unit-menulist"); + const unitPopup = this.querySelector(".snooze-unit-menupopup"); + const unitValue = this.querySelector(".snooze-value-textbox"); + let okButton = this.querySelector(".snooze-popup-ok-button"); + + function unitName(list) { + return { 1: "unitMinutes", 60: "unitHours", 1440: "unitDays" }[list.value] || "unitMinutes"; + } + + let pluralString = cal.l10n.getCalString(unitName(unitList)); + + const unitPlural = PluralForm.get(unitValue.value, pluralString).replace( + "#1", + unitValue.value + ); + + let okButtonAriaLabel = cal.l10n.getString("calendar-alarms", "reminderSnoozeOkA11y", [ + unitPlural, + ]); + okButton.setAttribute("aria-label", okButtonAriaLabel); + + const items = unitPopup.getElementsByTagName("menuitem"); + for (let menuItem of items) { + pluralString = cal.l10n.getCalString(unitName(menuItem)); + + menuItem.label = PluralForm.get(unitValue.value, pluralString).replace("#1", "").trim(); + } + } + } + + customElements.define("calendar-snooze-popup", MozCalendarSnoozePopup, { extends: "menupopup" }); +} diff --git a/comm/calendar/base/content/widgets/calendar-dnd-widgets.js b/comm/calendar/base/content/widgets/calendar-dnd-widgets.js new file mode 100644 index 0000000000..f0d75745b6 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-dnd-widgets.js @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals currentView MozElements MozXULElement */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + /** + * An abstract class to handle drag on drop for calendar. + * + * @abstract + */ + class CalendarDnDContainer extends MozXULElement { + constructor() { + super(); + this.addEventListener("dragstart", this.onDragStart); + this.addEventListener("dragover", this.onDragOver); + this.addEventListener("dragenter", this.onDragEnter); + this.addEventListener("drop", this.onDrop); + this.addEventListener("dragend", this.onDragEnd); + this.mCalendarView = null; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + this.hasConnected = true; + } + + /** + * The ViewController that supports the interface 'calICalendarView'. + * + * @returns {calICalendarView} + */ + get calendarView() { + return this.mCalendarView; + } + + set calendarView(val) { + this.mCalendarView = val; + } + + /** + * Method to add individual code e.g to set up the new item during 'ondrop'. + */ + onDropItem(aItem) { + // method that may be overridden by derived bindings... + } + + /** + * Adds the dropshadows to the children of the binding. + * The dropshadows are added at the first position of the children. + */ + addDropShadows() { + let offset = this.calendarView.mShadowOffset; + let shadowStartDate = this.date.clone(); + shadowStartDate.addDuration(offset); + this.calendarView.mDropShadows = []; + for (let i = 0; i < this.calendarView.mDropShadowsLength; i++) { + let box = this.calendarView.findDayBoxForDate(shadowStartDate); + if (box) { + box.setDropShadow(true); + this.calendarView.mDropShadows.push(box); + } + shadowStartDate.day += 1; + } + } + + /** + * Removes all dropShadows from the binding. + * Dropshadows are recognized as such by carrying an attribute "dropshadow". + */ + removeDropShadows() { + // method that may be overwritten by derived bindings... + if (this.calendarView.mDropShadows) { + for (let box of this.calendarView.mDropShadows) { + box.setDropShadow(false); + } + } + this.calendarView.mDropShadows = null; + } + + /** + * By setting the attribute "dropbox" to "true" or "false" the + * dropshadows are added or removed. + */ + setAttribute(aAttr, aVal) { + if (aAttr == "dropbox") { + let session = cal.dragService.getCurrentSession(); + if (session) { + session.canDrop = true; + // no shadows when dragging in the initial position + if (aVal == "true" && !this.contains(session.sourceNode)) { + this.addDropShadows(); + } else { + this.removeDropShadows(); + } + } + } + return XULElement.prototype.setAttribute.call(this, aAttr, aVal); + } + + onDragStart(event) { + let draggedDOMNode = document.monthDragEvent || event.target; + if (!draggedDOMNode?.occurrence || !this.contains(draggedDOMNode)) { + return; + } + let item = draggedDOMNode.occurrence.clone(); + let beginMoveDate = draggedDOMNode.mParentBox.date; + let itemStartDate = (item.startDate || item.entryDate || item.dueDate).getInTimezone( + this.calendarView.mTimezone + ); + let itemEndDate = (item.endDate || item.dueDate || item.entryDate).getInTimezone( + this.calendarView.mTimezone + ); + let oneMoreDay = itemEndDate.hour > 0 || itemEndDate.minute > 0; + itemStartDate.isDate = true; + itemEndDate.isDate = true; + let offsetDuration = itemStartDate.subtractDate(beginMoveDate); + let lenDuration = itemEndDate.subtractDate(itemStartDate); + let len = lenDuration.weeks * 7 + lenDuration.days; + + this.calendarView.mShadowOffset = offsetDuration; + this.calendarView.mDropShadowsLength = oneMoreDay ? len + 1 : len; + } + + onDragOver(event) { + let session = cal.dragService.getCurrentSession(); + if (!session?.sourceNode?.sourceObject) { + // No source item? Then this is not for us. + return; + } + + // We handled the event. + event.preventDefault(); + } + + onDragEnter(event) { + let session = cal.dragService.getCurrentSession(); + if (!session?.sourceNode?.sourceObject) { + // No source item? Then this is not for us. + return; + } + + // We can drop now, tell the drag service. + event.preventDefault(); + + if (!this.hasAttribute("dropbox") || this.getAttribute("dropbox") == "false") { + // As it turned out it was not possible to remove the remaining dropshadows + // at the "dragleave" event, majorly because it was not reliably + // fired. + // So we have to remove them at the currentView(). The restriction of course is + // that these containers so far may not be used for drag and drop from/to e.g. + // the today-pane. + currentView().removeDropShadows(); + } + this.setAttribute("dropbox", "true"); + } + + onDrop(event) { + let session = cal.dragService.getCurrentSession(); + let item = session?.sourceNode?.sourceObject; + if (!item) { + // No source node? Not our drag. + return; + } + this.setAttribute("dropbox", "false"); + let newItem = this.onDropItem(item).clone(); + let newStart = newItem.startDate || newItem.entryDate || newItem.dueDate; + let newEnd = newItem.endDate || newItem.dueDate || newItem.entryDate; + let offset = this.calendarView.mShadowOffset; + newStart.addDuration(offset); + newEnd.addDuration(offset); + this.calendarView.controller.modifyOccurrence(item, newStart, newEnd); + + // We handled the event. + event.stopPropagation(); + } + + onDragEnd(event) { + currentView().removeDropShadows(); + } + } + + MozElements.CalendarDnDContainer = CalendarDnDContainer; +} diff --git a/comm/calendar/base/content/widgets/calendar-filter-tree-view.js b/comm/calendar/base/content/widgets/calendar-filter-tree-view.js new file mode 100644 index 0000000000..8c2804baf0 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-filter-tree-view.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals cal, getEventStatusString, CalendarFilteredViewMixin, PROTO_TREE_VIEW */ + +class CalendarFilteredTreeView extends CalendarFilteredViewMixin(PROTO_TREE_VIEW) { + /** + * A function to, given a calendar item, determine whether it matches some + * condition, and should therefore be displayed. + * + * @callback filterFunction + * @param {calIItemBase} item The item to compute filter for + * @returns {boolean} Whether the item matches the filter + */ + + #collator = new Intl.Collator(undefined, { numeric: true }); + #sortColumn = "startDate"; + #sortDirection = "ascending"; + + /** @type {filterFunction?} */ + #filterFunction = null; + + /** @type {CalendarFilteredTreeViewRow[]} */ + #allRows = []; + + /** + * Set the function used to filter displayed rows and update the current view. + * + * @param {filterFunction} filterFunction The function to use as a filter + */ + setFilterFunction(filterFunction) { + this.#filterFunction = filterFunction; + + this._tree?.beginUpdateBatch(); + + if (this.#filterFunction) { + this._rowMap = this.#allRows.filter(row => this.#filterFunction(row.item)); + } else { + // With no filter function, all rows should be displayed. + this._rowMap = Array.from(this.#allRows); + } + + this._tree?.endUpdateBatch(); + + // Ensure that no items remain selected after filter change. + this.selection.clearSelection(); + } + + /** + * Clear the filter on the current view. + */ + clearFilter() { + this.setFilterFunction(null); + } + + /** + * Given a calendar item, determine whether it matches the current filter. + * + * @param {calIItemBase} item The item to compute filter for + * @returns {boolean} Whether the item matches the filter, or true if filter + * is unset + */ + #itemMatchesFilterIfAny(item) { + return !this.#filterFunction || this.#filterFunction(item); + } + + /** + * Save currently selected rows so that they can be restored after + * modifications to the tree. + */ + #saveSelection() { + const selection = this.selection; + if (selection) { + // Mark rows which are selected. + for (let i = 0; i < this._rowMap.length; i++) { + this._rowMap[i].wasSelected = selection.isSelected(i); + this._rowMap[i].wasCurrent = selection.currentIndex == i; + } + } + } + + /** + * Reselect rows which were selected before modifications were made to the + * tree. + */ + #restoreSelection() { + const selection = this.selection; + if (selection) { + selection.selectEventsSuppressed = true; + + let newCurrent; + for (let i = 0; i < this._rowMap.length; i++) { + if (this._rowMap[i].wasSelected != selection.isSelected(i)) { + selection.toggleSelect(i); + } + + if (this._rowMap[i].wasCurrent) { + newCurrent = i; + } + } + + selection.currentIndex = newCurrent; + + this.selectionChanged(); + selection.selectEventsSuppressed = false; + } + } + + // CalendarFilteredViewMixin implementation + + clearItems() { + this.#allRows.length = 0; + + this._tree?.beginUpdateBatch(); + this._rowMap.length = 0; + this._tree?.endUpdateBatch(); + } + + addItems(items) { + let anyItemsMatchedFilter = false; + + for (const item of items) { + const row = new CalendarFilteredTreeViewRow(item); + + const sortValue = row.getValue(this.#sortColumn); + + let addIndex = null; + for (let i = 0; addIndex === null && i < this.#allRows.length; i++) { + const comparison = this.#collator.compare( + sortValue, + this.#allRows[i].getValue(this.#sortColumn) + ); + if ( + (comparison < 0 && this.#sortDirection == "ascending") || + (comparison >= 0 && this.#sortDirection == "descending") + ) { + addIndex = i; + } + } + + if (addIndex === null) { + addIndex = this.#allRows.length; + } + this.#allRows.splice(addIndex, 0, row); + + if (this.#itemMatchesFilterIfAny(item)) { + anyItemsMatchedFilter = true; + } + } + + if (anyItemsMatchedFilter) { + this.#saveSelection(); + + this._tree?.beginUpdateBatch(); + this._rowMap = this.#allRows.filter(row => this.#itemMatchesFilterIfAny(row.item)); + this._tree?.endUpdateBatch(); + + this.#restoreSelection(); + } + } + + removeItems(items) { + const hashIDsToRemove = items.map(i => i.hashId); + for (let i = this.#allRows.length - 1; i >= 0; i--) { + if (hashIDsToRemove.includes(this.#allRows[i].item.hashId)) { + this.#allRows.splice(i, 1); + } + } + + this.#saveSelection(); + + this._tree?.beginUpdateBatch(); + for (let i = this._rowMap.length - 1; i >= 0; i--) { + if (hashIDsToRemove.includes(this._rowMap[i].item.hashId)) { + this._rowMap.splice(i, 1); + } + } + this._tree?.endUpdateBatch(); + + this.#restoreSelection(); + } + + removeItemsFromCalendar(calendarId) { + const itemsToRemove = this.#allRows + .filter(row => row.calendar.id == calendarId) + .map(row => row.item); + this.removeItems(itemsToRemove); + } + + // nsITreeView implementation + + isSorted() { + return true; + } + + cycleHeader(column) { + let direction = "ascending"; + if (column.id == this.#sortColumn && this.#sortDirection == "ascending") { + direction = "descending"; + } + + this.#sortBy(column.id, direction); + } + + #sortBy(sortColumn, sortDirection) { + // Sort underlying array of rows first. + if (sortColumn == this.#sortColumn) { + if (sortDirection == this.#sortDirection) { + // Sort order hasn't changed; do nothing. + return; + } + + this.#allRows.reverse(); + } else { + this.#allRows.sort((a, b) => { + const aValue = a.getValue(sortColumn); + const bValue = b.getValue(sortColumn); + + if (sortDirection == "descending") { + return this.#collator.compare(bValue, aValue); + } + + return this.#collator.compare(aValue, bValue); + }); + } + + this.#saveSelection(); + + // Refilter displayed rows from newly-sorted underlying array. + this._tree?.beginUpdateBatch(); + this._rowMap = this.#allRows.filter(row => this.#itemMatchesFilterIfAny(row.item)); + this._tree?.endUpdateBatch(); + + this.#restoreSelection(); + + this.#sortColumn = sortColumn; + this.#sortDirection = sortDirection; + } +} + +class CalendarFilteredTreeViewRow { + static listFormatter = new Services.intl.ListFormat( + Services.appinfo.name == "xpcshell" ? "en-US" : Services.locale.appLocalesAsBCP47, + { type: "unit" } + ); + + #columnTextCache = {}; + #columnValueCache = {}; + #item = null; + #calendar = null; + wasSelected = false; + wasCurrent = false; + + constructor(item) { + this.#item = item; + this.#calendar = item.calendar; + } + + #getTextByColumnID(columnID) { + switch (columnID) { + case "calendarName": + case "unifinder-search-results-tree-col-calendarname": + return this.#calendar.name; + case "categories": + case "unifinder-search-results-tree-col-categories": + return CalendarFilteredTreeViewRow.listFormatter.format(this.#item.getCategories()); + case "color": + case "unifinder-search-results-tree-col-color": + return cal.view.formatStringForCSSRule(this.#calendar.id); + case "endDate": + case "unifinder-search-results-tree-col-enddate": { + const endDate = this.#item.endDate.getInTimezone(cal.dtz.defaultTimezone); + if (endDate.isDate) { + endDate.day--; + } + + return cal.dtz.formatter.formatDateTime(endDate); + } + case "location": + case "unifinder-search-results-tree-col-location": + return this.#item.getProperty("LOCATION"); + case "startDate": + case "unifinder-search-results-tree-col-startdate": + return cal.dtz.formatter.formatDateTime( + this.#item.startDate.getInTimezone(cal.dtz.defaultTimezone) + ); + case "status": + case "unifinder-search-results-tree-col-status": + return getEventStatusString(this.#item); + case "title": + case "unifinder-search-results-tree-col-title": + return this.#item.title?.replace(/\n/g, " ") || ""; + } + + return ""; + } + + getText(columnID) { + if (!(columnID in this.#columnTextCache)) { + this.#columnTextCache[columnID] = this.#getTextByColumnID(columnID); + } + + return this.#columnTextCache[columnID]; + } + + #getValueByColumnID(columnID) { + switch (columnID) { + case "startDate": + case "unifinder-search-results-tree-col-startdate": + return this.#item.startDate.icalString; + case "endDate": + case "unifinder-search-results-tree-col-enddate": + return this.#item.endDate.icalString; + } + + return this.getText(columnID); + } + + getValue(columnID) { + if (!(columnID in this.#columnValueCache)) { + this.#columnValueCache[columnID] = this.#getValueByColumnID(columnID); + } + + return this.#columnValueCache[columnID]; + } + + getProperties() { + let properties = []; + if (this.#item.priority > 0 && this.#item.priority < 5) { + properties.push("highpriority"); + } else if (this.#item.priority > 5 && this.#item.priority < 10) { + properties.push("lowpriority"); + } + + properties.push("calendar-" + cal.view.formatStringForCSSRule(this.#calendar.name)); + + if (this.#item.status) { + properties.push("status-" + this.#item.status.toLowerCase()); + } + + if (this.#item.getAlarms().length) { + properties.push("alarm"); + } + + properties = properties.concat(this.#item.getCategories().map(cal.view.formatStringForCSSRule)); + return properties.join(" "); + } + + /** @type {calIItemBase} */ + get item() { + return this.#item; + } + + /** @type {calICalendar} */ + get calendar() { + return this.#calendar; + } + + get open() { + return false; + } + + get level() { + return 0; + } + + get children() { + return []; + } +} diff --git a/comm/calendar/base/content/widgets/calendar-filter.js b/comm/calendar/base/content/widgets/calendar-filter.js new file mode 100644 index 0000000000..d49ea0fe76 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-filter.js @@ -0,0 +1,1365 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../calendar-views-utils.js */ + +/* exported CalendarFilteredViewMixin */ + +var { PromiseUtils } = ChromeUtils.importESModule("resource://gre/modules/PromiseUtils.sys.mjs"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalReadableStreamFactory } = ChromeUtils.import( + "resource:///modules/CalReadableStreamFactory.jsm" +); + +/** + * Object that contains a set of filter properties that may be used by a calFilter object + * to filter a set of items. + * Supported filter properties: + * start, end: Specifies the relative date range to use when calculating the filter date + * range. The relative date range may relative to the current date and time, the + * currently selected date, or the dates range of the current view. The actual + * date range used to filter items will be calculated by the calFilter object + * by using the updateFilterDates function, which may be called multiple times + * to reflect changes in the current date and time, and changes to the view. + * + * + * The properties may be set to one of the following values: + * - FILTER_DATE_ALL: An unbound date range. + * - FILTER_DATE_XXX: One of the defined relative date ranges. + * - A string that may be converted to a calIDuration object that will be used + * as an offset to the current date and time. + * + * The start and end properties may have values representing different relative + * date ranges, in which case the filter start date will be calculated as the start + * of the relative range specified by the start property, while the filter end date + * will be calculated as the end of the relative range specified by the end + * property. + * + * due: Specifies the filter property for the due date of tasks. This filter has no + * effect when filtering events. + * + * The property has a bit field value, with the FILTER_DUE_XXX bit flags set + * to indicate that tasks with the corresponding due property value should match + * the filter. + * + * If the value is set to null the due date will not be considered when filtering. + * + * status: Specifies the filter property for the status of tasks. This filter has no + * effect when filtering events. + * + * The property has a bit field value, with the FILTER_STATUS_XXX bit flags set + * to indicate that tasks with the corresponding status property value should match + * the filter. + * + * If the value is set to null the status will not be considered when filtering. + * + * category: Specifies the filter property for the item category. + * + * The property may be set to one of the following values: + * - null: The item category will not be considered when filtering. + * - A string: The item will match the filter if any of it's categories match the + * category specified by the property. + * - An array: The item will match the filter if any of it's categories match any + * of the categories contained in the Array specified by the property. + * + * occurrences: Specifies the filter property for returning occurrences of repeating items. + * + * The property may be set to one of the following values: + * - null, FILTER_OCCURRENCES_BOUND: The default occurrence handling. Occurrences + * will be returned only for date ranges with a bound end date. + * - FILTER_OCCURRENCES_NONE: Only the parent items will be returned. + * - FILTER_OCCURRENCES_PAST_AND_NEXT: Returns past occurrences and the next future + * matching occurrence if one is found. + * + * onfilter: A callback function that may be used to apply additional custom filter + * constraints. If specified, the callback function will be called after any other + * specified filter properties are tested. + * + * The callback function will be called with the following parameters: + * - function(aItem, aResults, aFilterProperties, aFilter) + * + * @param aItem The item being tested. + * @param aResults The results of the test of the other specified + * filter properties. + * @param aFilterProperties The current filter properties being tested. + * @param aFilter The calFilter object performing the filter test. + * + * If specified, the callback function is responsible for returning a value that + * can be converted to true if the item should match the filter, or a value that + * can be converted to false otherwise. The return value will override the results + * of the testing of any other specified filter properties. + */ +function calFilterProperties() { + this.wrappedJSObject = this; +} + +calFilterProperties.prototype = { + FILTER_DATE_ALL: 0, + FILTER_DATE_VIEW: 1, + FILTER_DATE_SELECTED: 2, + FILTER_DATE_SELECTED_OR_NOW: 3, + FILTER_DATE_NOW: 4, + FILTER_DATE_TODAY: 5, + FILTER_DATE_CURRENT_WEEK: 6, + FILTER_DATE_CURRENT_MONTH: 7, + FILTER_DATE_CURRENT_YEAR: 8, + + FILTER_STATUS_INCOMPLETE: 1, + FILTER_STATUS_IN_PROGRESS: 2, + FILTER_STATUS_COMPLETED_TODAY: 4, + FILTER_STATUS_COMPLETED_BEFORE: 8, + FILTER_STATUS_ALL: 15, + + FILTER_DUE_PAST: 1, + FILTER_DUE_TODAY: 2, + FILTER_DUE_FUTURE: 4, + FILTER_DUE_NONE: 8, + FILTER_DUE_ALL: 15, + + FILTER_OCCURRENCES_BOUND: 0, + FILTER_OCCURRENCES_NONE: 1, + FILTER_OCCURRENCES_PAST_AND_NEXT: 2, + + start: null, + end: null, + due: null, + status: null, + category: null, + occurrences: null, + + onfilter: null, + + equals(aFilterProps) { + if (!(aFilterProps instanceof calFilterProperties)) { + return false; + } + let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"]; + return props.every(function (prop) { + return this[prop] == aFilterProps[prop]; + }, this); + }, + + clone() { + let cloned = new calFilterProperties(); + let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"]; + props.forEach(function (prop) { + cloned[prop] = this[prop]; + }, this); + + return cloned; + }, + + LOG(aString) { + cal.LOG( + "[calFilterProperties] " + + (aString || "") + + " start=" + + this.start + + " end=" + + this.end + + " status=" + + this.status + + " due=" + + this.due + + " category=" + + this.category + ); + }, +}; + +/** + * Object that allows filtering of a set of items using a set of filter properties. A set + * of property filters may be defined by a filter name, which may then be used to apply + * the defined filter properties. A set of commonly used property filters are predefined. + */ +function calFilter() { + this.wrappedJSObject = this; + this.mFilterProperties = new calFilterProperties(); + this.initDefinedFilters(); + this.mMaxIterations = Services.prefs.getIntPref("calendar.filter.maxiterations", 50); +} + +calFilter.prototype = { + mStartDate: null, + mEndDate: null, + mItemType: Ci.calICalendar.ITEM_FILTER_TYPE_ALL, + mSelectedDate: null, + mFilterText: "", + mDefinedFilters: {}, + mFilterProperties: null, + mToday: null, + mTomorrow: null, + mMaxIterations: 50, + + /** + * Initializes the predefined filters. + */ + initDefinedFilters() { + let filters = [ + "all", + "notstarted", + "overdue", + "open", + "completed", + "throughcurrent", + "throughtoday", + "throughsevendays", + "today", + "thisCalendarMonth", + "future", + "current", + "currentview", + ]; + filters.forEach(function (filter) { + if (!(filter in this.mDefinedFilters)) { + this.defineFilter(filter, this.getPreDefinedFilterProperties(filter)); + } + }, this); + }, + + /** + * Gets the filter properties for a predefined filter. + * + * @param aFilter The name of the filter to retrieve the filter properties for. + * @result The filter properties for the specified filter, or null if the filter + * not predefined. + */ + getPreDefinedFilterProperties(aFilter) { + let props = new calFilterProperties(); + + if (!aFilter) { + return props; + } + + switch (aFilter) { + // Predefined Task filters + case "notstarted": + props.status = props.FILTER_STATUS_INCOMPLETE; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "overdue": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS; + props.due = props.FILTER_DUE_PAST; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "open": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_ALL; + props.occurrences = props.FILTER_OCCURRENCES_PAST_AND_NEXT; + break; + case "completed": + props.status = props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "throughcurrent": + props.status = + props.FILTER_STATUS_INCOMPLETE | + props.FILTER_STATUS_IN_PROGRESS | + props.FILTER_STATUS_COMPLETED_TODAY; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "throughtoday": + props.status = + props.FILTER_STATUS_INCOMPLETE | + props.FILTER_STATUS_IN_PROGRESS | + props.FILTER_STATUS_COMPLETED_TODAY; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_TODAY; + break; + case "throughsevendays": + props.status = + props.FILTER_STATUS_INCOMPLETE | + props.FILTER_STATUS_IN_PROGRESS | + props.FILTER_STATUS_COMPLETED_TODAY; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = "P7D"; + break; + + // Predefined Event filters + case "today": + props.start = props.FILTER_DATE_TODAY; + props.end = props.FILTER_DATE_TODAY; + break; + case "thisCalendarMonth": + props.start = props.FILTER_DATE_CURRENT_MONTH; + props.end = props.FILTER_DATE_CURRENT_MONTH; + break; + case "future": + props.start = props.FILTER_DATE_NOW; + props.end = props.FILTER_DATE_ALL; + break; + case "current": + props.start = props.FILTER_DATE_SELECTED; + props.end = props.FILTER_DATE_SELECTED; + break; + case "currentview": + props.start = props.FILTER_DATE_VIEW; + props.end = props.FILTER_DATE_VIEW; + break; + + case "all": + default: + props.status = props.FILTER_STATUS_ALL; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_ALL; + } + + return props; + }, + + /** + * Defines a set of filter properties so that they may be applied by the filter name. If + * the specified filter name is already defined, it's associated filter properties will be + * replaced. + * + * @param aFilterName The name to define the filter properties as. + * @param aFilterProperties The filter properties to define. + */ + defineFilter(aFilterName, aFilterProperties) { + if (!(aFilterProperties instanceof calFilterProperties)) { + return; + } + + this.mDefinedFilters[aFilterName] = aFilterProperties; + }, + + /** + * Returns the set of filter properties that were previously defined by a filter name. + * + * @param aFilter The filter name of the defined filter properties. + * @returns The properties defined by the filter name, or null if + * the filter name was not previously defined. + */ + getDefinedFilterProperties(aFilter) { + if (aFilter in this.mDefinedFilters) { + return this.mDefinedFilters[aFilter].clone(); + } + return null; + }, + + /** + * Returns the filter name that a set of filter properties were previously defined as. + * + * @param aFilterProperties The filter properties previously defined. + * @returns The name of the first filter name that the properties + * were defined as, or null if the filter properties were + * not previously defined. + */ + getDefinedFilterName(aFilterProperties) { + for (let filter in this.mDefinedFilters) { + if (this.mDefinedFilters[filter].equals(aFilterProperties)) { + return filter; + } + } + return null; + }, + + /** + * Checks if the item matches the current filter text + * + * @param aItem The item to check. + * @returns Returns true if the item matches the filter text or no + * filter text has been set, false otherwise. + */ + textFilter(aItem) { + if (!this.mFilterText) { + return true; + } + + let searchText = this.mFilterText.toLowerCase(); + + if (!searchText.length || searchText.match(/^\s*$/)) { + return true; + } + + // TODO: Support specifying which fields to search on + for (let field of ["SUMMARY", "DESCRIPTION", "LOCATION", "URL"]) { + let val = aItem.getProperty(field); + if (val && val.toLowerCase().includes(searchText)) { + return true; + } + } + + return aItem.getCategories().some(cat => cat.toLowerCase().includes(searchText)); + }, + + /** + * Checks if the item matches the current filter date range. + * + * @param aItem The item to check. + * @returns Returns true if the item falls within the date range + * specified by mStartDate and mEndDate, false otherwise. + */ + dateRangeFilter(aItem) { + return !!cal.item.checkIfInRange(aItem, this.mStartDate, this.mEndDate); + }, + + /** + * Checks if the item matches the currently applied filter properties. Filter properties + * with a value of null or that are not applicable to the item's type are not tested. + * + * @param aItem The item to check. + * @returns Returns true if the item matches the filter properties + * currently applied, false otherwise. + */ + propertyFilter(aItem) { + let result; + let props = this.mFilterProperties; + if (!props) { + return false; + } + + // the today and tomorrow properties are precalculated in the updateFilterDates function + // for better performance when filtering batches of items. + let today = this.mToday; + if (!today) { + today = cal.dtz.now(); + today.isDate = true; + } + + let tomorrow = this.mTomorrow; + if (!tomorrow) { + tomorrow = today.clone(); + tomorrow.day++; + } + + // test the date range of the applied filter. + result = this.dateRangeFilter(aItem); + + // test the category property. If the property value is an array, only one category must + // match. + if (result && props.category) { + let cats = []; + + if (typeof props.category == "string") { + cats.push(props.category); + } else if (Array.isArray(props.category)) { + cats = props.category; + } + result = cats.some(cat => aItem.getCategories().includes(cat)); + } + + // test the status property. Only applies to tasks. + if (result && props.status != null && aItem.isTodo()) { + let completed = aItem.isCompleted; + let current = !aItem.completedDate || today.compare(aItem.completedDate) <= 0; + let percent = aItem.percentComplete || 0; + + result = + (props.status & props.FILTER_STATUS_INCOMPLETE || !(!completed && percent == 0)) && + (props.status & props.FILTER_STATUS_IN_PROGRESS || !(!completed && percent > 0)) && + (props.status & props.FILTER_STATUS_COMPLETED_TODAY || !(completed && current)) && + (props.status & props.FILTER_STATUS_COMPLETED_BEFORE || !(completed && !current)); + } + + // test the due property. Only applies to tasks. + if (result && props.due != null && aItem.isTodo()) { + let due = aItem.dueDate; + let now = cal.dtz.now(); + + result = + (props.due & props.FILTER_DUE_PAST || !(due && due.compare(now) < 0)) && + (props.due & props.FILTER_DUE_TODAY || + !(due && due.compare(now) >= 0 && due.compare(tomorrow) < 0)) && + (props.due & props.FILTER_DUE_FUTURE || !(due && due.compare(tomorrow) >= 0)) && + (props.due & props.FILTER_DUE_NONE || !(due == null)); + } + + // Call the filter properties onfilter callback if set. The return value of the + // callback function will override the result of this function. + if (props.onfilter && typeof props.onfilter == "function") { + return props.onfilter(aItem, result, props, this); + } + + return result; + }, + + /** + * Checks if the item matches the expected item type. + * + * @param {calIItemBase} aItem - The item to check. + * @returns {boolean} - True if the item matches the item type, false otherwise. + */ + itemTypeFilter(aItem) { + if (aItem.isTodo() && this.mItemType & Ci.calICalendar.ITEM_FILTER_TYPE_TODO) { + // If `mItemType` doesn't specify a completion status, the item passes. + if ((this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL) == 0) { + return true; + } + + // Otherwise, check it matches the completion status(es). + if (aItem.isCompleted) { + return (this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_YES) != 0; + } + return (this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_NO) != 0; + } + if (aItem.isEvent() && this.mItemType & Ci.calICalendar.ITEM_FILTER_TYPE_EVENT) { + return true; + } + return false; + }, + + /** + * Calculates the date from a date filter property. + * + * @param prop The value of the date filter property to calculate for. May + * be a constant specifying a relative date range, or a string + * representing a duration offset from the current date time. + * @param start If true, the function will return the date value for the + * start of the relative date range, otherwise it will return the + * date value for the end of the date range. + * @returns The calculated date for the property. + */ + getDateForProperty(prop, start) { + let props = this.mFilterProperties || new calFilterProperties(); + let result = null; + let selectedDate = this.mSelectedDate || currentView().selectedDay || cal.dtz.now(); + let nowDate = cal.dtz.now(); + + if (typeof prop == "string") { + let duration = cal.createDuration(prop); + if (duration) { + result = nowDate; + result.addDuration(duration); + } + } else { + switch (prop) { + case props.FILTER_DATE_ALL: + result = null; + break; + case props.FILTER_DATE_VIEW: + result = start ? currentView().startDay.clone() : currentView().endDay.clone(); + break; + case props.FILTER_DATE_SELECTED: + result = selectedDate.clone(); + result.isDate = true; + break; + case props.FILTER_DATE_SELECTED_OR_NOW: { + result = selectedDate.clone(); + let resultJSDate = cal.dtz.dateTimeToJsDate(result); + let nowJSDate = cal.dtz.dateTimeToJsDate(nowDate); + if ((start && resultJSDate > nowJSDate) || (!start && resultJSDate < nowJSDate)) { + result = nowDate; + } + result.isDate = true; + break; + } + case props.FILTER_DATE_NOW: + result = nowDate; + break; + case props.FILTER_DATE_TODAY: + result = nowDate; + result.isDate = true; + break; + case props.FILTER_DATE_CURRENT_WEEK: + result = start ? nowDate.startOfWeek : nowDate.endOfWeek; + break; + case props.FILTER_DATE_CURRENT_MONTH: + result = start ? nowDate.startOfMonth : nowDate.endOfMonth; + break; + case props.FILTER_DATE_CURRENT_YEAR: + result = start ? nowDate.startOfYear : nowDate.endOfYear; + break; + } + + // date ranges are inclusive, so we need to include the day for the end date + if (!start && result && prop != props.FILTER_DATE_NOW) { + result.day++; + } + } + + return result; + }, + + /** + * Calculates the current start and end dates for the currently applied filter. + * + * @returns The current [startDate, endDate] for the applied filter. + */ + getDatesForFilter() { + let startDate = null; + let endDate = null; + + if (this.mFilterProperties) { + startDate = this.getDateForProperty(this.mFilterProperties.start, true); + endDate = this.getDateForProperty(this.mFilterProperties.end, false); + + // swap the start and end dates if necessary + if (startDate && endDate && startDate.compare(endDate) > 0) { + let swap = startDate; + endDate = startDate; + startDate = swap; + } + } + + return [startDate, endDate]; + }, + + /** + * Gets the start date for the current filter date range. + * + * @return: The start date of the current filter date range, or null if + * the date range has an unbound start date. + */ + get startDate() { + return this.mStartDate; + }, + + /** + * Sets the start date for the current filter date range. This will override the date range + * calculated from the filter properties by the getDatesForFilter function. + */ + set startDate(aStartDate) { + this.mStartDate = aStartDate; + }, + + /** + * Gets the end date for the current filter date range. + * + * @return: The end date of the current filter date range, or null if + * the date range has an unbound end date. + */ + get endDate() { + return this.mEndDate; + }, + + /** + * Sets the end date for the current filter date range. This will override the date range + * calculated from the filter properties by the getDatesForFilter function. + */ + set endDate(aEndDate) { + this.mEndDate = aEndDate; + }, + + /** + * Gets the current item type filter. + */ + get itemType() { + return this.mItemType; + }, + + /** + * One of the calICalendar.ITEM_FILTER_TYPE constants, optionally bitwise-OR-ed with a + * calICalendar.ITEM_FILTER_COMPLETED value. Only items of this type will pass the filter. + * + * If an ITEM_FILTER_COMPLETED bit is set it will will take priority over applyFilter. + */ + set itemType(aItemType) { + this.mItemType = aItemType; + }, + + /** + * Gets the value used to perform the text filter. + */ + get filterText() { + return this.mFilterText; + }, + + /** + * Sets the value used to perform the text filter. + * + * @param aValue The string value to use for the text filter. + */ + set filterText(aValue) { + this.mFilterText = aValue; + }, + + /** + * Gets the selected date used by the getDatesForFilter function to calculate date ranges + * that are relative to the selected date. + */ + get selectedDate() { + return this.mSelectedDate; + }, + + /** + * Sets the selected date used by the getDatesForFilter function to calculate date ranges + * that are relative to the selected date. + */ + set selectedDate(aSelectedDate) { + this.mSelectedDate = aSelectedDate; + }, + + /** + * Gets the currently applied filter properties. + * + * @returns The currently applied filter properties. + */ + get filterProperties() { + return this.mFilterProperties ? this.mFilterProperties.clone() : null; + }, + + /** + * Gets the name of the currently applied filter. + * + * @returns The current defined name of the currently applied filter + * properties, or null if the current properties were not + * previously defined. + */ + get filterName() { + if (!this.mFilterProperties) { + return null; + } + + return this.getDefinedFilterName(this.mFilterProperties); + }, + + /** + * Applies the specified filter. + * + * @param aFilter The filter to apply. May be one of the following types: + * - a calFilterProperties object specifying the filter properties + * - a String representing a previously defined filter name + * - a String representing a duration offset from now + * - a Function to use for the onfilter callback for a custom filter + */ + applyFilter(aFilter) { + this.mFilterProperties = null; + + if (typeof aFilter == "string") { + if (aFilter in this.mDefinedFilters) { + this.mFilterProperties = this.getDefinedFilterProperties(aFilter); + } else { + let dur = cal.createDuration(aFilter); + if (dur.inSeconds > 0) { + this.mFilterProperties = new calFilterProperties(); + this.mFilterProperties.start = this.mFilterProperties.FILTER_DATE_NOW; + this.mFilterProperties.end = aFilter; + } + } + } else if (typeof aFilter == "object" && aFilter instanceof calFilterProperties) { + this.mFilterProperties = aFilter; + } else if (typeof aFilter == "function") { + this.mFilterProperties = new calFilterProperties(); + this.mFilterProperties.onfilter = aFilter; + } else { + this.mFilterProperties = new calFilterProperties(); + } + + if (this.mFilterProperties) { + this.updateFilterDates(); + // this.mFilterProperties.LOG("Applying filter:"); + } else { + cal.WARN("[calFilter] Unable to apply filter " + aFilter); + } + }, + + /** + * Calculates the current start and end dates for the currently applied filter, and updates + * the current filter start and end dates. This function can be used to update the date range + * for date range filters that are relative to the selected date or current date and time. + * + * @returns The current [startDate, endDate] for the applied filter. + */ + updateFilterDates() { + let [startDate, endDate] = this.getDatesForFilter(); + this.mStartDate = startDate; + this.mEndDate = endDate; + + // the today and tomorrow properties are precalculated here + // for better performance when filtering batches of items. + this.mToday = cal.dtz.now(); + this.mToday.isDate = true; + + this.mTomorrow = this.mToday.clone(); + this.mTomorrow.day++; + + return [startDate, endDate]; + }, + + /** + * Filters an array of items, returning a new array containing the items that match + * the currently applied filter properties and text filter. + * + * @param aItems The array of items to check. + * @param aCallback An optional callback function to be called with each item and + * the result of it's filter test. + * @returns A new array containing the items that match the filters, or + * null if no filter has been applied. + */ + filterItems(aItems, aCallback) { + if (!this.mFilterProperties) { + return null; + } + + return aItems.filter(function (aItem) { + let result = this.isItemInFilters(aItem); + + if (aCallback && typeof aCallback == "function") { + aCallback(aItem, result, this.mFilterProperties, this); + } + + return result; + }, this); + }, + + /** + * Checks if the item matches the currently applied filter properties and text filter. + * + * @param aItem The item to check. + * @returns Returns true if the item matches the filters, + * false otherwise. + */ + isItemInFilters(aItem) { + return this.itemTypeFilter(aItem) && this.propertyFilter(aItem) && this.textFilter(aItem); + }, + + /** + * Finds the next occurrence of a repeating item that matches the currently applied + * filter properties. + * + * @param aItem The parent item to find the next occurrence of. + * @returns Returns the next occurrence that matches the filters, + * or null if no match is found. + */ + getNextOccurrence(aItem) { + if (!aItem.recurrenceInfo) { + return this.isItemInFilters(aItem) ? aItem : null; + } + + let count = 0; + let start = cal.dtz.now(); + + // If the base item matches the filter, we need to check each future occurrence. + // Otherwise, we only need to check the exceptions. + if (this.isItemInFilters(aItem)) { + while (count++ < this.mMaxIterations) { + let next = aItem.recurrenceInfo.getNextOccurrence(start); + if (!next) { + // there are no more occurrences + return null; + } + + if (this.isItemInFilters(next)) { + return next; + } + start = next.startDate || next.entryDate; + } + + // we've hit the maximum number of iterations without finding a match + cal.WARN("[calFilter] getNextOccurrence: reached maximum iterations for " + aItem.title); + return null; + } + // the parent item doesn't match the filter, we can return the first future exception + // that matches the filter + let exMatch = null; + aItem.recurrenceInfo.getExceptionIds().forEach(function (rID) { + let ex = aItem.recurrenceInfo.getExceptionFor(rID); + if ( + ex && + cal.dtz.now().compare(ex.startDate || ex.entryDate) < 0 && + this.isItemInFilters(ex) + ) { + exMatch = ex; + } + }, this); + return exMatch; + }, + + /** + * Gets the occurrences of a repeating item that match the currently applied + * filter properties and date range. + * + * @param aItem The parent item to find occurrence of. + * @returns Returns an array containing the occurrences that + * match the filters, an empty array if there are no + * matches, or null if the filter is not initialized. + */ + getOccurrences(aItem) { + if (!this.mFilterProperties) { + return null; + } + let props = this.mFilterProperties; + let occs; + + if ( + !aItem.recurrenceInfo || + (!props.occurrences && !this.mEndDate) || + props.occurrences == props.FILTER_OCCURRENCES_NONE + ) { + // either this isn't a repeating item, the occurrence filter specifies that + // we don't want occurrences, or we have a default occurrence filter with an + // unbound date range, so we return just the unexpanded item. + occs = [aItem]; + } else { + occs = aItem.getOccurrencesBetween( + this.mStartDate || cal.createDateTime(), + this.mEndDate || cal.dtz.now() + ); + if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT && !this.mEndDate) { + // we have an unbound date range and the occurrence filter specifies + // that we also want the next matching occurrence if available. + let next = this.getNextOccurrence(aItem); + if (next) { + occs.push(next); + } + } + } + + return this.filterItems(occs); + }, + + /** + * Gets the items matching the currently applied filter properties from a calendar. + * + * @param {calICalendar} aCalendar - The calendar to get items from. + * @returns {ReadableStream<calIItemBase>} A stream of returned values. + */ + getItems(aCalendar) { + if (!this.mFilterProperties) { + return CalReadableStreamFactory.createEmptyReadableStream(); + } + let props = this.mFilterProperties; + + // Build the filter argument for calICalendar.getItems() from the filter properties. + let filter = this.mItemType; + + // For tasks, if `mItemType` doesn't specify a completion status, add one. + if ( + filter & Ci.calICalendar.ITEM_FILTER_TYPE_TODO && + (filter & Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL) == 0 + ) { + if ( + !props.status || + props.status & (props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE) + ) { + filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_YES; + } + if ( + !props.status || + props.status & (props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS) + ) { + filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + } + } + + if (!filter) { + return CalReadableStreamFactory.createEmptyReadableStream(); + } + + let startDate = this.startDate; + let endDate = this.endDate; + + // We only want occurrences returned from calICalendar.getItems() with a default + // occurrence filter property and a bound date range, otherwise the local listener + // will handle occurrence expansion. + if (!props.occurrences && this.endDate) { + filter |= Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + startDate = startDate || cal.createDateTime(); + endDate = endDate || cal.dtz.now(); + } + + // We use a local ReadableStream for the calICalendar.getItems() call, and use it + // to handle occurrence expansion and filter the results before forwarding them + // upstream. + return CalReadableStreamFactory.createMappedReadableStream( + aCalendar.getItems(filter, 0, startDate, endDate), + chunk => { + let items; + if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT) { + // with the FILTER_OCCURRENCES_PAST_AND_NEXT occurrence filter we will + // get parent items returned here, so we need to let the getOccurrences + // function handle occurrence expansion. + items = []; + for (let item of chunk) { + items = items.concat(this.getOccurrences(item)); + } + } else { + // with other occurrence filters the calICalendar.getItems() function will + // return expanded occurrences appropriately, we only need to filter them. + items = this.filterItems(chunk); + } + return items; + } + ); + }, +}; + +/** + * A mixin to use as a base class for calendar widgets. + * + * With startDate, endDate, and itemType set this mixin will inform the widget + * of any calendar item within the range that needs to be added to, or removed + * from, the UI. Widgets should implement clearItems, addItems, removeItems, + * and removeItemsFromCalendar to receive this information. + * + * To update the display (e.g. if the user wants to display a different month), + * just set the new date values and call refreshItems(). + * + * This mixin handles disabled and/or hidden calendars, so you don't have to. + * + * @note Instances must have an `id` for logging purposes. + */ +let CalendarFilteredViewMixin = Base => + class extends Base { + /** + * The filter responsible for collecting items when this view is refreshed, + * and checking new items as they appear. + * + * @type {calFilter} + */ + #filter = null; + + /** + * An object representing the most recent refresh job. + * This is used to check if a job that completes is still the most recent. + * + * @type {?object} + */ + #currentRefresh = null; + + /** + * The current PromiseUtils.jsm `Deferred` object (containing a Promise + * and methods to resolve/reject it). + * + * @type {object} + */ + #deferred = PromiseUtils.defer(); + + /** + * Any async iterator currently reading from a calendar. + * + * @type {Set<CalReadableStreamIterator>} + */ + #iterators = new Set(); + + constructor(...args) { + super(...args); + + this.#filter = new calFilter(); + this.#filter.itemType = 0; + } + + /** + * A Promise that resolves when the next refreshing of items is complete, + * or instantly if refreshing is already complete and still valid. + * + * Changes to the startDate, endDate, or itemType properties, or a call to + * refreshItems with the force argument, will delay this Promise until the + * refresh settles for the new values. + * + * @type {Promise} + */ + get ready() { + return this.#deferred.promise; + } + + /** + * The start of the filter range. Can be either a date or a datetime. + * + * @type {calIDateTime} + */ + get startDate() { + return this.#filter.startDate; + } + + set startDate(value) { + if ( + this.startDate?.compare(value) == 0 && + this.startDate.timezone.tzid == value.timezone.tzid + ) { + return; + } + + this.#filter.startDate = value.clone(); + this.#filter.startDate.makeImmutable(); + this.#invalidate(); + } + + /** + * The end of the filter range. Can be either a date or a datetime. + * If it is a date, the filter won't include items on that date, so use the + * day after the last day to be displayed. + * + * @type {calIDateTime} + */ + get endDate() { + return this.#filter.endDate; + } + + set endDate(value) { + if (this.endDate?.compare(value) == 0 && this.endDate.timezone.tzid == value.timezone.tzid) { + return; + } + + this.#filter.endDate = value.clone(); + this.#filter.endDate.makeImmutable(); + this.#invalidate(); + } + + /** + * One of the calICalendar.ITEM_FILTER_TYPE constants. + * This must be set to a non-zero value in order to display any items. + * + * @type {number} + */ + get itemType() { + return this.#filter.itemType; + } + + set itemType(value) { + if (this.itemType == value) { + return; + } + + this.#filter.itemType = value; + this.#invalidate(); + } + + #isActive = false; + + /** + * Whether the view is active. + * + * Whilst the view is active, it will listen for item changes. Otherwise, + * if the view is set to be inactive, it will stop listening for changes. + * + * @type {boolean} + */ + get isActive() { + return this.#isActive; + } + + /** + * Activate the view, refreshing items and listening for changes. + * + * @returns {Promise} a promise which resolves when refresh is complete + */ + activate() { + if (this.#isActive) { + return Promise.resolve(); + } + + this.#isActive = true; + this.#calendarObserver.self = this; + + cal.manager.addCalendarObserver(this.#calendarObserver); + return this.refreshItems(); + } + + /** + * Deactivate the view, cancelling any in-progress refresh and causing it to + * no longer listen for changes. + */ + deactivate() { + if (!this.#isActive) { + return; + } + + this.#isActive = false; + this.#calendarObserver.self = this; + + cal.manager.removeCalendarObserver(this.#calendarObserver); + this.#invalidate(); + } + + /** + * Clears the display and adds items that match the filter from all enabled + * and visible calendars. + * + * @param {boolean} force - Start refreshing again, even if a refresh is already in progress. + * @returns {Promise} A Promise resolved when all calendars have refreshed. This is the same + * Promise as returned from the `ready` getter. + */ + refreshItems(force = false) { + if (!this.#isActive) { + // If we're inactive, calling #refreshCalendar() will do nothing, but we + // will have created a refresh job with no effect and subsequent refresh + // attempts will fail. + return Promise.resolve(); + } else if (force) { + // Refresh, even if already refreshing or refreshed. + this.#invalidate(); + } else if (this.#currentRefresh) { + // We already have an ongoing refresh job, or one that has already completed. + return this.#deferred.promise; + } + + // Create a new refresh job. + let refresh = (this.#currentRefresh = { completed: false }); + + // Collect items from all of the calendars. + this.clearItems(); + let promises = []; + for (let calendar of cal.manager.getCalendars()) { + promises.push(this.#refreshCalendar(calendar)); + } + + Promise.all(promises).then(() => { + refresh.completed = true; + // Resolve the Promise if the current job is still the most recent one. + // In other words, if nothing has called `#invalidate` since `currentRefresh` was created. + if (this.#currentRefresh == refresh) { + this.#deferred.resolve(); + } + }); + + return this.#deferred.promise; + } + + /** + * Cancels any refresh in progress. + */ + #invalidate() { + for (let iterator of this.#iterators) { + iterator.cancel(); + } + this.#iterators.clear(); + if (this.#currentRefresh?.completed) { + // If a previous refresh completed, start a new Promise that resolves when the next refresh + // completes. Otherwise, continue with the current Promise. + // If #currentRefresh is completed, #deferred is already resolved, so we can safely discard it. + this.#deferred = PromiseUtils.defer(); + } + this.#currentRefresh = null; + } + + /** + * Checks if the given calendar is both enabled and visible. + * + * @param {calICalendar} calendar + * @returns {boolean} True if both enabled and visible. + */ + #isCalendarVisible(calendar) { + if (!calendar) { + // If this happens then something's wrong, but it's not our problem so just ignore it. + return false; + } + + return ( + !calendar.getProperty("disabled") && calendar.getProperty("calendar-main-in-composite") + ); + } + + /** + * Adds items that match the filter from a specific calendar. Does NOT + * remove existing items first, use removeItemsFromCalendar for that. + * + * @param {calICalendar} calendar + * @returns {Promise} A promise resolved when this calendar has refreshed. + */ + async #refreshCalendar(calendar) { + if (!this.#isActive || !this.itemType || !this.#isCalendarVisible(calendar)) { + return; + } + let iterator = cal.iterate.streamValues(this.#filter.getItems(calendar)); + this.#iterators.add(iterator); + for await (let chunk of iterator) { + this.addItems(chunk); + } + this.#iterators.delete(iterator); + } + + /** + * Implement this method to remove all items from the UI. + */ + clearItems() {} + + /** + * Implement this method to add items to the UI. + * + * @param {calIItemBase[]} items + */ + addItems(items) {} + + /** + * Implement this method to remove items from the UI. + * + * @param {calIItemBase[]} items + */ + removeItems(items) {} + + /** + * Implement this method to remove all items from a specific calendar from + * the UI. + * + * @param {string} calendarId + */ + removeItemsFromCalendar(calendarId) {} + + /** + * @implements {calIObserver} + */ + #calendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + + onStartBatch(calendar) {}, + onEndBatch(calendar) {}, + onLoad(calendar) { + if (calendar.type == "ics") { + // ICS doesn't bother telling us about events that disappeared when + // sync'ing, so just throw them all out and reload. This should get + // fixed somehow, and this hack removed. + this.self.removeItemsFromCalendar(calendar.id); + this.self.#refreshCalendar(calendar); + } + }, + onAddItem(item) { + if (!this.self.#isCalendarVisible(item.calendar)) { + return; + } + + let occurrences = this.self.#filter.getOccurrences(item); + if (occurrences.length) { + this.self.addItems(occurrences); + } + }, + onModifyItem(newItem, oldItem) { + if (!this.self.#isCalendarVisible(newItem.calendar)) { + return; + } + + // Ideally we'd calculate the intersection between oldOccurrences and + // newOccurrences, then call a modifyItems function, but it proved + // unreliable in some situations, so instead we remove and replace + // the occurrences. + + let oldOccurrences = this.self.#filter.getOccurrences(oldItem); + if (oldOccurrences.length) { + this.self.removeItems(oldOccurrences); + } + + let newOccurrences = this.self.#filter.getOccurrences(newItem); + if (newOccurrences.length) { + this.self.addItems(newOccurrences); + } + }, + onDeleteItem(deletedItem) { + if (!this.self.#isCalendarVisible(deletedItem.calendar)) { + return; + } + + this.self.removeItems(this.self.#filter.getOccurrences(deletedItem)); + }, + onError(calendar, errNo, message) {}, + onPropertyChanged(calendar, name, newValue, oldValue) { + if (!["calendar-main-in-composite", "disabled"].includes(name)) { + return; + } + + if ( + (name == "disabled" && newValue) || + (name == "calendar-main-in-composite" && !newValue) + ) { + this.self.removeItemsFromCalendar(calendar.id); + return; + } + + this.self.#refreshCalendar(calendar); + }, + onPropertyDeleting(calendar, name) {}, + }; + }; diff --git a/comm/calendar/base/content/widgets/calendar-invitation-panel.js b/comm/calendar/base/content/widgets/calendar-invitation-panel.js new file mode 100644 index 0000000000..aa2be5e29f --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-invitation-panel.js @@ -0,0 +1,799 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals cal, openLinkExternally, MozXULElement, MozElements */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + var { recurrenceRule2String } = ChromeUtils.import( + "resource:///modules/calendar/calRecurrenceUtils.jsm" + ); + + // calendar-invitation-panel.ftl is not globally loaded until now. + MozXULElement.insertFTLIfNeeded("calendar/calendar-invitation-panel.ftl"); + + const PROPERTY_REMOVED = -1; + const PROPERTY_UNCHANGED = 0; + const PROPERTY_ADDED = 1; + const PROPERTY_MODIFIED = 2; + + /** + * InvitationPanel displays the details of an iTIP event invitation in an + * interactive panel. + */ + class InvitationPanel extends HTMLElement { + static MODE_NEW = "New"; + static MODE_ALREADY_PROCESSED = "Processed"; + static MODE_UPDATE_MAJOR = "UpdateMajor"; + static MODE_UPDATE_MINOR = "UpdateMinor"; + static MODE_CANCELLED = "Cancelled"; + static MODE_CANCELLED_NOT_FOUND = "CancelledNotFound"; + + /** + * Used to retrieve a property value from an event. + * + * @callback GetValue + * @param {calIEvent} event + * @returns {string} + */ + + /** + * A function used to make a property value visible in to the user. + * + * @callback PropertyShow + * @param {HTMLElement} node - The element responsible for displaying the + * value. + * @param {string} value - The value of property to display. + * @param {string} oldValue - The previous value of the property if the + * there is a prior copy of the event. + * @param {calIEvent} item - The event item the property belongs to. + * @param {string} oldItem - The prior version of the event if there is one. + */ + + /** + * @typedef {Object} InvitationPropertyDescriptor + * @property {string} id - The id of the HTMLElement that displays + * the property. + * @property {GetValue} getValue - Function used to retrieve the displayed + * value of the property from the item. + * @property {boolean?} isList - Indicates the value of the property is a + * list. + * @property {PropertyShow?} show - Function to use to display the property + * value if it is not a list. + */ + + /** + * A static list of objects used in determining how to display each of the + * properties. + * + * @type {PropertyDescriptor[]} + */ + static propertyDescriptors = [ + { + id: "when", + getValue(item) { + let tz = cal.dtz.defaultTimezone; + let startDate = item.startDate?.getInTimezone(tz) ?? null; + let endDate = item.endDate?.getInTimezone(tz) ?? null; + return `${startDate.icalString}-${endDate?.icalString}`; + }, + show(intervalNode, newValue, oldValue, item) { + intervalNode.item = item; + }, + }, + { + id: "recurrence", + getValue(item) { + let parent = item.parentItem; + if (!parent.recurrenceInfo) { + return null; + } + return recurrenceRule2String(parent.recurrenceInfo, parent.recurrenceStartDate); + }, + show(recurrence, value) { + recurrence.appendChild(document.createTextNode(value)); + }, + }, + { + id: "location", + getValue(item) { + return item.getProperty("LOCATION"); + }, + show(location, value) { + location.appendChild(cal.view.textToHtmlDocumentFragment(value, document)); + }, + }, + { + id: "summary", + getValue(item) { + return item.getAttendees(); + }, + show(summary, value) { + summary.attendees = value; + }, + }, + { + id: "attendees", + isList: true, + getValue(item) { + return item.getAttendees(); + }, + }, + { + id: "attachments", + isList: true, + getValue(item) { + return item.getAttachments(); + }, + }, + { + id: "description", + getValue(item) { + return item.descriptionText; + }, + show(description, value) { + description.appendChild(cal.view.textToHtmlDocumentFragment(value, document)); + }, + }, + ]; + + /** + * mode determines how the UI should display the received invitation. It + * must be set to one of the MODE_* constants, defaults to MODE_NEW. + * + * @type {string} + */ + mode = InvitationPanel.MODE_NEW; + + /** + * A previous copy of the event item if found on an existing calendar. + * + * @type {calIEvent?} + */ + foundItem; + + /** + * The event item to be displayed. + * + * @type {calIEvent?} + */ + item; + + constructor(id) { + super(); + this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(this.shadowRoot); + + let link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "chrome://calendar/skin/shared/widgets/calendar-invitation-panel.css"; + this.shadowRoot.appendChild(link); + } + + /** + * Compares two like property values, an old and a new one, to determine + * what type of change has been made (if any). + * + * @param {any} oldValue + * @param {any} newValue + * @returns {number} - One of the PROPERTY_* constants. + */ + compare(oldValue, newValue) { + if (!oldValue && newValue) { + return PROPERTY_ADDED; + } + if (oldValue && !newValue) { + return PROPERTY_REMOVED; + } + return oldValue != newValue ? PROPERTY_MODIFIED : PROPERTY_UNCHANGED; + } + + connectedCallback() { + if (this.item && this.mode) { + let template = document.getElementById(`calendarInvitationPanel`); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + + if (this.foundItem && this.foundItem.title != this.item.title) { + let indicator = this.shadowRoot.getElementById("titleChangeIndicator"); + indicator.status = PROPERTY_MODIFIED; + indicator.hidden = false; + } + this.shadowRoot.getElementById("title").textContent = this.item.title; + + let statusBar = this.shadowRoot.querySelector("calendar-invitation-panel-status-bar"); + statusBar.status = this.mode; + + this.shadowRoot.querySelector("calendar-minidate").date = this.item.startDate; + + for (let prop of InvitationPanel.propertyDescriptors) { + let el = this.shadowRoot.getElementById(prop.id); + let value = prop.getValue(this.item); + let result = PROPERTY_UNCHANGED; + + if (prop.isList) { + let oldValue = this.foundItem ? prop.getValue(this.foundItem) : []; + if (value.length || oldValue.length) { + el.oldValue = oldValue; + el.value = value; + el.closest(".calendar-invitation-row").hidden = false; + } + continue; + } + + let oldValue = this.foundItem ? prop.getValue(this.foundItem) : null; + if (this.foundItem) { + result = this.compare(oldValue, value); + if (result) { + let indicator = this.shadowRoot.getElementById(`${prop.id}ChangeIndicator`); + if (indicator) { + indicator.type = result; + indicator.hidden = false; + } + } + } + if (value || oldValue) { + prop.show(el, value, oldValue, this.item, this.foundItem, result); + el.closest(".calendar-invitation-row").hidden = false; + } + } + + if ( + this.mode == InvitationPanel.MODE_NEW || + this.mode == InvitationPanel.MODE_UPDATE_MAJOR + ) { + for (let button of this.shadowRoot.querySelectorAll("#actionButtons > button")) { + button.addEventListener("click", e => + this.dispatchEvent( + new CustomEvent("calendar-invitation-panel-action", { + detail: { type: button.dataset.action }, + }) + ) + ); + } + this.shadowRoot.getElementById("footer").hidden = false; + } + } + } + } + customElements.define("calendar-invitation-panel", InvitationPanel); + + /** + * Object used to describe relevant arguments to MozElements.NotificationBox. + * appendNotification(). + * @type {Object} InvitationStatusBarDescriptor + * @property {string} label - An l10n id used used to generate the notification + * bar text. + * @property {number} priority - One of the notification box constants that + * indicate the priority of a notification. + * @property {object[]} buttons - An array of objects corresponding to the + * "buttons" argument of MozElements.NotificationBox.appendNotification(). + * See that method for details. + */ + + /** + * InvitationStatusBar generates a notification bar that informs the user about + * the status of the received invitation and possible actions they may take. + */ + class InvitationPanelStatusBar extends HTMLElement { + /** + * @type {NotificationBox} + */ + get notificationBox() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + this.append(element); + }); + } + return this._notificationBox; + } + + /** + * Map-like object where each key is an InvitationPanel mode and the values + * are descriptors used to generate the notification bar for that mode. + * + * @type {Object.<string, InvitationStatusBarDescriptor> + */ + notices = { + [InvitationPanel.MODE_NEW]: { + label: "calendar-invitation-panel-status-new", + buttons: [ + { + "l10n-id": "calendar-invitation-panel-more-button", + callback: (notification, opts, button, event) => + this._showMoreMenu(event, [ + { + l10nId: "calendar-invitation-panel-menu-item-save-copy", + name: "save", + command: e => + this.dispatchEvent( + new CustomEvent("calendar-invitation-panel-action", { + details: { type: "x-savecopy" }, + bubbles: true, + composed: true, + }) + ), + }, + ]), + }, + ], + }, + [InvitationPanel.MODE_ALREADY_PROCESSED]: { + label: "calendar-invitation-panel-status-processed", + buttons: [ + { + "l10n-id": "calendar-invitation-panel-view-button", + callback: () => { + this.dispatchEvent( + new CustomEvent("calendar-invitation-panel-action", { + detail: { type: "x-showdetails" }, + bubbles: true, + composed: true, + }) + ); + return true; + }, + }, + ], + }, + [InvitationPanel.MODE_UPDATE_MINOR]: { + label: "calendar-invitation-panel-status-updateminor", + priority: this.notificationBox.PRIORITY_WARNING_LOW, + buttons: [ + { + "l10n-id": "calendar-invitation-panel-update-button", + callback: () => { + this.dispatchEvent( + new CustomEvent("calendar-invitation-panel-action", { + detail: { type: "update" }, + bubbles: true, + composed: true, + }) + ); + return true; + }, + }, + ], + }, + [InvitationPanel.MODE_UPDATE_MAJOR]: { + label: "calendar-invitation-panel-status-updatemajor", + priority: this.notificationBox.PRIORITY_WARNING_LOW, + }, + [InvitationPanel.MODE_CANCELLED]: { + label: "calendar-invitation-panel-status-cancelled", + buttons: [{ "l10n-id": "calendar-invitation-panel-delete-button" }], + priority: this.notificationBox.PRIORITY_CRITICAL_LOW, + }, + [InvitationPanel.MODE_CANCELLED_NOT_FOUND]: { + label: "calendar-invitation-panel-status-cancelled-notfound", + priority: this.notificationBox.PRIORITY_CRITICAL_LOW, + }, + }; + + /** + * status corresponds to one of the MODE_* constants and will trigger + * rendering of the notification box. + * + * @type {string} status + */ + set status(value) { + let opts = this.notices[value]; + let priority = opts.priority || this.notificationBox.PRIORITY_INFO_LOW; + let buttons = opts.buttons || []; + let notification = this.notificationBox.appendNotification( + "invitationStatus", + { + label: { "l10n-id": opts.label }, + priority, + }, + buttons + ); + notification.removeAttribute("dismissable"); + } + + _showMoreMenu(event, menuitems) { + let menu = document.getElementById("calendarInvitationPanelMoreMenu"); + menu.replaceChildren(); + for (let { type, l10nId, name, command } of menuitems) { + let menuitem = document.createXULElement("menuitem"); + if (type) { + menuitem.type = type; + } + if (name) { + menuitem.name = name; + } + if (command) { + menuitem.addEventListener("command", command); + } + document.l10n.setAttributes(menuitem, l10nId); + menu.appendChild(menuitem); + } + menu.openPopup(event.originalTarget, "after_start", 0, 0, false, false, event); + return true; + } + } + customElements.define("calendar-invitation-panel-status-bar", InvitationPanelStatusBar); + + /** + * InvitationInterval displays the formatted interval of the event. Formatting + * relies on cal.dtz.formatter.formatIntervalParts(). + */ + class InvitationInterval extends HTMLElement { + /** + * The item whose interval to show. + * + * @type {calIEvent} + */ + set item(value) { + let [startDate, endDate] = cal.dtz.formatter.getItemDates(value); + let timezone = startDate.timezone.displayName; + let parts = cal.dtz.formatter.formatIntervalParts(startDate, endDate); + document.l10n.setAttributes(this, `calendar-invitation-interval-${parts.type}`, { + ...parts, + timezone, + }); + } + } + customElements.define("calendar-invitation-interval", InvitationInterval); + + const partStatOrder = ["ACCEPTED", "DECLINED", "TENTATIVE", "NEEDS-ACTION"]; + + /** + * InvitationPartStatSummary generates text indicating the aggregated + * participation status of each attendee in the event's attendees list. + */ + class InvitationPartStatSummary extends HTMLElement { + constructor() { + super(); + this.appendChild( + document.getElementById("calendarInvitationPartStatSummary").content.cloneNode(true) + ); + } + + /** + * Setting this property will trigger an update of the text displayed. + * + * @type {calIAttendee[]} + */ + set attendees(attendees) { + let counts = { + ACCEPTED: 0, + DECLINED: 0, + TENTATIVE: 0, + "NEEDS-ACTION": 0, + TOTAL: attendees.length, + OTHER: 0, + }; + + for (let { participationStatus } of attendees) { + if (counts.hasOwnProperty(participationStatus)) { + counts[participationStatus]++; + } else { + counts.OTHER++; + } + } + document.l10n.setAttributes( + this.querySelector("#partStatTotal"), + "calendar-invitation-panel-partstat-total", + { count: counts.TOTAL } + ); + + let shownPartStats = partStatOrder.filter(partStat => counts[partStat]); + let breakdown = this.querySelector("#partStatBreakdown"); + for (let partStat of shownPartStats) { + let span = document.createElement("span"); + span.setAttribute("class", "calendar-invitation-panel-partstat-summary"); + + // calendar-invitation-panel-partstat-accepted + // calendar-invitation-panel-partstat-declined + // calendar-invitation-panel-partstat-tentative + // calendar-invitation-panel-partstat-needs-action + document.l10n.setAttributes( + span, + `calendar-invitation-panel-partstat-${partStat.toLowerCase()}`, + { + count: counts[partStat], + } + ); + breakdown.appendChild(span); + } + } + } + customElements.define("calendar-invitation-partstat-summary", InvitationPartStatSummary); + + /** + * BaseInvitationChangeList is a <ul> element that can visually show changes + * between elements of a list value. + * + * @template T + */ + class BaseInvitationChangeList extends HTMLUListElement { + /** + * An array containing the old values to be compared against for changes. + * + * @type {T[]} + */ + oldValue = []; + + /** + * String indicating the type of list items to create. This is passed + * directly to the "is" argument of document.createElement(). + * + * @abstract + */ + listItem; + + _createListItem(value, status) { + let li = document.createElement("li", { is: this.listItem }); + li.changeStatus = status; + li.value = value; + return li; + } + + /** + * Setting this property will trigger rendering of the list. If no prior + * values are detected, change indicators are not touched. + * + * @type {T[]} + */ + set value(list) { + if (!this.oldValue.length) { + for (let value of list) { + this.append(this._createListItem(value)); + } + return; + } + for (let [value, status] of this.getChanges(this.oldValue, list)) { + this.appendChild(this._createListItem(value, status)); + } + } + + /** + * Implemented by sub-classes to generate a list of changes for each element + * of the new list. + * + * @param {T[]} oldValue + * @param {T[]} newValue + * @return {[T, number][]} + */ + getChanges(oldValue, newValue) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + } + + /** + * BaseInvitationChangeListItem is the <li> element used for change lists. + * + * @template {T} + */ + class BaseInvitationChangeListItem extends HTMLLIElement { + /** + * Indicates whether the item value has changed and should be displayed as + * such. Its value is one of the PROPERTY_* constants. + * + * @type {number} + */ + changeStatus = PROPERTY_UNCHANGED; + + /** + * Settings this property will render the list item including a change + * indicator if the changeStatus property != PROPERTY_UNCHANGED. + * + * @type {T} + */ + set value(itemValue) { + this.build(itemValue); + if (this.changeStatus) { + let changeIndicator = document.createElement("calendar-invitation-change-indicator"); + changeIndicator.type = this.changeStatus; + this.append(changeIndicator); + } + } + + /** + * Implemented by sub-classes to build the <li> inner DOM structure. + * + * @param {T} value + * @abstract + */ + build(value) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + } + + /** + * InvitationAttendeeList displays a list of all the attendees on an event's + * attendee list. + */ + class InvitationAttendeeList extends BaseInvitationChangeList { + listItem = "calendar-invitation-panel-attendee-list-item"; + + getChanges(oldValue, newValue) { + let diff = []; + for (let att of newValue) { + let oldAtt = oldValue.find(oldAtt => oldAtt.id == att.id); + if (!oldAtt) { + diff.push([att, PROPERTY_ADDED]); // New attendee. + } else if (oldAtt.participationStatus != att.participationStatus) { + diff.push([att, PROPERTY_MODIFIED]); // Participation status changed. + } else { + diff.push([att, PROPERTY_UNCHANGED]); // No change. + } + } + + // Insert removed attendees into the diff. + for (let [idx, att] of oldValue.entries()) { + let found = newValue.find(newAtt => newAtt.id == att.id); + if (!found) { + diff.splice(idx, 0, [att, PROPERTY_REMOVED]); + } + } + return diff; + } + } + customElements.define("calendar-invitation-panel-attendee-list", InvitationAttendeeList, { + extends: "ul", + }); + + /** + * InvitationAttendeeListItem displays a single attendee from the attendee + * list. + */ + class InvitationAttendeeListItem extends BaseInvitationChangeListItem { + build(value) { + let span = document.createElement("span"); + if (this.changeStatus == PROPERTY_REMOVED) { + span.setAttribute("class", "removed"); + } + span.textContent = value; + this.appendChild(span); + } + } + customElements.define( + "calendar-invitation-panel-attendee-list-item", + InvitationAttendeeListItem, + { + extends: "li", + } + ); + + /** + * InvitationAttachmentList displays a list of all attachments in the invitation + * that have URIs. Binary attachments are not supported. + */ + class InvitationAttachmentList extends BaseInvitationChangeList { + listItem = "calendar-invitation-panel-attachment-list-item"; + + getChanges(oldValue, newValue) { + let diff = []; + for (let attch of newValue) { + if (!attch.uri) { + continue; + } + let oldAttch = oldValue.find( + oldAttch => oldAttch.uri && oldAttch.uri.spec == attch.uri.spec + ); + + if (!oldAttch) { + // New attachment. + diff.push([attch, PROPERTY_ADDED]); + continue; + } + if ( + attch.hashId != oldAttch.hashId || + attch.getParameter("FILENAME") != oldAttch.getParameter("FILENAME") + ) { + // Contents changed or renamed. + diff.push([attch, PROPERTY_MODIFIED]); + continue; + } + // No change. + diff.push([attch, PROPERTY_UNCHANGED]); + } + + // Insert removed attachments into the diff. + for (let [idx, attch] of oldValue.entries()) { + if (!attch.uri) { + continue; + } + let found = newValue.find(newAtt => newAtt.uri && newAtt.uri.spec == attch.uri.spec); + if (!found) { + diff.splice(idx, 0, [attch, PROPERTY_REMOVED]); + } + } + return diff; + } + } + customElements.define("calendar-invitation-panel-attachment-list", InvitationAttachmentList, { + extends: "ul", + }); + + /** + * InvitationAttachmentListItem displays a link to an attachment attached to the + * event. + */ + class InvitationAttachmentListItem extends BaseInvitationChangeListItem { + /** + * Indicates whether the attachment has changed and should be displayed as + * such. Its value is one of the PROPERTY_* constants. + * + * @type {number} + */ + changeStatus = PROPERTY_UNCHANGED; + + /** + * Sets up the attachment to be displayed as a link with appropriate icon. + * Links are opened externally. + * + * @param {calIAttachment} + */ + build(value) { + let icon = document.createElement("img"); + let iconSrc = value.uri.spec.length ? value.uri.spec : "dummy.html"; + if (!value.uri.schemeIs("file")) { + // Using an uri directly, with e.g. a http scheme, wouldn't render any icon. + if (value.formatType) { + iconSrc = "goat?contentType=" + value.formatType; + } else { + // Let's try to auto-detect. + let parts = iconSrc.substr(value.uri.scheme.length + 2).split("/"); + if (parts.length) { + iconSrc = parts[parts.length - 1]; + } + } + } + icon.setAttribute("src", "moz-icon://" + iconSrc); + this.append(icon); + + let title = value.getParameter("FILENAME") || value.uri.spec; + if (this.changeStatus == PROPERTY_REMOVED) { + let span = document.createElement("span"); + span.setAttribute("class", "removed"); + span.textContent = title; + this.append(span); + } else { + let link = document.createElement("a"); + link.textContent = title; + link.setAttribute("href", value.uri.spec); + link.addEventListener("click", event => { + event.preventDefault(); + openLinkExternally(event.target.href); + }); + this.append(link); + } + } + } + customElements.define( + "calendar-invitation-panel-attachment-list-item", + InvitationAttachmentListItem, + { + extends: "li", + } + ); + + /** + * InvitationChangeIndicator is a visual indicator for indicating some piece + * of data has changed. + */ + class InvitationChangeIndicator extends HTMLElement { + _typeMap = { + [PROPERTY_REMOVED]: "removed", + [PROPERTY_ADDED]: "added", + [PROPERTY_MODIFIED]: "modified", + }; + + /** + * One of the PROPERTY_* constants that indicates what kind of change we + * are indicating (add/modify/delete) etc. + * + * @type {number} + */ + set type(value) { + let key = this._typeMap[value]; + document.l10n.setAttributes(this, `calendar-invitation-change-indicator-${key}`); + } + } + customElements.define("calendar-invitation-change-indicator", InvitationChangeIndicator); +} diff --git a/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml b/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml new file mode 100644 index 0000000000..aaca3c1a17 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml @@ -0,0 +1,96 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<!-- Template for <calendar-invitation-panel/> --> +<template id="calendarInvitationPanel" xmlns="http://www.w3.org/1999/xhtml"> + <calendar-invitation-panel-status-bar/> + <div class="calendar-invitation-panel-wrapper"> + <div class="calendar-invitation-panel-preview"> + <calendar-minidate/> + </div> + <div class="calendar-invitation-panel-details"> + <div class="calendar-invitation-panel-title"> + <calendar-invitation-change-indicator id="titleChangeIndicator" + hidden="hidden"/> + <h1 class="calendar-invitation-panel-title" id="title"></h1> + </div> + <table id="props" class="calendar-invitation-panel-props"> + <tbody> + <tr class="calendar-invitation-row"> + <th data-l10n-id="calendar-invitation-panel-prop-title-when"></th> + <td class="calendar-invitation-when"> + <calendar-invitation-change-indicator id="intervalChangeIndicator" + hidden="hidden"/> + <calendar-invitation-interval id="when"/> + </td> + </tr> + <tr hidden="hidden" class="calendar-invitation-row"> + <th data-l10n-id="calendar-invitation-panel-prop-title-recurrence"></th> + <td id="recurrence" class="calendar-invitation-recurrence"> + <calendar-invitation-change-indicator id="recurrenceChangeIndicator" + hidden="hidden"/> + </td> + </tr> + <tr hidden="hidden" class="calendar-invitation-row"> + <th data-l10n-id="calendar-invitation-panel-prop-title-location"></th> + <td id="location" class="content"> + <calendar-invitation-change-indicator id="locationChangeIndicator" + hidden="hidden"/> + </td> + </tr> + <tr hidden="hidden" class="calendar-invitation-row"> + <th data-l10n-id="calendar-invitation-panel-prop-title-attendees"></th> + <td> + <calendar-invitation-partstat-summary id="summary"/> + <ul id="attendees" + is="calendar-invitation-panel-attendee-list" + class="calendar-invitation-panel-list"></ul> + </td> + </tr> + <tr hidden="hidden" class="calendar-invitation-row"> + <th data-l10n-id="calendar-invitation-panel-prop-title-description"></th> + <td id="description" class="content"> + <calendar-invitation-change-indicator id="descriptionChangeIndicator" + hidden="hidden"/> + </td> + </tr> + <tr hidden="hidden" class="calendar-invitation-row"> + <th data-l10n-id="calendar-invitation-panel-prop-title-attachments"></th> + <td class="content"> + <ul id="attachments" + is="calendar-invitation-panel-attachment-list" + class="calendar-invitation-panel-list"></ul> + </td> + </tr> + </tbody> + </table> + </div> + </div> + <div id="footer" class="calendar-invitation-panel-details-footer" hidden="hidden"> + <div id="actionButtons" class="calendar-invitation-panel-response-buttons"> + <button id="acceptButton" + data-action="accepted" + class="primary" + data-l10n-id="calendar-invitation-panel-accept-button"></button> + <button id="declineButton" + data-action="declined" + data-l10n-id="calendar-invitation-panel-decline-button"></button> + <button id="tentativeButton" + data-action="tentative" + data-l10n-id="calendar-invitation-panel-tentative-button"></button> + </div> + </div> +</template> + +<!-- Template for <calendar-invitation-partstat-summary/> --> +<template id="calendarInvitationPartStatSummary" xmlns="http://www.w3.org/1999/xhtml"> + <div class="calendar-invitation-attendees-summary"> + <span id="partStatTotal" + class="calendar-invitation-panel-partstat-summary-total"></span> + <span id="partStatBreakdown" class="calendar-invitation-panel-partstat-breakdown"></span> + </div> +</template> + +<!-- Menu for the "More" button in the invitation panel. Populated via JavaScript.--> +<menupopup id="calendarInvitationPanelMoreMenu"></menupopup> diff --git a/comm/calendar/base/content/widgets/calendar-item-summary.js b/comm/calendar/base/content/widgets/calendar-item-summary.js new file mode 100644 index 0000000000..747c5e1d5d --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-item-summary.js @@ -0,0 +1,761 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozElements MozXULElement */ + +/* import-globals-from ../../src/calApplicationUtils.js */ +/* import-globals-from ../dialogs/calendar-summary-dialog.js */ + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + var { recurrenceStringFromItem } = ChromeUtils.import( + "resource:///modules/calendar/calRecurrenceUtils.jsm" + ); + + /** + * Represents a mostly read-only summary of a calendar item. Used in places + * like the calendar summary dialog and calendar import dialog. All instances + * should have an ID attribute. + */ + class CalendarItemSummary extends MozXULElement { + static get markup() { + return `<vbox class="item-summary-box" flex="1"> + <!-- General --> + <hbox class="calendar-caption" align="center"> + <label value="&read.only.general.label;" class="header"/> + <separator class="groove" flex="1"/> + </hbox> + <html:table class="calendar-summary-table"> + <html:tr> + <html:th> + &read.only.title.label; + </html:th> + <html:td class="item-title"> + </html:td> + </html:tr> + <html:tr class="calendar-row" hidden="hidden"> + <html:th> + &read.only.calendar.label; + </html:th> + <html:td class="item-calendar"> + </html:td> + </html:tr> + <html:tr class="item-date-row"> + <html:th class="item-start-row-label" + taskStartLabel="&read.only.task.start.label;" + eventStartLabel="&read.only.event.start.label;"> + </html:th> + <html:td class="item-date-row-start-date"> + </html:td> + </html:tr> + <html:tr class="item-date-row"> + <html:th class="item-due-row-label" + taskDueLabel="&read.only.task.due.label;" + eventEndLabel="&read.only.event.end.label;"> + </html:th> + <html:td class="item-date-row-end-date"> + </html:td> + </html:tr> + <html:tr class="repeat-row" hidden="hidden"> + <html:th> + &read.only.repeat.label; + </html:th> + <html:td class="repeat-details"> + </html:td> + </html:tr> + <html:tr class="location-row" hidden="hidden"> + <html:th> + &read.only.location.label; + </html:th> + <html:td class="item-location"> + </html:td> + </html:tr> + <html:tr class="category-row" hidden="hidden"> + <html:th> + &read.only.category.label; + </html:th> + <html:td class="item-category"> + </html:td> + </html:tr> + <html:tr class="item-organizer-row" hidden="hidden"> + <html:th> + &read.only.organizer.label; + </html:th> + <html:td class="item-organizer-cell"> + </html:td> + </html:tr> + <html:tr class="status-row" hidden="hidden"> + <html:th> + &task.status.label; + </html:th> + <html:td class="status-row-td"> + <html:div hidden="true" status="TENTATIVE">&newevent.status.tentative.label;</html:div> + <html:div hidden="true" status="CONFIRMED">&newevent.status.confirmed.label;</html:div> + <html:div hidden="true" status="CANCELLED">&newevent.eventStatus.cancelled.label;</html:div> + <html:div hidden="true" status="CANCELLED">&newevent.todoStatus.cancelled.label;</html:div> + <html:div hidden="true" status="NEEDS-ACTION">&newevent.status.needsaction.label;</html:div> + <html:div hidden="true" status="IN-PROCESS">&newevent.status.inprogress.label;</html:div> + <html:div hidden="true" status="COMPLETED">&newevent.status.completed.label;</html:div> + </html:td> + </html:tr> + <separator class="groove" flex="1" hidden="true"/> + <html:tr class="reminder-row" hidden="hidden"> + <html:th class="reminder-label"> + &read.only.reminder.label; + </html:th> + <html:td class="reminder-details"> + </html:td> + </html:tr> + <html:tr class="attachments-row item-attachments-row" hidden="hidden" > + <html:th class="attachments-label"> + &read.only.attachments.label; + </html:th> + <html:td> + <vbox class="item-attachment-cell"> + <!-- attachment box template --> + <hbox class="attachment-template" + hidden="true" + align="center" + disable-on-readonly="true"> + <html:img class="attachment-icon invisible-on-broken" + alt="" /> + <label class="text-link item-attachment-cell-label" + crop="end" + flex="1" /> + </hbox> + </vbox> + </html:td> + </html:tr> + </html:table> + <!-- Attendees --> + <box class="item-attendees-description"> + <box class="item-attendees" orient="vertical" hidden="true"> + <spacer class="default-spacer"/> + <hbox class="calendar-caption" align="center"> + <label value="&read.only.attendees.label;" + class="header"/> + <separator class="groove" flex="1"/> + </hbox> + <vbox class="item-attendees-list-container" + flex="1" + context="attendee-popup" + oncontextmenu="onAttendeeContextMenu(event)"> + </vbox> + </box> + + <splitter id="attendeeDescriptionSplitter" + class="item-summary-splitter" + collapse="after" + orient="vertical" + state="open"/> + + <!-- Description --> + <box class="item-description-box" hidden="true" orient="vertical"> + <hbox class="calendar-caption" align="center"> + <label value="&read.only.description.label;" + class="header"/> + <separator class="groove" flex="1"/> + </hbox> + <iframe class="item-description" + type="content" + flex="1" + oncontextmenu="openDescriptionContextMenu(event);"> + </iframe> + </box> + </box> + + <!-- URL link --> + <box class="event-grid-link-row" hidden="true" orient="vertical"> + <spacer class="default-spacer"/> + <hbox class="calendar-caption" align="center"> + <label value="&read.only.link.label;" + class="header"/> + <separator class="groove" flex="1"/> + </hbox> + <label class="url-link text-link default-indent" + crop="end"/> + </box> + </vbox>`; + } + + static get entities() { + return [ + "chrome://calendar/locale/global.dtd", + "chrome://calendar/locale/calendar.dtd", + "chrome://calendar/locale/calendar-event-dialog.dtd", + "chrome://branding/locale/brand.dtd", + ]; + } + + static get alarmMenulistFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment( + `<hbox align="center"> + <menulist class="item-alarm" + disable-on-readonly="true"> + <menupopup> + <menuitem label="&event.reminder.none.label;" + selected="true" + value="none"/> + <menuseparator/> + <menuitem label="&event.reminder.0minutes.before.label;" + length="0" + origin="before" + relation="START" + unit="minutes"/> + <menuitem label="&event.reminder.5minutes.before.label;" + length="5" + origin="before" + relation="START" + unit="minutes"/> + <menuitem label="&event.reminder.15minutes.before.label;" + length="15" + origin="before" + relation="START" + unit="minutes"/> + <menuitem label="&event.reminder.30minutes.before.label;" + length="30" + origin="before" + relation="START" + unit="minutes"/> + <menuseparator/> + <menuitem label="&event.reminder.1hour.before.label;" + length="1" + origin="before" + relation="START" + unit="hours"/> + <menuitem label="&event.reminder.2hours.before.label;" + length="2" + origin="before" + relation="START" + unit="hours"/> + <menuitem label="&event.reminder.12hours.before.label;" + length="12" + origin="before" + relation="START" + unit="hours"/> + <menuseparator/> + <menuitem label="&event.reminder.1day.before.label;" + length="1" + origin="before" + relation="START" + unit="days"/> + <menuitem label="&event.reminder.2days.before.label;" + length="2" + origin="before" + relation="START" + unit="days"/> + <menuitem label="&event.reminder.1week.before.label;" + length="7" + origin="before" + relation="START" + unit="days"/> + <menuseparator/> + <menuitem class="reminder-custom-menuitem" + label="&event.reminder.custom.label;" + value="custom"/> + </menupopup> + </menulist> + <hbox class="reminder-details"> + <hbox class="alarm-icons-box" align="center"/> + <!-- TODO oncommand? onkeypress? --> + <label class="reminder-multiple-alarms-label text-link" + hidden="true" + value="&event.reminder.multiple.label;" + disable-on-readonly="true" + flex="1" + hyperlink="true"/> + <label class="reminder-single-alarms-label text-link" + hidden="true" + disable-on-readonly="true" + flex="1" + hyperlink="true"/> + </hbox> + </hbox>`, + CalendarItemSummary.entities + ), + true + ); + Object.defineProperty(this, "alarmMenulistFragment", { value: frag }); + return frag; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + + this.appendChild(this.constructor.fragment); + + this.mItem = null; + this.mCalendar = null; + this.mReadOnly = true; + this.mIsInvitation = false; + + this.mIsToDoItem = null; + + let urlLink = this.querySelector(".url-link"); + urlLink.addEventListener("click", event => { + launchBrowser(urlLink.getAttribute("href"), event); + }); + urlLink.addEventListener("command", event => { + launchBrowser(urlLink.getAttribute("href"), event); + }); + } + + set item(item) { + this.mItem = item; + this.mIsToDoItem = item.isTodo(); + + // When used in places like the import dialog, there is no calendar (yet). + if (item.calendar) { + this.mCalendar = item.calendar; + + this.mIsInvitation = + item.calendar.supportsScheduling && + item.calendar.getSchedulingSupport()?.isInvitation(item); + + this.mReadOnly = !( + cal.acl.isCalendarWritable(this.mCalendar) && + (cal.acl.userCanModifyItem(item) || + (this.mIsInvitation && cal.acl.userCanRespondToInvitation(item))) + ); + } + + if (!item.descriptionHTML || !item.getAttendees().length) { + // Hide the splitter when there is no description or attendees. + document.getElementById("attendeeDescriptionSplitter").setAttribute("hidden", "true"); + } + } + + get item() { + return this.mItem; + } + + get calendar() { + return this.mCalendar; + } + + get readOnly() { + return this.mReadOnly; + } + + get isInvitation() { + return this.mIsInvitation; + } + + /** + * Update the item details in the UI. To be called when this element is + * first rendered and when the item changes. + */ + updateItemDetails() { + if (!this.item) { + // Setup not complete, do nothing for now. + return; + } + let item = this.item; + let isToDoItem = this.mIsToDoItem; + + this.querySelector(".item-title").textContent = item.title; + + if (this.calendar) { + this.querySelector(".calendar-row").removeAttribute("hidden"); + this.querySelector(".item-calendar").textContent = this.calendar.name; + } + + // Show start date. + let itemStartDate = item[cal.dtz.startDateProp(item)]; + + let itemStartRowLabel = this.querySelector(".item-start-row-label"); + let itemDateRowStartDate = this.querySelector(".item-date-row-start-date"); + + itemStartRowLabel.style.visibility = itemStartDate ? "visible" : "collapse"; + itemDateRowStartDate.style.visibility = itemStartDate ? "visible" : "collapse"; + + if (itemStartDate) { + itemStartRowLabel.textContent = itemStartRowLabel.getAttribute( + isToDoItem ? "taskStartLabel" : "eventStartLabel" + ); + itemDateRowStartDate.textContent = cal.dtz.getStringForDateTime(itemStartDate); + } + + // Show due date / end date. + let itemDueDate = item[cal.dtz.endDateProp(item)]; + + let itemDueRowLabel = this.querySelector(".item-due-row-label"); + let itemDateRowEndDate = this.querySelector(".item-date-row-end-date"); + + itemDueRowLabel.style.visibility = itemDueDate ? "visible" : "collapse"; + itemDateRowEndDate.style.visibility = itemDueDate ? "visible" : "collapse"; + + if (itemDueDate) { + // For all-day events, display the last day, not the finish time. + if (itemDueDate.isDate) { + itemDueDate = itemDueDate.clone(); + itemDueDate.day--; + } + itemDueRowLabel.textContent = itemDueRowLabel.getAttribute( + isToDoItem ? "taskDueLabel" : "eventEndLabel" + ); + itemDateRowEndDate.textContent = cal.dtz.getStringForDateTime(itemDueDate); + } + + let alarms = item.getAlarms(); + let hasAlarms = alarms && alarms.length; + let canShowReadOnlyReminders = hasAlarms && item.calendar; + let shouldShowReminderMenu = + !this.readOnly && + this.isInvitation && + item.calendar && + item.calendar.getProperty("capabilities.alarms.oninvitations.supported") !== false; + + // For invitations where the reminders can be edited, show a menu to + // allow setting the reminder, because you can't edit an invitation in + // the edit item dialog. For all other cases, show a plain text + // representation of the reminders but only if there are any. + if (shouldShowReminderMenu) { + if (!this.mAlarmsMenu) { + // Attempt to vertically align the label. It's not perfect but it's the best we've got. + let reminderLabel = this.querySelector(".reminder-label"); + reminderLabel.style.verticalAlign = "middle"; + let reminderCell = this.querySelector(".reminder-details"); + while (reminderCell.lastChild) { + reminderCell.lastChild.remove(); + } + + // Add the menulist dynamically only if it's going to be used. This removes a + // significant performance penalty in most use cases. + reminderCell.append(this.constructor.alarmMenulistFragment.cloneNode(true)); + this.mAlarmsMenu = this.querySelector(".item-alarm"); + this.mLastAlarmSelection = 0; + + this.mAlarmsMenu.addEventListener("command", () => { + this.updateReminder(); + }); + + this.querySelector(".reminder-multiple-alarms-label").addEventListener("click", () => { + this.updateReminder(); + }); + + this.querySelector(".reminder-single-alarms-label").addEventListener("click", () => { + this.updateReminder(); + }); + } + + if (hasAlarms) { + this.mLastAlarmSelection = loadReminders(alarms, this.mAlarmsMenu, this.mItem.calendar); + } + this.updateReminder(); + } else if (canShowReadOnlyReminders) { + this.updateReminderReadOnly(alarms); + } + + if (shouldShowReminderMenu || canShowReadOnlyReminders) { + this.querySelector(".reminder-row").removeAttribute("hidden"); + } + + let recurrenceDetails = recurrenceStringFromItem( + item, + "calendar-event-dialog", + "ruleTooComplexSummary" + ); + this.updateRecurrenceDetails(recurrenceDetails); + this.updateAttendees(item); + + let url = item.getProperty("URL")?.trim() || ""; + + let link = this.querySelector(".url-link"); + link.setAttribute("href", url); + link.setAttribute("value", url); + // Hide the row if there is no url. + this.querySelector(".event-grid-link-row").hidden = !url; + + let location = item.getProperty("LOCATION"); + if (location) { + this.updateLocation(location); + } + + let categories = item.getCategories(); + if (categories.length > 0) { + this.querySelector(".category-row").removeAttribute("hidden"); + // TODO: this join is unfriendly for l10n (categories.join(", ")). + this.querySelector(".item-category").textContent = categories.join(", "); + } + + if (item.organizer && item.organizer.id) { + this.updateOrganizer(item); + } + + let status = item.getProperty("STATUS"); + if (status && status.length) { + this.updateStatus(status, isToDoItem); + } + + let descriptionText = item.descriptionText?.trim(); + if (descriptionText) { + this.updateDescription(descriptionText, item.descriptionHTML); + } + + let attachments = item.getAttachments(); + if (attachments.length) { + this.updateAttachments(attachments); + } + } + + /** + * Updates the reminder, called when a reminder has been selected in the + * menulist. + */ + updateReminder() { + this.mLastAlarmSelection = commonUpdateReminder( + this.mAlarmsMenu, + this.mItem, + this.mLastAlarmSelection, + this.mItem.calendar, + this.querySelector(".reminder-details"), + null, + false + ); + } + + /** + * Updates the reminder to display the set reminders as read-only text. + * Depends on updateReminder() to get the text to display. + */ + updateReminderReadOnly(alarms) { + let reminderLabel = this.querySelector(".reminder-label"); + reminderLabel.style.verticalAlign = null; + let reminderCell = this.querySelector(".reminder-details"); + while (reminderCell.lastChild) { + reminderCell.lastChild.remove(); + } + delete this.mAlarmsMenu; + + switch (alarms.length) { + case 0: + reminderCell.textContent = ""; + break; + case 1: + reminderCell.textContent = alarms[0].toString(this.item); + break; + default: + for (let a of alarms) { + reminderCell.appendChild(document.createTextNode(a.toString(this.item))); + reminderCell.appendChild(document.createElement("br")); + } + break; + } + } + + /** + * Updates the item's recurrence details, i.e. shows text describing them, + * or hides the recurrence row if the item does not recur. + * + * @param {string | null} details - Recurrence details as a string or null. + * Passing null hides the recurrence row. + */ + updateRecurrenceDetails(details) { + let repeatRow = this.querySelector(".repeat-row"); + let repeatDetails = repeatRow.querySelector(".repeat-details"); + + repeatRow.toggleAttribute("hidden", !details); + repeatDetails.textContent = details ? details.replace(/\n/g, " ") : ""; + } + + /** + * Updates the attendee listbox, displaying all attendees invited to the item. + */ + updateAttendees(item) { + let attendees = item.getAttendees(); + if (attendees && attendees.length) { + this.querySelector(".item-attendees").removeAttribute("hidden"); + this.querySelector(".item-attendees-list-container").appendChild( + cal.invitation.createAttendeesList(document, attendees) + ); + } + } + + /** + * Updates the location, creating a link if the value is a URL. + * + * @param {string} location - The value of the location property. + */ + updateLocation(location) { + this.querySelector(".location-row").removeAttribute("hidden"); + let urlMatch = location.match(/(https?:\/\/[^ ]*)/); + let url = urlMatch && urlMatch[1]; + let itemLocation = this.querySelector(".item-location"); + if (url) { + let link = document.createElementNS("http://www.w3.org/1999/xhtml", "a"); + link.setAttribute("class", "item-location-link text-link"); + link.setAttribute("href", url); + link.title = url; + link.setAttribute("onclick", "launchBrowser(this.getAttribute('href'), event)"); + link.setAttribute("oncommand", "launchBrowser(this.getAttribute('href'), event)"); + + let label = document.createXULElement("label"); + label.setAttribute("context", "location-link-context-menu"); + label.textContent = location; + link.appendChild(label); + + itemLocation.replaceChildren(link); + } else { + itemLocation.textContent = location; + } + } + + /** + * Update the organizer part of the UI. + * + * @param {calIItemBase} item - The calendar item. + */ + updateOrganizer(item) { + this.querySelector(".item-organizer-row").removeAttribute("hidden"); + let organizerLabel = cal.invitation.createAttendeeLabel( + document, + item.organizer, + item.getAttendees() + ); + let organizerName = organizerLabel.querySelector(".attendee-name"); + organizerName.classList.add("text-link"); + organizerName.addEventListener("click", () => sendMailToOrganizer(this.mItem)); + this.querySelector(".item-organizer-cell").appendChild(organizerLabel); + } + + /** + * Update the status part of the UI. + * + * @param {string} status - The status of the calendar item. + * @param {boolean} isToDoItem - True if the calendar item is a todo, false if an event. + */ + updateStatus(status, isToDoItem) { + let statusRow = this.querySelector(".status-row"); + let statusRowData = this.querySelector(".status-row-td"); + + for (let i = 0; i < statusRowData.children.length; i++) { + if (statusRowData.children[i].getAttribute("status") == status) { + statusRow.removeAttribute("hidden"); + + if (status == "CANCELLED" && isToDoItem) { + // There are two status elements for CANCELLED, the second one is for + // todo items. Increment the counter here. + i++; + } + statusRowData.children[i].removeAttribute("hidden"); + break; + } + } + } + + /** + * Update the description part of the UI. + * + * @param {string} descriptionText - The value of the DESCRIPTION property. + * @param {string} descriptionHTML - HTML description if available. + */ + async updateDescription(descriptionText, descriptionHTML) { + this.querySelector(".item-description-box").removeAttribute("hidden"); + let itemDescription = this.querySelector(".item-description"); + if (itemDescription.contentDocument.readyState != "complete") { + // The iframe's document hasn't loaded yet. If we add to it now, what we add will be + // overwritten. Wait for the initial document to load. + await new Promise(resolve => { + itemDescription._listener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + onStateChange(webProgress, request, stateFlags, status) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + itemDescription.browsingContext.webProgress.removeProgressListener(this); + delete itemDescription._listener; + resolve(); + } + }, + }; + itemDescription.browsingContext.webProgress.addProgressListener( + itemDescription._listener, + Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + }); + } + let docFragment = cal.view.textToHtmlDocumentFragment( + descriptionText, + itemDescription.contentDocument, + descriptionHTML + ); + + // Make any links open in the user's default browser, not in Thunderbird. + for (let anchor of docFragment.querySelectorAll("a")) { + anchor.addEventListener("click", function (event) { + event.preventDefault(); + if (event.isTrusted) { + launchBrowser(anchor.getAttribute("href"), event); + } + }); + } + + itemDescription.contentDocument.body.appendChild(docFragment); + + const link = itemDescription.contentDocument.createElement("link"); + link.rel = "stylesheet"; + link.href = "chrome://messenger/skin/shared/editorContent.css"; + itemDescription.contentDocument.head.appendChild(link); + } + + /** + * Update the attachments part of the UI. + * + * @param {calIAttachment[]} attachments - Array of attachment objects. + */ + updateAttachments(attachments) { + // We only want to display URI type attachments and no ones received inline with the + // invitation message (having a CID: prefix results in about:blank) here. + let attCounter = 0; + attachments.forEach(aAttachment => { + if (aAttachment.uri && aAttachment.uri.spec != "about:blank") { + let attachment = this.querySelector(".attachment-template").cloneNode(true); + attachment.removeAttribute("id"); + attachment.removeAttribute("hidden"); + + let label = attachment.querySelector("label"); + label.setAttribute("value", aAttachment.uri.spec); + + label.addEventListener("click", () => { + openAttachmentFromItemSummary(aAttachment.hashId, this.mItem); + }); + + let icon = attachment.querySelector("img"); + let iconSrc = aAttachment.uri.spec.length ? aAttachment.uri.spec : "dummy.html"; + if (aAttachment.uri && !aAttachment.uri.schemeIs("file")) { + // Using an uri directly, with e.g. a http scheme, wouldn't render any icon. + if (aAttachment.formatType) { + iconSrc = "goat?contentType=" + aAttachment.formatType; + } else { + // Let's try to auto-detect. + let parts = iconSrc.substr(aAttachment.uri.scheme.length + 2).split("/"); + if (parts.length) { + iconSrc = parts[parts.length - 1]; + } + } + } + icon.setAttribute("src", "moz-icon://" + iconSrc); + + this.querySelector(".item-attachment-cell").appendChild(attachment); + attCounter++; + } + }); + + if (attCounter > 0) { + this.querySelector(".attachments-row").removeAttribute("hidden"); + } + } + } + + customElements.define("calendar-item-summary", CalendarItemSummary); +} diff --git a/comm/calendar/base/content/widgets/calendar-minidate.js b/comm/calendar/base/content/widgets/calendar-minidate.js new file mode 100644 index 0000000000..ebc270bd5b --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-minidate.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals cal */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + const format = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "2-digit", + year: "numeric", + }); + + const parts = ["month", "day", "year"]; + + function getParts(date) { + return format.formatToParts(date).reduce((prev, curr) => { + if (parts.includes(curr.type)) { + prev[curr.type] = curr.value; + } + return prev; + }, {}); + } + + /** + * CalendarMinidate displays a date in a visually appealing box meant to be + * glanced at quickly to figure out the date of an event. + */ + class CalendarMinidate extends HTMLElement { + /** + * @type {HTMLElement} + */ + _monthSpan; + + /** + * @type {HTMLElement} + */ + _daySpan; + + /** + * @type {HTMLElement} + */ + _yearSpan; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(this.shadowRoot); + this.shadowRoot.appendChild( + document.getElementById("calendarMinidate").content.cloneNode(true) + ); + this._monthSpan = this.shadowRoot.querySelector(".calendar-minidate-month"); + this._daySpan = this.shadowRoot.querySelector(".calendar-minidate-day"); + this._yearSpan = this.shadowRoot.querySelector(".calendar-minidate-year"); + } + + /** + * Setting the date property will trigger the rendering of this widget. + * + * @type {calIDateTime} + */ + set date(value) { + let { month, day, year } = getParts(cal.dtz.dateTimeToJsDate(value)); + this._monthSpan.textContent = month; + this._daySpan.textContent = day; + this._yearSpan.textContent = year; + } + + /** + * Provides the displayed date as a string in the format + * "month day year". + * + * @type {string} + */ + get fullDate() { + return `${this._monthSpan.textContent} ${this._daySpan.textContent} ${this._yearSpan.textContent}`; + } + } + customElements.define("calendar-minidate", CalendarMinidate); +} diff --git a/comm/calendar/base/content/widgets/calendar-minidate.xhtml b/comm/calendar/base/content/widgets/calendar-minidate.xhtml new file mode 100644 index 0000000000..ab8f4ecba2 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-minidate.xhtml @@ -0,0 +1,17 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<!-- Template for <calendar-minidate /> --> +<template id="calendarMinidate" xmlns="http://www.w3.org/1999/xhtml"> + <div class="calendar-minidate-wrapper"> + <link rel="stylesheet" href="chrome://calendar/skin/shared/widgets/calendar-minidate.css"/> + <div class="calendar-minidate-header"> + <span class="calendar-minidate-month"></span> + </div> + <div class="calendar-minidate-body"> + <span class="calendar-minidate-day"></span> + <span class="calendar-minidate-year"></span> + </div> + </div> +</template> diff --git a/comm/calendar/base/content/widgets/calendar-minimonth.js b/comm/calendar/base/content/widgets/calendar-minimonth.js new file mode 100644 index 0000000000..403841e69c --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-minimonth.js @@ -0,0 +1,1055 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals cal MozXULElement */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + + const lazy = {}; + ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm"); + + let dayFormatter = new Services.intl.DateTimeFormat(undefined, { day: "numeric" }); + let dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "long" }); + + /** + * MiniMonth Calendar: day-of-month grid component. + * Displays month name and year above grid of days of month by week rows. + * Arrows move forward or back a month or a year. + * Clicking on a day cell selects that day. + * At site, can provide id, and code to run when value changed by picker. + * <calendar-minimonth id="my-date-picker" onchange="myDatePick( this );"/> + * + * May get/set value in javascript with + * document.querySelector("#my-date-picker").value = new Date(); + * + * @implements {calIObserver} + * @implements {calICompositeObserver} + */ + class CalendarMinimonth extends MozXULElement { + constructor() { + super(); + // Set up custom interfaces. + this.calIObserver = this.getCustomInterfaceCallback(Ci.calIObserver); + this.calICompositeObserver = this.getCustomInterfaceCallback(Ci.calICompositeObserver); + + let onPreferenceChanged = () => { + this.dayBoxes.clear(); // Days have moved, force a refresh of the grid. + this.refreshDisplay(); + }; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "weekStart", + "calendar.week.start", + 0, + onPreferenceChanged + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "showWeekNumber", + "calendar.view-minimonth.showWeekNumber", + true, + onPreferenceChanged + ); + } + + static get inheritedAttributes() { + return { + ".minimonth-header": "readonly,month,year", + ".minimonth-year-name": "value=year", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + MozXULElement.insertFTLIfNeeded("calendar/calendar-widgets.ftl"); + + const minimonthHeader = ` + <html:div class="minimonth-header minimonth-month-box" + xmlns="http://www.w3.org/1999/xhtml"> + <div class="minimonth-nav-section"> + <button class="button icon-button icon-only minimonth-nav-btn today-button" + data-l10n-id="calendar-today-button-tooltip" + type="button" + dir="0"> + </button> + </div> + <div class="minimonth-nav-section"> + <button class="button icon-button icon-only minimonth-nav-btn months-back-button" + data-l10n-id="calendar-nav-button-prev-tooltip-month" + type="button" + dir="-1"> + </button> + <div class="minimonth-nav-item"> + <input class="minimonth-month-name" tabindex="-1" readonly="true" disabled="disabled" /> + </div> + <button class="button icon-button icon-only minimonth-nav-btn months-forward-button" + data-l10n-id="calendar-nav-button-next-tooltip-month" + type="button" + dir="1"> + </button> + </div> + <div class="minimonth-nav-section"> + <button class="button icon-button icon-only minimonth-nav-btn years-back-button" + data-l10n-id="calendar-nav-button-prev-tooltip-year" + type="button" + dir="-1"> + </button> + <div class="minimonth-nav-item"> + <input class="yearcell minimonth-year-name" tabindex="-1" readonly="true" disabled="disabled" /> + </div> + <button class="button icon-button icon-only minimonth-nav-btn years-forward-button" + data-l10n-id="calendar-nav-button-next-tooltip-year" + type="button" + dir="1"> + </button> + </div> + </html:div> + `; + + const minimonthWeekRow = ` + <html:tr class="minimonth-row-body"> + <html:th class="minimonth-week" scope="row"></html:th> + <html:td class="minimonth-day" tabindex="-1"></html:td> + <html:td class="minimonth-day" tabindex="-1"></html:td> + <html:td class="minimonth-day" tabindex="-1"></html:td> + <html:td class="minimonth-day" tabindex="-1"></html:td> + <html:td class="minimonth-day" tabindex="-1"></html:td> + <html:td class="minimonth-day" tabindex="-1"></html:td> + <html:td class="minimonth-day" tabindex="-1"></html:td> + </html:tr> + `; + + this.appendChild( + MozXULElement.parseXULToFragment( + ` + ${minimonthHeader} + <html:div class="minimonth-readonly-header minimonth-month-box"></html:div> + <html:table class="minimonth-calendar minimonth-cal-box"> + <html:tr class="minimonth-row-head"> + <html:th class="minimonth-row-header-week" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + <html:th class="minimonth-row-header" scope="col"></html:th> + </html:tr> + ${minimonthWeekRow} + ${minimonthWeekRow} + ${minimonthWeekRow} + ${minimonthWeekRow} + ${minimonthWeekRow} + ${minimonthWeekRow} + </html:table> + `, + ["chrome://calendar/locale/global.dtd"] + ) + ); + this.initializeAttributeInheritance(); + this.setAttribute("orient", "vertical"); + + // Set up header buttons. + this.querySelector(".months-back-button").addEventListener("click", () => + this.advanceMonth(-1) + ); + this.querySelector(".months-forward-button").addEventListener("click", () => + this.advanceMonth(1) + ); + this.querySelector(".years-back-button").addEventListener("click", () => + this.advanceYear(-1) + ); + this.querySelector(".years-forward-button").addEventListener("click", () => + this.advanceYear(1) + ); + this.querySelector(".today-button").addEventListener("click", () => { + this.value = new Date(); + }); + + this.dayBoxes = new Map(); + this.mValue = null; + this.mEditorDate = null; + this.mExtraDate = null; + this.mPixelScrollDelta = 0; + this.mObservesComposite = false; + this.mToday = null; + this.mSelected = null; + this.mExtra = null; + this.mValue = new Date(); // Default to "today". + this.mFocused = null; + + let width = 0; + // Start loop from 1 as it is needed to get the first month name string + // and avoid extra computation of adding one. + for (let i = 1; i <= 12; i++) { + let dateString = cal.l10n.getDateFmtString(`month.${i}.name`); + width = Math.max(dateString.length, width); + } + this.querySelector(".minimonth-month-name").style.width = `${width + 1}ch`; + + this.refreshDisplay(); + if (this.hasAttribute("freebusy")) { + this._setFreeBusy(this.getAttribute("freebusy") == "true"); + } + + // Add event listeners. + this.addEventListener("click", event => { + if (event.button == 0 && event.target.classList.contains("minimonth-day")) { + this.onDayActivate(event); + } + }); + + this.addEventListener("keypress", event => { + if (event.target.classList.contains("minimonth-day")) { + if (event.altKey || event.metaKey) { + return; + } + switch (event.keyCode) { + case KeyEvent.DOM_VK_LEFT: + this.onDayMovement(event, 0, 0, -1); + break; + case KeyEvent.DOM_VK_RIGHT: + this.onDayMovement(event, 0, 0, 1); + break; + case KeyEvent.DOM_VK_UP: + this.onDayMovement(event, 0, 0, -7); + break; + case KeyEvent.DOM_VK_DOWN: + this.onDayMovement(event, 0, 0, 7); + break; + case KeyEvent.DOM_VK_PAGE_UP: + if (event.shiftKey) { + this.onDayMovement(event, -1, 0, 0); + } else { + this.onDayMovement(event, 0, -1, 0); + } + break; + case KeyEvent.DOM_VK_PAGE_DOWN: + if (event.shiftKey) { + this.onDayMovement(event, 1, 0, 0); + } else { + this.onDayMovement(event, 0, 1, 0); + } + break; + case KeyEvent.DOM_VK_ESCAPE: + this.focusDate(this.mValue || this.mExtraDate); + event.stopPropagation(); + event.preventDefault(); + break; + case KeyEvent.DOM_VK_HOME: { + const today = new Date(); + this.update(today); + this.focusDate(today); + event.stopPropagation(); + event.preventDefault(); + break; + } + case KeyEvent.DOM_VK_RETURN: + this.onDayActivate(event); + break; + } + } + }); + + this.addEventListener("wheel", event => { + const pixelThreshold = 150; + let deltaView = 0; + if (this.getAttribute("readonly") == "true") { + // No scrolling on readonly months. + return; + } + if (event.deltaMode == event.DOM_DELTA_LINE || event.deltaMode == event.DOM_DELTA_PAGE) { + if (event.deltaY != 0) { + deltaView = event.deltaY > 0 ? 1 : -1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.mPixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + const classList = event.target.classList; + + if ( + classList.contains("years-forward-button") || + classList.contains("yearcell") || + classList.contains("years-back-button") + ) { + this.advanceYear(deltaView); + } else if (!classList.contains("today-button")) { + this.advanceMonth(deltaView); + } + } + + event.stopPropagation(); + event.preventDefault(); + }); + } + + set value(val) { + this.update(val); + } + + get value() { + return this.mValue; + } + + set extra(val) { + this.mExtraDate = val; + } + + get extra() { + return this.mExtraDate; + } + + /** + * Returns the first (inclusive) date of the minimonth as a calIDateTime object. + */ + get firstDate() { + let date = this._getCalBoxNode(1, 1).date; + return cal.dtz.jsDateToDateTime(date); + } + + /** + * Returns the last (exclusive) date of the minimonth as a calIDateTime object. + */ + get lastDate() { + let date = this._getCalBoxNode(6, 7).date; + let lastDateTime = cal.dtz.jsDateToDateTime(date); + lastDateTime.day = lastDateTime.day + 1; + return lastDateTime; + } + + get mReadOnlyHeader() { + return this.querySelector(".minimonth-readonly-header"); + } + + setBusyDaysForItem(aItem, aState) { + let items = aItem.recurrenceInfo + ? aItem.getOccurrencesBetween(this.firstDate, this.lastDate) + : [aItem]; + items.forEach(item => this.setBusyDaysForOccurrence(item, aState)); + } + + parseBoxBusy(aBox) { + let boxBusy = {}; + + let busyStr = aBox.getAttribute("busy"); + if (busyStr && busyStr.length > 0) { + let calChunks = busyStr.split("\u001A"); + for (let chunk of calChunks) { + let expr = chunk.split("="); + boxBusy[expr[0]] = parseInt(expr[1], 10); + } + } + + return boxBusy; + } + + updateBoxBusy(aBox, aBoxBusy) { + let calChunks = []; + + for (let calId in aBoxBusy) { + if (aBoxBusy[calId]) { + calChunks.push(calId + "=" + aBoxBusy[calId]); + } + } + + if (calChunks.length > 0) { + let busyStr = calChunks.join("\u001A"); + aBox.setAttribute("busy", busyStr); + } else { + aBox.removeAttribute("busy"); + } + } + + removeCalendarFromBoxBusy(aBox, aCalendar) { + let boxBusy = this.parseBoxBusy(aBox); + if (boxBusy[aCalendar.id]) { + delete boxBusy[aCalendar.id]; + } + this.updateBoxBusy(aBox, boxBusy); + } + + setBusyDaysForOccurrence(aOccurrence, aState) { + if (aOccurrence.getProperty("TRANSP") == "TRANSPARENT") { + // Skip transparent events. + return; + } + let start = aOccurrence[cal.dtz.startDateProp(aOccurrence)] || aOccurrence.dueDate; + let end = aOccurrence[cal.dtz.endDateProp(aOccurrence)] || start; + if (!start) { + return; + } + + if (start.compare(this.firstDate) < 0) { + start = this.firstDate.clone(); + } + + if (end.compare(this.lastDate) > 0) { + end = this.lastDate.clone(); + end.day++; + } + + // We need to compare with midnight of the current day, so reset the + // time here. + let current = start.clone().getInTimezone(cal.dtz.defaultTimezone); + current.hour = 0; + current.minute = 0; + current.second = 0; + + // Cache the result so the compare isn't called in each iteration. + let compareResult = start.compare(end) == 0 ? 1 : 0; + + // Setup the busy days. + while (current.compare(end) < compareResult) { + let box = this.getBoxForDate(current); + if (box) { + let busyCalendars = this.parseBoxBusy(box); + if (!busyCalendars[aOccurrence.calendar.id]) { + busyCalendars[aOccurrence.calendar.id] = 0; + } + busyCalendars[aOccurrence.calendar.id] += aState ? 1 : -1; + this.updateBoxBusy(box, busyCalendars); + } + current.day++; + } + } + + // calIObserver methods. + calendarsInBatch = new Set(); + + onStartBatch(aCalendar) { + this.calendarsInBatch.add(aCalendar); + } + + onEndBatch(aCalendar) { + this.calendarsInBatch.delete(aCalendar); + } + + onLoad(aCalendar) { + this.getItems(aCalendar); + } + + onAddItem(aItem) { + if (this.calendarsInBatch.has(aItem.calendar)) { + return; + } + + this.setBusyDaysForItem(aItem, true); + } + + onDeleteItem(aItem) { + this.setBusyDaysForItem(aItem, false); + } + + onModifyItem(aNewItem, aOldItem) { + if (this.calendarsInBatch.has(aNewItem.calendar)) { + return; + } + + this.setBusyDaysForItem(aOldItem, false); + this.setBusyDaysForItem(aNewItem, true); + } + + onError(aCalendar, aErrNo, aMessage) {} + + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "disabled": + this.resetAttributesForDate(); + this.getItems(); + break; + } + } + + onPropertyDeleting(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, null, null); + } + + // End of calIObserver methods. + // calICompositeObserver methods. + + onCalendarAdded(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + this.getItems(aCalendar); + } + } + + onCalendarRemoved(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + for (let box of this.dayBoxes.values()) { + this.removeCalendarFromBoxBusy(box, aCalendar); + } + } + } + + onDefaultCalendarChanged(aCalendar) {} + + // End calICompositeObserver methods. + + refreshDisplay() { + if (!this.mValue) { + this.mValue = new Date(); + } + this.setHeader(); + this.showMonth(this.mValue); + this.updateAccessibleLabel(); + } + + _getCalBoxNode(aRow, aCol) { + if (!this.mCalBox) { + this.mCalBox = this.querySelector(".minimonth-calendar"); + } + return this.mCalBox.children[aRow].children[aCol]; + } + + setHeader() { + // Reset the headers. + let dayList = new Array(7); + let longDayList = new Array(7); + let tempDate = new Date(); + let i, j; + let useOSFormat; + tempDate.setDate(tempDate.getDate() - (tempDate.getDay() - this.weekStart)); + for (i = 0; i < 7; i++) { + // If available, use UILocale days, else operating system format. + try { + dayList[i] = cal.l10n.getDateFmtString(`day.${tempDate.getDay() + 1}.short`); + } catch (e) { + dayList[i] = tempDate.toLocaleDateString(undefined, { weekday: "short" }); + useOSFormat = true; + } + longDayList[i] = tempDate.toLocaleDateString(undefined, { weekday: "long" }); + tempDate.setDate(tempDate.getDate() + 1); + } + + if (useOSFormat) { + // To keep datepicker popup compact, shrink localized weekday + // abbreviations down to 1 or 2 chars so each column of week can + // be as narrow as 2 digits. + // + // 1. Compute the minLength of the day name abbreviations. + let minLength = dayList.map(name => name.length).reduce((min, len) => Math.min(min, len)); + + // 2. If some day name abbrev. is longer than 2 chars (not Catalan), + // and ALL localized day names share same prefix (as in Chinese), + // then trim shared "day-" prefix. + if (dayList.some(dayAbbr => dayAbbr.length > 2)) { + for (let endPrefix = 0; endPrefix < minLength; endPrefix++) { + let suffix = dayList[0][endPrefix]; + if (dayList.some(dayAbbr => dayAbbr[endPrefix] != suffix)) { + if (endPrefix > 0) { + for (i = 0; i < dayList.length; i++) { + // trim prefix chars. + dayList[i] = dayList[i].substring(endPrefix); + } + } + break; + } + } + } + // 3. Trim each day abbreviation to 1 char if unique, else 2 chars. + for (i = 0; i < dayList.length; i++) { + let foundMatch = 1; + for (j = 0; j < dayList.length; j++) { + if (i != j) { + if (dayList[i].substring(0, 1) == dayList[j].substring(0, 1)) { + foundMatch = 2; + break; + } + } + } + dayList[i] = dayList[i].substring(0, foundMatch); + } + } + + this._getCalBoxNode(0, 0).hidden = !this.showWeekNumber; + for (let column = 1; column < 8; column++) { + let node = this._getCalBoxNode(0, column); + node.textContent = dayList[column - 1]; + node.setAttribute("aria-label", longDayList[column - 1]); + } + } + + showMonth(aDate) { + // Use mExtraDate if aDate is null. + aDate = new Date(aDate || this.mExtraDate); + + aDate.setDate(1); + // We set the hour and minute to something highly unlikely to be the + // exact change point of DST, so timezones like America/Sao Paulo + // don't display some days twice. + aDate.setHours(12); + aDate.setMinutes(34); + aDate.setSeconds(0); + aDate.setMilliseconds(0); + // Don't fire onmonthchange event upon initialization + let monthChanged = this.mEditorDate && this.mEditorDate.valueOf() != aDate.valueOf(); + this.mEditorDate = aDate; // Only place mEditorDate is set. + + if (this.mSelected) { + this.mSelected.removeAttribute("selected"); + this.mSelected = null; + } + + // Get today's date. + let today = new Date(); + + if (!monthChanged && this.dayBoxes.size > 0) { + this.mSelected = this.getBoxForDate(this.value); + if (this.mSelected) { + this.mSelected.setAttribute("selected", "true"); + } + + let todayBox = this.getBoxForDate(today); + if (this.mToday != todayBox) { + if (this.mToday) { + this.mToday.removeAttribute("today"); + } + this.mToday = todayBox; + if (this.mToday) { + this.mToday.setAttribute("today", "true"); + } + } + return; + } + + if (this.mToday) { + this.mToday.removeAttribute("today"); + this.mToday = null; + } + + if (this.mExtra) { + this.mExtra.removeAttribute("extra"); + this.mExtra = null; + } + + // Update the month and year title. + this.setAttribute("year", aDate.getFullYear()); + this.setAttribute("month", aDate.getMonth()); + + let miniMonthName = this.querySelector(".minimonth-month-name"); + let dateString = cal.l10n.getDateFmtString(`month.${aDate.getMonth() + 1}.name`); + miniMonthName.setAttribute("value", dateString); + miniMonthName.setAttribute("monthIndex", aDate.getMonth()); + this.mReadOnlyHeader.textContent = dateString + " " + aDate.getFullYear(); + + // Update the calendar. + let calbox = this.querySelector(".minimonth-calendar"); + let date = this._getStartDate(aDate); + + if (aDate.getFullYear() == (this.mValue || this.mExtraDate).getFullYear()) { + calbox.setAttribute("aria-label", dateString); + } else { + let monthName = cal.l10n.formatMonth(aDate.getMonth() + 1, "calendar", "monthInYear"); + let label = cal.l10n.getCalString("monthInYear", [monthName, aDate.getFullYear()]); + calbox.setAttribute("aria-label", label); + } + + this.dayBoxes.clear(); + let defaultTz = cal.dtz.defaultTimezone; + for (let k = 1; k < 7; k++) { + // Set the week number. + let firstElement = this._getCalBoxNode(k, 0); + firstElement.hidden = !this.showWeekNumber; + if (this.showWeekNumber) { + let weekNumber = cal.weekInfoService.getWeekTitle( + cal.dtz.jsDateToDateTime(date, defaultTz) + ); + let weekTitle = cal.l10n.getCalString("WeekTitle", [weekNumber]); + firstElement.textContent = weekNumber; + firstElement.setAttribute("aria-label", weekTitle); + } + + for (let i = 1; i < 8; i++) { + let day = this._getCalBoxNode(k, i); + this.setBoxForDate(date, day); + + if (this.getAttribute("readonly") != "true") { + day.setAttribute("interactive", "true"); + } + + if (aDate.getMonth() == date.getMonth()) { + day.removeAttribute("othermonth"); + } else { + day.setAttribute("othermonth", "true"); + } + + // Highlight today. + if (this._sameDay(today, date)) { + this.mToday = day; + day.setAttribute("today", "true"); + } + + // Highlight the current date. + let val = this.value; + if (this._sameDay(val, date)) { + this.mSelected = day; + day.setAttribute("selected", "true"); + } + + // Highlight the extra date. + if (this._sameDay(this.mExtraDate, date)) { + this.mExtra = day; + day.setAttribute("extra", "true"); + } + + if (aDate.getMonth() == date.getMonth() && aDate.getFullYear() == date.getFullYear()) { + day.setAttribute("aria-label", dayFormatter.format(date)); + } else { + day.setAttribute("aria-label", dateFormatter.format(date)); + } + + day.removeAttribute("busy"); + + day.date = new Date(date); + day.textContent = date.getDate(); + date.setDate(date.getDate() + 1); + + this.resetAttributesForBox(day); + } + } + + if (!this.mFocused) { + this.setFocusedDate(this.mValue || this.mExtraDate); + } + + this.fireEvent("monthchange"); + + if (this.getAttribute("freebusy") == "true") { + this.getItems(); + } + } + + /** + * Attention - duplicate!!!! + */ + fireEvent(aEventName) { + this.dispatchEvent(new CustomEvent(aEventName, { bubbles: true })); + } + + _boxKeyForDate(aDate) { + if (aDate instanceof lazy.CalDateTime || aDate instanceof Ci.calIDateTime) { + return aDate.getInTimezone(cal.dtz.defaultTimezone).toString().substring(0, 10); + } + return [ + aDate.getFullYear(), + (aDate.getMonth() + 1).toString().padStart(2, "0"), + aDate.getDate().toString().padStart(2, "0"), + ].join("-"); + } + + /** + * Fetches the table cell for the given date, or null if the date isn't displayed. + * + * @param {calIDateTime|Date} aDate + * @returns {HTMLTableCellElement|null} + */ + getBoxForDate(aDate) { + return this.dayBoxes.get(this._boxKeyForDate(aDate)) ?? null; + } + + /** + * Stores the table cell for the given date. + * + * @param {Date} aDate + * @param {HTMLTableCellElement} aBox + */ + setBoxForDate(aDate, aBox) { + this.dayBoxes.set(this._boxKeyForDate(aDate), aBox); + } + + /** + * Remove attributes that may have been added to a table cell. + * + * @param {HTMLTableCellElement} aBox + */ + resetAttributesForBox(aBox) { + let allowedAttributes = 0; + while (aBox.attributes.length > allowedAttributes) { + switch (aBox.attributes[allowedAttributes].nodeName) { + case "selected": + case "othermonth": + case "today": + case "extra": + case "interactive": + case "class": + case "tabindex": + case "role": + case "aria-label": + allowedAttributes++; + break; + default: + aBox.removeAttribute(aBox.attributes[allowedAttributes].nodeName); + break; + } + } + } + + /** + * Remove attributes that may have been added to a table cell, or all table cells. + * + * @param {Date} [aDate] - If specified, the date of the cell to reset, + * otherwise all date cells will be reset. + */ + resetAttributesForDate(aDate) { + if (aDate) { + let box = this.getBoxForDate(aDate); + if (box) { + this.resetAttributesForBox(box); + } + } else { + for (let k = 1; k < 7; k++) { + for (let i = 1; i < 8; i++) { + this.resetAttributesForBox(this._getCalBoxNode(k, i)); + } + } + } + } + + _setFreeBusy(aFreeBusy) { + if (aFreeBusy) { + if (!this.mObservesComposite) { + cal.view.getCompositeCalendar(window).addObserver(this.calICompositeObserver); + this.mObservesComposite = true; + this.getItems(); + } + } else if (this.mObservesComposite) { + cal.view.getCompositeCalendar(window).removeObserver(this.calICompositeObserver); + this.mObservesComposite = false; + } + } + + removeAttribute(aAttr) { + if (aAttr == "freebusy") { + this._setFreeBusy(false); + } + return super.removeAttribute(aAttr); + } + + setAttribute(aAttr, aVal) { + if (aAttr == "freebusy") { + this._setFreeBusy(aVal == "true"); + } + return super.setAttribute(aAttr, aVal); + } + + async getItems(aCalendar) { + // The minimonth automatically clears extra styles on a month change. + // Therefore we only need to fill the minimonth with new info. + + let calendar = aCalendar || cal.view.getCompositeCalendar(window); + let filter = + calendar.ITEM_FILTER_COMPLETED_ALL | + calendar.ITEM_FILTER_CLASS_OCCURRENCES | + calendar.ITEM_FILTER_ALL_ITEMS; + + // Get new info. + for await (let items of cal.iterate.streamValues( + calendar.getItems(filter, 0, this.firstDate, this.lastDate) + )) { + items.forEach(item => this.setBusyDaysForOccurrence(item, true)); + } + } + + updateAccessibleLabel() { + let label; + if (this.mValue) { + label = dateFormatter.format(this.mValue); + } else { + label = cal.l10n.getCalString("minimonthNoSelectedDate"); + } + this.setAttribute("aria-label", label); + } + + update(aValue) { + let changed = + this.mValue && + aValue && + (this.mValue.getFullYear() != aValue.getFullYear() || + this.mValue.getMonth() != aValue.getMonth() || + this.mValue.getDate() != aValue.getDate()); + + this.mValue = aValue; + if (changed) { + this.fireEvent("change"); + } + this.showMonth(aValue); + if (aValue) { + this.setFocusedDate(aValue); + } + this.updateAccessibleLabel(); + } + + setFocusedDate(aDate, aForceFocus) { + let newFocused = this.getBoxForDate(aDate); + if (!newFocused) { + return; + } + if (this.mFocused) { + this.mFocused.setAttribute("tabindex", "-1"); + } + this.mFocused = newFocused; + this.mFocused.setAttribute("tabindex", "0"); + // Only actually move the focus if it is already in the calendar box. + if (!aForceFocus) { + let calbox = this.querySelector(".minimonth-calendar"); + aForceFocus = calbox.contains(document.commandDispatcher.focusedElement); + } + if (aForceFocus) { + this.mFocused.focus(); + } + } + + focusDate(aDate) { + this.showMonth(aDate); + this.setFocusedDate(aDate); + } + + switchMonth(aMonth) { + let newMonth = new Date(this.mEditorDate); + newMonth.setMonth(aMonth); + this.showMonth(newMonth); + } + + switchYear(aYear) { + let newMonth = new Date(this.mEditorDate); + newMonth.setFullYear(aYear); + this.showMonth(newMonth); + } + + selectDate(aDate, aMainDate) { + if ( + !aMainDate || + aDate < this._getStartDate(aMainDate) || + aDate > this._getEndDate(aMainDate) + ) { + aMainDate = new Date(aDate); + aMainDate.setDate(1); + } + // Note that aMainDate and this.mEditorDate refer to the first day + // of the corresponding month. + let sameMonth = this._sameDay(aMainDate, this.mEditorDate); + let sameDate = this._sameDay(aDate, this.mValue); + if (!sameMonth && !sameDate) { + // Change month and select day. + this.mValue = aDate; + this.showMonth(aMainDate); + } else if (!sameMonth) { + // Change month only. + this.showMonth(aMainDate); + } else if (!sameDate) { + // Select day only. + let day = this.getBoxForDate(aDate); + if (this.mSelected) { + this.mSelected.removeAttribute("selected"); + } + this.mSelected = day; + day.setAttribute("selected", "true"); + this.mValue = aDate; + this.setFocusedDate(aDate); + } + } + + _getStartDate(aMainDate) { + let date = new Date(aMainDate); + let firstWeekday = (7 + aMainDate.getDay() - this.weekStart) % 7; + date.setDate(date.getDate() - firstWeekday); + return date; + } + + _getEndDate(aMainDate) { + let date = this._getStartDate(aMainDate); + let calbox = this.querySelector(".minimonth-calendar"); + let days = (calbox.children.length - 1) * 7; + date.setDate(date.getDate() + days - 1); + return date; + } + + _sameDay(aDate1, aDate2) { + if ( + aDate1 && + aDate2 && + aDate1.getDate() == aDate2.getDate() && + aDate1.getMonth() == aDate2.getMonth() && + aDate1.getFullYear() == aDate2.getFullYear() + ) { + return true; + } + return false; + } + + advanceMonth(aDir) { + let advEditorDate = new Date(this.mEditorDate); // At 1st of month. + let advMonth = this.mEditorDate.getMonth() + aDir; + advEditorDate.setMonth(advMonth); + this.showMonth(advEditorDate); + } + + advanceYear(aDir) { + let advEditorDate = new Date(this.mEditorDate); // At 1st of month. + let advYear = this.mEditorDate.getFullYear() + aDir; + advEditorDate.setFullYear(advYear); + this.showMonth(advEditorDate); + } + + moveDateByOffset(aYears, aMonths, aDays) { + const date = new Date( + this.mFocused.date.getFullYear() + aYears, + this.mFocused.date.getMonth() + aMonths, + this.mFocused.date.getDate() + aDays + ); + this.focusDate(date); + } + + focusCalendar() { + this.mFocused.focus(); + } + + onDayActivate(aEvent) { + // The associated date might change when setting this.value if month changes. + const date = aEvent.target.date; + if (this.getAttribute("readonly") != "true") { + this.value = date; + this.fireEvent("select"); + } + this.setFocusedDate(date, true); + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + + onDayMovement(event, years, months, days) { + this.moveDateByOffset(years, months, days); + event.stopPropagation(); + event.preventDefault(); + } + + disconnectedCallback() { + if (this.mObservesComposite) { + cal.view.getCompositeCalendar(window).removeObserver(this.calICompositeObserver); + } + } + } + + MozXULElement.implementCustomInterface(CalendarMinimonth, [ + Ci.calIObserver, + Ci.calICompositeObserver, + ]); + customElements.define("calendar-minimonth", CalendarMinimonth); +} diff --git a/comm/calendar/base/content/widgets/calendar-modebox.js b/comm/calendar/base/content/widgets/calendar-modebox.js new file mode 100644 index 0000000000..417c790e34 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-modebox.js @@ -0,0 +1,244 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * A calendar-modebox directly extends to a xul:box element with extra functionality. Like a + * xul:hbox it has a horizontal orientation. It is designed to be displayed only: + * 1) in given application modes (e.g "task" mode, "calendar" mode) and + * 2) only in relation to the "checked" attribute of a control (e.g. a command or checkbox). + * + * - The attribute "mode" denotes a comma-separated list of all modes that the modebox should + * not be collapsed in, e.g. `mode="calendar,task"`. + * - The attribute "current" denotes the current viewing mode. + * - The attribute "refcontrol" points to a control, either a "command", "checkbox" or other + * elements that support a "checked" attribute, that is often used to denote whether a + * modebox should be displayed or not. If "refcontrol" is set to the id of a command you + * can there set the oncommand attribute like: + * `oncommand='document.getElementById('my-mode-pane').togglePane(event)`. + * In case it is a checkbox element or derived checkbox element this is done automatically + * by listening to the event "CheckboxChange". So if the current application mode is one of + * the modes listed in the "mode" attribute it is additionally verified whether the element + * denoted by "refcontrol" is checked or not. + * - The attribute "collapsedinmodes" is a comma-separated list of the modes the modebox + * should be collapsed in (e.g. "mail,calendar"). For example, if the user collapses a + * modebox when in a given mode, that mode would be added to "collapsedinmodes". This + * attribute is made persistent across restarts. + * + * @augments {MozXULElement} + */ + class CalendarModebox extends MozXULElement { + static get observedAttributes() { + return ["current"]; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.mRefControl = null; + + if (this.hasAttribute("refcontrol")) { + this.mRefControl = document.getElementById(this.getAttribute("refcontrol")); + if (this.mRefControl && this.mRefControl.localName == "checkbox") { + this.mRefControl.addEventListener("CheckboxStateChange", this, true); + } + } + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "current" && oldValue != newValue) { + let display = this.isVisibleInMode(newValue); + this.setVisible(display, false, true); + } + } + + get currentMode() { + return this.getAttribute("current"); + } + + /** + * The event handler for various events relevant to CalendarModebox. + * + * @param {Event} event - The event. + */ + handleEvent(event) { + if (event.type == "CheckboxStateChange") { + this.onCheckboxStateChange(event); + } + } + + /** + * A "mode attribute" contains comma-separated lists of values, for example: + * `modewidths="200,200,200"`. Each of these values corresponds to one of the modes in + * the "mode" attribute: `mode="mail,calendar,task"`. This function sets a new value for + * a given mode in a given "mode attribute". + * + * @param {string} attributeName - A "mode attribute" in which to set a new value. + * @param {string} value - A new value to set. + * @param {string} [mode=this.currentMode] - Set the value for this mode. + */ + setModeAttribute(attributeName, value, mode = this.currentMode) { + if (!this.hasAttribute(attributeName)) { + return; + } + let attributeValues = this.getAttribute(attributeName).split(","); + let modes = this.getAttribute("mode").split(","); + attributeValues[modes.indexOf(mode)] = value; + this.setAttribute(attributeName, attributeValues.join(",")); + } + + /** + * A "mode attribute" contains comma-separated lists of values, for example: + * `modewidths="200,200,200"`. Each of these values corresponds to one of the modes in + * the "mode" attribute: `mode="mail,calendar,task"`. This function returns the value + * for a given mode in a given "mode attribute". + * + * @param {string} attributeName - A "mode attribute" to get a value from. + * @param {string} [mode=this.currentMode] - Get the value for this mode. + * @returns {string} The value found in the mode attribute or an empty string. + */ + getModeAttribute(attributeName, mode = this.currentMode) { + if (!this.hasAttribute(attributeName)) { + return ""; + } + let attributeValues = this.getAttribute(attributeName).split(","); + let modes = this.getAttribute("mode").split(","); + return attributeValues[modes.indexOf(mode)]; + } + + /** + * Sets the visibility (collapsed state) of this modebox and (optionally) updates the + * `collapsedinmode` attribute and (optionally) notifies the `refcontrol`. + * + * @param {boolean} visible - Whether the modebox should become visible or not. + * @param {boolean} [toPushModeCollapsedAttribute=true] - Whether to push the current mode + * to `collapsedinmodes` attribute. + * @param {boolean} [toNotifyRefControl=true] - Whether to notify the `refcontrol`. + */ + setVisible(visible, toPushModeCollapsedAttribute = true, toNotifyRefControl = true) { + let pushModeCollapsedAttribute = toPushModeCollapsedAttribute === true; + let notifyRefControl = toNotifyRefControl === true; + + let collapsedModes = []; + let modeIndex = -1; + let collapsedInMode = false; + + if (this.hasAttribute("collapsedinmodes")) { + collapsedModes = this.getAttribute("collapsedinmodes").split(","); + modeIndex = collapsedModes.indexOf(this.currentMode); + collapsedInMode = modeIndex > -1; + } + + let display = visible; + if (display && !pushModeCollapsedAttribute) { + display = !collapsedInMode; + } + + this.collapsed = !display || !this.isVisibleInMode(); + + if (pushModeCollapsedAttribute) { + if (!display) { + if (modeIndex == -1) { + collapsedModes.push(this.currentMode); + if (this.getAttribute("collapsedinmodes") == ",") { + collapsedModes.splice(0, 2); + } + } + } else if (modeIndex > -1) { + collapsedModes.splice(modeIndex, 1); + if (collapsedModes.join(",") == "") { + collapsedModes[0] = ","; + } + } + this.setAttribute("collapsedinmodes", collapsedModes.join(",")); + + Services.xulStore.persist(this, "collapsedinmodes"); + } + + if (notifyRefControl && this.hasAttribute("refcontrol")) { + let command = document.getElementById(this.getAttribute("refcontrol")); + if (command) { + command.setAttribute("checked", display); + command.disabled = !this.isVisibleInMode(); + } + } + } + + /** + * Return whether this modebox is visible for a given mode, according to both its + * `mode` and `collapsedinmodes` attributes. + * + * @param {string} [mode=this.currentMode] - Is the modebox visible for this mode? + * @returns {boolean} Whether this modebox is visible for the given mode. + */ + isVisible(mode = this.currentMode) { + if (!this.isVisibleInMode(mode)) { + return false; + } + let collapsedModes = this.getAttribute("collapsedinmodes").split(","); + return !collapsedModes.includes(mode); + } + + /** + * Returns whether this modebox is visible for a given mode, according to its + * `mode` attribute. + * + * @param {string} [mode=this.currentMode] - Is the modebox visible for this mode? + * @returns {boolean} Whether this modebox is visible for the given mode. + */ + isVisibleInMode(mode = this.currentMode) { + return this.hasAttribute("mode") ? this.getAttribute("mode").split(",").includes(mode) : true; + } + + /** + * Used to toggle the checked state of a command connected to this modebox, and set the + * visibility of this modebox accordingly. + * + * @param {Event} event - An event with a command (with a checked attribute) as its target. + */ + togglePane(event) { + let command = event.target; + let newValue = command.getAttribute("checked") == "true" ? "false" : "true"; + command.setAttribute("checked", newValue); + this.setVisible(newValue == "true", true, true); + } + + /** + * Handles a change in a checkbox state, by making this modebox visible or not. + * + * @param {Event} event - An event with a target that has a `checked` attribute. + */ + onCheckboxStateChange(event) { + let newValue = event.target.checked; + this.setVisible(newValue, true, true); + } + } + + customElements.define("calendar-modebox", CalendarModebox); + + /** + * A `calendar-modebox` but with a vertical orientation like a `vbox`. (Different Custom + * Elements cannot be defined using the same class, thus we need this subclass.) + * + * @augments {CalendarModebox} + */ + class CalendarModevbox extends CalendarModebox { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + super.connectedCallback(); + this.setAttribute("orient", "vertical"); + } + } + + customElements.define("calendar-modevbox", CalendarModevbox); +} diff --git a/comm/calendar/base/content/widgets/calendar-notifications-setting.js b/comm/calendar/base/content/widgets/calendar-notifications-setting.js new file mode 100644 index 0000000000..1f772992c7 --- /dev/null +++ b/comm/calendar/base/content/widgets/calendar-notifications-setting.js @@ -0,0 +1,259 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); + + /** + * A calendar-notifications-setting provides controls to config notifications + * times of a calendar. + * + * @augments {MozXULElement} + */ + class CalendarNotificationsSetting extends MozXULElement { + connectedCallback() { + MozXULElement.insertFTLIfNeeded("calendar/calendar-widgets.ftl"); + } + + /** + * @type {string} A string in the form of "PT5M PT0M" to represent the notifications times. + */ + get value() { + return [...this._elList.children] + .map(row => { + let count = row.querySelector("input").value; + let unit = row.querySelector(".unit-menu").value; + let [relation, tag] = row.querySelector(".relation-menu").value.split("-"); + + tag = tag == "END" ? "END:" : ""; + relation = relation == "before" ? "-" : ""; + let durTag = unit == "D" ? "P" : "PT"; + return `${tag}${relation}${durTag}${count}${unit}`; + }) + .join(","); + } + + set value(value) { + // An array of notifications times, each item is in the form of [5, "M", + // "before-start"], i.e. a triple of time, unit and relation. + let items = []; + let durations = value?.split(",") || []; + for (let dur of durations) { + dur = dur.trim(); + if (!dur) { + continue; + } + let [relation, value] = dur.split(":"); + if (!value) { + value = relation; + relation = "START"; + } + if (value.startsWith("-")) { + relation = `before-${relation}`; + value = value.slice(1); + } else { + relation = `after-${relation}`; + } + let prefix = value.slice(0, 2); + if (prefix != "PT") { + prefix = value[0]; + } + let unit = value.slice(-1); + if ((prefix == "P" && unit != "D") || (prefix == "PT" && !["M", "H"].includes(unit))) { + continue; + } + value = value.slice(prefix.length, -1); + items.push([value, unit, relation]); + } + this._render(items); + } + + /** + * @type {boolean} If true, all form controls should be disabled. + */ + set disabled(disabled) { + this._disabled = disabled; + this._updateDisabled(); + } + + /** + * Update the disabled attributes of all form controls to this._disabled. + */ + _updateDisabled() { + for (let el of this.querySelectorAll("label, input, button, menulist")) { + el.disabled = this._disabled; + } + } + + /** + * Because form controls can be dynamically added/removed, we bind events to + * _elButtonAdd and _elList. + */ + _bindEvents() { + this._elButtonAdd.addEventListener("click", e => { + // Add a notification time row. + this._addNewRow(0, "M", "before-START"); + this._emit(); + }); + + this._elList.addEventListener("change", e => { + if (!HTMLInputElement.isInstance(e.target)) { + // We only care about change event of input elements. + return; + } + // We don't want this to interfere with the 'change' event emitted by + // calendar-notifications-setting itself. + e.stopPropagation(); + this._updateMenuLists(); + this._emit(); + }); + + this._elList.addEventListener("command", e => { + let el = e.target; + if (el.tagName == "menuitem") { + this._emit(); + } else if (el.tagName == "button") { + // Remove a notification time row. + el.closest("hbox").remove(); + this._updateAddButton(); + this._emit(); + } + }); + } + + /** + * Render the layout and the add button, then bind events. This is delayed + * until the first `set value` call, so that l10n works correctly. + */ + _renderLayout() { + this.appendChild( + MozXULElement.parseXULToFragment(` + <hbox align="center"> + <label data-l10n-id="calendar-notifications-label"></label> + <spacer flex="1"></spacer> + <button class="add-button" + data-l10n-id="calendar-add-notification-button"/> + </hbox> + <separator class="thin"/> + <vbox class="calendar-notifications-list indent"></vbox> + `) + ); + this._elList = this.querySelector(".calendar-notifications-list"); + this._elButtonAdd = this.querySelector("button"); + this._bindEvents(); + } + + /** + * Render this_items to a list of rows. + * + * @param {Array<[number, string, string]>} items - An array of count, unit and relation. + */ + _render(items) { + this._renderLayout(); + + // Render a row for each item in this._items. + items.forEach(([value, unit, relation]) => { + this._addNewRow(value, unit, relation); + }); + if (items.length) { + this._updateMenuLists(); + this._updateDisabled(); + } + } + + /** + * Render a notification entry to a row. Each row contains a time input, a + * unit menulist, a relation menulist and a remove button. + */ + _addNewRow(value, unit, relation) { + let fragment = MozXULElement.parseXULToFragment(` + <hbox class="calendar-notifications-row" align="center"> + <html:input class="size3" value="${value}" type="number" min="0"/> + <menulist class="unit-menu" crop="none" value="${unit}"> + <menupopup> + <menuitem value="M"/> + <menuitem value="H"/> + <menuitem value="D"/> + </menupopup> + </menulist> + <menulist class="relation-menu" crop="none" value="${relation}"> + <menupopup class="reminder-relation-origin-menupopup"> + <menuitem data-id="reminderCustomOriginBeginBeforeEvent" + value="before-START"/> + <menuitem data-id="reminderCustomOriginBeginAfterEvent" + value="after-START"/> + <menuitem data-id="reminderCustomOriginEndBeforeEvent" + value="before-END"/> + <menuitem data-id="reminderCustomOriginEndAfterEvent" + value="after-END"/> + </menupopup> + </menulist> + <button class="remove-button"></button> + </hbox> + `); + this._elList.appendChild(fragment); + this._updateMenuLists(); + this._updateAddButton(); + } + + /** + * To prevent a too crowded UI, hide the add button if already have 5 rows. + */ + _updateAddButton() { + if (this._elList.childElementCount >= 5) { + this._elButtonAdd.hidden = true; + } else { + this._elButtonAdd.hidden = false; + } + } + + /** + * Iterate all rows, update the plurality of menulist (unit) to the input + * value (time). + */ + _updateMenuLists() { + for (let row of this._elList.children) { + let input = row.querySelector("input"); + let menulist = row.querySelector(".unit-menu"); + this._updateMenuList(input.value, menulist); + for (let menuItem of row.querySelectorAll(".relation-menu menuitem")) { + menuItem.label = cal.l10n.getString("calendar-alarms", menuItem.dataset.id); + } + } + } + + /** + * Update the plurality of a menulist (unit) options to the input value (time). + */ + _updateMenuList(length, menu) { + let getUnitEntry = unit => + ({ + M: "unitMinutes", + H: "unitHours", + D: "unitDays", + }[unit] || "unitMinutes"); + + for (let menuItem of menu.getElementsByTagName("menuitem")) { + menuItem.label = PluralForm.get(length, cal.l10n.getCalString(getUnitEntry(menuItem.value))) + .replace("#1", "") + .trim(); + } + } + + /** + * Emit a change event. + */ + _emit() { + this.dispatchEvent(new CustomEvent("change", { detail: this.value })); + } + } + + customElements.define("calendar-notifications-setting", CalendarNotificationsSetting); +} diff --git a/comm/calendar/base/content/widgets/datetimepickers.js b/comm/calendar/base/content/widgets/datetimepickers.js new file mode 100644 index 0000000000..ae2c87caf8 --- /dev/null +++ b/comm/calendar/base/content/widgets/datetimepickers.js @@ -0,0 +1,1529 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements, MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + + // Leave these first arguments as `undefined`, to use the OS style if + // intl.regional_prefs.use_os_locales is true or the app language matches the OS language. + // Otherwise, the app language is used. + let dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "short" }); + let timeFormatter = new Services.intl.DateTimeFormat(undefined, { timeStyle: "short" }); + + let probeSucceeded; + let alphaMonths; + let yearIndex, monthIndex, dayIndex; + let ampmIndex, amRegExp, pmRegExp; + let parseTimeRegExp, parseShortDateRegex; + + class MozTimepickerMinute extends MozXULElement { + static get observedAttributes() { + return ["label", "selected"]; + } + + constructor() { + super(); + + this.addEventListener("wheel", event => { + const pixelThreshold = 50; + let deltaView = 0; + + if (event.deltaMode == event.DOM_DELTA_PAGE || event.deltaMode == event.DOM_DELTA_LINE) { + // Line/Page scrolling is usually vertical + if (event.deltaY) { + deltaView = event.deltaY < 0 ? -1 : 1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + // The natural direction for pixel scrolling is left/right + this.pixelScrollDelta += event.deltaX; + if (this.pixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.pixelScrollDelta = 0; + } else if (this.pixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.pixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.moveMinutes(deltaView); + } + + event.stopPropagation(); + event.preventDefault(); + }); + + this.clickMinute = (minuteItem, minuteNumber) => { + this.closest("timepicker-grids").clickMinute(minuteItem, minuteNumber); + }; + this.moveMinutes = number => { + this.closest("timepicker-grids").moveMinutes(number); + }; + } + + connectedCallback() { + if (this.hasChildNodes()) { + return; + } + + const spacer = document.createXULElement("spacer"); + spacer.setAttribute("flex", "1"); + + const minutebox = document.createXULElement("vbox"); + minutebox.addEventListener("click", () => { + this.clickMinute(this, this.getAttribute("value")); + }); + + const box = document.createXULElement("box"); + + this.label = document.createXULElement("label"); + this.label.classList.add("time-picker-minute-label"); + + box.appendChild(this.label); + minutebox.appendChild(box); + + this.appendChild(spacer.cloneNode()); + this.appendChild(minutebox); + this.appendChild(spacer); + + this.pixelScrollDelta = 0; + + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.label) { + return; + } + + if (this.hasAttribute("label")) { + this.label.setAttribute("value", this.getAttribute("label")); + } else { + this.label.removeAttribute("value"); + } + + if (this.hasAttribute("selected")) { + this.label.setAttribute("selected", this.getAttribute("selected")); + } else { + this.label.removeAttribute("selected"); + } + } + } + + class MozTimepickerHour extends MozXULElement { + static get observedAttributes() { + return ["label", "selected"]; + } + + constructor() { + super(); + + this.addEventListener("wheel", event => { + const pixelThreshold = 50; + let deltaView = 0; + + if (event.deltaMode == event.DOM_DELTA_PAGE || event.deltaMode == event.DOM_DELTA_LINE) { + // Line/Page scrolling is usually vertical + if (event.deltaY) { + deltaView = event.deltaY < 0 ? -1 : 1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + // The natural direction for pixel scrolling is left/right + this.pixelScrollDelta += event.deltaX; + if (this.pixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.pixelScrollDelta = 0; + } else if (this.pixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.pixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.moveHours(deltaView); + } + + event.stopPropagation(); + event.preventDefault(); + }); + + this.clickHour = (hourItem, hourNumber) => { + this.closest("timepicker-grids").clickHour(hourItem, hourNumber); + }; + this.moveHours = number => { + this.closest("timepicker-grids").moveHours(number); + }; + this.doubleClickHour = (hourItem, hourNumber) => { + this.closest("timepicker-grids").doubleClickHour(hourItem, hourNumber); + }; + } + + connectedCallback() { + if (this.hasChildNodes()) { + return; + } + + const spacer = document.createXULElement("spacer"); + spacer.setAttribute("flex", "1"); + + const hourbox = document.createXULElement("vbox"); + hourbox.addEventListener("click", () => { + this.clickHour(this, this.getAttribute("value")); + }); + hourbox.addEventListener("dblclick", () => { + this.doubleClickHour(this, this.getAttribute("value")); + }); + + const box = document.createXULElement("box"); + + this.label = document.createXULElement("label"); + this.label.classList.add("time-picker-hour-label"); + + box.appendChild(this.label); + hourbox.appendChild(box); + hourbox.appendChild(spacer.cloneNode()); + + this.appendChild(spacer.cloneNode()); + this.appendChild(hourbox); + this.appendChild(spacer); + + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.label) { + return; + } + + if (this.hasAttribute("label")) { + this.label.setAttribute("value", this.getAttribute("label")); + } else { + this.label.removeAttribute("value"); + } + + if (this.hasAttribute("selected")) { + this.label.setAttribute("selected", this.getAttribute("selected")); + } else { + this.label.removeAttribute("selected"); + } + } + } + + /** + * The MozTimepickerGrids widget displays the grid of times to select, e.g. for an event. + * Typically it represents the popup content that let's the user select a time, in a + * <timepicker> widget. + * + * @augments MozXULElement + */ + class MozTimepickerGrids extends MozXULElement { + constructor() { + super(); + + this.content = MozXULElement.parseXULToFragment(` + <vbox class="time-picker-grids"> + <vbox class="time-picker-hour-grid" format12hours="false"> + <hbox flex="1" class="timepicker-topRow-hour-class"> + <timepicker-hour class="time-picker-hour-box-class" value="0" label="0"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="1" label="1"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="2" label="2"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="3" label="3"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="4" label="4"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="5" label="5"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="6" label="6"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="7" label="7"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="8" label="8"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="9" label="9"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="10" label="10"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="11" label="11"></timepicker-hour> + <hbox class="timepicker-amLabelBox-class amLabelBox" hidden="true"> + <label></label> + </hbox> + </hbox> + <hbox flex="1" class="timepicker-bottomRow-hour-class"> + <timepicker-hour class="time-picker-hour-box-class" value="12" label="12"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="13" label="13"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="14" label="14"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="15" label="15"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="16" label="16"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="17" label="17"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="18" label="18"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="19" label="19"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="20" label="20"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="21" label="21"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="22" label="22"></timepicker-hour> + <timepicker-hour class="time-picker-hour-box-class" value="23" label="23"></timepicker-hour> + <hbox class="pmLabelBox timepicker-pmLabelBox-class" hidden="true"> + <label></label> + </hbox> + </hbox> + </vbox> + <vbox class="time-picker-five-minute-grid-box"> + <vbox class="time-picker-five-minute-grid"> + <hbox flex="1"> + <timepicker-minute class="time-picker-five-minute-class" value="0" label=":00" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="5" label=":05" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="10" label=":10" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="15" label=":15" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="20" label=":20" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="25" label=":25" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-five-minute-class" value="30" label=":30" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="35" label=":35" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="40" label=":40" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="45" label=":45" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="50" label=":50" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-five-minute-class" value="55" label=":55" flex="1"></timepicker-minute> + </hbox> + </vbox> + <hbox class="time-picker-minutes-bottom"> + <spacer flex="1"></spacer> + <label class="time-picker-more-control-label" value="»" onclick="clickMore()"></label> + </hbox> + </vbox> + <vbox class="time-picker-one-minute-grid-box" flex="1" hidden="true"> + <vbox class="time-picker-one-minute-grid" flex="1"> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="0" label=":00" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="1" label=":01" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="2" label=":02" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="3" label=":03" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="4" label=":04" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="5" label=":05" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="6" label=":06" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="7" label=":07" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="8" label=":08" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="9" label=":09" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="10" label=":10" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="11" label=":11" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="12" label=":12" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="13" label=":13" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="14" label=":14" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="15" label=":15" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="16" label=":16" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="17" label=":17" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="18" label=":18" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="19" label=":19" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="20" label=":20" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="21" label=":21" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="22" label=":22" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="23" label=":23" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="24" label=":24" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="25" label=":25" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="26" label=":26" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="27" label=":27" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="28" label=":28" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="29" label=":29" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="30" label=":30" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="31" label=":31" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="32" label=":32" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="33" label=":33" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="34" label=":34" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="35" label=":35" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="36" label=":36" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="37" label=":37" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="38" label=":38" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="39" label=":39" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="40" label=":40" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="41" label=":41" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="42" label=":42" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="43" label=":43" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="44" label=":44" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="45" label=":45" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="46" label=":46" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="47" label=":47" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="48" label=":48" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="49" label=":49" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="50" label=":50" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="51" label=":51" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="52" label=":52" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="53" label=":53" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="54" label=":54" flex="1"></timepicker-minute> + </hbox> + <hbox flex="1"> + <timepicker-minute class="time-picker-one-minute-class" value="55" label=":55" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="56" label=":56" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="57" label=":57" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="58" label=":58" flex="1"></timepicker-minute> + <timepicker-minute class="time-picker-one-minute-class" value="59" label=":59" flex="1"></timepicker-minute> + </hbox> + </vbox> + <hbox class="time-picker-minutes-bottom"> + <spacer flex="1"></spacer> + <label class="time-picker-more-control-label" value="«" onclick="clickLess()"></label> + </hbox> + </vbox> + </vbox> + `); + } + + connectedCallback() { + if (!this.hasChildNodes()) { + this.appendChild(document.importNode(this.content, true)); + } + + // set by onPopupShowing + this.mPicker = null; + + // The currently selected time + this.mSelectedTime = new Date(); + // The selected hour and selected minute items + this.mSelectedHourItem = null; + this.mSelectedMinuteItem = null; + // constants use to specify one and five minute view + this.kMINUTE_VIEW_FIVE = 5; + this.kMINUTE_VIEW_ONE = 1; + } + + /** + * Sets new mSelectedTime. + * + * @param {string | Array} val new mSelectedTime value + */ + set value(val) { + if (typeof val == "string") { + val = parseTime(val); + } else if (Array.isArray(val)) { + let [hours, minutes] = val; + val = new Date(); + val.setHours(hours); + val.setMinutes(minutes); + } + this.mSelectedTime = val; + } + + /** + * @returns {Array} An array containing mSelectedTime hours and mSelectedTime minutes + */ + get value() { + return [this.mSelectedTime.getHours(), this.mSelectedTime.getMinutes()]; + } + + /** + * Set up the picker, called when the popup pops. + */ + onPopupShowing() { + // select the hour item + let hours24 = this.mSelectedTime.getHours(); + let hourItem = this.querySelector(`.time-picker-hour-box-class[value="${hours24}"]`); + this.selectHourItem(hourItem); + + // Show the five minute view if we are an even five minutes, + // otherwise one minute view + let minutesByFive = this.calcNearestFiveMinutes(this.mSelectedTime); + + if (minutesByFive == this.mSelectedTime.getMinutes()) { + this.clickLess(); + } else { + this.clickMore(); + } + } + + /** + * Switches popup to minute view and selects the selected minute item. + */ + clickMore() { + // switch to one minute view + this.switchMinuteView(this.kMINUTE_VIEW_ONE); + + // select minute box corresponding to the time + let minutes = this.mSelectedTime.getMinutes(); + let oneMinuteItem = this.querySelector(`.time-picker-one-minute-class[value="${minutes}"]`); + this.selectMinuteItem(oneMinuteItem); + } + + /** + * Switches popup to five-minute view and selects the five-minute item nearest to selected + * minute item. + */ + clickLess() { + // switch to five minute view + this.switchMinuteView(this.kMINUTE_VIEW_FIVE); + + // select closest five minute box, + // BUT leave the selected time at what may NOT be an even five minutes + // So that If they click more again the proper non-even-five minute + // box will be selected + let minutesByFive = this.calcNearestFiveMinutes(this.mSelectedTime); + let fiveMinuteItem = this.querySelector( + `.time-picker-five-minute-class[value="${minutesByFive}"]` + ); + this.selectMinuteItem(fiveMinuteItem); + } + + /** + * Selects the hour item which was clicked. + * + * @param {Node} hourItem - Hour item which was clicked + * @param {number} hourNumber - Hour value of the clicked hour item + */ + clickHour(hourItem, hourNumber) { + // select the item + this.selectHourItem(hourItem); + + // Change the hour in the selected time. + this.mSelectedTime.setHours(hourNumber); + + this.hasChanged = true; + } + + /** + * Called when one of the hour boxes is double clicked. + * Sets the time to the selected hour, on the hour, and closes the popup. + * + * @param {Node} hourItem - Hour item which was clicked + * @param {number} hourNumber - Hour value of the clicked hour item + */ + doubleClickHour(hourItem, hourNumber) { + // set the minutes to :00 + this.mSelectedTime.setMinutes(0); + + this.dispatchEvent(new CustomEvent("select")); + } + + /** + * Changes selectedTime's minute, calls the client's onchange and closes + * the popup. + * + * @param {Node} minuteItem - Minute item which was clicked + * @param {number} minuteNumber - Minute value of the clicked minute item + */ + clickMinute(minuteItem, minuteNumber) { + // set the minutes in the selected time + this.mSelectedTime.setMinutes(minuteNumber); + this.selectMinuteItem(minuteItem); + this.hasChanged = true; + + this.dispatchEvent(new CustomEvent("select")); + } + + /** + * Helper function to switch between "one" and "five" minute views. + * + * @param {number} view - Number representing minute view + */ + switchMinuteView(view) { + let fiveMinuteBox = this.querySelector(".time-picker-five-minute-grid-box"); + let oneMinuteBox = this.querySelector(".time-picker-one-minute-grid-box"); + + if (view == this.kMINUTE_VIEW_ONE) { + fiveMinuteBox.setAttribute("hidden", true); + oneMinuteBox.setAttribute("hidden", false); + } else { + fiveMinuteBox.setAttribute("hidden", false); + oneMinuteBox.setAttribute("hidden", true); + } + } + + /** + * Selects an hour item. + * + * @param {Node} hourItem - Hour item node to be selected + */ + selectHourItem(hourItem) { + // clear old selection, if there is one + if (this.mSelectedHourItem != null) { + this.mSelectedHourItem.removeAttribute("selected"); + } + // set selected attribute, to cause the selected style to apply + hourItem.setAttribute("selected", "true"); + // remember the selected item so we can deselect it + this.mSelectedHourItem = hourItem; + } + + /** + * Selects a minute item. + * + * @param {Node} minuteItem - Minute item node to be selected + */ + selectMinuteItem(minuteItem) { + // clear old selection, if there is one + if (this.mSelectedMinuteItem != null) { + this.mSelectedMinuteItem.removeAttribute("selected"); + } + // set selected attribute, to cause the selected style to apply + minuteItem.setAttribute("selected", "true"); + // remember the selected item so we can deselect it + this.mSelectedMinuteItem = minuteItem; + } + + /** + * Moves minute by the number passed and handle rollover cases where the minutes gets + * greater than 59 or less than 60. + * + * @param {number} number - Moves minute by the number 'number' + */ + moveMinutes(number) { + if (!this.mSelectedTime) { + return; + } + + let idPrefix = ".time-picker-one-minute-class"; + + // Everything above assumes that we are showing the one-minute-grid, + // If not, we need to do these corrections; + let fiveMinuteBox = this.querySelector(".time-picker-five-minute-grid-box"); + + if (!fiveMinuteBox.hidden) { + number *= 5; + idPrefix = ".time-picker-five-minute-class"; + + // If the detailed view was shown before, then mSelectedTime.getMinutes + // might not be a multiple of 5. + this.mSelectedTime.setMinutes(this.calcNearestFiveMinutes(this.mSelectedTime)); + } + + let newMinutes = this.mSelectedTime.getMinutes() + number; + + // Handle rollover cases + if (newMinutes < 0) { + newMinutes += 60; + } + if (newMinutes > 59) { + newMinutes -= 60; + } + + this.mSelectedTime.setMinutes(newMinutes); + + let minuteItemId = `${idPrefix}[value="${this.mSelectedTime.getMinutes()}"]`; + let minuteItem = this.querySelector(minuteItemId); + + this.selectMinuteItem(minuteItem); + this.mPicker.kTextBox.value = this.mPicker.formatTime(this.mSelectedTime); + this.hasChanged = true; + } + + /** + * Moves hours by the number passed and handle rollover cases where the hours gets greater + * than 23 or less than 0. + * + * @param {number} number - Moves hours by the number 'number' + */ + moveHours(number) { + if (!this.mSelectedTime) { + return; + } + + let newHours = this.mSelectedTime.getHours() + number; + + // Handle rollover cases + if (newHours < 0) { + newHours += 24; + } + if (newHours > 23) { + newHours -= 24; + } + + this.mSelectedTime.setHours(newHours); + + let hourItemId = `.time-picker-hour-box-class[value="${this.mSelectedTime.getHours()}"]`; + let hourItem = this.querySelector(hourItemId); + + this.selectHourItem(hourItem); + this.mPicker.kTextBox.value = this.mPicker.formatTime(this.mSelectedTime); + this.hasChanged = true; + } + + /** + * Calculates the nearest even five minutes. + * + * @param {calDateTime} time - Time near to which nearest five minutes have to be found + */ + calcNearestFiveMinutes(time) { + let minutes = time.getMinutes(); + let minutesByFive = Math.round(minutes / 5) * 5; + + if (minutesByFive > 59) { + minutesByFive = 55; + } + return minutesByFive; + } + + /** + * Changes to 12 hours format by showing am/pm label. + * + * @param {string} amLabel - amLabelBox value + * @param {string} pmLabel - pmLabelBox value + */ + changeTo12HoursFormat(amLabel, pmLabel) { + if (!this.firstElementChild) { + this.appendChild(document.importNode(this.content, true)); + } + + let amLabelBox = this.querySelector(".amLabelBox"); + amLabelBox.removeAttribute("hidden"); + amLabelBox.firstElementChild.setAttribute("value", amLabel); + let pmLabelBox = this.querySelector(".pmLabelBox"); + pmLabelBox.removeAttribute("hidden"); + pmLabelBox.firstElementChild.setAttribute("value", pmLabel); + this.querySelector(".time-picker-hour-box-class[value='0']").setAttribute("label", "12"); + for (let i = 13; i < 24; i++) { + this.querySelector(`.time-picker-hour-box-class[value="${i}"]`).setAttribute( + "label", + i - 12 + ); + } + this.querySelector(".time-picker-hour-grid").setAttribute("format12hours", "true"); + } + } + + class CalendarDatePicker extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.prepend(CalendarDatePicker.fragment.cloneNode(true)); + this._menulist = this.querySelector(".datepicker-menulist"); + this._inputField = this._menulist._inputField; + this._popup = this._menulist.menupopup; + this._minimonth = this.querySelector("calendar-minimonth"); + + if (this.getAttribute("type") == "forever") { + this._valueIsForever = false; + this._foreverString = cal.l10n.getString( + "calendar-event-dialog", + "eventRecurrenceForeverLabel" + ); + + this._foreverItem = document.createXULElement("button"); + this._foreverItem.setAttribute("label", this._foreverString); + this._popup.appendChild(document.createXULElement("menuseparator")); + this._popup.appendChild(this._foreverItem); + + this._foreverItem.addEventListener("command", () => { + this.value = "forever"; + this._popup.hidePopup(); + }); + } + + this.value = this.getAttribute("value") || new Date(); + + // Other attributes handled in inheritedAttributes. + this._handleMutation = mutations => { + this.value = this.getAttribute("value"); + }; + this._attributeObserver = new MutationObserver(this._handleMutation); + this._attributeObserver.observe(this, { + attributes: true, + attributeFilter: ["value"], + }); + + this.initializeAttributeInheritance(); + + this.addEventListener("keydown", event => { + if (event.key == "Escape") { + this._popup.hidePopup(); + } + }); + this._menulist.addEventListener("change", event => { + event.stopPropagation(); + + let value = parseDateTime(this._inputBoxValue); + if (!value) { + this._inputBoxValue = this._minimonthValue; + return; + } + this._inputBoxValue = this._minimonthValue = value; + this._valueIsForever = false; + + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + }); + this._popup.addEventListener("popupshown", () => { + this._minimonth.focusDate(this._minimonthValue); + const calendar = this._minimonth.querySelector(".minimonth-calendar"); + calendar.querySelector("td[selected]").focus(); + }); + this._minimonth.addEventListener("change", event => { + event.stopPropagation(); + }); + this._minimonth.addEventListener("select", () => { + this._inputBoxValue = this._minimonthValue; + this._valueIsForever = false; + this._popup.hidePopup(); + + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this._attributeObserver.disconnect(); + + if (this._menulist) { + this._menulist.remove(); + this._menulist = null; + this._inputField = null; + this._popup = null; + this._minimonth = null; + this._foreverItem = null; + } + } + + static get fragment() { + // Accessibility information of these nodes will be + // presented on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <menulist is="menulist-editable" class="datepicker-menulist" editable="true" sizetopopup="false"> + <menupopup ignorekeys="true" popupanchor="bottomright" popupalign="topright"> + <calendar-minimonth tabindex="0"/> + </menupopup> + </menulist> + `), + true + ); + + Object.defineProperty(this, "fragment", { value: frag }); + return frag; + } + + static get inheritedAttributes() { + return { ".datepicker-menulist": "disabled" }; + } + + set value(val) { + let wasForever = this._valueIsForever; + if (this.getAttribute("type") == "forever" && val == "forever") { + this._valueIsForever = true; + this._inputBoxValue = val; + if (!wasForever) { + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + } + return; + } else if (typeof val == "string") { + val = parseDateTime(val); + } + + let existingValue = this._minimonthValue; + this._valueIsForever = false; + this._inputBoxValue = this._minimonthValue = val; + + if ( + wasForever || + existingValue.getFullYear() != val.getFullYear() || + existingValue.getMonth() != val.getMonth() || + existingValue.getDate() != val.getDate() + ) { + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + } + } + + get value() { + if (this._valueIsForever) { + return "forever"; + } + return this._minimonth.value; + } + + focus() { + this._menulist.focus(); + } + + set _inputBoxValue(val) { + if (val == "forever") { + this._inputField.value = this._foreverString; + return; + } + this._inputField.value = formatDate(val); + } + + get _inputBoxValue() { + return this._inputField.value; + } + + set _minimonthValue(val) { + if (val == "forever") { + return; + } + this._minimonth.value = val; + } + + get _minimonthValue() { + return this._minimonth.value; + } + } + + const MenuBaseControl = MozElements.BaseControlMixin(MozElements.MozElementMixin(XULMenuElement)); + MenuBaseControl.implementCustomInterface(CalendarDatePicker, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + class CalendarTimePicker extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.prepend(CalendarTimePicker.fragment.cloneNode(true)); + this._menulist = this.firstElementChild; + this._inputField = this._menulist._inputField; + this._popup = this._menulist.menupopup; + this._grid = this._popup.firstElementChild; + + this.value = this.getAttribute("value") || new Date(); + + // Change the grids in the timepicker-grids for 12-hours time format. + if (ampmIndex) { + // Find the locale strings for the AM/PM prefix/suffix. + let amTime = new Date(2000, 0, 1, 6, 12, 34); + let pmTime = new Date(2000, 0, 1, 18, 12, 34); + amTime = timeFormatter.format(amTime); + pmTime = timeFormatter.format(pmTime); + let amLabel = parseTimeRegExp.exec(amTime)[ampmIndex] || "AM"; + let pmLabel = parseTimeRegExp.exec(pmTime)[ampmIndex] || "PM"; + + this._grid.changeTo12HoursFormat(amLabel, pmLabel); + } + + // Other attributes handled in inheritedAttributes. + this._handleMutation = mutations => { + this.value = this.getAttribute("value"); + }; + this._attributeObserver = new MutationObserver(this._handleMutation); + this._attributeObserver.observe(this, { + attributes: true, + attributeFilter: ["value"], + }); + + this.initializeAttributeInheritance(); + + this._inputField.addEventListener("change", event => { + event.stopPropagation(); + + let value = parseTime(this._inputBoxValue); + if (!value) { + this._inputBoxValue = this._gridValue; + return; + } + this.value = value; + }); + this._menulist.menupopup.addEventListener("popupshowing", () => { + this._grid.onPopupShowing(); + }); + this._menulist.menupopup.addEventListener("popuphiding", () => { + this.value = this._gridValue; + }); + this._grid.addEventListener("select", event => { + event.stopPropagation(); + + this.value = this._gridValue; + this._popup.hidePopup(); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this._attributeObserver.disconnect(); + + if (this._menulist) { + this._menulist.remove(); + this._menulist = null; + this._inputField = null; + this._popup = null; + this._grid = null; + } + } + + static get fragment() { + // Accessibility information of these nodes will be + // presented on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <menulist is="menulist-editable" class="timepicker-menulist" editable="true" sizetopopup="false"> + <menupopup popupanchor="bottomright" popupalign="topright"> + <timepicker-grids/> + </menupopup> + </menulist> + `), + true + ); + + Object.defineProperty(this, "fragment", { value: frag }); + return frag; + } + + static get inheritedAttributes() { + return { ".timepicker-menulist": "disabled" }; + } + + set value(val) { + if (typeof val == "string") { + val = parseTime(val); + } else if (Array.isArray(val)) { + let [hours, minutes] = val; + val = new Date(); + val.setHours(hours); + val.setMinutes(minutes); + } + if (val.getHours() != this._hours || val.getMinutes() != this._minutes) { + let settingInitalValue = this._hours === undefined; + + this._inputBoxValue = this._gridValue = val; + [this._hours, this._minutes] = this._gridValue; + + if (!settingInitalValue) { + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + } + } + } + + get value() { + return [this._hours, this._minutes]; + } + + focus() { + this._menulist.focus(); + } + + set _inputBoxValue(val) { + if (typeof val == "string") { + val = parseTime(val); + } else if (Array.isArray(val)) { + let [hours, minutes] = val; + val = new Date(); + val.setHours(hours); + val.setMinutes(minutes); + } + this._inputField.value = formatTime(val); + } + + get _inputBoxValue() { + return this._inputField.value; + } + + set _gridValue(val) { + this._grid.value = val; + } + + get _gridValue() { + return this._grid.value; + } + } + + MenuBaseControl.implementCustomInterface(CalendarTimePicker, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + class CalendarDateTimePicker extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this._datepicker = document.createXULElement("datepicker"); + this._datepicker.classList.add("datetimepicker-datepicker"); + this._datepicker.setAttribute("anonid", "datepicker"); + this._timepicker = document.createXULElement("timepicker"); + this._timepicker.classList.add("datetimepicker-timepicker"); + this._timepicker.setAttribute("anonid", "timepicker"); + this.appendChild(this._datepicker); + this.appendChild(this._timepicker); + + if (this.getAttribute("value")) { + this._datepicker.value = this.getAttribute("value"); + this._timepicker.value = this.getAttribute("value"); + } + + this.initializeAttributeInheritance(); + + this._datepicker.addEventListener("change", event => { + event.stopPropagation(); + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + }); + this._timepicker.addEventListener("change", event => { + event.stopPropagation(); + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + if (this._datepicker) { + this._datepicker.remove(); + } + if (this._timepicker) { + this._timepicker.remove(); + } + } + + static get inheritedAttributes() { + return { + ".datetimepicker-datepicker": "value,disabled,disabled=datepickerdisabled", + ".datetimepicker-timepicker": "value,disabled,disabled=timepickerdisabled", + }; + } + + set value(val) { + this._datepicker.value = this._timepicker.value = val; + } + + get value() { + let dateValue = this._datepicker.value; + let [hours, minutes] = this._timepicker.value; + dateValue.setHours(hours); + dateValue.setMinutes(minutes); + dateValue.setSeconds(0); + dateValue.setMilliseconds(0); + return dateValue; + } + + focus() { + this._datepicker.focus(); + } + } + + initDateFormat(); + initTimeFormat(); + customElements.define("timepicker-minute", MozTimepickerMinute); + customElements.define("timepicker-hour", MozTimepickerHour); + customElements.define("timepicker-grids", MozTimepickerGrids); + customElements.whenDefined("menulist-editable").then(() => { + customElements.define("datepicker", CalendarDatePicker); + customElements.define("timepicker", CalendarTimePicker); + customElements.define("datetimepicker", CalendarDateTimePicker); + }); + + /** + * Parameter aValue may be a date or a date time. Dates are + * read according to locale/OS setting (d-m-y or m-d-y or ...). + * (see initDateFormat). Uses parseTime() for times. + */ + function parseDateTime(aValue) { + let tempDate = null; + if (!probeSucceeded) { + return null; // avoid errors accessing uninitialized data. + } + + let year = Number.MIN_VALUE; + let month = -1; + let day = -1; + let timeString = null; + + if (alphaMonths == null) { + // SHORT NUMERIC DATE, such as 2002-03-04, 4/3/2002, or CE2002Y03M04D. + // Made of digits & nonDigits. (Nondigits may be unicode letters + // which do not match \w, esp. in CJK locales.) + // (.*)? binds to null if no suffix. + let parseNumShortDateRegex = /^\D*(\d+)\D+(\d+)\D+(\d+)(.*)?$/; + let dateNumbersArray = parseNumShortDateRegex.exec(aValue); + if (dateNumbersArray != null) { + year = Number(dateNumbersArray[yearIndex]); + month = Number(dateNumbersArray[monthIndex]) - 1; // 0-based + day = Number(dateNumbersArray[dayIndex]); + timeString = dateNumbersArray[4]; + } + } else { + // SHORT DATE WITH ALPHABETIC MONTH, such as "dd MMM yy" or "MMMM dd, yyyy" + // (\d+|[^\d\W]) is digits or letters, not both together. + // Allows 31dec1999 (no delimiters between parts) if OS does (w2k does not). + // Allows Dec 31, 1999 (comma and space between parts) + // (Only accepts ASCII month names; JavaScript RegExp does not have an + // easy way to describe unicode letters short of a HUGE character range + // regexp derived from the Alphabetic ranges in + // http://www.unicode.org/Public/UNIDATA/DerivedCoreProperties.txt) + // (.*)? binds to null if no suffix. + let parseAlphShortDateRegex = + /^\s*(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)(.*)?$/; + let datePartsArray = parseAlphShortDateRegex.exec(aValue); + if (datePartsArray != null) { + year = Number(datePartsArray[yearIndex]); + let monthString = datePartsArray[monthIndex].toUpperCase(); + for (let monthIdx = 0; monthIdx < alphaMonths.length; monthIdx++) { + if (monthString == alphaMonths[monthIdx]) { + month = monthIdx; + break; + } + } + day = Number(datePartsArray[dayIndex]); + timeString = datePartsArray[4]; + } + } + if (year != Number.MIN_VALUE && month != -1 && day != -1) { + // year, month, day successfully parsed + if (year >= 0 && year < 100) { + // If 0 <= year < 100, treat as 2-digit year (like formatDate): + // parse year as up to 30 years in future or 69 years in past. + // (Covers 30-year mortgage and most working people's birthdate.) + // otherwise will be treated as four digit year. + let currentYear = new Date().getFullYear(); + let currentCentury = currentYear - (currentYear % 100); + year = currentCentury + year; + if (year < currentYear - 69) { + year += 100; + } + if (year > currentYear + 30) { + year -= 100; + } + } + // if time is also present, parse it + let hours = 0; + let minutes = 0; + let seconds = 0; + if (timeString != null) { + let time = parseTime(timeString); + if (time != null) { + hours = time.getHours(); + minutes = time.getMinutes(); + seconds = time.getSeconds(); + } + } + tempDate = new Date(year, month, day, hours, minutes, seconds, 0); + } // else did not match regex, not a valid date + return tempDate; + } + + /** + * Parse a variety of time formats so that cut and paste is likely to work. + * separator: ':' '.' ' ' symbol none + * "12:34:56" "12.34.56" "12 34 56" "12h34m56s" "123456" + * seconds optional: "02:34" "02.34" "02 34" "02h34m" "0234" + * minutes optional: "12" "12" "12" "12h" "12" + * 1st hr digit optional:"9:34" " 9.34" "9 34" "9H34M" "934am" + * skip nondigit prefix " 12:34" "t12.34" " 12 34" "T12H34M" "T0234" + * am/pm optional "02:34 a.m.""02.34pm" "02 34 A M" "02H34M P.M." "0234pm" + * am/pm prefix "a.m. 02:34""pm02.34" "A M 02 34" "P.M. 02H34M" "pm0234" + * am/pm cyrillic "02:34\u0430.\u043c." "02 34 \u0420 \u041c" + * am/pm arabic "\u063502:34" (RTL 02:34a) "\u0645 02.34" (RTL 02.34 p) + * above/below noon "\u4e0a\u534802:34" "\u4e0b\u5348 02 34" + * noon before/after "\u5348\u524d02:34" "\u5348\u5f8c 02 34" + */ + function parseTime(aValue) { + let now = new Date(); + + let noon = cal.l10n.getDateFmtString("noon"); + if (aValue.toLowerCase() == noon.toLowerCase()) { + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0); + } + + let midnight = cal.l10n.getDateFmtString("midnight"); + if (aValue.toLowerCase() == midnight.toLowerCase()) { + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); + } + + let time = null; + let timePartsArray = parseTimeRegExp.exec(aValue); + const PRE_INDEX = 1, + HR_INDEX = 2, + MIN_INDEX = 4, + SEC_INDEX = 6, + POST_INDEX = 8; + + if (timePartsArray != null) { + let hoursString = timePartsArray[HR_INDEX]; + let hours = Number(hoursString); + if (!(hours >= 0 && hours < 24)) { + return null; + } + + let minutesString = timePartsArray[MIN_INDEX]; + let minutes = minutesString == null ? 0 : Number(minutesString); + if (!(minutes >= 0 && minutes < 60)) { + return null; + } + + let secondsString = timePartsArray[SEC_INDEX]; + let seconds = secondsString == null ? 0 : Number(secondsString); + if (!(seconds >= 0 && seconds < 60)) { + return null; + } + + let ampmCode = null; + if (timePartsArray[PRE_INDEX] || timePartsArray[POST_INDEX]) { + if (ampmIndex && timePartsArray[ampmIndex]) { + // try current format order first + let ampmString = timePartsArray[ampmIndex]; + if (amRegExp.test(ampmString)) { + ampmCode = "AM"; + } else if (pmRegExp.test(ampmString)) { + ampmCode = "PM"; + } + } + if (ampmCode == null) { + // not yet found + // try any format order + let preString = timePartsArray[PRE_INDEX]; + let postString = timePartsArray[POST_INDEX]; + if ( + (preString && amRegExp.test(preString)) || + (postString && amRegExp.test(postString)) + ) { + ampmCode = "AM"; + } else if ( + (preString && pmRegExp.test(preString)) || + (postString && pmRegExp.test(postString)) + ) { + ampmCode = "PM"; + } // else no match, ignore and treat as 24hour time. + } + } + if (ampmCode == "AM") { + if (hours == 12) { + hours = 0; + } + } else if (ampmCode == "PM") { + if (hours < 12) { + hours += 12; + } + } + time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, seconds, 0); + } // else did not match regex, not valid time + return time; + } + + function initDateFormat() { + // probe the dateformat + yearIndex = -1; + monthIndex = -1; + dayIndex = -1; + alphaMonths = null; + probeSucceeded = false; + + // SHORT NUMERIC DATE, such as 2002-03-04, 4/3/2002, or CE2002Y03M04D. + // Made of digits & nonDigits. (Nondigits may be unicode letters + // which do not match \w, esp. in CJK locales.) + parseShortDateRegex = /^\D*(\d+)\D+(\d+)\D+(\d+)\D?$/; + // Make sure to use UTC date and timezone here to avoid the pattern + // detection to fail if the probe date output would have an timezone + // offset due to our lack of support of historic timezone definitions. + let probeDate = new Date(Date.UTC(2002, 3, 6)); // month is 0-based + let probeString = formatDate(probeDate, cal.dtz.UTC); + let probeArray = parseShortDateRegex.exec(probeString); + if (probeArray) { + // Numeric month format + for (let i = 1; i <= 3; i++) { + switch (Number(probeArray[i])) { + case 2: // falls through + case 2002: + yearIndex = i; + break; + case 4: + monthIndex = i; + break; + case 5: // falls through for OS timezones western to GMT + case 6: + dayIndex = i; + break; + } + } + // All three indexes are set (not -1) at this point. + probeSucceeded = true; + } else { + // SHORT DATE WITH ALPHABETIC MONTH, such as "dd MMM yy" or "MMMM dd, yyyy" + // (\d+|[^\d\W]) is digits or letters, not both together. + // Allows 31dec1999 (no delimiters between parts) if OS does (w2k does not). + // Allows Dec 31, 1999 (comma and space between parts) + // (Only accepts ASCII month names; JavaScript RegExp does not have an + // easy way to describe unicode letters short of a HUGE character range + // regexp derived from the Alphabetic ranges in + // http://www.unicode.org/Public/UNIDATA/DerivedCoreProperties.txt) + parseShortDateRegex = /^\s*(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\s*$/; + probeArray = parseShortDateRegex.exec(probeString); + if (probeArray != null) { + for (let j = 1; j <= 3; j++) { + switch (Number(probeArray[j])) { + case 2: // falls through + case 2002: + yearIndex = j; + break; + case 5: // falls through for OS timezones western to GMT + case 6: + dayIndex = j; + break; + default: + monthIndex = j; + break; + } + } + if (yearIndex != -1 && dayIndex != -1 && monthIndex != -1) { + probeSucceeded = true; + // Fill alphaMonths with month names. + alphaMonths = new Array(12); + for (let monthIdx = 0; monthIdx < 12; monthIdx++) { + probeDate.setMonth(monthIdx); + probeString = formatDate(probeDate); + probeArray = parseShortDateRegex.exec(probeString); + if (probeArray) { + alphaMonths[monthIdx] = probeArray[monthIndex].toUpperCase(); + } else { + probeSucceeded = false; + } + } + } + } + } + if (!probeSucceeded) { + dump("\nOperating system short date format is not recognized: " + probeString + "\n"); + } + } + + /** + * Time format in 24-hour format or 12-hour format with am/pm string. + * Should match formats + * HH:mm, H:mm, HH:mm:ss, H:mm:ss + * hh:mm tt, h:mm tt, hh:mm:ss tt, h:mm:ss tt + * tt hh:mm, tt h:mm, tt hh:mm:ss, tt h:mm:ss + * where + * HH is 24 hour digits, with leading 0. H is 24 hour digits, no leading 0. + * hh is 12 hour digits, with leading 0. h is 12 hour digits, no leading 0. + * mm and ss are is minutes and seconds digits, with leading 0. + * tt is localized AM or PM string. + * ':' may be ':' or a units marker such as 'h', 'm', or 's' in 15h12m00s + * or may be omitted as in 151200. + */ + function initTimeFormat() { + // probe the Time format + ampmIndex = null; + // Digits HR sep MIN sep SEC sep + // Index: 2 3 4 5 6 7 + // prettier-ignore + let digitsExpr = "(\\d?\\d)\\s?(\\D)?\\s?(?:(\\d\\d)\\s?(\\D)?\\s?(?:(\\d\\d)\\s?(\\D)?\\s?)?)?"; + // digitsExpr has 6 captures, so index of first ampmExpr is 1, of last is 8. + let probeTimeRegExp = new RegExp("^\\s*(\\D*)\\s?" + digitsExpr + "\\s?(\\D*)\\s*$"); + const PRE_INDEX = 1, + HR_INDEX = 2, + // eslint-disable-next-line no-unused-vars + MIN_INDEX = 4, + SEC_INDEX = 6, + POST_INDEX = 8; + let amProbeTime = new Date(2000, 0, 1, 6, 12, 34); + let pmProbeTime = new Date(2000, 0, 1, 18, 12, 34); + let amProbeString = timeFormatter.format(amProbeTime); + let pmProbeString = timeFormatter.format(pmProbeTime); + let amFormatExpr = null, + pmFormatExpr = null; + if (amProbeString != pmProbeString) { + let amProbeArray = probeTimeRegExp.exec(amProbeString); + let pmProbeArray = probeTimeRegExp.exec(pmProbeString); + if (amProbeArray != null && pmProbeArray != null) { + if ( + amProbeArray[PRE_INDEX] && + pmProbeArray[PRE_INDEX] && + amProbeArray[PRE_INDEX] != pmProbeArray[PRE_INDEX] + ) { + ampmIndex = PRE_INDEX; + } else if (amProbeArray[POST_INDEX] && pmProbeArray[POST_INDEX]) { + if (amProbeArray[POST_INDEX] == pmProbeArray[POST_INDEX]) { + // check if need to append previous character, + // captured by the optional separator pattern after seconds digits, + // or after minutes if no seconds, or after hours if no minutes. + for (let k = SEC_INDEX; k >= HR_INDEX; k -= 2) { + let nextSepI = k + 1; + let nextDigitsI = k + 2; + if ( + (k == SEC_INDEX || (!amProbeArray[nextDigitsI] && !pmProbeArray[nextDigitsI])) && + amProbeArray[nextSepI] && + pmProbeArray[nextSepI] && + amProbeArray[nextSepI] != pmProbeArray[nextSepI] + ) { + amProbeArray[POST_INDEX] = amProbeArray[nextSepI] + amProbeArray[POST_INDEX]; + pmProbeArray[POST_INDEX] = pmProbeArray[nextSepI] + pmProbeArray[POST_INDEX]; + ampmIndex = POST_INDEX; + break; + } + } + } else { + ampmIndex = POST_INDEX; + } + } + if (ampmIndex) { + let makeFormatRegExp = function (string) { + // make expr to accept either as provided, lowercased, or uppercased + let regExp = string.replace(/(\W)/g, "[$1]"); // escape punctuation + let lowercased = string.toLowerCase(); + if (string != lowercased) { + regExp += "|" + lowercased; + } + let uppercased = string.toUpperCase(); + if (string != uppercased) { + regExp += "|" + uppercased; + } + return regExp; + }; + amFormatExpr = makeFormatRegExp(amProbeArray[ampmIndex]); + pmFormatExpr = makeFormatRegExp(pmProbeArray[ampmIndex]); + } + } + } + // International formats ([roman, cyrillic]|arabic|chinese/kanji characters) + // covering languages of U.N. (en,fr,sp,ru,ar,zh) and G8 (en,fr,de,it,ru,ja). + // See examples at parseTimeOfDay. + let amExpr = "[Aa\u0410\u0430][. ]?[Mm\u041c\u043c][. ]?|\u0635|\u4e0a\u5348|\u5348\u524d"; + let pmExpr = "[Pp\u0420\u0440][. ]?[Mm\u041c\u043c][. ]?|\u0645|\u4e0b\u5348|\u5348\u5f8c"; + if (ampmIndex) { + amExpr = amFormatExpr + "|" + amExpr; + pmExpr = pmFormatExpr + "|" + pmExpr; + } + let ampmExpr = amExpr + "|" + pmExpr; + // Must build am/pm formats into parse time regexp so that it can + // match them without mistaking the initial char for an optional divider. + // (For example, want to be able to parse both "12:34pm" and + // "12H34M56Spm" for any characters H,M,S and any language's "pm". + // The character between the last digit and the "pm" is optional. + // Must recognize "pm" directly, otherwise in "12:34pm" the "S" pattern + // matches the "p" character so only "m" is matched as ampm suffix.) + // + // digitsExpr has 6 captures, so index of first ampmExpr is 1, of last is 8. + parseTimeRegExp = new RegExp( + "(" + ampmExpr + ")?\\s?" + digitsExpr + "(" + ampmExpr + ")?\\s*$" + ); + amRegExp = new RegExp("^(?:" + amExpr + ")$"); + pmRegExp = new RegExp("^(?:" + pmExpr + ")$"); + } + + function formatDate(aDate, aTimezone) { + // Usually, floating is ok here, so no need to pass aTimezone - we just need to pass + // it in if we need to make sure formatting happens without a timezone conversion. + let formatter = aTimezone + ? new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeZone: aTimezone.tzid, + }) + : dateFormatter; + return formatter.format(aDate); + } + + function formatTime(aValue) { + return timeFormatter.format(aValue); + } +} diff --git a/comm/calendar/base/content/widgets/mouseoverPreviews.js b/comm/calendar/base/content/widgets/mouseoverPreviews.js new file mode 100644 index 0000000000..38e5c1e24f --- /dev/null +++ b/comm/calendar/base/content/widgets/mouseoverPreviews.js @@ -0,0 +1,439 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Code which generates event and task (todo) preview tooltips/titletips + * when the mouse hovers over either the event list, the task list, or + * an event or task box in one of the grid views. + * + * (Portions of this code were previously in calendar.js and unifinder.js, + * some of it duplicated.) + */ + +/* exported onMouseOverItem, showToolTip, getPreviewForItem, + getEventStatusString, getToDoStatusString */ + +/* import-globals-from ../calendar-ui-utils.js */ + +/** + * PUBLIC: This changes the mouseover preview based on the start and end dates + * of an occurrence of a (one-time or recurring) calEvent or calToDo. + * Used by all grid views. + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * PUBLIC: Displays a tooltip with details when hovering over an item in the views + * + * @param {DOMEvent} occurrenceBoxMouseEvent the triggering event + * @returns {boolean} true, if the tooltip is displayed + */ +function onMouseOverItem(occurrenceBoxMouseEvent) { + if ("occurrence" in occurrenceBoxMouseEvent.currentTarget) { + // occurrence of repeating event or todo + let occurrence = occurrenceBoxMouseEvent.currentTarget.occurrence; + const toolTip = document.getElementById("itemTooltip"); + return showToolTip(toolTip, occurrence); + } + return false; +} + +/** + * PUBLIC: Displays a tooltip for a given item + * + * @param {Node} aTooltip the node to hold the tooltip + * @param {CalIEvent|calIToDo} aItem the item to create the tooltip for + * @returns {boolean} true, if the tooltip is displayed + */ +function showToolTip(aToolTip, aItem) { + if (aItem) { + let holderBox = getPreviewForItem(aItem); + if (holderBox) { + while (aToolTip.lastChild) { + aToolTip.lastChild.remove(); + } + aToolTip.appendChild(holderBox); + return true; + } + } + return false; +} + +/** + * PUBLIC: Called when a user hovers over a todo element and the text for the + * mouse over is changed. + * + * @param {calIToDo} toDoItem - the item to create the preview for + * @param {boolean} aIsTooltip enabled if used for tooltip composition (default) + */ +function getPreviewForItem(aItem, aIsTooltip = true) { + if (aItem.isEvent()) { + return getPreviewForEvent(aItem, aIsTooltip); + } else if (aItem.isTodo()) { + return getPreviewForTask(aItem, aIsTooltip); + } + return null; +} + +/** + * PUBLIC: Returns the string for status (none), Tentative, Confirmed, or + * Cancelled for a given event + * + * @param {calIEvent} aEvent The event + * @returns {string} The string for the status property of the event + */ +function getEventStatusString(aEvent) { + switch (aEvent.status) { + // Event status value keywords are specified in RFC2445sec4.8.1.11 + case "TENTATIVE": + return cal.l10n.getCalString("statusTentative"); + case "CONFIRMED": + return cal.l10n.getCalString("statusConfirmed"); + case "CANCELLED": + return cal.l10n.getCalString("eventStatusCancelled"); + default: + return ""; + } +} + +/** + * PUBLIC: Returns the string for status (none), NeedsAction, InProcess, + * Cancelled, orCompleted for a given ToDo + * + * @param {calIToDo} aToDo The ToDo + * @returns {string} The string for the status property of the event + */ +function getToDoStatusString(aToDo) { + switch (aToDo.status) { + // Todo status keywords are specified in RFC2445sec4.8.1.11 + case "NEEDS-ACTION": + return cal.l10n.getCalString("statusNeedsAction"); + case "IN-PROCESS": + return cal.l10n.getCalString("statusInProcess"); + case "CANCELLED": + return cal.l10n.getCalString("todoStatusCancelled"); + case "COMPLETED": + return cal.l10n.getCalString("statusCompleted"); + default: + return ""; + } +} + +/** + * PRIVATE: Called when a user hovers over a todo element and the text for the + * mouse overis changed. + * + * @param {calIToDo} toDoItem - the item to create the preview for + * @param {boolean} aIsTooltip enabled if used for tooltip composition (default) + */ +function getPreviewForTask(toDoItem, aIsTooltip = true) { + if (toDoItem) { + const vbox = document.createXULElement("vbox"); + vbox.setAttribute("class", "tooltipBox"); + if (aIsTooltip) { + // tooltip appears above or below pointer, so may have as little as + // one half the screen height available (avoid top going off screen). + vbox.style.maxHeight = Math.floor(screen.height / 2); + } else { + vbox.setAttribute("flex", "1"); + } + boxInitializeHeaderTable(vbox); + + let hasHeader = false; + + if (toDoItem.title) { + boxAppendLabeledText(vbox, "tooltipTitle", toDoItem.title); + hasHeader = true; + } + + let location = toDoItem.getProperty("LOCATION"); + if (location) { + boxAppendLabeledText(vbox, "tooltipLocation", location); + hasHeader = true; + } + + // First try to get calendar name appearing in tooltip + if (toDoItem.calendar.name) { + let calendarNameString = toDoItem.calendar.name; + boxAppendLabeledText(vbox, "tooltipCalName", calendarNameString); + } + + if (toDoItem.entryDate && toDoItem.entryDate.isValid) { + boxAppendLabeledDateTime(vbox, "tooltipStart", toDoItem.entryDate); + hasHeader = true; + } + + if (toDoItem.dueDate && toDoItem.dueDate.isValid) { + boxAppendLabeledDateTime(vbox, "tooltipDue", toDoItem.dueDate); + hasHeader = true; + } + + if (toDoItem.priority && toDoItem.priority != 0) { + let priorityInteger = parseInt(toDoItem.priority, 10); + let priorityString; + + // These cut-offs should match calendar-event-dialog.js + if (priorityInteger >= 1 && priorityInteger <= 4) { + priorityString = cal.l10n.getCalString("highPriority"); + } else if (priorityInteger == 5) { + priorityString = cal.l10n.getCalString("normalPriority"); + } else { + priorityString = cal.l10n.getCalString("lowPriority"); + } + boxAppendLabeledText(vbox, "tooltipPriority", priorityString); + hasHeader = true; + } + + if (toDoItem.status && toDoItem.status != "NONE") { + let status = getToDoStatusString(toDoItem); + boxAppendLabeledText(vbox, "tooltipStatus", status); + hasHeader = true; + } + + if ( + toDoItem.status != null && + toDoItem.percentComplete != 0 && + toDoItem.percentComplete != 100 + ) { + boxAppendLabeledText(vbox, "tooltipPercent", String(toDoItem.percentComplete) + "%"); + hasHeader = true; + } else if (toDoItem.percentComplete == 100) { + if (toDoItem.completedDate == null) { + boxAppendLabeledText(vbox, "tooltipPercent", "100%"); + } else { + boxAppendLabeledDateTime(vbox, "tooltipCompleted", toDoItem.completedDate); + } + hasHeader = true; + } + + let description = toDoItem.descriptionText; + if (description) { + // display wrapped description lines like body of message below headers + if (hasHeader) { + boxAppendBodySeparator(vbox); + } + boxAppendBody(vbox, description, aIsTooltip); + } + + return vbox; + } + return null; +} + +/** + * PRIVATE: Called when mouse moves over a different, or when mouse moves over + * event in event list. The instStartDate is date of instance displayed at event + * box (recurring or multiday events may be displayed by more than one event box + * for different days), or null if should compute next instance from now. + * + * @param {calIEvent} aEvent - the item to create the preview for + * @param {boolean} aIsTooltip enabled if used for tooltip composition (default) + */ +function getPreviewForEvent(aEvent, aIsTooltip = true) { + let event = aEvent; + const vbox = document.createXULElement("vbox"); + vbox.setAttribute("class", "tooltipBox"); + if (aIsTooltip) { + // tooltip appears above or below pointer, so may have as little as + // one half the screen height available (avoid top going off screen). + vbox.maxHeight = Math.floor(screen.height / 2); + } else { + vbox.setAttribute("flex", "1"); + } + boxInitializeHeaderTable(vbox); + + if (event) { + if (event.title) { + boxAppendLabeledText(vbox, "tooltipTitle", aEvent.title); + } + + let location = event.getProperty("LOCATION"); + if (location) { + boxAppendLabeledText(vbox, "tooltipLocation", location); + } + if (!(event.startDate && event.endDate)) { + // Event may be recurrent event. If no displayed instance specified, + // use next instance, or previous instance if no next instance. + event = getCurrentNextOrPreviousRecurrence(event); + } + boxAppendLabeledDateTimeInterval(vbox, "tooltipDate", event); + + // First try to get calendar name appearing in tooltip + if (event.calendar.name) { + let calendarNameString = event.calendar.name; + boxAppendLabeledText(vbox, "tooltipCalName", calendarNameString); + } + + if (event.status && event.status != "NONE") { + let statusString = getEventStatusString(event); + boxAppendLabeledText(vbox, "tooltipStatus", statusString); + } + + if (event.organizer && event.getAttendees().length > 0) { + let organizer = event.organizer; + boxAppendLabeledText(vbox, "tooltipOrganizer", organizer); + } + + let description = event.descriptionText; + if (description) { + boxAppendBodySeparator(vbox); + // display wrapped description lines, like body of message below headers + boxAppendBody(vbox, description, aIsTooltip); + } + return vbox; + } + return null; +} + +/** + * PRIVATE: Append a separator, a thin space between header and body. + * + * @param {Node} vbox box to which to append separator. + */ +function boxAppendBodySeparator(vbox) { + const separator = document.createXULElement("separator"); + separator.setAttribute("class", "tooltipBodySeparator"); + vbox.appendChild(separator); +} + +/** + * PRIVATE: Append description to box for body text. Rendered as HTML. + * Indentation and line breaks are preserved. + * + * @param {Node} box - Box to which to append the body. + * @param {string} textString - Text of the body. + * @param {boolean} aIsTooltip - True for "tooltip" and false for "conflict-dialog" case. + */ +function boxAppendBody(box, textString, aIsTooltip) { + let type = aIsTooltip ? "description" : "vbox"; + let xulDescription = document.createXULElement(type); + xulDescription.setAttribute("class", "tooltipBody"); + if (!aIsTooltip) { + xulDescription.setAttribute("flex", "1"); + } + let docFragment = cal.view.textToHtmlDocumentFragment(textString, document); + xulDescription.appendChild(docFragment); + box.appendChild(xulDescription); +} + +/** + * PRIVATE: Use dateFormatter to format date and time, + * and to header table append a row containing localized Label: date. + * + * @param {Node} box The node to add the date label to + * @param {string} labelProperty The label + * @param {calIDateTime} date - The datetime object to format and add + */ +function boxAppendLabeledDateTime(box, labelProperty, date) { + date = date.getInTimezone(cal.dtz.defaultTimezone); + let formattedDateTime = cal.dtz.formatter.formatDateTime(date); + boxAppendLabeledText(box, labelProperty, formattedDateTime); +} + +/** + * PRIVATE: Use dateFormatter to format date and time interval, + * and to header table append a row containing localized Label: interval. + * + * @param box contains header table. + * @param labelProperty name of property for localized field label. + * @param item the event or task + */ +function boxAppendLabeledDateTimeInterval(box, labelProperty, item) { + let dateString = cal.dtz.formatter.formatItemInterval(item); + boxAppendLabeledText(box, labelProperty, dateString); +} + +/** + * PRIVATE: create empty 2-column table for header fields, and append it to box. + * + * @param {Node} box The node to create a column table for + */ +function boxInitializeHeaderTable(box) { + let table = document.createElementNS("http://www.w3.org/1999/xhtml", "table"); + table.setAttribute("class", "tooltipHeaderTable"); + box.appendChild(table); +} + +/** + * PRIVATE: To headers table, append a row containing Label: value, where label + * is localized text for labelProperty. + * + * @param box box containing headers table + * @param labelProperty name of property for localized name of header + * @param textString value of header field. + */ +function boxAppendLabeledText(box, labelProperty, textString) { + let labelText = cal.l10n.getCalString(labelProperty); + let table = box.querySelector("table"); + let row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr"); + + row.appendChild(createTooltipHeaderLabel(labelText)); + row.appendChild(createTooltipHeaderDescription(textString)); + + table.appendChild(row); +} + +/** + * PRIVATE: Creates an element for field label (for header table) + * + * @param {string} text The text to display in the node + * @returns {Node} The node + */ +function createTooltipHeaderLabel(text) { + let labelCell = document.createElementNS("http://www.w3.org/1999/xhtml", "th"); + labelCell.setAttribute("class", "tooltipHeaderLabel"); + labelCell.textContent = text; + return labelCell; +} + +/** + * PRIVATE: Creates an element for field value (for header table) + * + * @param {string} text The text to display in the node + * @returns {Node} The node + */ +function createTooltipHeaderDescription(text) { + let descriptionCell = document.createElementNS("http://www.w3.org/1999/xhtml", "td"); + descriptionCell.setAttribute("class", "tooltipHeaderDescription"); + descriptionCell.textContent = text; + return descriptionCell; +} + +/** + * PRIVATE: If now is during an occurrence, return the occurrence. If now is + * before an occurrence, return the next occurrence or otherwise the previous + * occurrence. + * + * @param {calIEvent} calendarEvent The text to display in the node + * @returns {mixed} Returns a calIDateTime for the detected + * occurrence or calIEvent, if this is a + * non-recurring event + */ +function getCurrentNextOrPreviousRecurrence(calendarEvent) { + if (!calendarEvent.recurrenceInfo) { + return calendarEvent; + } + + let dur = calendarEvent.duration.clone(); + dur.isNegative = true; + + // To find current event when now is during event, look for occurrence + // starting duration ago. + let probeTime = cal.dtz.now(); + probeTime.addDuration(dur); + + let occ = calendarEvent.recurrenceInfo.getNextOccurrence(probeTime); + + if (!occ) { + let occs = calendarEvent.recurrenceInfo.getOccurrences( + calendarEvent.startDate, + probeTime, + 0, + {} + ); + occ = occs[occs.length - 1]; + } + return occ; +} |