querying based on autocompleted involvement works. the problem is that the search results thing has CSS issues, owing to the mangling of classes in the shadow tree to have their own namespacing. tried to quickly cram the css down in so it might be all magic, but the naive approach obviously fell down. (it's possible the rules are spanning the shadow tree firewall or they just aren't getting contributed to the global concept of them, and/or the application mechanism is just something odd that I need to look into.)
authorAndrew Sutherland <asutherland@asutherland.org>
Sun, 11 Jan 2009 19:38:15 -0800
changeset 9 c1c595baa286dbc1de51bac512f4b654a920228b
parent 8 cf212c1c26684f7b524433bfe51ba1e8deab3ff6
child 10 e9cb6ec291b51e4686b40d8d6371d8dacede4b17
push id1
push userroot
push dateWed, 08 Apr 2009 01:46:05 +0000
querying based on autocompleted involvement works. the problem is that the search results thing has CSS issues, owing to the mangling of classes in the shadow tree to have their own namespacing. tried to quickly cram the css down in so it might be all magic, but the naive approach obviously fell down. (it's possible the rules are spanning the shadow tree firewall or they just aren't getting contributed to the global concept of them, and/or the application mechanism is just something odd that I need to look into.)
client/autocomplete.xml
client/bindings.css
client/bubbles.xml
client/cloda.js
client/index.xhtml
client/jquery.couch.js
client/messages.xml
client/utils.js
server/python/junius/model.py
--- a/client/autocomplete.xml
+++ b/client/autocomplete.xml
@@ -26,16 +26,19 @@
         _serial: 0,
         goFish: function (aText) {
           var serial = this._serial++;
           this._autocompleters.forEach(function (completer) {
             completer.complete(this, aText);
           }, this);
         },
         haveSomeResults: function(aText, aNodes) {
+          // bail if this is not the reslt for what is currently typed
+          if (aText != this._entry.value)
+            return;
           this.clearResultsList();
           $(this._resultDiv).append(aNodes);
           this.showResultsList();
         },
         /* ===== Results List Stuff ===== */
         _resultDiv: null,
         resultsVisible: false,
         showResultsList: function() {
--- a/client/bindings.css
+++ b/client/bindings.css
@@ -1,15 +1,19 @@
 #autocomplete {
   binding: url("autocomplete.xml#autocomplete");
 }
 
 #constraints {
   binding: url("bubbles.xml#constraint-list");
 }
 
+#query {
+  binding: url("messages.xml#query");
+}
+
 .message {
   binding: url("messages.xml#message");
 }
 
 .auco_contact {
   binding: url("autocomplete.xml#contact-completion");
 }
--- a/client/bubbles.xml
+++ b/client/bubbles.xml
@@ -13,25 +13,27 @@
         _nConstraints: null,
         addContact: function(aContact) {
           var node = $("<div/>").addClass("bubble").attr("type", "contact")[0];
           ElementXBL.prototype.addBinding.call(node, "bubbles.xml#constraint-contact");
           node.setContact(aContact);
           $(this._nConstraints).append(node);
           console.log("Contact", aContact, "added");
         },
+        getContacts: function() {
+          return $(this._nConstraints).children().map(function (index, item) {
+            console.log("getting contact from", item);
+            return item.getContact();
+          }).get();
+        },
         xblBindingAttached: function () {
           this._nConstraints = this.shadowTree.getElementById("holder");
         },
       })
     ]]></xbl:implementation>
-    <xbl:handlers>
-      <xbl:handler event="click"><![CDATA[
-      ]]></xbl:handler>
-    </xbl:handlers>
   </xbl:binding>
 
   <xbl:binding id="constraint-contact">
     <xbl:template>
       <img id="picture" height="18" width="18" class="con_contactpic"/>
       <span id="name"></span>
     </xbl:template>
     <xbl:resources>
