Bug 953891 - Participants Need Context Menu, r=florian.
authoraleth <aleth@instantbird.org>
Thu, 29 Aug 2013 01:12:25 +0200
changeset 19138 707ddca9b0b355adef5b952eaa85323d8569e1aa
parent 19137 a6fb09a42352b65234203f082f334e3713da8515
child 19139 2a5f27e886249a41ba9702f5472671bbaca3bef7
push id1103
push usermbanner@mozilla.com
push dateTue, 18 Mar 2014 07:44:06 +0000
treeherdercomm-beta@50c6279a0af0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersflorian
bugs953891
Bug 953891 - Participants Need Context Menu, r=florian.
chat/components/src/imContacts.js
chat/modules/imThemes.jsm
im/content/addbuddy.js
im/content/blist.js
im/content/blist.xul
im/content/conversation.xml
im/content/instantbird.xul
im/content/nsContextMenu.js
im/locales/en-US/chrome/instantbird/instantbird.properties
im/modules/Makefile.in
im/modules/ibTagMenu.jsm
--- a/chat/components/src/imContacts.js
+++ b/chat/components/src/imContacts.js
@@ -136,16 +136,21 @@ TagsService.prototype = {
     let statement = DBConn.createStatement("SELECT id FROM tags where name = :name");
     statement.params.name = aName;
     if (!statement.executeStep())
       return null;
     return this.getTagById(statement.row.id);
   },
   // Get an array of all existing tags.
   getTags: function(aTagCount) {
+    if (Tags.length)
+      Tags.sort(function(a, b) a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
+    else
+      this.defaultTag;
+
     if (aTagCount)
       aTagCount.value = Tags.length;
     return Tags;
   },
 
   isTagHidden: function(aTag) aTag.id in otherContactsTag._hiddenTags,
   hideTag: function(aTag) { otherContactsTag.hideTag(aTag); },
   showTag: function(aTag) { otherContactsTag.showTag(aTag); },
--- a/chat/modules/imThemes.jsm
+++ b/chat/modules/imThemes.jsm
@@ -403,17 +403,18 @@ const messageReplacements = {
       if (iconURL)
         return iconURL.spec;
     }
 
     // Fallback to the theme's default icons.
     return (aMsg.incoming ? "Incoming" : "Outgoing") + "/buddy_icon.png";
   },
   senderScreenName: function(aMsg) TXTToHTML(aMsg.who),
-  sender: function(aMsg) TXTToHTML(aMsg.alias || aMsg.who),
+  sender: function(aMsg)
+    "<span class=\"ib-sender\">" + TXTToHTML(aMsg.alias || aMsg.who) + "</span>",
   senderColor: function(aMsg) aMsg.color,
   senderStatusIcon: function(aMsg)
     getStatusIconFromBuddy(getBuddyFromMessage(aMsg)),
   messageDirection: function(aMsg) "ltr",
   // no theme actually use this, don't bother making sure this is the real
   // serverside alias
   senderDisplayName: function(aMsg) TXTToHTML(aMsg.alias || aMsg.who),
   service: function(aMsg) aMsg.conversation.account.protocol.name,
