Source for "Gmail Conversation Preview"

By abartonkc
Has no other scripts.


Add Syntax Highlighting (this will take a few seconds, probably freezing your browser while it works)

// ==UserScript==
// @name          Gmail Conversation Preview
// @namespace     
// @description   Right-click on any conversation to get a preview bubble.
// @include       http://mail.google.com/*
// @include       https://mail.google.com/*

// ==/UserScript==

// Shorthand
function newNode(type) {return unsafeWindow.document.createElement(type);}
function newText(text) {return unsafeWindow.document.createTextNode(text);}
function getNode(id) {return unsafeWindow.document.getElementById(id);}

// Contants
const POINT_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB8AAAAtCA" +
  "YAAABWHLCfAAAABGdBTUEAANbY1E9YMgAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFk" +
  "eXHJZTwAAAKaSURBVHjaxJgxqFJRGMevpiWkYfiQhEgIHBycQhcnoUFoDpdcXQPByTEnCbcGlz" +
  "eLmxHYYhA%2BCgfFMi1DNA1RrEiJDJ%2Fe9%2FV9l3vgJfW693o8%2FuHPOSqc3znn417%2FfC" +
  "YAkA4hk8lkMR0CjmAajg8Fv4fDM%2BFwBB%2Fh8IXmZsFg4lVpHo1GJYlOLsKqHqPB7XavZ7MZ" +
  "iISHCWw2m%2BVKpQIgEHwNvSJ4Op0GJhFgeq5eEzgcDsubzUYoPE1gp9O5Hg6HcF77Bt8hMLlU" +
  "KsG29gm%2Biv5B4GQyCX%2FTPuv8ksDBYFBerVZC4Q8J7HA41v1%2BH%2F6lfYD9rM6FQgEuEm" +
  "%2FwFfQ3AicSCfifeL8%2BnxLY7%2FfLy%2BVSKPwBgW02m9xut0GLeIFvsTrn83nQKh5gC3pE" +
  "4FgsBnrEo87HBPZ6vev5fC4UTnEIrFarXKvVQK92AR%2BxOmezWTAio%2BBL6PcExjgERmW0zk" +
  "8I7PF4lDgkEn6XxaFqtQq7SC%2F4OotDmUwGdpUeMMXeNwSORCJ%2FxCER8EcEdrlc68lkAjyk" +
  "FRxij1W5XAZe0hWHUqkU8BSXOLQvuKY4xB2uJw5xheuNQ9zgRuIQT7juOMQFjrptJA4ZleVc1%" +
  "2BAyDq9oHo%2FHJaw1167EYrGQer2e1Ol0pPF4LA0GA0npyajdoQI65vP5No1Gw2K323UDaFFy" +
  "s9lUFqc5wQhK8G2xk98nMMahs2KxeCG42%2B1Ko9FIWbzVaikA%2Blyv17Xs77P60jpBf7LgqW" +
  "%2FgpEi%2F5HI5cyAQUBaiBcls5wQhsAbRLt6iX6B76Bl6iv4FW60vuu%2BbtKNQKKTAptOpFs" +
  "Bz9e%2F1nQr6iqboutTVnWLwre9P1dugxT%2BiP6i7%2F4mAU26tMRXO9F29njMRfbnfAgwAHZ" +
  "MoiqxU6iwAAAAASUVORK5CYII%3D";

const SCROLLER_PADDING = 2 * 5;
const BUTTON_BAR_PADDING = 2 * 6;

const SHOW_PREVIEW_KEY = 86; // V

// Equivalents to values in the "More Actions..." menu
const ARCHIVE_COMMAND = "rc_^i";
const MARK_UNREAD_COMMAND = "ru";
const TRASH_COMMAND = "tr";

const CONVERSATION_DATA_MAP = [
  "id",
  "isUnread",
  "isStarred",
  "time",
  "people",
  "personalLevelIndicator",
  "subject",
  "snippet",
  "labels",
  "attachments",
  "id2",
  "isLongSnippet",
  "date"
];

