Bug 691141 - rework AB contact photo storing UI. ui-r=Paenglab, r=aceman,jorgk a=jorgk
authorSamuel Mueller <samuel.mueller@leven.ch>
Sun, 22 Apr 2018 11:07:30 +0200
changeset 31411 5f19482796a3b598893add08b18ae7cb42661af8
parent 31410 19e815a73cb363490dc7ea803a44872754a076c6
child 31412 6569818bab2f1502ebdbbaa84d330ef1806f9ae4
push id383
push userclokep@gmail.com
push dateMon, 07 May 2018 21:52:48 +0000
reviewersPaenglab, aceman, jorgk, jorgk
bugs691141
Bug 691141 - rework AB contact photo storing UI. ui-r=Paenglab, r=aceman,jorgk a=jorgk
mail/components/addrbook/content/abCard.inc
mail/components/addrbook/content/abCard.js
mail/components/addrbook/content/abEditCardDialog.xul
mail/components/addrbook/content/abNewCardDialog.xul
mail/locales/en-US/chrome/messenger/addressbook/abCard.dtd
mail/themes/linux/mail/addrbook/cardDialog.css
mail/themes/osx/mail/addrbook/cardDialog.css
mail/themes/windows/mail/addrbook/cardDialog.css
--- a/mail/components/addrbook/content/abCard.inc
+++ b/mail/components/addrbook/content/abCard.inc
@@ -423,55 +423,70 @@
             <hbox class="CardEditWidth">
               <textbox id="IRC" flex="1" onchange="updateChatName();"/>
             </hbox>
           </hbox>
         </vbox>
       </hbox>
 
       <!-- ** Photo Tab ** -->
-      <hbox id="abPhotoTab" align="center">
-        <vbox align="center" style="min-width: 25ch; max-width: 25ch;">
-          <image id="photo" style="max-width: 25ch; max-height: 25ch;"/>
+      <hbox id="abPhotoTab">
+        <vbox align="center" id="PhotoContainer"
+              style="height: 25ch; width: 25ch;"
+              ondrop="doDropPhoto(event);"
+              ondragenter="checkDropPhoto(event);"
+              ondragover="checkDropPhoto(event);">
+          <image id="photo" style="max-height: 25ch; max-width: 25ch;"/>
+          <hbox id="PhotoDropTarget" flex="1" pack="center" align="center">
+            <description>&PhotoDropTarget.label;</description>
+          </hbox>
         </vbox>
-        <groupbox flex="1">
-          <caption label="&PhotoDesc.label;"/>
-          <radiogroup id="PhotoType" onselect="onSwitchPhotoType();">
-              <vbox id="GenericPhotoContainer">
-                <radio id="GenericPhotoType" value="generic" label="&GenericPhoto.label;"
-                       accesskey="&GenericPhoto.accesskey;"/>
-                <menulist id="GenericPhotoList" class="indent" flex="1"
-                          oncommand="onSwitchPhotoType('generic');">
-                  <menupopup>
-                    <menuitem label="&DefaultPhoto.label;" selected="true"
-                              value="default"/>
-                  </menupopup>
-                </menulist>
-              </vbox>
+
+        <vbox flex="1">
+          <radiogroup id="PhotoType" oncommand="onSwitchPhotoType();">
+            <vbox id="GenericPhotoContainer">
+              <radio id="GenericPhotoType" value="generic" label="&GenericPhoto.label;"
+                     accesskey="&GenericPhoto.accesskey;"/>
+              <menulist id="GenericPhotoList" class="indent" flex="1"
+                        oncommand="onSwitchPhotoType('generic', event);">
+                <menupopup>
+                  <menuitem label="&DefaultPhoto.label;" selected="true"
+                            value="default"/>
+                </menupopup>
+              </menulist>
+            </vbox>
 
-              <vbox id="FilePhotoContainer">
-                <radio id="FilePhotoType" value="file" label="&PhotoFile.label;"
-                       accesskey="&PhotoFile.accesskey;"/>
-                <hbox class="indent">
-                  <filefield id="PhotoFile" readonly="true" maxlength="255" flex="1"/>
-                   <button oncommand="browsePhoto();" id="BrowsePhoto"
-                          label="&BrowsePhoto.label;"
-                          accesskey="&BrowsePhoto.accesskey;"/>
-                </hbox>
-              </vbox>
+            <vbox id="FilePhotoContainer">
+              <radio id="FilePhotoType" value="file" label="&PhotoFile.label;"
+                     accesskey="&PhotoFile.accesskey;"/>
+              <hbox class="indent">
+                <filefield id="PhotoFile" readonly="true" maxlength="255" flex="1"/>
+                <button id="BrowsePhoto"
+                        oncommand="browsePhoto(event);"
+                        label="&BrowsePhoto.label;"
+                        accesskey="&BrowsePhoto.accesskey;"/>
+              </hbox>
+            </vbox>
 
