diTii.com Digital News Hub

Sign up with your email address to be the first to know about latest news and more.

I agree to have my personal information transfered to MailChimp (more information)

Nov242006

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 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 +="" '

' +
"

" +
'' + sender + "" +
" to " + m.to +
"

" +
'

' + m.body + "

" +
'

';
}

// 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 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"); } }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

Share This Story, Choose Your Platform!

Get Latest News

Subscribe to Digital News Hub

Get our daily newsletter about the latest news in the industry.
First Name
Last Name
Email address
Secure and Spam free...