const MESSAGE_INFO_DATA_MAP = [
  "ignored",
  "unknown",
  "unknown",
  "id",
  "unknown",
  "unknown",
  "senderFullName",
  "senderShortName",
  "senderEmail",
  "recipients",
  "date",
  "to",
  "cc",
  "unknown",
  "replyTo",
  "date",
  "subject",
  "unknown",  
  "unknown",
  "unknown",
  "unknown",
  "unknown",
  "date",
  "snippet",
  "snippet"
];

const SENDER_COLOR_MAP = [
  "#00681c", "#cc0060", "#008391", "#009486", "#5b1094", "#846600", "#670099", 
  "#790619"
];

const RULES = [ 
  ".PV_bubble {position: absolute; width: 600px; border: solid 2px #000; " +
    "background: #fff; font-size: 12px; margin: 0; padding: 0;}",
  ".PV_bubble.PV_loading {width: auto; height: auto;}",
  ".PV_bubble.PV_loading .PV_scroller {text-align: center; color: #999; " +
    "font-style: italic; padding: 2em; }",
  ".PV_bubble .PV_scroller {overflow: auto; padding: 5px; margin: 0;}",
  // Hide quoted portions, signatures and other non-essential bits
  ".PV_bubble .q, .PV_bubble .ea, .PV_bubble .sg, .PV_bubble .gmail_quote, " +
    ".PV_bubble .ad {display: none}",
  ".PV_bubble h1 {font-size: 12px; font-weight: normal; margin: 0;}",
  ".PV_bubble h1 .sender {font-weight: bold}",
  ".PV_bubble .PV_message {border-bottom: solid 2px #ccc; margin: 0;}",
  ".PV_bubble .PV_message:last-child {border-bottom: 0}",
  ".PV_bubble .PV_message .PV_message-body {margin: 0; padding: 0}",
  ".PV_bubble .PV_point {position: absolute; top: 10px; " + 
    "left: 0; margin-left: -31px; width: 31px; height: 45px;}",
  ".PV_bubble .PV_buttons {padding: 6px; border-bottom: solid 1px #616c7f; " +
    "border-left: solid 1px #616c7f; white-space: nowrap; margin: 0 0 0 7px; " +
    "background: #c3d9ff; -moz-border-radius: 0 0 0 7px;}",
  ".PV_bubble .PV_subject {padding: 3px; border-bottom: solid 1px #616c7f; " +
    "border-left: solid 1px #616c7f; margin: 0 0 0 7px; font-size: 10pt;" +
    "background: #ffffff; -moz-border-radius: 0 0 0 7px;}",
  ".PV_bubble .PV_button {padding: 3px 5px 3px 5px; margin-right: 4px; " +
     "border-right: solid 1px #616c7f}",
  ".PV_bubble span.PV_button:last-child {border-right: 0;}"
];

gCurrentConversationList = [];
unsafeWindow.gCurrentWindow = null;
unsafeWindow.gCurrentContextMenuHandler = null;

// All data received from the server goes through the function top.js.P. By
// overriding it (but passing through data we get), we can be informed when
// new sets conversations arrive and update the display accordingly.
try {
  if (unsafeWindow.P && typeof(unsafeWindow.P) == "function") {
    var oldP = unsafeWindow.P;
    var thisWindow = window;
    
    unsafeWindow.P = function(window, data) {
      // Only override if it's a P(window, data) signature that we know about
      if (arguments.length == 2) {
        hookData(data);
      }
      oldP.apply(thisWindow, arguments);
    }
  }  
} catch (error) {
  // ignore;
}

function hookData(data) {
  var mode = data[0];
  
  switch (mode) {
    // start of conversation list
    case "ts":
      gCurrentConversationList = [];
      break;
    // conversation data
    case "t":
      for (var i = 1; i < data.length; i++) {
        var conversationData = data[i];    
        var conversation = {};
      
        for (var index in CONVERSATION_DATA_MAP) {
          var field = CONVERSATION_DATA_MAP[index];
          
          conversation[field] = conversationData[index];
        }
        
        gCurrentConversationList.push(conversation);
      }
      break;
    // end of conversation list
    case "te":
      window.setTimeout(function() {
        triggerHook(gCurrentConversationList);
      }, 0);
      break;
  }
}