--- a/im/content/addbuddy.js
+++ b/im/content/addbuddy.js
@@ -23,23 +23,17 @@ var addBuddy = {
       document.getElementById("addBuddyDialog").cancelDialog();
       throw "No connected account!";
     }
     accountList.selectedIndex = 0;
   },
 
   buildTagList: function ab_buildTagList() {
     var tagList = document.getElementById("taglist");
-    let tags = Services.tags.getTags();
-    if (!tags.length) {
-      let bundle = document.getElementById("instantbirdBundle");
-      tags.push(Services.tags.defaultTag);
-    }
-
-    tags.forEach(function (tag) {
+    Services.tags.getTags().forEach(function(tag) {
       tagList.appendItem(tag.name, tag.id);
     });
     tagList.selectedIndex = 0;
   },
 
   oninput: function ab_oninput() {
     document.documentElement.getButton("accept").disabled =
       !addBuddy.getValue("name");
--- a/im/content/blist.js
+++ b/im/content/blist.js
@@ -81,16 +81,20 @@ function buddyListContextMenu(aXulMenu) 
 
   let detach = document.getElementById("context-detach");
   detach.hidden = !this.onBuddy;
   if (this.onBuddy)
     detach.disabled = this.target.buddy.contact.getBuddies().length == 1;
 
   document.getElementById("context-openconversation").disabled =
     !hide && !this.target.canOpenConversation();
+
+  Components.utils.import("resource:///modules/ibTagMenu.jsm");
+  this.tagMenu = new TagMenu(this, window,
+                             this.onBuddy ? this.target.contact : this.target);
 }
 
 // Prototype for buddyListContextMenu "class."
 buddyListContextMenu.prototype = {
   openConversation: function blcm_openConversation() {
     if (this.onContact || this.onBuddy || this.onConv)
       this.target.openConversation();
   },
@@ -136,74 +140,26 @@ buddyListContextMenu.prototype = {
                 prompts.BUTTON_TITLE_CANCEL * prompts.BUTTON_POS_1 +
                 prompts.BUTTON_POS_1_DEFAULT;
     if (prompts.confirmEx(window, promptTitle, promptMessage, flags,
                           deleteButton, null, null, null, {}))
       return;
 
     this.target.remove();
   },
-  tagsPopupShowing: function blcm_tagsPopupShowing() {
-    if (!this.onContact && !this.onBuddy)
-      return;
-
-    let popup = document.getElementById("context-tags-popup");
-    let item;
-    while ((item = popup.firstChild) && item.localName != "menuseparator")
-      popup.removeChild(item);
-
-    let contact = (this.onBuddy ? this.target.contact : this.target).contact;
-    let tags = contact.getTags();
-    let groupId =
-      (this.onBuddy ? this.target.contact : this.target).group.groupId;
-    let sortFunction = function (a, b) {
-      [a, b] = [a.name.toLowerCase(), b.name.toLowerCase()];
-      return a < b ? 1 : a > b ? -1 : 0;
-    };
-    Services.tags.getTags()
-            .sort(sortFunction)
-            .forEach(function (aTag) {
-      item = document.createElement("menuitem");
-      item.setAttribute("label", aTag.name);
-      item.setAttribute("type", "checkbox");
-      let id = aTag.id;
-      item.groupId = id;
-      if (tags.some(function (t) t.id == id)) {
-        item.setAttribute("checked", "true");
-        if (tags.length == 1)
-          item.setAttribute("disabled", "true"); // can't remove the last tag.
-      }
-      popup.insertBefore(item, popup.firstChild);
-    });
+  addTag: function blcm_addTag(aTag) {
+    // If the contact already has the tag, addTag will return early.
+    this.tagMenu.target.contact.addTag(aTag);
   },
-  tag: function blcm_tag(aEvent) {
-    let id = aEvent.originalTarget.groupId;
-    if (!id)
-      return;
-
-    let tag = Services.tags.getTagById(id);
-    let contact = (this.onBuddy ? this.target.contact : this.target).contact;
-    if (contact.getTags().some(function (t) t.id == id))
-      contact.removeTag(tag);
+  toggleTag: function blcm_toggleTag(aTag) {
+    let contact = this.tagMenu.target.contact;
+    if (contact.getTags().some(function(t) t.id == aTag.id))
+      contact.removeTag(aTag);
     else
-      contact.addTag(tag);
-  },
-  addNewTag: function blcm_addNewTag() {
-    let bundle = document.getElementById("instantbirdBundle").stringBundle;
-    let title = bundle.GetStringFromName("newTagPromptTitle");
-    let message = bundle.GetStringFromName("newTagPromptMessage");
-    let name = {};
-    if (!Services.prompt.prompt(window, title, message, name, null,
-                                {value: false}) || !name.value)
-      return; // the user canceled
-
-    let contact = (this.onBuddy ? this.target.contact : this.target).contact;
-    // If the tag already exists, createTag will return it, and if the
-    // contact already has it, addTag will return early.
-    contact.addTag(Services.tags.createTag(name.value));
+      contact.addTag(aTag);
   },
   _getLogs: function blcm_getLogs() {
     if (this.onContact)
       return Services.logs.getLogsForContact(this.target.contact, true);
     if (this.onBuddy)
       return Services.logs.getLogsForBuddy(this.target.buddy, true);
     if (this.onConv)
       return Services.logs.getLogsForConversation(this.target.conv, true);
--- a/im/content/blist.xul
+++ b/im/content/blist.xul
@@ -80,23 +80,23 @@
       <menuitem id="context-delete"
                 label="&deleteCmd.label;"
                 accesskey="&deleteCmd.accesskey;"
                 oncommand="gBuddyListContextMenu.delete();"/>
       <menu id="context-tags"
             label="&tagsCmd.label;"
             accesskey="&tagsCmd.accesskey;">
         <menupopup id="context-tags-popup"
-                   oncommand="gBuddyListContextMenu.tag(event);"
-                   onpopupshowing="gBuddyListContextMenu.tagsPopupShowing();">
+                   oncommand="gBuddyListContextMenu.tagMenu.tag(event, gBuddyListContextMenu.toggleTag.bind(gBuddyListContextMenu));"
+                   onpopupshowing="gBuddyListContextMenu.tagMenu.tagsPopupShowing();">
           <menuseparator id="context-create-tag-separator"/>
           <menuitem id="context-create-tag"
                     label="&addNewTagCmd.label;"
                     accesskey="&addNewTagCmd.accesskey;"
-                    oncommand="gBuddyListContextMenu.addNewTag();"/>
+                    oncommand="gBuddyListContextMenu.tagMenu.addNewTag(gBuddyListContextMenu.addTag.bind(gBuddyListContextMenu));"/>
         </menupopup>
       </menu>
       <menuitem id="context-hide-tag"
                 label="&hideTagCmd.label;"
                 accesskey="&hideTagCmd.accesskey;"
                 oncommand="gBuddyListContextMenu.hideTag();"/>
       <menu id="context-visible-tags"
             label="&visibleTagsCmd.label;"
--- a/im/content/conversation.xml
+++ b/im/content/conversation.xml
@@ -37,16 +37,17 @@
             <xul:hbox align="baseline" class="conv-nicklist-header">
               <xul:label class="conv-nicklist-header-label"
                          anonid="participantLabel"
                          value="&chat.participants;"/>
               <xul:textbox flex="1" readonly="true" class="plain" anonid="participantCount"/>
             </xul:hbox>
             <xul:listbox anonid="nicklist" class="conv-nicklist"
                          flex="1" seltype="multiple"
+                         xbl:inherits="contextmenu=contentcontextmenu"
                          tooltip="buddyTooltip"
                          onclick="onNickClick(event);"
                          onkeypress="onNicklistKeyPress(event);"/>
           </xul:vbox>
         </xul:hbox>
         <xul:splitter class="splitter" anonid="splitter-bottom"/>
         <xul:vbox anonid="conv-bottom" class="conv-bottom">
           <xul:textbox anonid="inputBox" class="conv-textbox" multiline="true" flex="1"
--- a/im/content/instantbird.xul
+++ b/im/content/instantbird.xul
@@ -121,18 +121,35 @@
   </stringbundleset>
 
   <popupset id="mainPopupSet">
     <tooltip id="aHTMLTooltip"
              onpopupshowing="return getBrowser().FillInHTMLTooltip(document.tooltipNode);"/>
     <tooltip id="buddyTooltip" type="buddy"/>
 
     <menupopup id="contentAreaContextMenu"
-               onpopupshowing="if (event.target != this) return true; gContextMenu = new nsContextMenu(this, window.getTabBrowser()); return gContextMenu.shouldDisplay;"
+               onpopupshowing="if (event.target != this) return true; gContextMenu = new nsContextMenu(this, window.getBrowser()); return gContextMenu.shouldDisplay;"
                onpopuphiding="if (event.target == this &amp;&amp; gContextMenu) { gContextMenu.cleanup(); gContextMenu = null; }">
+      <menuitem id="context-nick-openconv"
+                oncommand="gContextMenu.nickOpenConv();"/>
+      <menuitem id="context-nick-showlogs"
+                oncommand="gContextMenu.nickShowLogs();"/>
+      <menu id="context-nick-addcontact">
+        <menupopup id="context-tags-popup"
+                   oncommand="gContextMenu.tagMenu.tag(event, gContextMenu.nickAddContact.bind(gContextMenu));"
+                   onpopupshowing="gContextMenu.tagMenu.tagsPopupShowing();">
+          <menuseparator id="context-create-tag-separator"/>
+          <menuitem id="context-create-tag"
+                    label="&addNewTagCmd.label;"
+                    accesskey="&addNewTagCmd.accesskey;"
+                    oncommand="gContextMenu.tagMenu.addNewTag(gContextMenu.nickAddContact.bind(gContextMenu));"/>
+        </menupopup>
+      </menu>
+      <menuseparator id="context-sep-nick"/>
+
       <menuitem id="context-openlink"
                 label="&openLinkCmd.label;"
                 accesskey="&openLinkCmd.accesskey;"
                 oncommand="gContextMenu.openLink();"/>
       <menuitem id="context-copyemail"
                 label="&copyEmailCmd.label;"
                 accesskey="&copyEmailCmd.accesskey;"
                 oncommand="gContextMenu.copyEmail();"/>
--- a/im/content/nsContextMenu.js
+++ b/im/content/nsContextMenu.js
@@ -1,35 +1,46 @@
 /* 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:///modules/ibTagMenu.jsm");
+
 var gContextMenu = null;
 
 function nsContextMenu(aXulMenu, aBrowser) {
   this.target            = null;
   this.browser           = null;
+  this.conv              = null;
   this.menu              = null;
+  this.tagMenu           = null;
   this.onLink            = false;
   this.onMailtoLink      = false;
   this.onSaveableLink    = false;
   this.link              = false;
   this.linkURL           = "";
   this.linkURI           = null;
   this.linkProtocol      = null;
+  this.onNick            = false;
+  this.nick              = "";
+  this.buddy             = null;
+  this.isNickOpenConv    = false;
+  this.isNickShowLogs    = false;
+  this.isNickAddContact  = false;
   this.isTextSelected    = false;
   this.isContentSelected = false;
   this.shouldDisplay     = true;
-  this.ellipsis = "\u2026";
 
   try {
     this.ellipsis =
       Services.prefs.getComplexValue("intl.ellipsis",
                                      Ci.nsIPrefLocalizedString).data;
-  } catch (e) { }
+  } catch (e) {
+    this.ellipsis = "\u2026";
+  }
 
   // Initialize new menu.
   this.initMenu(aXulMenu, aBrowser);
 }
 
 // Prototype for nsContextMenu "class."
 nsContextMenu.prototype = {
   cleanup: function() {
@@ -41,84 +52,164 @@ nsContextMenu.prototype = {
       elt = tmp;
     }
   },
 
   // Initialize context menu.
   initMenu: function CM_initMenu(aPopup, aBrowser) {
     this.menu = aPopup;
     this.browser = aBrowser;
+    this.conv = this.browser._conv;
 
     // Get contextual info.
     let node = document.popupNode;
+    if (node.localName == "listbox") {
+      // Clicked the participant list, but not a listitem.
+      this.shouldDisplay = false;
+      return;
+    }
     this.setTarget(node);
 
+    let isParticipantList = node.localName == "listitem";
+    let nickActions = this.getNickActions(isParticipantList);
+    this.onNick = nickActions.some(function(action) action.visible);
+    if (isParticipantList && !this.onNick) {
+      // If we're in the participant list, there will be no other entries.
+      this.shouldDisplay = false;
+      return;
+    }
+
     let actions = [];
     while (node) {
       if (node._originalMsg) {
         let msg = node._originalMsg;
         actions = msg.getActions();
         break;
       }
       node = node.parentNode;
     }
 
     this.isTextSelected = this.isTextSelection();
     this.isContentSelected = this.isContentSelection();
 
     // Initialize (disable/remove) menu items.
     // Open/Save/Send link depends on whether we're in a link.
-    var shouldShow = this.onSaveableLink;
+    let shouldShow = this.onSaveableLink;
     this.showItem("context-openlink", shouldShow);
     this.showItem("context-sep-open", shouldShow);
     this.showItem("context-savelink", shouldShow);
 
     this.showItem("context-searchselect", this.isTextSelected);
     this.showItem("context-searchselect-with", this.isTextSelected);
 
     // Copy depends on whether there is selected text.
     // Enabling this context menu item is now done through the global
     // command updating system
     goUpdateGlobalEditMenuItems();
 
     this.showItem("context-copy", this.isContentSelected);
-    this.showItem("context-selectall", !this.onLink || this.isContentSelected);
+    this.showItem("context-selectall", (!this.onNick && !this.onLink) ||
+                                       this.isContentSelected);
     this.showItem("context-sep-selectall", actions.length);
     this.showItem("context-sep-messageactions", this.isTextSelected);
 
     // Copy email link depends on whether we're on an email link.
     this.showItem("context-copyemail", this.onMailtoLink);
 
     // Copy link location depends on whether we're on a non-mailto link.
     this.showItem("context-copylink", this.onLink && !this.onMailtoLink);
     this.showItem("context-sep-copylink", this.onLink && this.isContentSelected);
 
+    // Display nick menu items.
+    let isNonNickItems = this.isContentSelected || this.isTextSelected ||
+                         this.onLink || actions.length;
+    this.showItem("context-sep-nick", this.onNick && isNonNickItems);
+    for (let action of nickActions)
+      this.showItem(action.id, action.visible);
+
     // Display action menu items.
     let before = document.getElementById("context-sep-messageactions");
     for each (let action in actions) {
       let menuitem = document.createElement("menuitem");
       menuitem.setAttribute("label", action.label);
       menuitem.setAttribute("oncommand", "this.action.run();");
       menuitem.action = action;
       before.parentNode.insertBefore(menuitem, before);
     }
   },
 
-  // Set various context menu attributes based on the state of the world.
-  setTarget: function (aNode) {
+  getNormalizedName: function(aNick) {
+    // Unfortunately there is currently no way to obtain the normalizedName
+    // corresponding to the nick (bug 2115).
+    // Therefore we may sometimes not find existing logs for a nick,
+    // and offer "add contact" despite a buddy already existing.
+    return aNick;
+  },
+  getLogsForNick: function(aNick) {
+    return Services.logs.getLogsForAccountAndName(this.conv.account,
+                                                  this.getNormalizedName(aNick),
+                                                  true);
+  },
+  getNickActions: function(aIsParticipantList) {
+    let bundle = document.getElementById("bundle_instantbird");
+    let nick = this.nick;
+    let actions = [];
+    let addAction = function(aId, aVisible) {
+      let domId = "context-nick-" + aId.toLowerCase();
+      let stringId = "contextmenu.nick" + aId;
+      if (!aIsParticipantList)
+        stringId += ".withNick";
+      document.getElementById(domId).label =
+        bundle.getFormattedString(stringId, [nick]);
+      actions.push({id: domId, visible: aVisible});
+    };
+
+    // Special-case twitter. XXX Drop this when twitter DMs work.
+    let isTwitter = this.conv.account.protocol.id == "prpl-twitter";
 
-    // Initialize contextual info.
-    this.onLink            = false;
-    this.linkURL           = "";
-    this.linkURI           = null;
-    this.linkProtocol      = "";
+    addAction("OpenConv", this.onNick && !isTwitter);
+    addAction("ShowLogs", this.onNick && this.getLogsForNick(nick).hasMoreElements());
+    this.buddy = Services.contacts
+                         .getBuddyByNameAndProtocol(this.getNormalizedName(nick),
+                                                    this.conv.account.protocol);
+    let isAddContact = this.onNick && !isTwitter && !this.buddy;
+    if (isAddContact)
+      this.tagMenu = new TagMenu(this, window);
+    addAction("AddContact", isAddContact);
 
+    return actions;
+  },
+  nickOpenConv: function() {
+    let name = this.conv.target.getNormalizedChatBuddyName(this.nick);
+    let newConv = this.conv.account.createConversation(name);
+    Conversations.focusConversation(newConv);
+  },
+  nickAddContact: function(aTag)
+    this.conv.account.addBuddy(aTag, this.nick),
+  nickShowLogs: function() {
+    let nick = this.nick;
+    let enumerator = this.getLogsForNick(nick);
+    if (!enumerator.hasMoreElements())
+      return;
+    window.openDialog("chrome://instantbird/content/viewlog.xul",
+                      "Logs", "chrome,resizable", {logs: enumerator}, nick);
+  },
+
+  // Set various context menu attributes based on the state of the world.
+  setTarget: function(aNode) {
     // Remember the node that was clicked.
     this.target = aNode;
 
+    // Check if we are in the participant list.
+    if (this.target.localName == "listitem") {
+      this.onNick = true;
+      this.nick = this.target.label;
+      return;
+    }
+
     // First, do checks for nodes that never have children.
     // Second, bubble out, looking for items of interest that can have childen.
     // Always pick the innermost link, background image, etc.
     const XMLNS = "http://www.w3.org/XML/1998/namespace";
     var elem = this.target;
     while (elem) {
       if (elem.nodeType == Node.ELEMENT_NODE) {
         // Link?
@@ -150,16 +241,23 @@ nsContextMenu.prototype = {
           // Remember corresponding element.
           this.link = realLink;
           this.linkURL = this.getLinkURL();
           this.linkURI = this.getLinkURI();
           this.linkProtocol = this.getLinkProtocol();
           this.onMailtoLink = (this.linkProtocol == "mailto");
           this.onSaveableLink = this.isLinkSaveable(this.link);
         }
+
+        // Nick?
+        if (!this.onNick && this.conv.isChat &&
+            (elem.classList.contains("ib-nick") || elem.classList.contains("ib-sender"))) {
+          this.nick = elem.textContent;
+          this.onNick = true;
+        }
       }
 
       elem = elem.parentNode;
     }
   },
 
   // Returns true if clicked-on link targets a resource that can be saved.
   isLinkSaveable: function(aLink) {
@@ -218,17 +316,17 @@ nsContextMenu.prototype = {
     // but let's be on the safe side.
     if (!submission)
       return;
 
     this.openLink(submission.uri);
   },
 
   // Open linked-to URL in a new window.
-  openLink: function (aURI) {
+  openLink: function(aURI) {
     Cc["@mozilla.org/uriloader/external-protocol-service;1"].
     getService(Ci.nsIExternalProtocolService).
     loadURI(aURI || this.linkURI, window);
   },
 
   // Generate email address and put it on clipboard.
   copyEmail: function() {
     // Copy the comma-separated list of email addresses only.
--- a/im/locales/en-US/chrome/instantbird/instantbird.properties
+++ b/im/locales/en-US/chrome/instantbird/instantbird.properties
@@ -132,8 +132,22 @@ networkOffline=Your account is disconnec
 #LOCALIZATION NOTE
 # This is shown when the user attempts to send a message while the status is offline.
 statusOffline=Your account is disconnected because your status is currently set to offline.
 
 #LOCALIZATION NOTE
 # This is shown when the user attempts to send a message to a disconnected account.
 # %1$S is the name of the protocol of the account, %2$S the name of the account.
 accountDisconnected=Your %1$S account %2$S is disconnected.
+
+#LOCALIZATION NOTE
+# These appear in the context menu of the chat participants in the
+# participant list.
+contextmenu.nickOpenConv=Private Conversation
+contextmenu.nickShowLogs=Show Logs
+contextmenu.nickAddContact=Add Contact…
+# These appear in the context menu for the nicks of chat participants
+# highlighted in messages, and extend those for the participant list
+# by mentioning the nick.
+# %S is the nick of the chat participant.
+contextmenu.nickOpenConv.withNick=Private Conversation with %S
+contextmenu.nickShowLogs.withNick=Show Logs for %S
+contextmenu.nickAddContact.withNick=Add %S to Contacts…
--- a/im/modules/Makefile.in
+++ b/im/modules/Makefile.in
@@ -8,16 +8,17 @@ srcdir		= @srcdir@
 VPATH		= @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 EXTRA_JS_MODULES = \
 	ibInterruptions.jsm \
 	ibNotifications.jsm \
 	ibSounds.jsm \
+	ibTagMenu.jsm \
 	$(NULL)
 
 EXTRA_PP_JS_MODULES = \
 	ibCore.jsm \
 	imWindows.jsm \
 	$(NULL)
 
 ifeq ($(OS_ARCH),WINNT)
new file mode 100644
--- /dev/null
+++ b/im/modules/ibTagMenu.jsm
@@ -0,0 +1,86 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["TagMenu"];
+
+const Cu = Components.utils;
+Cu.import("resource:///modules/imServices.jsm");
+Cu.import("resource:///modules/imXPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "_", function()
+  l10nHelper("chrome://instantbird/locale/instantbird.properties")
+);
+
+// If a contact binding is given in aTarget, the menu checkmarks the existing
+// tags on this contact.
+function TagMenu(aParent, aWindow, aTarget = null) {
+  this.parent = aParent;
+  this.window = aWindow;
+  if (aWindow)
+    this.document = aWindow.document;
+  this.target = aTarget;
+}
+TagMenu.prototype = {
+  document: null,
+  window: null,
+  target: null,
+  tagsPopupShowing: function() {
+    if (!this.parent.onContact && !this.parent.onBuddy && !this.parent.onNick)
+      return;
+
+    let popup = this.document.getElementById("context-tags-popup");
+    let item;
+    while ((item = popup.firstChild) && item.localName != "menuseparator")
+      popup.removeChild(item);
+
+    if (this.target) {
+      var tags = this.target.contact.getTags();
+      var groupId = this.target.group.groupId;
+    }
+
+    let allTags = Services.tags.getTags().reverse();
+    for (let tag of allTags) {
+      item = this.document.createElement("menuitem");
+      item.setAttribute("label", tag.name);
+      let id = tag.id;
+      item.groupId = id;
+      if (this.target) {
+        item.setAttribute("type", "checkbox");
+        if (tags.some(function(t) t.id == id)) {
+          item.setAttribute("checked", "true");
+          if (tags.length == 1)
+            item.setAttribute("disabled", "true"); // can't remove the last tag.
+        }
+      }
+      popup.insertBefore(item, popup.firstChild);
+    }
+  },
+  tag: function(aEvent, aCallback) {
+    let id = aEvent.originalTarget.groupId;
+    if (!id)
+      return false;
+
+    try {
+      return aCallback(Services.tags.getTagById(id));
+    } catch(e) {
+      Cu.reportError(e);
+      return false;
+    }
+  },
+  addNewTag: function(aCallback) {
+    let name = {};
+    if (!Services.prompt.prompt(this.window, _("newTagPromptTitle"),
+                                _("newTagPromptMessage"), name, null,
+                                {value: false}) || !name.value)
+      return false; // the user canceled
+
+    try {
+      // If the tag already exists, createTag will return it.
+      return aCallback(Services.tags.createTag(name.value));
+    } catch(e) {
+      Cu.reportError(e);
+      return false;
+    }
+  }
+};