diff options
Diffstat (limited to '')
-rw-r--r-- | comm/calendar/base/content/calendar-ui-utils.js | 596 |
1 files changed, 596 insertions, 0 deletions
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"); +} |