function triggerHook(conversationList) {
  if (unsafeWindow.top.gCurrentWindow) {
    try {
    unsafeWindow.top.gCurrentWindow.PreviewBubble.hook(conversationList);
    } catch (error) {
      alert("exception: " + error);
    }
  } else {
    window.setTimeout(function() {
      triggerHook(conversationList);
    }, 10);
  }
}

if (getNode("tbd")) {
  initializeStyles();

  unsafeWindow.top.gCurrentWindow = unsafeWindow;
  unsafeWindow.top.gCurrentWindow.PreviewBubble = PreviewBubble;
  unsafeWindow.top.gCurrentBubble = null;
  unsafeWindow.top.gCurrentContextMenuHandler = null;    
}

function PreviewBubble(conversationRow) {
  this.conversationRow = conversationRow;
  this.conversationCheckbox = conversationRow.getElementsByTagName("input")[0];
  this.initialConversationSelectionState = this.conversationCheckbox.checked;
  this.subjectText = conversationRow.getElementsByTagName("td")[4].innerHTML;
  this.subjectTextNode = null;

  // bubble
  this.bubbleNode = newNode("div");
  this.bubbleNode.className = "PV_bubble PV_loading";
  
  // buttons
  this.buttonsNode = newNode("div");
  this.buttonsNode.className = "PV_buttons";
  this.bubbleNode.appendChild(this.buttonsNode);
  
  this.buttonBarWidth = BUTTON_BAR_PADDING;

  var self = this;
  this.addButton("Close", function() {self.close();});
  this.addButton("Archive", bind(this, this.archive));
  this.addButton("Leave Unread", bind(this, this.markUnread));
  this.addButton("Delete", bind(this, this.trash));

  // subject
  this.subjectNode = newNode("div");
  this.subjectNode.className = "PV_subject";
  this.bubbleNode.appendChild(this.subjectNode);
  this.addSubject(this.subjectText);

  // point
  this.pointNode = newNode("img");
  this.pointNode.src = POINT_IMAGE;
  this.pointNode.className = "PV_point";
  this.bubbleNode.appendChild(this.pointNode);  
  
  // scroller
  this.scrollerNode = newNode("div");
  this.scrollerNode.className = "PV_scroller";
  this.scrollerNode.innerHTML = "Loading...";  
  this.bubbleNode.appendChild(this.scrollerNode);

  var conversationPosition = getAbsolutePosition(conversationRow);
  this.bubbleNode.style.top = (conversationPosition.top - 
    conversationRow.offsetHeight/2 - 30) + "px";
  var peopleNode = conversationRow.getElementsByTagName("td")[2];
  var peopleNodePosition = getAbsolutePosition(peopleNode);
  this.bubbleNode.style.left = (peopleNodePosition.left + 
    peopleNode.offsetWidth * 0.1 + this.pointNode.offsetWidth) + "px";

  this.bubbleNode.style.display = "none";
  unsafeWindow.document.body.appendChild(this.bubbleNode);

//  this.bubbleNode.style.display = "block";
}

PreviewBubble.hook = function PreviewBubble_hook(conversationList) {
  // The bubble can be shown in response to a right click
  if (unsafeWindow.top.gCurrentContextMenuHandler) {
    window.removeEventListener("contextmenu", 
                               unsafeWindow.top.gCurrentContextMenuHandler, 
                               false);
  }

  // Since contextMenuHandler is an inner function, there are several
  // instances of it. We must keep track of the one that we install so that
  // we can remove it later (when the conversation list gets refreshed)
  unsafeWindow.top.gCurrentContextMenuHandler = function(event) {
    return PreviewBubble.contextMenuHandler(event, conversationList);
  };
  window.addEventListener("contextmenu", 
                          unsafeWindow.top.gCurrentContextMenuHandler,
                          false);

  // Or by pressing V.
  if (unsafeWindow.top.gCurrentKeyHandler) {
    window.removeEventListener("keydown",
                               unsafeWindow.top.gCurrentKeyHandler,
                               false);
  }
  unsafeWindow.top.gCurrentKeyHandler = function(event) {
    return PreviewBubble.keyHandler(event, conversationList);
  }
  window.addEventListener('keydown', 
                          unsafeWindow.top.gCurrentKeyHandler,
                          false);
}