-              <vbox id="WebPhotoContainer">
-                <radio id="WebPhotoType" value="web" label="&PhotoURL.label;"
-                       accesskey="&PhotoURL.accesskey;"/>
-                <hbox class="indent">
-                  <textbox id="PhotoURI" maxlength="255" flex="1"
-                           placeholder="&PhotoURL.placeholder;"/>
-                  <button oncommand="onSwitchPhotoType('web');" id="UpdatePhoto"
-                          label="&UpdatePhoto.label;"
-                          accesskey="&UpdatePhoto.accesskey;"/>
-                </hbox>
-              </vbox>
+            <vbox id="WebPhotoContainer">
+              <radio id="WebPhotoType" value="web" label="&PhotoURL.label;"
+                     accesskey="&PhotoURL.accesskey;"/>
+              <hbox class="indent">
+                <textbox id="PhotoURI" maxlength="255" flex="1"
+                         placeholder="&PhotoURL.placeholder;"/>
+                <button id="UpdatePhoto"
+                        oncommand="onSwitchPhotoType('web', event);"
+                        label="&UpdatePhoto.label;"
+                        accesskey="&UpdatePhoto.accesskey;"/>
+              </hbox>
+            </vbox>
           </radiogroup>
-        </groupbox>
+
+          <hbox id="ProgressContainer" align="begin">
+            <label id="PhotoStatus"/>
+            <spacer flex="2"/>
+            <progressmeter id="PhotoDownloadProgress" value="0" mode="determined" hidden="true" flex="1"/>
+          </hbox>
+        </vbox>
       </hbox>
     </tabpanels>
   </tabbox>
 </vbox>
--- a/mail/components/addrbook/content/abCard.js
+++ b/mail/components/addrbook/content/abCard.js
@@ -69,16 +69,22 @@ var kVcardFields =
         ];
 
 var gEditCard;
 var gOnSaveListeners = new Array();
 var gOnLoadListeners = new Array();
 var gOkCallback = null;
 var gHideABPicker = false;
 var gPhotoHandlers = {};
+// If any new photos were added to the card, this stores the name of the original
+// and any temporary new filenames used to store photos of the card.
+// 'null' is a valid value when there was no photo (e.g. the generic photo).
+var gOldPhotos = [];
+// If a new photo was added, the name is stored here.
+var gNewPhoto = null;
 
 function OnLoadNewCard()
 {
   InitEditCard();
 
   gEditCard.card =
     (("arguments" in window) && (window.arguments.length > 0) &&
      (window.arguments[0] instanceof Ci.nsIAbCard))
@@ -160,24 +166,40 @@ function OnLoadNewCard()
   var focus = document.getElementById(gEditCard.displayLastNameFirst
                                       ? "LastName" : "FirstName");
   if (focus) {
     // XXX Using the setTimeout hack until bug 103197 is fixed
     setTimeout( function(firstTextBox) { firstTextBox.focus(); }, 0, focus );
   }
 }
 
+/**
+ * Get the source directory containing the card we are editing.
+ */
+function getContainingDirectory() {
+  let directory = GetDirectoryFromURI(gEditCard.abURI);
+  // If the source directory is "All Address Books", find the parent
+  // address book of the card being edited and reflect the changes in it.
+  if (directory.URI == kAllDirectoryRoot + "?") {
+    let dirId =
+      gEditCard.card.directoryId
+                    .substring(0, gEditCard.card.directoryId.indexOf("&"));
+    directory = MailServices.ab.getDirectoryFromId(dirId);
+  }
+  return directory;
+}
+
 function EditCardOKButton()
 {
   if (!CheckCardRequiredDataPresence(document))
     return false;  // don't close window
 
   // See if this card is in any mailing list
   // if so then we need to update the addresslists of those mailing lists
-  var directory = GetDirectoryFromURI(gEditCard.abURI);
+  let directory = getContainingDirectory();
 
   // if the directory is a mailing list we need to search all the mailing lists
   // in the parent directory if the card exists.
   if (directory.isMailList) {
     var parentURI = GetParentDirectoryFromMailingListURI(gEditCard.abURI);
     directory = GetDirectoryFromURI(parentURI);
   }
 
@@ -199,25 +221,16 @@ function EditCardOKButton()
         if (card.equals(gEditCard.card))
           foundDirectories.push({directory:subdir, cardIndex:index});
       }
     }
   }
 
   CheckAndSetCardValues(gEditCard.card, document, false);
 
