Gmail Conversation Preview Bubbles

Mihai Parparita’s —”Gmail Conversation Preview Greasemonkey script”, lets you preview your conversations by right-click on any conversation to see its recent messages in a preview bubble. The all new Yahoo Mail and Hotmail offeres a preview/reading pane which will let you see message contents at a glance without having to navigate to an entirely new view, but they takes […]

Mihai Parparita’s —”Gmail Conversation Preview Greasemonkey script”, lets you preview your conversations by right-click on any conversation to see its recent messages in a preview bubble.

The all new Yahoo Mail and Hotmail offeres a preview/reading pane which will let you see message contents at a glance without having to navigate to an entirely new view, but they takes lots of space.

Gmail offers a lightweight version of this already, by showing the first hundred or so characters of each message as a snippet next to the subject. While this is handy for one-liner emails, a full-blown preview pane is often more appropriate.

How to use: You need to have Greasemonkey in Firefox or Trixie in IE, then install the script:
// ==UserScript== // @name Gmail Conversation Preview // @namespace http://persistent.info/greasemonkey // @description Right-click on any conversation to get a preview bubble. // @include http://mail.google.com/* // @include https://mail.google.com/*// ==/UserScript==// TODO(mihaip): fix up list after archive // TODO(mihaip): make arrow keys scroll the bubble// 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_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; // bubble this.bubblenode ="" newnode("div"); this.bubblenode.classname ="" "PV_bubble PV_loading"; unsafewindow.document.body.appendchild(this.bubblenode); // 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("Trash", bind(this, this.trash)); // 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("span")[0]; var peoplenodeposition ="" getabsoluteposition(peoplenode); this.bubblenode.style.left ="" (peoplenodeposition.left + peoplenode.offsetwidth * 0.1 + this.pointnode.offsetwidth) + "px";this.bubblenode.style.display ="" "none"; 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.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() { var bubblePosition = getAbsolutePosition(this.bubbleNode); var rowPosition = getAbsolutePosition(this.conversationRow); // 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 minWidth = this.buttonBarWidth; // 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"; var currentHeight = this.scrollerNode.clientHeight; if (currentHeight == startHeight) { maxWidth = currentWidth; } else { minWidth = currentWidth; } } this.scrollerNode.style.width = "auto"; this.bubbleNode.style.width = maxWidth + 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 - 20; 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"; } }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 functionsfunction 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"); } }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; }

Persistent.info | GOS

Conversation Preview Bubbles, Greasemonkey, Trixie