PreviewBubble.contextMenuHandler = 
    function PreviewBubble_contextMenuHandler(event, conversationList) {
  var target = event.target;
      
  while (target && target.id.indexOf("w_") != 0) {
    target = target.parentNode;
   }
      
  if (target) {
    event.preventDefault();
    event.stopPropagation();
        
    var index = parseInt(target.id.substring(2));
        
    PreviewBubble.showBubble(target, conversationList[index]);
  }
}

PreviewBubble.keyHandler = function PreviewBubble_keyHandler(event, conversationList) {
  // Apparently we still see Firefox shortcuts like control-T for a new tab
  // and checking for modifiers lets us ignore those
  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
    return false;
  }
      
  // We also don't want to interfere with regular user typing
  if (event.target && event.target.nodeName) {
    var targetNodeName = event.target.nodeName.toLowerCase();
    if (targetNodeName == "textarea" ||
        (targetNodeName == "input" &&
         (!event.target.getAttribute("type") ||
          event.target.getAttribute("type").toLowerCase() == "text"))) {
      return false;
     }
  }
      
  if (event.keyCode != SHOW_PREVIEW_KEY) {
    if (unsafeWindow.top.gCurrentBubble) {
      // We don't close the bubble straight away since we want the
      // conversation to still be selected so that built-in keyboard
      // shortcuts still work
      window.setTimeout(function() {
        unsafeWindow.top.gCurrentBubble.close();
      }, 100);
    }

    return false;
  }
      
  var currentConversation = PreviewBubble.getCurrentConversation();
      
  if (currentConversation == -1) {
    return false;
  }
      
  PreviewBubble.showBubble(getNode("w_" + currentConversation),
                           conversationList[currentConversation]);
      
  return true;
}


PreviewBubble.getCurrentConversation = function PreviewBubble_getCurrentConversation() {
  var chevron = getNode("ar");
  var conversationTable = getNode("tb");
  var row = getNode("w_0");
      
  if (!row || !chevron || !conversationTable) {
    return -1;
  }
      
  return (chevron.offsetTop - conversationTable.offsetTop - 5)/
    row.offsetHeight;
}

PreviewBubble.showBubble = 
    function PreviewBubble_showBubble(conversationRow, conversation) {
  if (unsafeWindow.top.gCurrentBubble) {
    var sameRow = unsafeWindow.top.gCurrentBubble.conversationRow == 
                  conversationRow;
    unsafeWindow.top.gCurrentBubble.close();
    if (sameRow) {
      return;
    }
  }
      
  hideTooltips();
  var bubble =
    unsafeWindow.top.gCurrentBubble = 
    new PreviewBubble(conversationRow);
      
  bubble.selectConversation();
  bubble.installGlobalHideHandler();
  bubble.fill(conversation);          
}


PreviewBubble.prototype.selectConversation = 
    function PreviewBubble_selectConversation() {
  if (!this.conversationCheckbox.checked) {
    fakeMouseEvent(this.conversationCheckbox, "click");
    // We have to reset the classname for the conversation to be displayed as
    // read, since clicking on the checkbox causes it to be redrawn, and
    // according to Gmail's internal state it's still unread
    this.conversationRow.className = "rr sr";
  }
}

PreviewBubble.prototype.deselectConversation = 
    function PreviewBubble_deselectConversation(leaveUnread) {
  if (!this.initialConversationSelectionState) {
    fakeMouseEvent(this.conversationCheckbox, "click");    
  }
  
  if (!leaveUnread) {
    this.conversationRow.className = "rr";
  }
}