-  // If the source directory is "All Address Books", find the parent
-  // address book of the card being edited and reflect the changes in it.
-  if (directory.URI == kAllDirectoryRoot + "?") {
-    let dirId =
-      gEditCard.card.directoryId
-                    .substring(0, gEditCard.card.directoryId.indexOf("&"));
-    directory = MailServices.ab.getDirectoryFromId(dirId);
-  }
-
   directory.modifyCard(gEditCard.card);
 
   while (foundDirectories.length) {
     // Update the addressLists item for this card
     let foundItem = foundDirectories.pop();
     foundItem.directory
              .addressLists
              .replaceElementAt(gEditCard.card, foundItem.cardIndex);
@@ -227,16 +240,22 @@ function EditCardOKButton()
 
   // callback to allow caller to update
   if (gOkCallback)
     gOkCallback();
 
   return true;  // close the window
 }
 
+function EditCardCancelButton()
+{
+  // If a new photo was created, remove it now as it won't be used.
+  purgeOldPhotos(false);
+}
+
 function OnLoadEditCard()
 {
   InitEditCard();
 
   gEditCard.titleProperty = "editContactTitle";
 
   if (window.arguments && window.arguments[0])
   {
@@ -502,23 +521,19 @@ function GetCardValues(cardproperty, doc
 
   // get phonetic fields if exist
   try {
     doc.getElementById("PhoneticFirstName").value = cardproperty.getProperty("PhoneticFirstName", "");
     doc.getElementById("PhoneticLastName").value = cardproperty.getProperty("PhoneticLastName", "");
   }
   catch (ex) {}
 
-  // Store the original photo URI and update the photo
   // Select the type if there is a valid value stored for that type, otherwise
   // select the generic photo
-  var photoType = cardproperty.getProperty("PhotoType", "");
-  document.getElementById("PhotoType").value = photoType;
   loadPhoto(cardproperty);
-  setCardEditorPhoto(photoType, cardproperty);
 
   updateChatName();
 }
 
 // when the ab card dialog is being loaded to show a vCard,
 // hide the fields which aren't supported
 // by vCard so the user does not try to edit them.
 function HideNonVcardFields()
@@ -570,17 +585,26 @@ function CheckAndSetCardValues(cardprope
 
   // set phonetic fields if exist
   try {
     cardproperty.setProperty("PhoneticFirstName", doc.getElementById("PhoneticFirstName").value);
     cardproperty.setProperty("PhoneticLastName", doc.getElementById("PhoneticLastName").value);
   }
   catch (ex) {}
 
-  savePhoto(cardproperty);
+  let photoType = doc.getElementById("PhotoType").value;
+  if (gPhotoHandlers[photoType]) {
+    if (!gPhotoHandlers[photoType].onSave(cardproperty, doc)) {
+      photoType = "generic";
+      onSwitchPhotoType("generic");
+      gPhotoHandlers[photoType].onSave(cardproperty, doc);
+    }
+  }
+  cardproperty.setProperty("PhotoType", photoType);
+  purgeOldPhotos(true);
 
   return true;
 }
 
 function CleanUpWebPage(webPage)
 {
   // no :// yet so we should add something
   if (webPage.length && !webPage.includes("://"))
@@ -906,89 +930,76 @@ function updateChatName()
       value = val;
       break;
     }
   }
   document.getElementById("ChatName").value = value;
 }
 
 /**
- * Updates the photo displayed in the contact editor based on the
- * type of photo selected.  If the type is not recognized, the
- * photo will automatically switch to the generic photo.
- *
- * @param aType The type of photo (web, file, or generic available
- *              by default).
- * @param aCard The nsIAbCard being edited
- *
- */
-function setCardEditorPhoto(aType, aCard)
-{
-  if (!gPhotoHandlers[aType] ||
-      !gPhotoHandlers[aType].onShow(aCard, document, "photo"))
-    gPhotoHandlers["generic"].onShow(aCard, document, "photo");
-}
-
-/**
  * Extract the photo information from an nsIAbCard, and populate
  * the appropriate input fields in the contact editor.  If the
  * nsIAbCard returns an unrecognized PhotoType, the generic
  * display photo is switched to.
  *
  * @param aCard The nsIAbCard to extract the information from.
  *
  */