@@ -40,16 +42,19 @@
           width: 18;
           height: 18;
         }
       ]]></xbl:style>
     </xbl:resources>
     <xbl:implementation><![CDATA[
       ({
         contact: null,
+        getContact: function() {
+          return this.contact;
+        },
         setContact: function(aContact) {
           this.contact = aContact;
           console.log("setContact", this);
           this.shadowTree.getElementById("name").textContent = this.contact.name;
           var bestEmail = null;
           var emailText = this.contact.identities.forEach(function (identity) {
             if (identity.kind == "email")
               bestEmail = identity.value;
--- a/client/cloda.js
+++ b/client/cloda.js
@@ -1,13 +1,195 @@
 /**
  * Cloda, it's a play on gloda and cloud.  Get it?!
  */
 
+var GlodaConversationProto = {
+
+};
+
+var GlodaMessageProto = {
+  get subject() {
+    return this.headers["Subject"];
+  },
+  _bodyTextHelper: function(aPart) {
+    var result = "";
+    if (aPart.parts) {
+      return aPart.parts.map(this._bodyTextHelper, this).join("");
+    }
+    else if (aPart.contentType == "text/plain")
+      return aPart.data;
+    else
+      return "";
+  },
+  get bodyText() {
+    return this._bodyTextHelper(this.bodyPart);
+  },
+  get bodySnippet() {
+    return this.bodyText.substring(0, 128);
+  }
+};
+
+function GlodaConvQuery(aConstraints, aCallback, aCallbackThis) {
+  this.constraints = aConstraints;
+  this.callback = aCallback;
+  this.callbackThis = aCallbackThis;
+
+  var dis = this;
+  this.wrappedProcessResults = function() {
+    dis.processResults.apply(dis, arguments);
+  };
+  this.seenConversations = null;
+
+  this.constraintsPending = aConstraints.length;
+  this.constraints.forEach(this.dispatchConstraint, this);
+}
+GlodaConvQuery.prototype = {
+  dispatchConstraint: function(aConstraint) {
+    var viewName = aConstraint.view;
+    delete aConstraint.view;
+    aConstraint["success"] = this.wrappedProcessResults;
+    Gloda.dbMessages.view(viewName, aConstraint);
+  },
+  processResults: function(result) {
+    var nextSeen = {}, rows = result.rows, iRow, row, conversationId;
+    if (this.seenConversations == null) {
+      for (iRow = 0; iRow < rows.length; iRow++) {
+        row = rows[iRow];
+        nextSeen[row.value] = true;
+      }
+    }
+    else {
+      for (iRow = 0; iRow < rows.length; iRow++) {
+        conversationId = rows[iRow].value;
+        if (conversationId in this.seenConversations)
+          nextSeen[conversationId] = true;
+      }
+    }
+    this.seenConversations = nextSeen;
+    console.log("processResults", this.seenConversations);
+    if (--this.constraintsPending == 0) {
+      var conversationIds = [];
+      for (conversationId in this.seenConversations) {
+        conversationIds.push(conversationId);
+      }
+      var dis = this;
+      Gloda.dbMessages.view("by_conversation/by_conversation", {
+        keys: conversationIds, include_docs: true,
+        success: function(result) {
+          dis.processConversationFetch(result);
+        }
+      });
+    }
+  },
+  processConversationFetch: function(result) {
+    // we receive the list of fetched messages.  we need to group them by
+    //  conversation (this should be trivially easy because they should come
+    //  back grouped, but we're not going to leverage that.)
+    // we also need to get the list of distinct contact id's seen so that we
+    //  can look up the contacts.
+    var conversations = this.conversations = {};
+    var seenContactIds = {};
+    var rows = result.rows, iRow, row, contact_id;
+    for (iRow = 0; iRow < rows.length; iRow++) {
+      row = rows[iRow];
+      var message = row.doc;
+      var conversation = conversations[message.conversation_id];
+      if (conversation === undefined)
+        conversation = conversations[message.conversation_id] = {
+          __proto__: GlodaConversationProto,
+          oldest: message.timestamp, newest: message.timestamp,
+          involves_contact_ids: {}, messages: []
+        };
+      conversation.messages.push(message);
+      if (conversation.oldest > message.timestamp)
+        conversation.oldest = message.timestamp;
+      if (conversation.newest < message.timestamp)
+        conversation.newest = message.timestamp;
+      for (var iContactId = 0; iContactId < message.involves_contact_ids.length;
+           iContactId++) {
+        contact_id = message.involves_contact_ids[iContactId];
+        conversation.involves_contact_ids[contact_id] = true;
+        seenContactIds[contact_id] = true;
+      }
+    }
+
+    console.log("seenContactIds", seenContactIds);
+    var contact_ids = [];
+    for (contact_id in seenContactIds)
+      contact_ids.push(contact_id);
+
+    console.log("contact lookup list:", contact_ids);
+
+    var dis = this;
+    Gloda.dbContacts.allDocs({ keys: contact_ids, include_docs: true,
+      success: function(result) {
+        dis.processContactFetch(result);
+      }
+    });
+  },
+  processContactFetch: function(result) {
+    // --- receive the contacts, translate them into the messages...
+    // -- build the contact map
+    var rows = result.rows, iRow, row, contact;
+    var contacts = {};
+    for (iRow = 0; iRow < rows.length; iRow++) {
+      row = rows[iRow];
+      contact = row.doc;
+      contacts[contact._id] = contact;
+    }
+
+    function mapContactList(aList) {
+      var out = [];
+      for (var i = 0; i < aList.length; i++) {
+        out.push(contacts[aList[i]]);
+      }
+      return out;
+    }
+
+    // -- process the conversations
+    var convList = [];
+    for each (var conversation in this.conversations) {
+      conversation.involves = mapContactList(conversation.involves_contact_ids);
+      convList.push(conversation);
+
+      for (var iMsg = 0; iMsg < conversation.messages.length; iMsg++) {
+        var message = conversation.messages[iMsg];
+        message.__proto__ = GlodaMessageProto;
+        message.from = contacts[message.from_contact_id];
+        message.to = mapContactList(message.to_contact_ids);
+        message.cc = mapContactList(message.cc_contact_ids);
+        message.involves = mapContactList(message.involves_contact_ids);
+      }
+    }
+
+    convList.sort(function (a, b) { return a.newest - b.newest; });
+    console.log("callback with conv list", convList);
+    this.callback.call(this.callbackThis, convList);
+  }
+};
+
+var MAX_TIMESTAMP = 4000000000;
+
 var Gloda = {
   dbContacts: $.couch.db("contacts"),
   dbMessages: $.couch.db("messages"),
 
   _init: function () {
 
+  },
+
+  queryByInvolved: function(aInvolvedContactIds, aCallback, aCallbackThis) {
+    // -- for each involved person, get the set of conversations they're in
+    var constraints = aInvolvedContactIds.map(function (contact) {
+      return {
+        view: "by_involves/by_involves",
+        startkey: [contact._id, 0], endkey: [contact._id, MAX_TIMESTAMP]
+      };
+    }, this);
+    var query = new GlodaConvQuery(constraints, aCallback, aCallbackThis);
+
+    // -- intersect all those conversations
+    // -- (fetch the conversation meta-info)
+    // -- fetch the messages in the conversations
   }
 };
 Gloda._init();
\ No newline at end of file
--- a/client/index.xhtml
+++ b/client/index.xhtml
@@ -1,45 +1,49 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
   <title>Junius</title>
   <script src="/_utils/script/json2.js"></script>
   <script src="/_utils/script/jquery.js?1.2.6"></script>
-  <script src="/_utils/script/jquery.couch.js?0.9.0"></script>
+  <script src="jquery.couch.js"></script>
   <script src="xbl-src.js"></script>
   <script src="md5.js"></script>
   <link rel="stylesheet" href="bindings.css" type="text/css"></link>
   <link rel="stylesheet" href="autocomplete.css" type="text/css"></link>
   <link rel="stylesheet" href="bubbles.css" type="text/css"></link>
   <link rel="stylesheet" href="searchResults.css" type="text/css"></link>
   <script src="cloda.js"></script>
   <script src="cloda-completers.js"></script>
+  <script src="utils.js"></script>
   <script type="text/javascript" charset="utf-8"><![CDATA[
     function funkyInit() {
       var autocompleter = document.getElementById("autocomplete");
       console.log("autocompleter", autocompleter);
       autocompleter.addCompleter(ContactCompleter);
       console.log("contact completer registered");
-      
+
+      var query = document.getElementById("query");
+
       var constraints = document.getElementById("constraints");
       autocompleter.addActionListener(function (item) {
         console.log("!!!Action Listener!!!", item);
         var itemType = item.getType();
         console.log(".type:", itemType);
         if (itemType == "contact") {
           console.log("..contact case");
           var contact = item.getContact();
           console.log("contact", contact);
           constraints.addContact(contact);
           console.log("added contact", contact);
         }
         else
           return;
+        query.updateConstraints(constraints);
       });
       console.log("constrainer registered");
     }
 
     $(function() {
       window.setTimeout(funkyInit, 1000);
       //DocumentXBL.prototype.loadBindingDocument.call(document, "messages.xml");
       /*
@@ -61,12 +65,13 @@
       })
       */
     });
   ]]></script>
 </head>
 <body>
   <div id="autocomplete"></div>
   <div id="constraints"/>
-  <div id="messages">
+  <div id="query"/>
+  <div id="conversations">
   </div>
 </body>
 </html>
--- a/client/jquery.couch.js
+++ b/client/jquery.couch.js
@@ -119,16 +119,35 @@
                 alert("Database information could not be retrieved: " +
                   resp.reason);
               }
             }
           });
         },
         allDocs: function(options) {
           options = options || {};
+          if (options.keys) {
+            $.ajax({
+              type: "POST",
+              url: this.uri + "_all_docs" + encodeOptions(options),
+              contentType: "application/json",
+              data: toJSON({keys: options.keys}), dataType: "json",
+              complete: function(req) {
+                var resp = $.httpData(req, "json");
+                if (req.status == 200) {
+                  if (options.success) options.success(resp);
+                } else if (options.error) {
+                  options.error(req.status, resp.error, resp.reason);
+                } else {
+                  alert("An error occurred accessing the view: " + resp.reason);
+                }
+              }
+            });
+            return;
+          }
           $.ajax({
             type: "GET", url: this.uri + "_all_docs" + encodeOptions(options),
             dataType: "json",
             complete: function(req) {
               var resp = $.httpData(req, "json");
               if (req.status == 200) {
                 if (options.success) options.success(resp);
               } else if (options.error) {
@@ -228,16 +247,35 @@
               } else {
                 alert("An error occurred querying the database: " + resp.reason);
               }
             }
           });
         },
         view: function(name, options) {
           options = options || {};
+          if (options.keys) {
+            $.ajax({
+              type: "POST",
+              url: this.uri + "_view/" + name + encodeOptions(options),
+              contentType: "application/json",
+              data: toJSON({keys: options.keys}), dataType: "json",
+              complete: function(req) {
+                var resp = $.httpData(req, "json");
+                if (req.status == 200) {
+                  if (options.success) options.success(resp);
+                } else if (options.error) {
+                  options.error(req.status, resp.error, resp.reason);
+                } else {
+                  alert("An error occurred accessing the view: " + resp.reason);
+                }
+              }
+            });
+            return;
+          }
           $.ajax({
             type: "GET", url: this.uri + "_view/" + name + encodeOptions(options),
             dataType: "json",
             complete: function(req) {
               var resp = $.httpData(req, "json");
               if (req.status == 200) {
                 if (options.success) options.success(resp);
               } else if (options.error) {
@@ -292,16 +330,18 @@
   // Convert a options object to an url query string.
   // ex: {key:'value',key2:'value2'} becomes '?key="value"&key2="value2"'
   function encodeOptions(options) {
     var buf = []
     if (typeof(options) == "object" && options !== null) {
       for (var name in options) {
         if (name == "error" || name == "success") continue;
         var value = options[name];
+        // keys will result in a POST, so we don't need them in our GET part
+        if (name == "keys") continue;
         if (name == "key" || name == "startkey" || name == "endkey") {
           value = toJSON(value);
         }
         buf.push(encodeURIComponent(name) + "=" + encodeURIComponent(value));
       }
     }
     return buf.length ? "?" + buf.join("&") : "";
   }
--- a/client/messages.xml
+++ b/client/messages.xml
@@ -1,13 +1,154 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <xbl:xbl
   xmlns="http://www.w3.org/1999/xhtml"
   xmlns:xbl="http://www.w3.org/ns/xbl">
 
+  <xbl:binding id="query">
+    <xbl:template>
+      <span>I am the query binding.  Perhaps you would do faceting here?</span>
+    </xbl:template>
+    <xbl:implementation><![CDATA[
+      ({
+        resultsAhoy: function(aConversations) {
+          console.log("results ahoy", aConversations);
+          var convNode = $("#conversations").empty();
+          convNode.append(aConversations.map(function (conversation) {
+            var node = $("<div/>").addClass("auco_contact")[0];
+            ElementXBL.prototype.addBinding.call(node, "messages.xml#conversation");
+            node.setConversation(conversation);
+            return node;
+          }));
+        },
+        updateConstraints: function(aConstraints) {
+          console.log("updating constraints");
+          Gloda.queryByInvolved(aConstraints.getContacts(), this.resultsAhoy, this);
+          console.log("constraints updated");
+        }
+      })
+    ]]></xbl:implementation>
+    <xbl:handlers>
+      <xbl:handler event="click"><![CDATA[
+
+      ]]></xbl:handler>
+    </xbl:handlers>
+  </xbl:binding>
+
+  <xbl:binding id="conversation">
+    <xbl:template>
+      <div class="checkbox">
+        <input id="checkbox" type="checkbox" tabindex="-1"/>
+      </div>
+      <div class="target">
+        <div class="header">
+          <div class="meta">
+            <div id="oldestMessageDate" class="oldestMessageDate"/>
+            <div id="attachments" class="attachments"/>
+          </div>
+          <div id="subject" class="subject"/>
+          <div class="addressing">
+            <span id="author" class="author"/>
+            <span id="verb" class="verb">writes</span>
+            <div id="recipients" class="recipients"/>
+            <span id="date" class="date"/>
+          </div>
+          <div id="tags" class="tags"/>
+        </div>
+        <div id="snippet" class="body"/>
+      </div>
+      <div id="replies" class="replies"/>
+    </xbl:template>
+    <xbl:resources>
+      <xbl:style><![CDATA[
+/* CONVERSATION */
+conversation { padding: 1em 1px; margin: 0px;
+               border-top: 1px solid transparent; border-bottom: 1px solid #ddd;
+               display: block;
+               color: #555; background-color: #f5f6f7; /* default read */
+              }
+conversation:focus { border: 1px dotted #111; padding: 1em 0px; }
+conversation[unread="true"]:focus { border: 1px dotted #111; padding: 1em 0px; }
+conversation:focus:last-child { border: 1px dotted #111; padding: 1em 0px; }
+conversation:focus:first-child { border: 1px dotted #111; padding: 1em 0px; }
+conversation:last-child { border-bottom: 1px solid transparent; }
+conversation:first-child { border-top: 1px solid #ddd; }
+
+conversation > .target > .header,
+conversation > .target > .body,
+conversation > .target > .subject,
+conversation > .replies { margin-left: 24px; font-size: 95%; }
+
+conversation > .target { display: block; padding: 0.2em 0em; padding-right: 1em; }
+conversation > .target { cursor: pointer; }
+
+/* CHECKBOX */
+.checkbox { float: left; text-align: center; width: 24px; height: 24px;
+            padding: 0.1em 0.2em }
+.checkbox > input { vertical-align: middle; }
+
+/* HEADER */
+.header { margin-bottom: 0.5em; }
+.header .meta { float: right; padding-left: 2em; text-align: right; color: #999; font-size: 90%; }
+.header .meta .attachments { padding-right: 18px; background: url("chrome://messenger/skin/icons/attachment.png") transparent no-repeat center right; display: none; }
+.header .meta .attachments[count] { display: inline; }
+.header .meta .attachments:before { content: "("; }
+.header .meta .attachments:after { content: ")"; }
+.header .addressing .verb { font-size: 90%; color: #777; }
+.header .addressing .date { color: #999; font-size: 90%; }
+.header .addressing .date:before { content: "\2014  "; }
+      ]]></xbl:style>
+    </xbl:resources>
+    <xbl:implementation><![CDATA[
+      ({
+        conversation: null,
+        setConversation: function(aConversation) {
+          this.conversation = aConversation;
+          this.shadowTree.getElementById("oldestMessageDate").textContent =
+            makeTimestampFriendly(this.conversation.oldest);
+          var firstMessage = this.conversation.messages[0];
+          this.shadowTree.getElementById("subject").textContent = firstMessage.subject;
+          this.shadowTree.getElementById("snippet").textContent = firstMessage.bodySnippet;
+          this.shadowTree.getElementById("author").textContent = firstMessage.from.name;
+          this.shadowTree.getElementById("date").textContent =
+            makeTimestampFriendly(firstMessage.timestamp);
+
+          var replyNodes = this.shadowTree.getElementById("replies");
+          for (var iMsg = 1; iMsg < this.conversation.messages.length; iMsg++) {
+            var message = this.conversation.messages[iMsg];
+            var node = $("<div/>")[0];
+            ElementXBL.prototype.addBinding.call(node, "messages.xml#reply");
+            node.setMessage(message);
+          }
+        },
+      })
+    ]]></xbl:implementation>
+  </xbl:binding>
+
+  <xbl:binding id="reply">
+    <xbl:template>
+      <div class="author">
+        <span id="from" class="name"/>
+        <span id="date" class="data"/>
+      </div>
+      <div id="snippet" class="body"/>
+    </xbl:template>
+    <xbl:implementation><![CDATA[
+      ({
+        setMessage: function(aMessage) {
+          this.message = aMessage;
+          this.shadowTree.getElementById("from").textContent = aMessage.from.name;
+          this.shadowTree.getElementById("date").textContent =
+            makeTimestampFriendly(aMessage.timestamp);
+          this.shadowTree.getElementById("snippet").textContent = aMessage.bodySnippet;
+        },
+      })
+    ]]></xbl:implementation>
+  </xbl:binding>
+
   <xbl:binding id="message">
     <xbl:template>
       <div>
         <span id="subject"></span><br/>
         <pre id="body"></pre>
       </div>
     </xbl:template>
     <xbl:implementation><![CDATA[
new file mode 100644
--- /dev/null
+++ b/client/utils.js
@@ -0,0 +1,18 @@
+function makeTimestampFriendly(timestamp) {
+  return makeDateFriendly(new Date(timestamp * 1000));
+}
+
+function makeDateFriendly(date) {
+  return date.toString();
+}
+
+function makeFriendlyName(name)
+{
+  var firstName = name.split(' ')[0];
+  if (firstName.indexOf('@') != -1)
+      firstName = firstName.split('@')[0];
+  firstName = firstName.replace(" ", "");
+  firstName = firstName.replace("'", "");
+  firstName = firstName.replace('"', "");
+  return firstName;
+}
\ No newline at end of file
--- a/server/python/junius/model.py
+++ b/server/python/junius/model.py
@@ -102,33 +102,42 @@ class Message(schema.Document):
                 out.count += cur.count;
                 involve_fuse(cur.involves);
             }
             out.involves = [];
             for (var contact_id in out_involves)
               out.involves.push(contact_id);
             return out;
         }''', group=True, group_level=1)
+    by_conversation = schema.View('by_conversation', '''\
+        function(doc) {
+            emit(doc.conversation_id, null);
+        }''', include_docs=True)
 
     # -- message (id) views
     by_header_id = schema.View('by_header_id', '''\
         function(doc) {
             emit(doc.header_message_id, null);
         }''', include_docs=True)    
     
     by_timestamp = schema.View('by_timestamp', '''\
         function(doc) {
             emit(doc.timestamp, null);
         }''', include_docs=True)    
-    
+
+    # the key includes the timestamp so we can use it to limit our queries plus
+    #  pick up where we left off if we need to page/chunk.
+    # we expose the conversation id as the value because set intersection
+    #  on a conversation-basis demands it, and it would theoretically be too
+    #  expensive to just return the whole document via include_docs.
     by_involves = schema.View('by_involves', '''\
         function(doc) {
             for each (var contact_id in doc.involves_contact_ids)
-                emit(contact_id, null);
-        }''', include_docs=True)
+                emit([contact_id, doc.timestamp], doc.conversation_id);
+        }''')
     
     # -- storage info views
     # so, this key is theoretically just wildly expensive
     by_storage = schema.View('by_storage', '''\
         function(doc) {
             emit([doc.account_id, doc.storage_path, doc.storage_id], null);
         }''', include_docs=False)