PreviewBubble.prototype.addButton = 
    function PreviewBubble_addButton(buttonTitle, action) {
  var buttonNode = newNode("span");
  buttonNode.innerHTML = buttonTitle;
  buttonNode.className = "PV_button lk";
  buttonNode.addEventListener("click", action, true);
  this.buttonsNode.appendChild(buttonNode);
  
  this.buttonBarWidth += buttonNode.offsetWidth;
}

PreviewBubble.prototype.addSubject = 
    function PreviewBubble_addSubject(subjectTitle) {
  this.subjectTextNode = newNode("span");
  this.subjectTextNode.innerHTML = "<b>" + subjectTitle + "</b>";
  this.subjectNode.appendChild(this.subjectTextNode);
  
  if (this.subjectTextNode.offsetWidth > this.buttonBarWidth) {
    this.buttonBarWidth = this.subjectTextNode.offsetWidth;
  }
}


PreviewBubble.prototype.fill = function PreviewBubble_fill(conversation) {      
  this.conversation = conversation;
  
  var self = this; 

  GM_xmlhttpRequest({
    'method': 'GET',
    'url' : getParentUrl() + "?&view=cv&search=all&th=" + 
              conversation.id + "&lvp=-1&cvp=2&qt=",
    'onload': function(details) {
      var messages = parseMessages(details.responseText);
      self.setContents(messages);
      self.shrinkToFit();
    }
  });
}

PreviewBubble.prototype.setContents = 
    function PreviewBubble_setContents(messages) {
  var senderColors = {};
  var senderColorCount = 0;
      
  this.scrollerNode.innerHTML = "";
      
  for (var i=0; i < messages.length; i++) {
    var m = messages[i];
        
    if (!m.body) {
      continue;
    }
        
    var sender = m.senderFullName;
    if (!senderColors[sender]) {
      senderColors[sender] = 
        SENDER_COLOR_MAP[senderColorCount % SENDER_COLOR_MAP.length];
      senderColorCount++;
    }
        
    this.scrollerNode.innerHTML += 
      '<div class="PV_message">' +
        "<h1>" + 
          '<span class="PV_sender" style="color: ' + senderColors[sender] + 
            '">' + sender + "</span>" +
          " to " + m.to +
        "</h1>" +
        '<div class="PV_message-body">' + m.body + "</div>" +
      '</div>';
  }

  // Remove PV_loading CSS class
  this.bubbleNode.className = "PV_bubble";  
}