-function loadPhoto(aCard)
-{
-  var type = aCard.getProperty("PhotoType", "")
-  if (!gPhotoHandlers[type] ||
-      !gPhotoHandlers[type].onLoad(aCard, document))
-    gPhotoHandlers["generic"].onLoad(aCard, document);
-}
+function loadPhoto(aCard) {
+  var type = aCard.getProperty("PhotoType", "");
 
-/**
- * Given the fields in the current contact editor, commit
- * the photo to an nsIAbCard.  If the photo cannot be saved,
- * the generic contact photo is saved instead.
- *
- * @param aType
- *
- */
-function savePhoto(aCard)
-{
-  var type = document.getElementById("PhotoType").value;
-  if (!gPhotoHandlers[type] ||
-      !gPhotoHandlers[type].onSave(aCard, document))
-    gPhotoHandlers["generic"].onSave(aCard, document);
+  if (!gPhotoHandlers[type] || !gPhotoHandlers[type].onLoad(aCard, document)) {
+    type = "generic";
+    gPhotoHandlers[type].onLoad(aCard, document);
+  }
+
+  document.getElementById("PhotoType").value = type;
+  gPhotoHandlers[type].onShow(aCard, document, "photo");
 }
 
 /**
  * Event handler for when the user switches the type of
- * photo for the nsIAbCard being edited.  Called from
- * abCardOverlay.xul.
+ * photo for the nsIAbCard being edited. Tries to initiate a
+ * photo download.
+ *
+ * @param aPhotoType {string}  The type to switch to
+ * @param aEvent {Event}       The event object if used as an event handler
  */
-function onSwitchPhotoType(photoType)
-{
+function onSwitchPhotoType(aPhotoType, aEvent) {
   if (!gEditCard)
     return;
 
-  if (photoType)
-    document.getElementById("PhotoType").value = photoType;
-  else
-    photoType = document.getElementById("PhotoType").value;
+  // Stop event propagation to the radiogroup command event in case that the
+  // child button is pressed. Otherwise, the download is started twice in a row.
+  if (aEvent) {
+    aEvent.stopPropagation();
+  }
 
-  setCardEditorPhoto(photoType, gEditCard.card);
+  if (aPhotoType) {
+    if (aPhotoType != document.getElementById("PhotoType").value) {
+      document.getElementById("PhotoType").value = aPhotoType;
+    }
+  } else {
+    aPhotoType = document.getElementById("PhotoType").value;
+  }
+
+  if (gPhotoHandlers[aPhotoType]) {
+    if (!gPhotoHandlers[aPhotoType].onRead(gEditCard.card, document)) {
+      onSwitchPhotoType("generic");
+    }
+  }
 }
 
 /**
  * Removes the photo file at the given path, if present.
  *
  * @param aName The name of the photo to remove from the Photos directory.
+ *              'null' value is allowed and means to remove no file.
  *
- * @return true if the file was deleted.
+ * @return {boolean} True if the file was deleted, false otherwise.
  */
 function removePhoto(aName) {
   if (!aName)
     return false;
   // Get the directory with all the photos
   var file = getPhotosDir();
   // Get the photo (throws an exception for invalid names)
   try {
@@ -996,40 +1007,185 @@ function removePhoto(aName) {
     file.remove(false);
     return true;
   }
   catch (e) {}
   return false;
 }
 
 /**
+ * Remove previous and temporary photo files from the Photos directory.
+ *
+ * @param aSaved {boolean}  Whether the new card is going to be saved/committed.
+ */
+function purgeOldPhotos(aSaved = true) {
+  // If photo was changed, the array contains at least one member, the original photo.
+  while (gOldPhotos.length > 0) {
+    let photoName = gOldPhotos.pop();
+    if (!aSaved && (gOldPhotos.length == 0)) {
+      // If the saving was cancelled, we want to keep the original photo of the card.
+      break;
+    }
+    removePhoto(photoName);
+  }
+
+  if (aSaved) {
+    // The new photo should stay so we clear the reference to it.
+    gNewPhoto = null;
+  } else {
+    // Changes to card not saved, we don't need the new photo.
+    // It may be null when there was no change of it.
+    removePhoto(gNewPhoto);
+  }
+}
+
+/**
  * Opens a file picker with image filters to look for a contact photo.
  * If the user selects a file and clicks OK then the PhotoURI textbox is set
  * with a file URI pointing to that file and updatePhoto is called.
+ *
+ * @param aEvent {Event} The event object if used as an event handler.
  */
-function browsePhoto() {
-  var nsIFilePicker = Ci.nsIFilePicker;
+function browsePhoto(aEvent) {
+  // Stop event propagation to the radiogroup command event in case that the
+  // child button is pressed. Otherwise, the download is started twice in a row.
+  if (aEvent)
+    aEvent.stopPropagation();
+
   var fp = Cc["@mozilla.org/filepicker;1"]
-             .createInstance(nsIFilePicker);
-  fp.init(window, gAddressBookBundle.getString("browsePhoto"), nsIFilePicker.modeOpen);
+             .createInstance(Ci.nsIFilePicker);
+  fp.init(window, gAddressBookBundle.getString("browsePhoto"), Ci.nsIFilePicker.modeOpen);
+
+  // Open the directory of the currently chosen photo (if any)
+  let currentPhotoFile = document.getElementById("PhotoFile").file
+  if (currentPhotoFile) {
+    fp.displayDirectory = currentPhotoFile.parent;
+  }
 
   // Add All Files & Image Files filters and select the latter
-  fp.appendFilters(nsIFilePicker.filterImages);
-  fp.appendFilters(nsIFilePicker.filterAll);
+  fp.appendFilters(Ci.nsIFilePicker.filterImages);
+  fp.appendFilters(Ci.nsIFilePicker.filterAll);
 
   fp.open(rv => {
-    if (rv != nsIFilePicker.returnOK) {
+    if (rv != Ci.nsIFilePicker.returnOK) {
       return;
     }
     document.getElementById("PhotoFile").file = fp.file;
-    let photoType = document.getElementById("FilePhotoType").value;
-    onSwitchPhotoType(photoType);
+    onSwitchPhotoType("file");
   });
 }
 
+/**
+ * Handlers to add drag and drop support.
+ */
+function checkDropPhoto(aEvent) {
+  // Just allow anything to be dropped. Different types of data are handled
+  // in doDropPhoto() below.
+  aEvent.preventDefault();
+}
+
+function doDropPhoto(aEvent) {
+  aEvent.preventDefault();
+
+  let photoType = "";
+
+  // Check if a file has been dropped.
+  let file = aEvent.dataTransfer.mozGetDataAt("application/x-moz-file", 0);
+  if (file instanceof Ci.nsIFile) {
+    photoType = "file";
+    document.getElementById("PhotoFile").file = file;
+  } else {
+    // Check if a URL has been dropped.
+    let link = aEvent.dataTransfer.getData("URL");
+    if (link) {
+      photoType = "web";
+      document.getElementById("PhotoURI").value = link;
+    } else {
+      // Check if dropped text is a URL.
+      link = aEvent.dataTransfer.getData("text/plain");
+      if (/^(ftps?|https?):\/\//i.test(link)) {
+        photoType = "web";
+        document.getElementById("PhotoURI").value = link;
+      }
+    }
+  }
+
+  onSwitchPhotoType(photoType);
+}
+
+/**
+ * Self-contained object to manage the user interface used for downloading
+ * and storing contact photo images.
+ */
+var gPhotoDownloadUI = (function() {
+  // UI DOM elements
+  let elProgressbar;
+  let elProgressLabel;
+  let elPhotoType;
+  let elProgressContainer;
+
+  window.addEventListener("load", function load(event) {
+    if (!elProgressbar)
+      elProgressbar = document.getElementById("PhotoDownloadProgress");
+    if (!elProgressLabel)
+      elProgressLabel = document.getElementById("PhotoStatus");
+    if (!elPhotoType)
+      elPhotoType = document.getElementById("PhotoType");
+    if (!elProgressContainer)
+      elProgressContainer = document.getElementById("ProgressContainer");
+  }, false);
+
+  function onStart() {
+    elProgressContainer.setAttribute("class", "expanded");
+    elProgressLabel.value = "";
+    elProgressbar.hidden = false;
+    elProgressbar.value = 3; // Start with a tiny visible progress
+  }
+
+  function onSuccess() {
+    elProgressLabel.value = "";
+    elProgressContainer.setAttribute("class", "");
+  }
+
+  function onError(state) {
+    let msg;
+    switch (state) {
+      case gImageDownloader.ERROR_INVALID_URI:
+        msg = gAddressBookBundle.getString("errorInvalidUri");
+        break;
+      case gImageDownloader.ERROR_UNAVAILABLE:
+        msg = gAddressBookBundle.getString("errorNotAvailable");
+        break;
+      case gImageDownloader.ERROR_INVALID_IMG:
+        msg = gAddressBookBundle.getString("errorInvalidImage");
+        break;
+      case gImageDownloader.ERROR_SAVE:
+        msg = gAddressBookBundle.getString("errorSaveOperation");
+        break;
+    }
+    if (msg) {
+      elProgressLabel.value = msg;
+      elProgressbar.hidden = true;
+      onSwitchPhotoType("generic");
+    }
+  }
+
+  function onProgress(state, percent) {
+    elProgressbar.value = percent;
+    elProgressLabel.value = gAddressBookBundle.getString("stateImageSave");
+  }
+
+  return {
+    onStart: onStart,
+    onSuccess: onSuccess,
+    onError: onError,
+    onProgress: onProgress
+  }
+})();
+
 /* A photo handler defines the behaviour of the contact editor
  * for a particular photo type. Each photo handler must implement
  * the following interface:
  *
  * onLoad: function(aCard, aDocument):
  *   Called when the editor wants to populate the contact editor
  *   input fields with information about aCard's photo.  Note that
  *   this does NOT make aCard's photo appear in the contact editor -
@@ -1041,141 +1197,189 @@ function browsePhoto() {
  *   Called when the editor wants to show this photo type.
  *   The onShow method should take the input fields in the document,
  *   and render the requested photo in the IMG tag with id
  *   aTargetID.  Note that onShow does NOT save the photo for aCard -
  *   this job is left to the onSave function.  Returns true on success.
  *   If the function returns false, the generic photo handler onShow
  *   function will be called.
  *
- * onSave: function(aCard, aDocument)
- *   Called when the editor wants to save this photo type.  The
- *   onSave method is responsible for analyzing the photo of this
+ * onRead: function(aCard, aDocument)
+ *   Called when the editor wants to read the user supplied new photo.
+ *   The onRead method is responsible for analyzing the photo of this
  *   type requested by the user, and storing it, as well as the
  *   other fields required by onLoad/onShow to retrieve and display
- *   the photo again.  Returns true on success.  If the function
- *   returns false, the generic photo handler onSave function will
+ *   the photo again. Returns true on success.  If the function
+ *   returns false, the generic photo handler onRead function will
  *   be called.
+ *
+ * onSave: function(aCard, aDocument)
+ *   Called when the editor wants to save this photo type to the card.
+ *   Returns true on success.
  */
 
 var genericPhotoHandler = {
-
   onLoad: function(aCard, aDocument) {
     return true;
   },
 
   onShow: function(aCard, aDocument, aTargetID) {
+    // XXX TODO: this ignores any other value from the generic photos
+    // menulist than "default".
     aDocument.getElementById(aTargetID)
              .setAttribute("src", defaultPhotoURI);
     return true;
   },
 
+  onRead: function(aCard, aDocument) {
+    gPhotoDownloadUI.onSuccess();
+
+    newPhotoAdded("", aCard);
+
+    genericPhotoHandler.onShow(aCard, aDocument, "photo");
+    return true;
+  },
+
   onSave: function(aCard, aDocument) {
-    // If we had the photo saved locally, clear it.
-    removePhoto(aCard.getProperty("PhotoName", null));
+    // XXX TODO: this ignores any other value from the generic photos
+    // menulist than "default".
+
+    // Update contact
     aCard.setProperty("PhotoName", "");
     aCard.setProperty("PhotoURI", "");
-    aCard.setProperty("PhotoType", "generic");
     return true;
   }
 }
 
 var filePhotoHandler = {
-
   onLoad: function(aCard, aDocument) {
-    var photoURI = aCard.getProperty("PhotoURI", "");
+    let photoURI = aCard.getProperty("PhotoURI", "");
+    let file;
     try {
-      var file = Services.io.newURI(photoURI)
-                            .QueryInterface(Ci.nsIFileURL)
-                            .file;
+      // The original file may not exist anymore, but we still display it.
+      file = Services.io.newURI(photoURI)
+                        .QueryInterface(Ci.nsIFileURL)
+                        .file;
     } catch (e) {}
 
     if (!file)
       return false;
 
     aDocument.getElementById("PhotoFile").file = file;
     return true;
   },
 
   onShow: function(aCard, aDocument, aTargetID) {
-    var file = aDocument.getElementById("PhotoFile").file;
-    try {
-      var value = Services.io.newFileURI(file).spec;
-    } catch (e) {}
+    let photoName = gNewPhoto || aCard.getProperty("PhotoName", null);
+    let photoURI = getPhotoURI(photoName);
+    aDocument.getElementById(aTargetID).setAttribute("src", photoURI);
+    return true;
+  },
 
-    if (!value)
+  onRead: function(aCard, aDocument) {
+    let file = aDocument.getElementById("PhotoFile").file;
+    if (!file)
+      return false;
+
+    // If the local file has been removed/renamed, keep the current photo as is.
+    if (!file.exists() || !file.isFile())
       return false;
 
-    aDocument.getElementById(aTargetID).setAttribute("src", value);
+    let photoURI = Services.io.newFileURI(file).spec;
+
+    gPhotoDownloadUI.onStart();
+
+    let cbSuccess = function(newPhotoName) {
+      gPhotoDownloadUI.onSuccess();
+
+      newPhotoAdded(newPhotoName, aCard);
+      aDocument.getElementById("PhotoFile").setAttribute("PhotoURI", photoURI);
+
+      filePhotoHandler.onShow(aCard, aDocument, "photo");
+    };
+
+    gImageDownloader.savePhoto(photoURI, cbSuccess,
+                               gPhotoDownloadUI.onError,
+                               gPhotoDownloadUI.onProgress);
     return true;
   },
 
   onSave: function(aCard, aDocument) {
-    var file = aDocument.getElementById("PhotoFile").file;
-    if (!file)
-      return false;
-
-    // If the local file has been removed/renamed, keep the current photo as is
-    if (!file.exists() || !file.isFile())
-      return true;
-
-    var photoURI = Services.io.newFileURI(file).spec;
-
-    var file = storePhoto(photoURI);
-
-    if (!file)
-      return false;
-
-    // Remove the original, if any
-    removePhoto(aCard.getProperty("PhotoName", null));
-    aCard.setProperty("PhotoName", file.leafName);
-    aCard.setProperty("PhotoType", "file");
-    aCard.setProperty("PhotoURI", photoURI);
+    // Update contact
+    if (gNewPhoto) {
+      // The file may not be valid unless the photo has changed.
+      let photoURI = aDocument.getElementById("PhotoFile").getAttribute("PhotoURI");
+      aCard.setProperty("PhotoName", gNewPhoto);
+      aCard.setProperty("PhotoURI", photoURI);
+    }
     return true;
   }
 }
 
 var webPhotoHandler = {
-
   onLoad: function(aCard, aDocument) {
-    var photoURI = aCard.getProperty("PhotoURI", null);
+    let photoURI = aCard.getProperty("PhotoURI", null);
 
     if (!photoURI)
       return false;
 
     aDocument.getElementById("PhotoURI").value = photoURI;
     return true;
   },
 
   onShow: function(aCard, aDocument, aTargetID) {
-    var photoURI = aDocument.getElementById("PhotoURI").value;
+    let photoName = gNewPhoto || aCard.getProperty("PhotoName", null);
+    if (!photoName)
+      return false;
 
-    if (!photoURI)
-      return false;
+    let photoURI = getPhotoURI(photoName);
 
     aDocument.getElementById(aTargetID).setAttribute("src", photoURI);
     return true;
   },
 
-  onSave: function(aCard, aDocument) {
-    var photoURI = aDocument.getElementById("PhotoURI").value;
-
-    var file = storePhoto(photoURI);
-    if (!file)
+  onRead: function(aCard, aDocument) {
+    let photoURI = aDocument.getElementById("PhotoURI").value;
+    if (!photoURI)
       return false;
 
-    // Remove the original, if any
-    removePhoto(aCard.getProperty("PhotoName", null));
-    aCard.setProperty("PhotoName", file.leafName);
-    aCard.setProperty("PhotoURI", photoURI);
-    aCard.setProperty("PhotoType", "web");
+    gPhotoDownloadUI.onStart();
+
+    let cbSuccess = function(newPhotoName) {
+      gPhotoDownloadUI.onSuccess();
+
+      newPhotoAdded(newPhotoName, aCard);
+
+      webPhotoHandler.onShow(aCard, aDocument, "photo");
+
+    }
+
+    gImageDownloader.savePhoto(photoURI, cbSuccess,
+                               gPhotoDownloadUI.onError,
+                               gPhotoDownloadUI.onProgress);
+    return true;
+  },
+
+  onSave: function(aCard, aDocument) {
+    // Update contact
+    if (gNewPhoto) {
+      let photoURI = aDocument.getElementById("PhotoURI").value;
+      aCard.setProperty("PhotoName", gNewPhoto);
+      aCard.setProperty("PhotoURI", photoURI);
+    }
     return true;
   }
 }
 
+function newPhotoAdded(aPhotoName, aCard) {
+  // If we had the photo saved locally, shedule it for removal if card is saved.
+  gOldPhotos.push(gNewPhoto !== null ? gNewPhoto : aCard.getProperty("PhotoName", null));
+  gNewPhoto = aPhotoName;
+}
+
 /* In order for other photo handlers to be recognized for
  * a particular type, they must be registered through this
  * function.
  * @param aType the type of photo to handle
  * @param aPhotoHandler the photo handler to register
  */
 function registerPhotoHandler(aType, aPhotoHandler)
 {
--- a/mail/components/addrbook/content/abEditCardDialog.xul
+++ b/mail/components/addrbook/content/abEditCardDialog.xul
@@ -7,17 +7,18 @@
 <?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
 <?xml-stylesheet href="chrome://messenger/content/bindings.css" type="text/css"?>
 
 <!DOCTYPE dialog SYSTEM "chrome://messenger/locale/addressbook/abCard.dtd">
 
 <dialog xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
   id="abcardWindow"
   onload="OnLoadEditCard()"
-  ondialogaccept="return EditCardOKButton();">
+  ondialogaccept="return EditCardOKButton();"
+  ondialogcancel="return EditCardCancelButton();">
 
   <stringbundleset id="stringbundleset">
     <stringbundle id="bundle_addressBook" src="chrome://messenger/locale/addressbook/addressBook.properties"/>
   </stringbundleset>
 
   <script type="application/javascript" src="chrome://messenger/content/addressbook/abCommon.js"/>
   <script type="application/javascript" src="chrome://messenger/content/addressbook/abCard.js"/>
 
--- a/mail/components/addrbook/content/abNewCardDialog.xul
+++ b/mail/components/addrbook/content/abNewCardDialog.xul
@@ -13,17 +13,18 @@
   <!ENTITY % abCardDTD SYSTEM "chrome://messenger/locale/addressbook/abCard.dtd">
   %abCardDTD;
 ]>
 
 <dialog xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
     id="abcardWindow"
     windowtype="mailnews:newcarddialog"
     onload="OnLoadNewCard()"
-    ondialogaccept="return NewCardOKButton();">
+    ondialogaccept="return NewCardOKButton();"
+    ondialogcancel="return NewCardCancelButton();">
 
   <stringbundleset id="stringbundleset">
     <stringbundle id="bundle_addressBook" src="chrome://messenger/locale/addressbook/addressBook.properties"/>
   </stringbundleset>
 
   <script type="application/javascript" src="chrome://messenger/content/addressbook/abCommon.js"/>
   <script type="application/javascript" src="chrome://messenger/content/addressbook/abCard.js"/>
 
--- a/mail/locales/en-US/chrome/messenger/addressbook/abCard.dtd
+++ b/mail/locales/en-US/chrome/messenger/addressbook/abCard.dtd
@@ -136,17 +136,16 @@
 <!ENTITY ICQ.accesskey                  "I">
 <!ENTITY XMPP.label                     "Jabber ID:">
 <!ENTITY XMPP.accesskey                 "J">
 <!ENTITY IRC.label                      "IRC Nick:">
 <!ENTITY IRC.accesskey                  "R">
 
 <!ENTITY Photo.tab                      "Photo">
 <!ENTITY Photo.accesskey                "o">
-<!ENTITY PhotoDesc.label                "Pick one of the following:">
 <!ENTITY GenericPhoto.label             "Generic Photo">
 <!ENTITY GenericPhoto.accesskey         "G">
 <!ENTITY DefaultPhoto.label             "Default">
 <!ENTITY PhotoFile.label                "On this Computer">
 <!ENTITY PhotoFile.accesskey            "n">
 <!ENTITY BrowsePhoto.label              "Browse">
 <!ENTITY BrowsePhoto.accesskey          "r">
 <!ENTITY PhotoURL.label                 "On the Web">
--- a/mail/themes/linux/mail/addrbook/cardDialog.css
+++ b/mail/themes/linux/mail/addrbook/cardDialog.css
@@ -76,8 +76,31 @@
 
 #photo {
   list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic.png");
 }
 
 #GenericPhotoList[value="default"] {
   list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic-tiny.png");
 }
+
+#PhotoContainer {
+  margin: 5px;
+}
+
+#PhotoDropTarget {
+  margin-top: 5px;
+}
+
+#PhotoDropTarget:hover {
+  border: 1px dashed #CACAFF;
+}
+
+#ProgressContainer {
+  max-height: 0;
+  transition: all .5s ease-out;
+  overflow: hidden;
+}
+
+#ProgressContainer.expanded {
+  margin-top: 10px;
+  max-height: 40px; /* something higher than the actual height, but not too large */
+}
--- a/mail/themes/osx/mail/addrbook/cardDialog.css
+++ b/mail/themes/osx/mail/addrbook/cardDialog.css
@@ -76,16 +76,39 @@
 #photo {
   list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic.png");
 }
 
 #GenericPhotoList[value="default"] {
   list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic-tiny.png");
 }
 
