Bug 533265 - Show differences when receiving an event update via email;r=philipp
authorMakeMyDay <makemyday@gmx-topmail.de>
Thu, 06 Aug 2015 16:27:00 +0200
changeset 23042 e30a4f7f6a2fb0fbbf943f01b46fbfc73fdf81a8
parent 23041 7c6189f9d917688fbe1da0a91124774b817e8aba
child 23043 5a7b45efb096c40146f1750337123901acc4a2c1
push id1474
push usermbanner@mozilla.com
push dateMon, 21 Sep 2015 17:20:48 +0000
treeherdercomm-beta@3094bab4c31f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersphilipp
bugs533265
Bug 533265 - Show differences when receiving an event update via email;r=philipp
calendar/base/modules/calItipUtils.jsm
calendar/lightning/components/lightningTextCalendarConverter.js
calendar/lightning/content/imip-bar.js
calendar/lightning/content/lightning.js
calendar/lightning/modules/ltnInvitationUtils.jsm
calendar/lightning/modules/ltnUtils.jsm
calendar/lightning/modules/moz.build
calendar/lightning/moz.build
calendar/lightning/themes/common/imip.css
--- a/calendar/base/modules/calItipUtils.jsm
+++ b/calendar/base/modules/calItipUtils.jsm
@@ -140,21 +140,21 @@ cal.itip = {
         if (imipMethod && imipMethod.length != 0 && imipMethod.toLowerCase() != "nomethod") {
             itipItem.receivedMethod = imipMethod.toUpperCase();
         } else { // There is no METHOD in the content-type header (spec violation).
                  // Fall back to using the one from the itipItem's ICS.
             imipMethod = itipItem.receivedMethod;
         }
         cal.LOG("iTIP method: " + imipMethod);
 
-        function isWritableCalendar(aCalendar) {
+        let isWritableCalendar = function (aCalendar) {
             /* TODO: missing ACL check for existing items (require callback API) */
             return (cal.itip.isSchedulingCalendar(aCalendar)
                     && cal.userCanAddItemsToCalendar(aCalendar));
-        }
+        };
 
         let writableCalendars = cal.getCalendarManager().getCalendars({}).filter(isWritableCalendar);
         if (writableCalendars.length > 0) {
             let compCal = Components.classes["@mozilla.org/calendar/calendar;1?type=composite"]
                                     .createInstance(Components.interfaces.calICompositeCalendar);
             writableCalendars.forEach(compCal.addCalendar, compCal);
             itipItem.targetCalendar = compCal;
         }
@@ -754,17 +754,17 @@ cal.itip = {
             //           find original occurrence
             oldItem = oldItem.recurrenceInfo.getOccurrenceFor(newItem.recurrenceId);
             cal.ASSERT(oldItem, "unexpected!");
             if (!oldItem) {
                 return newItem;
             }
         }
 
-        function hashMajorProps(aItem) {
+        let hashMajorProps = function (aItem) {
             const majorProps = {
                 DTSTART: true,
                 DTEND: true,
                 DURATION: true,
                 DUE: true,
                 RDATE: true,
                 RRULE: true,
                 EXDATE: true,
@@ -777,17 +777,17 @@ cal.itip = {
                 for (let prop in cal.ical.propertyIterator(item.icalComponent)) {
                     if (prop.propertyName in majorProps) {
                         propStrings.push(item.recurrenceId + "#" + prop.icalString);
                     }
                 }
             }
             propStrings.sort();
             return propStrings.join("");
-        }
+        };
 
         let h1 = hashMajorProps(newItem);
         let h2 = hashMajorProps(oldItem);
         if (h1 != h2) {
             newItem = newItem.clone();
             // bump SEQUENCE, it never decreases (mind undo scenario here)
             newItem.setProperty("SEQUENCE",
                                 String(Math.max(cal.itip.getSequence(oldItem),
@@ -985,32 +985,32 @@ function sendMessage(aItem, aMethod, aRe
     }
 
     let aTransport = aItem.calendar.getProperty("itip.transport");
     if (!aTransport) { // can only send if there's a transport for the calendar
         return false;
     }
     aTransport = aTransport.QueryInterface(Components.interfaces.calIItipTransport);
 
-    function _sendItem(aSendToList, aSendItem) {
+    let _sendItem = function (aSendToList, aSendItem) {
         let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
                                  .createInstance(Components.interfaces.calIItipItem);
         itipItem.init(cal.getSerializedItem(aSendItem));
         itipItem.responseMethod = aMethod;
         itipItem.targetCalendar = aSendItem.calendar;
         itipItem.autoResponse = ((autoResponse && autoResponse.value) ? Components.interfaces.calIItipItem.AUTO
                                                                       : Components.interfaces.calIItipItem.USER);
         if (autoResponse) {
             autoResponse.value = true; // auto every following
         }
         // XXX I don't know whether the below are used at all, since we don't use the itip processor
         itipItem.isSend = true;
 
         return aTransport.sendItems(aSendToList.length, aSendToList, itipItem);
-    }
+    };
 
     // split up transport, if attendee undisclosure is requested
     // and this is a message send by the organizer
     if((aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED") == "TRUE") &&
        aMethod != "REPLY" &&
        aMethod != "REFRESH" &&
        aMethod != "COUNTER") {
         for each( aRecipient in aRecipientsList) {
--- a/calendar/lightning/components/lightningTextCalendarConverter.js
+++ b/calendar/lightning/components/lightningTextCalendarConverter.js
@@ -2,16 +2,17 @@
  * 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/. */
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 Components.utils.import("resource://calendar/modules/calUtils.jsm");
 Components.utils.import("resource://calendar/modules/calXMLUtils.jsm");
 Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm");
+Components.utils.import("resource://calendar/modules/ltnInvitationUtils.jsm");
 
 function ltnMimeConverter() {
     this.wrappedJSObject = this;
 }
 
 const ltnMimeConverterClassID = Components.ID("{c70acb08-464e-4e55-899d-b2c84c5409fa}");
 const ltnMimeConverterInterfaces = [Components.interfaces.nsISimpleMimeConverter];
 ltnMimeConverter.prototype = {
@@ -20,270 +21,24 @@ ltnMimeConverter.prototype = {
 
     classInfo: XPCOMUtils.generateCI({
         classID: ltnMimeConverterClassID,
         contractID: "@mozilla.org/lightning/mime-converter;1",
         classDescription: "Lightning text/calendar handler",
         interfaces: ltnMimeConverterInterfaces
     }),
 
-    /**
-     * Append the text to node, converting contained URIs to <a> links.
-     *
-     * @param text      The text to convert.
-     * @param node      The node to append the text to.
-     */
-    linkifyText: function linkifyText(text, node) {
-        let doc = node.ownerDocument;
-        let localText = text;
-
-        // XXX This should be improved to also understand abbreviated urls, could be
-        // extended to only linkify urls that have an internal protocol handler, or
-        // have an external protocol handler that has an app assigned. The same
-        // could be done for mailto links which are not handled here either.
-
-        // XXX Ideally use mozITXTToHTMLConv here, but last time I tried it didn't work.
-
-        while (localText.length) {
-            let pos = localText.search(/(^|\s+)([a-zA-Z0-9]+):\/\/[^\s]+/);
-            if (pos == -1) {
-                node.appendChild(doc.createTextNode(localText));
-                break;
-            }
-            pos += localText.substr(pos).match(/^\s*/)[0].length;
-            let endPos = pos + localText.substr(pos).search(/([.!,<>(){}]+)?(\s+|$)/);
-            let url = localText.substr(pos, endPos - pos);
-
-            if (pos > 0) {
-                node.appendChild(doc.createTextNode(localText.substr(0, pos)));
-            }
-            let a = doc.createElement("a");
-            a.setAttribute("href", url);
-            a.textContent = url;
-
-            node.appendChild(a);
-
-            localText = localText.substr(endPos);
-        }
-    },
-
-    /**
-     * Returns a header title for an ITIP item depending on the response method
-     * @param       aItipItem  the event
-     * @return string the header title
-     */
-    getItipHeader: function getItipHeader(aItipItem) {
-        let header;
-
-        if (aItipItem) {
-            let item = aItipItem.getItemList({})[0];
-            let summary = item.getProperty("SUMMARY") || "";
-            let organizer = item.organizer;
-            let organizerString = (organizer) ?
-              (organizer.commonName || organizer.toString()) : "";
-
-            switch (aItipItem.responseMethod) {
-                case "REQUEST":
-                    header = cal.calGetString("lightning",
-                                              "itipRequestBody",
-                                              [organizerString, summary],
-                                              "lightning");
-                    break;
-                case "CANCEL":
-                    header = cal.calGetString("lightning",
-                                              "itipCancelBody",
-                                              [organizerString, summary],
-                                              "lightning");
-                    break;
-                case "REPLY": {
-                    // This is a reply received from someone else, there should
-                    // be just one attendee, the attendee that replied. If
-                    // there is more than one attendee, just take the first so
-                    // code doesn't break here.
-                    let attendees = item.getAttendees({});
-                    if (attendees && attendees.length >= 1) {
-                        let sender = attendees[0];
-                        let statusString = (sender.participationStatus == "DECLINED" ?
-                                            "itipReplyBodyDecline" :
-                                            "itipReplyBodyAccept");
-
-                        header = cal.calGetString("lightning",
-                                                  statusString,
-                                                  [sender.toString()],
-                                                  "lightning");
-                    } else {
-                        header = "";
-                    }
-                    break;
-                }
-            }
-        }
-
-        if (!header) {
-            header = cal.calGetString("lightning", "imipHtml.header", null, "lightning");
-        }
-
-        return header;
-    },
-
-    /**
-     * Returns the html representation of the event as a DOM document.
-     *
-     * @param event         The calIItemBase to parse into html.
-     * @param aNewItipItem  The parsed itip item.
-     * @return              The DOM document with values filled in.
-     */
-    createHtml: function createHtml(event, aNewItipItem) {
-        // Creates HTML using the Node strings in the properties file
-        let doc = cal.xml.parseFile("chrome://lightning/content/lightning-invitation.xhtml");
-        let formatter = cal.getDateFormatter();
-
-        let self = this;
-        function field(field, contentText, linkify) {
-            let descr = doc.getElementById("imipHtml-" + field + "-descr");
-            if (descr) {
-                let labelText = cal.calGetString("lightning", "imipHtml." + field, null, "lightning");
-                descr.textContent = labelText;
-            }
-
-            if (contentText) {
-                let content = doc.getElementById("imipHtml-" + field + "-content");
-                doc.getElementById("imipHtml-" + field + "-row").hidden = false;
-                if (linkify) {
-                    self.linkifyText(contentText, content);
-                } else {
-                    content.textContent = contentText;
-                }
-            }
-        }
-
-        // Simple fields
-        let headerDescr = doc.getElementById("imipHtml-header-descr");
-        if (headerDescr) {
-            headerDescr.textContent = this.getItipHeader(aNewItipItem);
-        }
-
-        field("summary", event.title);
-        field("location", event.getProperty("LOCATION"));
-
-        let dateString = formatter.formatItemInterval(event);
-
-        if (event.recurrenceInfo) {
-            let kDefaultTimezone = cal.calendarDefaultTimezone();
-            let startDate =  event.startDate;
-            let endDate = event.endDate;
-            startDate = startDate ? startDate.getInTimezone(kDefaultTimezone) : null;
-            endDate = endDate ? endDate.getInTimezone(kDefaultTimezone) : null;
-            let repeatString = recurrenceRule2String(event.recurrenceInfo, startDate,
-                                                     endDate, startDate.isDate);
-            if (repeatString) {
-                dateString = repeatString;
-            }
-
-            let formattedExDates = [];
-            let modifiedOccurrences = [];
-            function dateComptor(a,b) a.startDate.compare(b.startDate);
-
-            // Show removed instances
-            for each (let exc in event.recurrenceInfo.getRecurrenceItems({})) {
-                if (exc instanceof Components.interfaces.calIRecurrenceDate) {
-                    if (exc.isNegative) {
-                        // This is an EXDATE
-                        formattedExDates.push(formatter.formatDateTime(exc.date));
-                    } else {
-                        // This is an RDATE, close enough to a modified occurrence
-                        let excItem = event.recurrenceInfo.getOccurrenceFor(exc.date);
-                        cal.binaryInsert(modifiedOccurrences, excItem, dateComptor, true)
-                    }
-                }
-            }
-            if (formattedExDates.length > 0) {
-                field("canceledOccurrences", formattedExDates.join("\n"));
-            }
-
-            // Show modified occurrences
-            for each (let recurrenceId in event.recurrenceInfo.getExceptionIds({})) {
-                let exc = event.recurrenceInfo.getExceptionFor(recurrenceId);
-                let excLocation = exc.getProperty("LOCATION");
-
-                // Only show modified occurrence if start, duration or location
-                // has changed.
-                if (exc.startDate.compare(exc.recurrenceId) != 0 ||
-                    exc.duration.compare(event.duration) != 0 ||
-                    excLocation != event.getProperty("LOCATION")) {
-                    cal.binaryInsert(modifiedOccurrences, exc, dateComptor, true)
-                }
-            }
-
-            function stringifyOcc(occ) {
-                let formattedExc = formatter.formatItemInterval(occ);
-                let occLocation = occ.getProperty("LOCATION");
-                if (occLocation != event.getProperty("LOCATION")) {
-                    let location = cal.calGetString("lightning", "imipHtml.newLocation", [occLocation], "lightning");
-                    formattedExc += " (" + location + ")";
-                }
-                return formattedExc;
-            }
-
-            if (modifiedOccurrences.length > 0) {
-                field("modifiedOccurrences", modifiedOccurrences.map(stringifyOcc).join("\n"));
-            }
-        }
-
-        field("when", dateString);
-        field("comment", event.getProperty("COMMENT"), true);
-
-        // DESCRIPTION field
-        let eventDescription = (event.getProperty("DESCRIPTION") || "")
-                                    /* Remove the useless "Outlookism" squiggle. */
-                                    .replace("*~*~*~*~*~*~*~*~*~*", "");
-        field("description", eventDescription, true);
-
-        // ATTENDEE and ORGANIZER fields
-        let attendees = event.getAttendees({});
-        let attendeeTemplate = doc.getElementById("attendee-template");
-        let attendeeTable = doc.getElementById("attendee-table");
-        let organizerTable = doc.getElementById("organizer-table");
-        doc.getElementById("imipHtml-attendees-row").hidden = (attendees.length < 1);
-        doc.getElementById("imipHtml-organizer-row").hidden = !event.organizer;
-
-        function setupAttendee(attendee) {
-            let row = attendeeTemplate.cloneNode(true);
-            row.removeAttribute("id");
-            row.removeAttribute("hidden");
-            row.getElementsByClassName("status-icon")[0].setAttribute("status", attendee.participationStatus);
-            row.getElementsByClassName("attendee-name")[0].textContent = attendee.toString();
-            return row;
-        }
-
-        // Fill rows for attendees and organizer
-        field("attendees");
-        for each (let attendee in attendees) {
-            attendeeTable.appendChild(setupAttendee(attendee));
-        }
-
-        field("organizer");
-        if (event.organizer) {
-            organizerTable.appendChild(setupAttendee(event.organizer));
-        }
-
-        return doc;
-    },
-
-
-    /* nsISimpleMimeConverter */
-
     uri: null,
 
     convertToHTML: function lmcCTH(contentType, data) {
         let parser = Components.classes["@mozilla.org/calendar/ics-parser;1"]
                                .createInstance(Components.interfaces.calIIcsParser);
         parser.parseString(data);
         let event = null;
-        for each (let item in parser.getItems({})) {
+        for (let item of parser.getItems({})) {
             if (cal.isEvent(item)) {
                 if (item.hasProperty("X-MOZ-FAKED-MASTER")) {
                     // if it's a faked master, take any overridden item to get a real occurrence:
                     let exc = item.recurrenceInfo.getExceptionFor(item.startDate);
                     cal.ASSERT(exc, "unexpected!");
                     if (exc) {
                         item = exc;
                     }
@@ -292,43 +47,47 @@ ltnMimeConverter.prototype = {
                 break;
             }
         }
         if (!event) {
             return '';
         }
 
         let itipItem = null;
+        let msgOverlay = '';
 
         try {
             // this.uri is the message URL that we are processing.
             // We use it to get the nsMsgHeaderSink to store the calItipItem.
             if (this.uri) {
                 let msgWindow = null;
                 try {
                     let msgUrl = this.uri.QueryInterface(Components.interfaces.nsIMsgMailNewsUrl);
                     // msgWindow is optional in some scenarios
                     // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then)
                     msgWindow = msgUrl.msgWindow;
                 } catch (exc) {
                 }
                 if (msgWindow) {
                     itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
-                                             .createInstance(Components.interfaces.calIItipItem);
+                                         .createInstance(Components.interfaces.calIItipItem);
                     itipItem.init(data);
+                    let dom = ltn.invitation.createInvitationOverlay(event, itipItem);
+                    msgOverlay = cal.xml.serializeDOM(dom);
 
                     let sinkProps = msgWindow.msgHeaderSink.properties;
                     sinkProps.setPropertyAsInterface("itipItem", itipItem);
+                    sinkProps.setPropertyAsAUTF8String("msgOverlay", msgOverlay);
 
                     // Notify the observer that the itipItem is available
                     Services.obs.notifyObservers(null, "onItipItemCreation", 0);
                 }
             }
         } catch (e) {
             cal.ERROR("[ltnMimeConverter] convertToHTML: " + e);
         }
 
         // Create the HTML string for display
-        return cal.xml.serializeDOM(this.createHtml(event, itipItem));
+        return msgOverlay;
     }
 };
 
 var NSGetFactory = XPCOMUtils.generateNSGetFactory([ltnMimeConverter]);
--- a/calendar/lightning/content/imip-bar.js
+++ b/calendar/lightning/content/imip-bar.js
@@ -1,24 +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/. */
 
 Components.utils.import("resource://calendar/modules/calUtils.jsm");
 Components.utils.import("resource://calendar/modules/calItipUtils.jsm");
+Components.utils.import("resource://calendar/modules/calXMLUtils.jsm");
+Components.utils.import("resource://calendar/modules/ltnInvitationUtils.jsm");
+Components.utils.import("resource://gre/modules/Preferences.jsm");
 
 /**
  * This bar lives inside the message window.
  * Its lifetime is the lifetime of the main thunderbird message window.
  */
 var ltnImipBar = {
 
     actionFunc: null,
     itipItem: null,
     foundItems: null,
+    msgOverlay: null,
 
     /**
      * Thunderbird Message listener interface, hide the bar before we begin
      */
     onStartHeaders: function onImipStartHeaders() {
       ltnImipBar.resetBar();
     },
 
@@ -60,39 +64,44 @@ var ltnImipBar = {
 
         ltnImipBar.resetBar();
         Services.obs.removeObserver(ltnImipBar, "onItipItemCreation");
     },
 
     observe: function ltnImipBar_observe(subject, topic, state) {
         if (topic == "onItipItemCreation") {
             let itipItem = null;
+            let msgOverlay = null;
             try {
                 if (!subject) {
                     let sinkProps = msgWindow.msgHeaderSink.properties;
                     // This property was set by lightningTextCalendarConverter.js
-                    itipItem = sinkProps.getPropertyAsInterface("itipItem", Components.interfaces.calIItipItem);
+                    itipItem = sinkProps.getPropertyAsInterface("itipItem",
+                                                                Components.interfaces.calIItipItem);
+                    msgOverlay = sinkProps.getPropertyAsAUTF8String("msgOverlay");
                 }
             } catch (e) {
                 // This will throw on every message viewed that doesn't have the
                 // itipItem property set on it. So we eat the errors and move on.
 
                 // XXX TODO: Only swallow the errors we need to. Throw all others.
             }
-            if (!itipItem || !gMessageDisplay.displayedMessage) {
+            if (!itipItem || !msgOverlay || !gMessageDisplay.displayedMessage) {
                 return;
             }
 
             let imipMethod = gMessageDisplay.displayedMessage.getStringProperty("imip_method");
             cal.itip.initItemFromMsgData(itipItem, imipMethod, gMessageDisplay.displayedMessage);
 
             let imipBar = document.getElementById("imip-bar");
             imipBar.setAttribute("collapsed", "false");
             imipBar.setAttribute("label",  cal.itip.getMethodText(itipItem.receivedMethod));
 
+            ltnImipBar.msgOverlay = msgOverlay;
+
             cal.itip.processItipItem(itipItem, ltnImipBar.setupOptions);
         }
     },
 
     /**
      * Hide the imip bar and reset the itip item.
      */
     resetBar: function ltnResetImipBar() {
@@ -176,17 +185,17 @@ var ltnImipBar = {
     /**
      * 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
+     *                      in subscribed calendars
      */
     setupOptions: function setupOptions(itipItem, rc, actionFunc, foundItems) {
         let imipBar =  document.getElementById("imip-bar");
         let data = cal.itip.getOptionsText(itipItem, rc, actionFunc, foundItems);
 
         if (Components.isSuccessCode(rc)) {
             ltnImipBar.itipItem = itipItem;
             ltnImipBar.actionFunc = actionFunc;
@@ -197,16 +206,38 @@ var ltnImipBar = {
         // let's reset all buttons first
         ltnImipBar.resetButtons();
         // menu items are visible by default, let's hide what's not available
         data.hideMenuItems.forEach(function(aElementId) hideElement(document.getElementById(aElementId)));
         // buttons are hidden by default, let's make required buttons visible
         data.buttons.forEach(function(aElementId) showElement(document.getElementById(aElementId)));
         // adjust button style if necessary
         ltnImipBar.conformButtonType();
+        if (Preferences.get('calendar.itip.displayInvitationChanges', false)) {
+            // display event modifications if any
+            ltnImipBar.displayModifications();
+        } else if (msgWindow && ltnImipBar.msgOverlay) {
+            msgWindow.displayHTMLInMessagePane('', ltnImipBar.msgOverlay, false);
+        }
+    },
+
+    /**
+     * Displays changes in case of invitation updates in invitation overlay
+     */
+    displayModifications: function () {
+        if (!ltnImipBar.foundItems.length || !ltnImipBar.itipItem || !ltnImipBar.msgOverlay || !msgWindow) {
+            return;
+        }
+        let oldOverlay = ltn.invitation.createInvitationOverlay(ltnImipBar.foundItems[0],
+                                                                ltnImipBar.itipItem);
+        let organizerId = ltnImipBar.itipItem.targetCalendar.getProperty("organizerId");
+        let msgOverlay = ltn.invitation.compareInvitationOverlay(cal.xml.serializeDOM(oldOverlay),
+                                                                 ltnImipBar.msgOverlay,
+                                                                 organizerId);
+        msgWindow.displayHTMLInMessagePane('', msgOverlay, false);
     },
 
     executeAction: function ltnExecAction(partStat, extendResponse) {
 
         function _execAction(aActionFunc, aItipItem, aWindow, aPartStat) {
             if (cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) {
                 // filter out fake partstats
                 if (aPartStat.startsWith("X-")) {
--- a/calendar/lightning/content/lightning.js
+++ b/calendar/lightning/content/lightning.js
@@ -67,16 +67,19 @@ pref("calendar.itip.compatSendMode", 0);
 pref("calendar.itip.notify", true);
 
 // whether the organizer propagates replies of attendees to all attendees
 pref("calendar.itip.notify-replies", false);
 
 // whether email invitation updates are send out to all attendees if (only) adding a new attendee
 pref("calendar.itip.updateInvitationForNewAttendeesOnly", false);
 
+//whether changes in email invitation updates should be displayed
+pref("calendar.itip.displayInvitationChanges", true);
+
 // whether CalDAV (experimental) scheduling is enabled or not.
 pref("calendar.caldav.sched.enabled", false);
 
 // 0=Sunday, 1=Monday, 2=Tuesday, etc.  One day we might want to move this to
 // a locale specific file.
 pref("calendar.week.start", 0);
 pref("calendar.weeks.inview", 4);
 pref("calendar.previousweeks.inview", 0);
new file mode 100644
--- /dev/null
+++ b/calendar/lightning/modules/ltnInvitationUtils.jsm
@@ -0,0 +1,350 @@
+/* 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/. */
+
+Components.utils.import("resource://calendar/modules/calUtils.jsm");
+Components.utils.import("resource://calendar/modules/calXMLUtils.jsm");
+Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm");
+Components.utils.import("resource://gre/modules/Preferences.jsm");
+Components.utils.import("resource://calendar/modules/ltnUtils.jsm");
+
+this.EXPORTED_SYMBOLS = ["ltn"]; // even though it's defined in ltnUtils.jsm, import needs this
+ltn.invitation = {
+    /**
+     * Returns a header title for an ITIP item depending on the response method
+     * @param  {calItipItem}     aItipItem  the itip item to check
+     * @return {String}          the header title
+     */
+    getItipHeader: function (aItipItem) {
+        let header;
+
+        if (aItipItem) {
+            let item = aItipItem.getItemList({})[0];
+            let summary = item.getProperty("SUMMARY") || "";
+            let organizer = item.organizer;
+            let organizerString = (organizer) ?
+              (organizer.commonName || organizer.toString()) : "";
+
+            switch (aItipItem.responseMethod) {
+                case "REQUEST":
+                    header = ltn.getString("lightning",
+                                           "itipRequestBody",
+                                           [organizerString, summary]);
+                    break;
+                case "CANCEL":
+                    header = ltn.getString("lightning",
+                                           "itipCancelBody",
+                                           [organizerString, summary]);
+                    break;
+                case "REPLY": {
+                    // This is a reply received from someone else, there should
+                    // be just one attendee, the attendee that replied. If
+                    // there is more than one attendee, just take the first so
+                    // code doesn't break here.
+                    let attendees = item.getAttendees({});
+                    if (attendees && attendees.length >= 1) {
+                        let sender = attendees[0];
+                        let statusString = (sender.participationStatus == "DECLINED" ?
+                                            "itipReplyBodyDecline" :
+                                            "itipReplyBodyAccept");
+
+                        header = ltn.getString("lightning", statusString, [sender.toString()]);
+                    } else {
+                        header = "";
+                    }
+                    break;
+                }
+            }
+        }
+
+        if (!header) {
+            header = ltn.getString("lightning", "imipHtml.header", null);
+        }
+
+        return header;
+    },
+
+    /**
+     * Returns the html representation of the event as a DOM document.
+     *
+     * @param  {calIItemBase} aEvent     The event to parse into html.
+     * @param  {calItipItem}  aItipItem  The itip item, which containes aEvent.
+     * @return {DOM}                     The html representation of aEvent.
+     */
+    createInvitationOverlay: function (aEvent, aItipItem) {
+        // Creates HTML using the Node strings in the properties file
+        let doc = cal.xml.parseFile("chrome://lightning/content/lightning-invitation.xhtml");
+        let formatter = cal.getDateFormatter();
+
+        let linkConverter = Components.classes["@mozilla.org/txttohtmlconv;1"]
+                                      .getService(Components.interfaces.mozITXTToHTMLConv);
+
+        let field = function (aField, aContentText, aConvert) {
+            let descr = doc.getElementById("imipHtml-" + aField + "-descr");
+            if (descr) {
+                let labelText = ltn.getString("lightning", "imipHtml." + aField, null);
+                descr.textContent = labelText;
+            }
+
+            if (aContentText) {
+                let content = doc.getElementById("imipHtml-" + aField + "-content");
+                doc.getElementById("imipHtml-" + aField + "-row").hidden = false;
+                if (aConvert) {
+                    let mode = Components.interfaces.mozITXTToHTMLConv.kStructPhrase +
+                               Components.interfaces.mozITXTToHTMLConv.kGlyphSubstitution +
+                               Components.interfaces.mozITXTToHTMLConv.kURLs;
+                    content.innerHTML = linkConverter.scanHTML(aContentText, mode);
+                } else {
+                    content.textContent = aContentText;
+                }
+            }
+        }
+
+        // Simple fields
+        let headerDescr = doc.getElementById("imipHtml-header-descr");
+        if (headerDescr) {
+            headerDescr.textContent = ltn.invitation.getItipHeader(aItipItem);
+        }
+
+        field("summary", aEvent.title);
+        field("location", aEvent.getProperty("LOCATION"));
+
+        let dateString = formatter.formatItemInterval(aEvent);
+
+        if (aEvent.recurrenceInfo) {
+            let kDefaultTimezone = cal.calendarDefaultTimezone();
+            let startDate =  aEvent.startDate;
+            let endDate = aEvent.endDate;
+            startDate = startDate ? startDate.getInTimezone(kDefaultTimezone) : null;
+            endDate = endDate ? endDate.getInTimezone(kDefaultTimezone) : null;
+            let repeatString = recurrenceRule2String(aEvent.recurrenceInfo, startDate,
+                                                     endDate, startDate.isDate);
+            if (repeatString) {
+                dateString = repeatString;
+            }
+
+            let formattedExDates = [];
+            let modifiedOccurrences = [];
+
+            let dateComptor = function (a,b) {
+                return a.startDate.compare(b.startDate);
+            }
+
+            // Show removed instances
+            for (let exc of aEvent.recurrenceInfo.getRecurrenceItems({})) {
+                if (exc instanceof Components.interfaces.calIRecurrenceDate) {
+                    if (exc.isNegative) {
+                        // This is an EXDATE
+                        formattedExDates.push(formatter.formatDateTime(exc.date));
+                    } else {
+                        // This is an RDATE, close enough to a modified occurrence
+                        let excItem = aEvent.recurrenceInfo.getOccurrenceFor(exc.date);
+                        cal.binaryInsert(modifiedOccurrences, excItem, dateComptor, true)
+                    }
+                }
+            }
+            if (formattedExDates.length > 0) {
+                field("canceledOccurrences", formattedExDates.join("\n"));
+            }
+
+            // Show modified occurrences
+            for (let recurrenceId of aEvent.recurrenceInfo.getExceptionIds({})) {
+                let exc = aEvent.recurrenceInfo.getExceptionFor(recurrenceId);
+                let excLocation = exc.getProperty("LOCATION");
+
+                // Only show modified occurrence if start, duration or location
+                // has changed.
+                if (exc.startDate.compare(exc.recurrenceId) != 0 ||
+                    exc.duration.compare(aEvent.duration) != 0 ||
+                    excLocation != aEvent.getProperty("LOCATION")) {
+                    cal.binaryInsert(modifiedOccurrences, exc, dateComptor, true)
+                }
+            }
+
+            let stringifyOcc = function (occ) {
+                let formattedExc = formatter.formatItemInterval(occ);
+                let occLocation = occ.getProperty("LOCATION");
+                if (occLocation != aEvent.getProperty("LOCATION")) {
+                    let location = ltn.getString("lightning", "imipHtml.newLocation", [occLocation]);
+                    formattedExc += " (" + location + ")";
+                }
+                return formattedExc;
+            }
+
+            if (modifiedOccurrences.length > 0) {
+                field("modifiedOccurrences", modifiedOccurrences.map(stringifyOcc).join("\n"));
+            }
+        }
+
+        field("when", dateString);
+        field("comment", aEvent.getProperty("COMMENT"), true);
+
+        // DESCRIPTION field
+        let eventDescription = (aEvent.getProperty("DESCRIPTION") || "")
+                                    /* Remove the useless "Outlookism" squiggle. */
+                                    .replace("*~*~*~*~*~*~*~*~*~*", "");
+        field("description", eventDescription, true);
+
+        // ATTENDEE and ORGANIZER fields
+        let attendees = aEvent.getAttendees({});
+        let attendeeTemplate = doc.getElementById("attendee-template");
+        let attendeeTable = doc.getElementById("attendee-table");
+        let organizerTable = doc.getElementById("organizer-table");
+        doc.getElementById("imipHtml-attendees-row").hidden = (attendees.length < 1);
+        doc.getElementById("imipHtml-organizer-row").hidden = !aEvent.organizer;
+
+        let setupAttendee = function (attendee) {
+            let row = attendeeTemplate.cloneNode(true);
+            row.removeAttribute("id");
+            row.removeAttribute("hidden");
+            row.getElementsByClassName("status-icon")[0].setAttribute("status",
+                                                                      attendee.participationStatus);
+            row.getElementsByClassName("attendee-name")[0].textContent = attendee.toString();
+            return row;
+        };
+
+        // Fill rows for attendees and organizer
+        field("attendees");
+        for (let attendee of attendees) {
+            attendeeTable.appendChild(setupAttendee(attendee));
+        }
+
+        field("organizer");
+        if (aEvent.organizer) {
+            organizerTable.appendChild(setupAttendee(aEvent.organizer));
+        }
+
+        return doc;
+    },
+
+    /**
+     * Expects and return a serialized DOM - use cal.xml.serializeDOM(aDOM)
+     * @param  {String} aOldDoc    serialized DOM of the the old document
+     * @param  {String} aNewDoc    serialized DOM of the the new document
+     * @param  {String} aIgnoreId  attendee id to ignore, usually the organizer
+     * @return {String}            updated serialized DOM of the new document
+     */
+    compareInvitationOverlay: function (aOldDoc, aNewDoc, aIgnoreId) {
+        /**
+         * Transforms text node content to formated child nodes. Decorations are defined in imip.css
+         * @param {Node}    aToNode text node to change
+         * @param {String}  aType   use 'newline' for the same, 'added' or 'removed' for decoration
+         * @param {String}  aText   [optional]
+         * @param {Boolean} aClear  [optional] for consecutive changes on the same node, set to false
+         */
+        function _content2Child(aToNode, aType, aText = '', aClear = true) {
+            let nodeDoc = aToNode.ownerDocument;
+            if (aClear && aToNode.hasChildNodes()) {
+                aToNode.removeChild(aToNode.firstChild);
+            }
+            let n = nodeDoc.createElement((aType.toLowerCase() == 'newline') ? 'br' : 'span');
+            switch (aType) {
+                case 'added':
+                case 'modified':
+                case 'removed':
+                    n.className = aType;
+                    if (Preferences.get('calendar.view.useSystemColors', false)) {
+                        n.setAttribute('systemcolors', true);
+                    }
+                    break;
+            }
+            n.textContent = aText;
+            aToNode.appendChild(n);
+        }
+        /**
+         * Extracts attendees from the given document
+         * @param   {Node}   aDoc      document to search in
+         * @param   {String} aElement  element name as used in _compareElement()
+         * @returns {Array}            attendee nodes
+         */
+        function _getAttendees(aDoc, aElement) {
+            let attendees = [];
+            for (let att of aDoc.getElementsByClassName('attendee-name')) {
+                if (!att.parentNode.hidden &&
+                    att.parentNode.parentNode.id == (aElement + '-table')) {
+                    attendees[att.textContent] = att;
+                }
+            }
+            return attendees;
+        }
+        /**
+         * Compares both documents for elements related to the given name
+         * @param {String} aElement  part of the element id within the html template
+         */
+        function _compareElement(aElement) {
+            let element = (aElement == 'attendee') ? aElement + 's' : aElement;
+            let oldRow = aOldDoc.getElementById('imipHtml-' + element + '-row');
+            let newRow = aNewDoc.getElementById('imipHtml-' + element + '-row');
+            let row = doc.getElementById('imipHtml-' + element + '-row');
+            let oldContent = aOldDoc.getElementById('imipHtml-' + aElement + '-content');
+            let content = doc.getElementById('imipHtml-' + aElement + '-content');
+
+            if (newRow.hidden && !oldRow.hidden) {
+                // element was removed
+                // we only need to check for simple elements here: attendee or organizer row
+                // cannot be removed
+                if (oldContent) {
+                    _content2Child(content, 'removed', oldContent.textContent);
+                    row.hidden = false;
+                }
+            } else if (!newRow.hidden && oldRow.hidden) {
+                // the element was added
+                // we only need to check for simple elements here: attendee or organizer row
+                // must have been there before
+                if (content) {
+                    _content2Child(content, 'added', content.textContent);
+                }
+            } else if (!newRow.hidden && !oldRow.hidden) {
+                // the element may have been modified
+                if (content) {
+                    if (content.textContent != oldContent.textContent) {
+                        _content2Child(content, 'added', content.textContent);
+                        _content2Child(content, 'newline', null, false);
+                        _content2Child(content, 'removed', oldContent.textContent, false);
+                    }
+                } else {
+                    content = doc.getElementById(aElement + '-table');
+                    oldContent = aOldDoc.getElementById(aElement + '-table');
+                    let excludeAddress = cal.removeMailTo(aIgnoreId);
+                    if (content && oldContent && !content.isEqualNode(oldContent)) {
+                        // extract attendees
+                        let attendees = _getAttendees(doc, aElement);
+                        let oldAttendees = _getAttendees(aOldDoc, aElement);
+                        // decorate newly added attendees
+                        for (let att of Object.keys(attendees)) {
+                            if (!(att in oldAttendees)) {
+                                _content2Child(attendees[att], 'added', att);
+                            }
+                        }
+                        // decorate removed attendees
+                        for (let att of Object.keys(oldAttendees)) {
+                            // if att is the user his/herself, who accepted an invitation he/she was
+                            // not invited to, we must exclude him/her here
+                            if (!(att in attendees) && !att.includes(excludeAddress)) {
+                                _content2Child(oldAttendees[att], 'removed', att);
+                                content.appendChild(oldAttendees[att].parentNode.cloneNode(true));
+                            }
+                        }
+                        // highlight partstat changes (excluding the user)
+                        for (let att of Object.keys(oldAttendees)) {
+                            if ((att in attendees) && !att.includes(excludeAddress)) {
+                                let oldPS = oldAttendees[att].parentNode.childNodes[1].childNodes[0];
+                                let newPS = attendees[att].parentNode.childNodes[1].childNodes[0];
+                                if (oldPS.attributes[1].value != newPS.attributes[1].value) {
+                                    _content2Child(attendees[att], 'modified', att);
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        aOldDoc = cal.xml.parseString(aOldDoc);
+        aNewDoc = cal.xml.parseString(aNewDoc);
+        let doc = aNewDoc.cloneNode(true);
+        // elements to consider for comparison
+        ['summary', 'location', 'when', 'canceledOccurrences',
+         'modifiedOccurrences', 'organizer', 'attendee'].forEach(_compareElement);
+        return cal.xml.serializeDOM(doc);
+    }
+};
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/calendar/lightning/modules/ltnUtils.jsm
@@ -0,0 +1,20 @@
+/* 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/. */
+
+Components.utils.import("resource://calendar/modules/calUtils.jsm");
+
+this.EXPORTED_SYMBOLS = ["ltn"];
+let ltn = {
+    /**
+     * Gets the value of a string in a .properties file from the lightning bundle
+     *
+     * @param {String} aBundleName  the name of the properties file. It is assumed that the
+     *                              file lives in chrome://lightning/locale/
+     * @param {String} aStringName  the name of the string within the properties file
+     * @param {Array}  aParams      [optional] array of parameters to format the string
+     */
+    getString: function(aBundleName, aStringName, aParams) {
+        return cal.calGetString(aBundleName, aStringName, aParams, "lightning");
+    }
+};
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/calendar/lightning/modules/moz.build
@@ -0,0 +1,9 @@
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES += [
+    'ltnInvitationUtils.jsm',
+    'ltnUtils.jsm',
+]
--- a/calendar/lightning/moz.build
+++ b/calendar/lightning/moz.build
@@ -6,16 +6,17 @@
 DIRS += [
     '../libical',
     '../base',
     '../providers',
     '../import-export',
     '../itip',
     'components',
     'locales',
+    'modules',
 ]
 
 TEST_DIRS += ['../test']
 
 XPI_NAME = 'lightning'
 export('XPI_NAME')
 
 DIST_FILES += [
--- a/calendar/lightning/themes/common/imip.css
+++ b/calendar/lightning/themes/common/imip.css
@@ -83,8 +83,29 @@
 }
 .content {
        width: 29em;
        background-color: -moz-default-background-color;
 }
 .content p {
        white-space: pre-wrap;
 }
+.added {
+       color: rgb(255, 0, 0);
+}
+.added[systemcolors] {
+       color: -moz-DialogText;
+       font-weight: bold;
+}
+.modified {
+       color: rgb(255, 0, 0);
+       font-style: italic;
+}
+.modified[systemcolors] {
+       color: -moz-DialogText;
+}
+.removed {
+       color: rgb(125, 125, 125);
+       text-decoration: line-through;
+}
+.removed[systemcolors] {
+       color: -moz-DialogText;
+}