PreviewBubble.prototype.shrinkToFit = function PreviewBubble_shrinkToFit() {
  this.bubbleNode.style.display = "block";
  this.pointNode.style.display = "none";

  var bubblePosition = getAbsolutePosition(this.bubbleNode);
  var rowPosition = getAbsolutePosition(this.conversationRow);
  var currentHeight;

  // We first try to find the ideal width. We do a binary between the maximum
  // (all the way to the right edge of the conversation list) and the minimum
  // (the button bar's width). 
  this.bubbleNode.style.width = 
    (rowPosition.left + this.conversationRow.offsetWidth - bubblePosition.left - 
    4) + "px";

  var maxWidth = this.scrollerNode.offsetWidth - SCROLLER_PADDING;
  var minScrollWidth = this.scrollerNode.scrollWidth;

  var minWidth = this.buttonBarWidth;
//  if (minWidth < this.subjectTextNode.offsetWidth) {
//    minWidth = this.subjectTextNode.offsetWidth;
//  }

  // We use the height of the scroller node as the conditional, since if the
  // bubble gets too narrow the height will increase. We use the clientHeight
  // attribute as opposed to the offsetHeight one because we want to detect
  // the case where horizontal scrollbars show up (for HTML messages that
  // don't wrap)
  var startHeight = this.scrollerNode.clientHeight;
  
  while (maxWidth - minWidth > 1) {
    var currentWidth = Math.round((maxWidth + minWidth)/2);
    this.scrollerNode.style.width = currentWidth + "px";
    
    currentHeight = this.scrollerNode.clientHeight;
    
    if (currentHeight == startHeight) {
      maxWidth = currentWidth;
    } else {
      minWidth = currentWidth;
    }
    if (minScrollWidth > this.scrollerNode.scrollWidth + SCROLLER_PADDING) {
      minScrollWidth = this.scrollerNode.scrollWidth + SCROLLER_PADDING;
    }
  }

  this.scrollerNode.style.width = "auto";
  if (minScrollWidth < (window.innerWidth * .66) && 
      startHeight != currentHeight) {
    this.bubbleNode.style.width = window.innerWidth * .66;
  }
  else {
    if (minScrollWidth < minWidth) {
      this.bubbleNode.style.width = minWidth;
    }
    else {
      this.bubbleNode.style.width = minScrollWidth + SCROLLER_PADDING;
    }
  }
  
  if (this.scrollerNode.innerHTML == "") {
    this.scrollerNode.style.display = "none";
  }

  // We want the bubble to be no taller than the window height (minus some
  // padding). We also don't want to shift up the bubble more than necessary,
  // so that the action links stay as close to the user's cursor as possible.
  var newBubbleTop = -1;
  var maxHeight = window.innerHeight - 36;
  var minTop = window.scrollY + 10;
  
  if (this.bubbleNode.offsetHeight > maxHeight) {
    this.scrollerNode.style.height = 
      (maxHeight - SCROLLER_PADDING - this.buttonsNode.offsetHeight - 4) + "px";
    newBubbleTop = minTop;
  } else {
    var bubblePosition = getAbsolutePosition(this.bubbleNode);
    var bubbleBottom = bubblePosition.top + this.bubbleNode.offsetHeight;
    
    if (bubbleBottom > window.scrollY + 10 + maxHeight) {
      newBubbleTop = 
        window.scrollY + 10 + maxHeight - this.bubbleNode.offsetHeight;
    }
  }  
    
  if (newBubbleTop != -1) {
    var oldTop = this.bubbleNode.offsetTop;
    this.bubbleNode.style.top = newBubbleTop + "px";
    var delta = this.bubbleNode.offsetTop - oldTop;
    this.pointNode.style.marginTop = (-delta) + "px";
  }
  this.pointNode.style.display = "block";
}

PreviewBubble.prototype.installGlobalHideHandler = 
    function PreviewBubble_installGlobalHideHandler() {
  if (this.bodyClickClosure) {
    this.removeGlobalHideHandler();
  }
  
  this.bodyClickClosure = bind(this, 
    function(event) {
      var insideBubble = false;
      var node = event.target;
      while (node) {
        if (node == this.bubbleNode) {
          insideBubble = true;
          break;
        }
        node = node.parentNode;
      }
      
      if (!insideBubble) {
        this.close();
      }
    });
  
  document.body.addEventListener("click", this.bodyClickClosure, true);
}

PreviewBubble.prototype.removeGlobalHideHandler = 
    function PreviewBubble_removeGlobalHideHandler() {
  if (this.bodyClickClosure) {
    document.body.removeEventListener("click", this.bodyClickClosure, true);  
    
    this.bodyClickClosure = null;
  }
}

PreviewBubble.prototype.close = function PreviewBubble_close(leaveUnread) {
  this.bubbleNode.parentNode.removeChild(this.bubbleNode);
      
  this.removeGlobalHideHandler();
  this.deselectConversation(leaveUnread);
  
  showTooltips();
  
  unsafeWindow.top.gCurrentBubble = null;
}

PreviewBubble.prototype.archive = function PreviewBubble_archive() {
  doCommand(ARCHIVE_COMMAND);
  this.close();
}