+#PhotoContainer {
+  margin: 5px;
+}
+
+#PhotoDropTarget {
+  margin-top: 5px;
+}
+
+#PhotoDropTarget:hover {
+  border: 1px dashed #CACAFF;
+}
+
+#ProgressContainer {
+  max-height: 0;
+  transition: all .5s ease-out;
+  overflow: hidden;
+}
+
+#ProgressContainer.expanded {
+  margin-top: 10px;
+  max-height: 40px; /* something higher than the actual height, but not too large */
+}
+
 @media (min-resolution: 2dppx) {
   .person-icon {
     list-style-image: url("chrome://messenger/skin/addressbook/icons/addressbook@2x.png");
     -moz-image-region: rect(0px 160px 32px 128px);
   }
 
   #photo {
     list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic@2x.png");
--- a/mail/themes/windows/mail/addrbook/cardDialog.css
+++ b/mail/themes/windows/mail/addrbook/cardDialog.css
@@ -76,8 +76,30 @@
 
 #photo {
   list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic.png");
 }
 
 #GenericPhotoList[value="default"] {
   list-style-image: url("chrome://messenger/skin/addressbook/icons/contact-generic-tiny.png");
 }
+
+#PhotoContainer {
+  margin: 5px;
+}
+
+#PhotoDropTarget {
+  margin-top: 5px;
+}
+
+#PhotoDropTarget:hover {
+  border: 1px dashed #CACAFF;
+}
+
+#ProgressContainer {
+  max-height: 0;
+  transition: all .5s ease-out;
+  overflow: hidden;
+}
+#ProgressContainer.expanded {
+  margin-top: 10px;
+  max-height: 40px; /* something higher than the actual height, but not too large */
+}