PreviewBubble.prototype.markUnread = function PreviewBubble_markUnread() {
  if (this.conversation) {
    var postData = "act=ur&at=" + getCookie("GMAIL_AT") + 
                   "&vp=&msq=&ba=false&t=" + this.conversation.id;
    GM_xmlhttpRequest({
      'method': 'POST',
      'url': getParentUrl() + "?&search=inbox&view=tl&start=0" + 
               this.conversation.id + "&lvp=-1&cvp=2&qt=",
      'headers': {
        'Content-Length': postData.length,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      'data': postData,
      // TODO(mihaip): check for success?
      'onload': function() {}
    });
  }

  this.close(true);
}

PreviewBubble.prototype.trash = function PreviewBubble_trash() {
  doCommand(TRASH_COMMAND);
  this.close();
}


// Utility functions

function initializeStyles() {
  var styleNode = newNode("style");
  
  document.body.appendChild(styleNode);

  var styleSheet = document.styleSheets[document.styleSheets.length - 1];

  for (var i=0; i < RULES.length; i++) {
    styleSheet.insertRule(RULES[i], 0);
  }  
}

function hideTooltips() {
  var styleNode = newNode("style");
  styleNode.id = "tooltipHider";
  
  document.body.appendChild(styleNode);

  var styleSheet = document.styleSheets[document.styleSheets.length - 1];
  
  styleSheet.insertRule("#pop {display: none !important}", 0);
  styleSheet.insertRule("#tip {display: none !important}", 0);
}

function showTooltips() {
  var styleNode = getNode("tooltipHider");
  
  styleNode.parentNode.removeChild(styleNode);
}

function doCommand(command) {      
  // Command execution is accomplished by creating a fake action menu and
  // faking a selection from it (we can't use the real action menu since the
  // command may not be in it, if it's a button)
  var actionMenu = newNode("select");
  var commandOption = newNode("option");
  commandOption.value = command;
  commandOption.innerHTML = command;
  actionMenu.appendChild(commandOption);  
  actionMenu.selectedIndex = 0;
  
  var actionMenuNode = getActionMenu();
    
  if (actionMenuNode) {
    var onchangeHandler = actionMenuNode.onchange;
    
    onchangeHandler.apply(actionMenu, null);    
  } else {
    GM_log("Not able to find a 'More Actions...' menu");
    return;
  }    
}

function fakeMouseEvent(node, eventType) {
  var event = node.ownerDocument.createEvent("MouseEvents");
    
  event.initMouseEvent(eventType,
                       true, // can bubble
                       true, // cancellable
                       node.ownerDocument.defaultView,
                       1, // clicks
                       50, 50, // screen coordinates
                       50, 50, // client coordinates
                       false, false, false, false, // control/alt/shift/meta
                       0, // button,
                       node);

  node.dispatchEvent(event);
}

function bind(object, func) {
  return function() { 
    return func.apply(object, arguments); 
  }
}

function getAbsolutePosition(node) {
  var top = node.offsetTop;
  var left = node.offsetLeft;
  
  for (var parent = node.offsetParent; parent; parent = parent.offsetParent) {
    top += parent.offsetTop;
    left += parent.offsetLeft;
  }

  return {top: top, left: left};
}

const DATA_BLOCK_RE = new RegExp('(D\\(\\["[\\s\\S]*?\n\\);\n)', 'gm');

function parseMessages(conversationText) {
  // Unfortunately we can't parse the text to a DOM since it's HTML and
  // DOMParser can only deal with XML. RegExps it is.
  
  var parsedText = "";
  
  var matches = conversationText.match(DATA_BLOCK_RE);
  
  var messages = [];
  var currentMessage = null;
  
  function D(data) {
    mode = data[0];
    switch (mode) {
      case "mi": 
        currentMessage = {};
        for (var i=1; i < data.length; i++) {
          currentMessage[MESSAGE_INFO_DATA_MAP[i]] = data[i];
        }
        currentMessage.body = "";
        messages.push(currentMessage);
        break;
      case "mb": 
        currentMessage.body += data[1];
        break;
    }
  }
  
  eval(matches.join(""));
  
  return messages;
}

function getCookie(name) {
  var re = new RegExp(name + "=([^;]+)");
  var value = re.exec(document.cookie);
  return (value != null) ? unescape(value[1]) : null;
}

function getParentUrl() {
  return window.location.href.replace(/\?.*/, '');
}

function getActionMenu() {
  const ACTION_MENU_IDS = ["tam", "ctam", "tamu", "ctamu"];

  for (var i = 0, id; id = ACTION_MENU_IDS[i]; i++) {
    if (getNode(id) != null) {
      return getNode(id);
    }
  }

  return null;
}