diff --git a/assets/default/css/theme.css b/assets/default/css/theme.css
index 26680176598c1b8916aaccacb041713d34074a16..ba4fc8e02c8e1db4b67936aaa174a6522b6ba3ca 100644
--- a/assets/default/css/theme.css
+++ b/assets/default/css/theme.css
@@ -999,7 +999,7 @@ img.sign-in-image {
 }
 .span8 {
     display: inline-block;
-    width: 66.5%;
+    width: 66.0%;
     margin: 0 1%;
     vertical-align: top;
 }
diff --git a/css/thread.css b/css/thread.css
index 5d8ecb7a2014832c05decdcaf34321399cf4f68e..4e810094a70447cb92dd33671fe85bb4c58704eb 100644
--- a/css/thread.css
+++ b/css/thread.css
@@ -420,12 +420,16 @@
 	margin: 0;
 	margin-bottom: 10px;
 	border: none;
+    background: none;
+	box-shadow: none !important;
+    text-indent: 0 !important;
+}
+
+.thread-body pre {
     background: #f5f5f5;
     background-color: rgba(0,0,0,0.05);
     border-radius: 5px;
     padding: 0.5em;
-	box-shadow: none !important;
-    text-indent: 0 !important;
 }
 
 .thread-body iframe,
diff --git a/include/ajax.thread.php b/include/ajax.thread.php
index 4a8b72016d69ce2ff4565d5026134b380f6e0649..470f9276e24a6d3c4c15a7d054422799db70c9b1 100644
--- a/include/ajax.thread.php
+++ b/include/ajax.thread.php
@@ -114,19 +114,6 @@ class ThreadAjaxAPI extends AjaxController {
             // FIXME: Refuse to add ticket owner??
             if (($c=$thread->addCollaborator($user,
                             array('isactive'=>1), $errors))) {
-                $note = Format::htmlchars(sprintf(__('%s <%s> added as a collaborator'),
-                            Format::htmlchars($c->getName()), $c->getEmail()));
-
-                $thread->getObject()->postThreadEntry('N',
-                        array(
-                            'title' => __('New Collaborator Added'),
-                            'note' => $note
-                            ),
-                        array(
-                            'poster' => $thisstaff,
-                            'alert' => false
-                            )
-                        );
                 $info = array('msg' => sprintf(__('%s added as a collaborator'),
                             Format::htmlchars($c->getName())));
                 return self::_collaborators($thread, $info);
diff --git a/include/class.faq.php b/include/class.faq.php
index f3d9d5bb76cfde2e0d454387b153d12475ade707..9f0b83ccd5161f08700fbfb03cfec1fea7a47d1a 100644
--- a/include/class.faq.php
+++ b/include/class.faq.php
@@ -367,10 +367,10 @@ class FAQ extends VerySimpleModel {
     }
 
     static function allPublic() {
-        return static::objects()->exclude(array(
+        return static::objects()->exclude(Q::any(array(
             'ispublished'=>self::VISIBILITY_PRIVATE,
             'category__ispublic'=>Category::VISIBILITY_PRIVATE,
-        ));
+        )));
     }
 
     static function countPublishedFAQs() {
diff --git a/include/class.file.php b/include/class.file.php
index 5f4013ff762f85b780c61bc746d84190deba714b..e0953876d7571c4e6f322d78b4dd7a48a59ea85a 100644
--- a/include/class.file.php
+++ b/include/class.file.php
@@ -25,6 +25,12 @@ class AttachmentFile extends VerySimpleModel {
             ),
         ),
     );
+    static $keyCache = array();
+
+    function __onload() {
+        // Cache for lookup in the ::lookupByHash method below
+        static::$keyCache[$this->key] = $this;
+    }
 
     function getHashtable() {
         return $this->ht;
@@ -532,13 +538,11 @@ class AttachmentFile extends VerySimpleModel {
     }
 
     static function lookupByHash($hash) {
-        static $keyCache = array();
-
-        if (isset($keyCache[$hash]))
-            return $keyCache[$hash];
+        if (isset(static::$keyCache[$hash]))
+            return static::$keyCache[$hash];
 
         // Cache a negative lookup if no such file exists
-        return $keyCache[$hash] = parent::lookup(array('key' => $hash));
+        return parent::lookup(array('key' => $hash));
     }
 
     static function lookup($id) {
diff --git a/include/class.format.php b/include/class.format.php
index d062b69fdc5011712aa71863ae3c86c2dd13b65a..d13c2dd8b39a292698b73beda6272052d127eee6 100644
--- a/include/class.format.php
+++ b/include/class.format.php
@@ -317,7 +317,7 @@ class Format {
         global $ost;
 
         // Find all text between tags
-        $text = preg_replace_callback(':^[^<]+|>[^<]+:',
+        return preg_replace_callback(':^[^<]+|>[^<]+:',
             function($match) {
                 // Scan for things that look like URLs
                 return preg_replace_callback(
@@ -344,32 +344,6 @@ class Format {
                     $match[0]);
             },
             $text);
-
-        // Now change @href and @src attributes to come back through our
-        // system as well
-        $config = array(
-            'hook_tag' => function($e, $a=0) use ($target) {
-                static $eE = array('area'=>1, 'br'=>1, 'col'=>1, 'embed'=>1,
-                    'hr'=>1, 'img'=>1, 'input'=>1, 'isindex'=>1, 'param'=>1);
-                if ($e == 'a' && $a) {
-                    $a['target'] = $target;
-                    $a['class'] = 'no-pjax';
-                }
-
-                $at = '';
-                if (is_array($a)) {
-                    foreach ($a as $k=>$v)
-                        $at .= " $k=\"$v\"";
-                    return "<{$e}{$at}".(isset($eE[$e])?" /":"").">";
-                } else {
-                    return "</{$e}>";
-                }
-            },
-            'schemes' => 'href: aim, feed, file, ftp, gopher, http, https, irc, mailto, news, nntp, sftp, ssh, telnet; *:file, http, https; src: cid, http, https, data',
-            'elements' => '*+iframe',
-            'spec' => 'span=data-src,width,height;img=data-cid',
-        );
-        return Format::html($text, $config);
     }
 
     function stripEmptyLines($string) {
@@ -379,18 +353,9 @@ class Format {
 
     function viewableImages($html, $script=false) {
         $cids = $images = array();
-        // Try and get information for all the files in one query
-        if (preg_match_all('/"cid:([\w._-]{32})"/', $html, $cids)) {
-            foreach (AttachmentFile::objects()
-                ->filter(array('key__in' => $cids[1]))
-                as $file
-            ) {
-                $images[strtolower($file->getKey())] = $file;
-            }
-        }
         return preg_replace_callback('/"cid:([\w._-]{32})"/',
         function($match) use ($script, $images) {
-            if (!($file = $images[strtolower($match[1])]))
+            if (!($file = AttachmentFile::lookup($match[1])))
                 return $match[0];
             return sprintf('"%s" data-cid="%s"',
                 $file->getDownloadUrl(false, 'inline', $script), $match[1]);
@@ -441,6 +406,7 @@ class Format {
     function __formatDate($timestamp, $format, $fromDb, $dayType, $timeType,
             $strftimeFallback, $timezone, $user=false) {
         global $cfg;
+        static $cache;
 
         if (!$timestamp)
             return '';
@@ -449,18 +415,28 @@ class Format {
             $timestamp = Misc::db2gmtime($timestamp);
 
         if (class_exists('IntlDateFormatter')) {
-            $formatter = new IntlDateFormatter(
-                Internationalization::getCurrentLocale($user),
-                $dayType,
-                $timeType,
-                $timezone,
-                IntlDateFormatter::GREGORIAN,
-                $format ?: null
-            );
-            if ($cfg->isForce24HourTime()) {
-                $format = str_replace(array('a', 'h'), array('', 'H'),
-                    $formatter->getPattern());
-                $formatter->setPattern($format);
+            $locale = Internationalization::getCurrentLocale($user);
+            $key = "{$locale}:{$dayType}:{$timeType}:{$timezone}:{$format}";
+            if (!isset($cache[$key])) {
+                // Setting up the IntlDateFormatter is pretty expensive, so
+                // cache it since there aren't many variations of the
+                // arguments passed to the constructor
+                $cache[$key] = $formatter = new IntlDateFormatter(
+                    $locale,
+                    $dayType,
+                    $timeType,
+                    $timezone,
+                    IntlDateFormatter::GREGORIAN,
+                    $format ?: null
+                );
+                if ($cfg->isForce24HourTime()) {
+                    $format = str_replace(array('a', 'h'), array('', 'H'),
+                        $formatter->getPattern());
+                    $formatter->setPattern($format);
+                }
+            }
+            else {
+                $formatter = $cache[$key];
             }
             return $formatter->format($timestamp);
         }
diff --git a/include/class.forms.php b/include/class.forms.php
index a3dc7579634996b2da6d1837bdd4e31b3faf42f4..f4f5491226e4e63f36b338bb3b18a49c1174ff48 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -2124,8 +2124,16 @@ class FileUploadField extends FormField {
     static function getFileTypes() {
         static $filetypes;
 
-        if (!isset($filetypes))
-            $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml');
+        if (!isset($filetypes)) {
+            if (function_exists('apc_fetch')) {
+                $key = md5(SECRET_SALT . GIT_VERSION . 'filetypes');
+                $filetypes = apc_fetch($key);
+            }
+            if (!$filetypes)
+                $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml');
+            if ($key)
+                apc_store($key, $filetypes, 7200);
+        }
         return $filetypes;
     }
 
diff --git a/include/class.i18n.php b/include/class.i18n.php
index f68be50e6d57c00b6088ed2505989e4c21247689..ed3df93bfcdbbb44fcfc9b4a1f7d0e02dcff2ece 100644
--- a/include/class.i18n.php
+++ b/include/class.i18n.php
@@ -280,6 +280,10 @@ class Internationalization {
     // Algorithm borrowed from Drupal 7 (locale.inc)
     static function getDefaultLanguage() {
         global $cfg;
+        static $lang;
+
+        if (isset($lang))
+            return $lang;
 
         if (empty($_SERVER["HTTP_ACCEPT_LANGUAGE"]))
             return $cfg ? $cfg->getPrimaryLanguage() : 'en_US';
@@ -358,10 +362,9 @@ class Internationalization {
           }
         }
 
-        if (self::isLanguageInstalled($best_match_langcode))
-            return $best_match_langcode;
-        else
-            return $cfg->getPrimaryLanguage();
+        return $lang = self::isLanguageInstalled($best_match_langcode)
+            ? $best_match_langcode
+            : $cfg->getPrimaryLanguage();
     }
 
     static function getCurrentLanguage($user=false) {
diff --git a/include/class.orm.php b/include/class.orm.php
index 676390409859f92f996ab167e3987bdf8a304a66..eab05d3a074fa805a428a5c1ab93ad50efd07a19 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -51,23 +51,24 @@ class ModelMeta implements ArrayAccess {
 
     function __construct($model) {
         $this->model = $model;
-        $parent = get_parent_class($model);
 
         // Merge ModelMeta from parent model (if inherited)
+        $parent = get_parent_class($this->model);
         if (is_subclass_of($parent, 'VerySimpleModel')) {
-            $parent::_inspect();
-            $meta = $parent::$meta->extend($model::$meta);
+            $meta = $parent::getMeta()->extend($model::$meta);
         }
         else {
             $meta = $model::$meta + self::$base;
         }
 
-        if (!$meta['table'])
-            throw new OrmConfigurationException(
-                sprintf(__('%s: Model does not define meta.table'), $model));
-        elseif (!$meta['pk'])
-            throw new OrmConfigurationException(
-                sprintf(__('%s: Model does not define meta.pk'), $model));
+        if (!$meta['view']) {
+            if (!$meta['table'])
+                throw new OrmConfigurationException(
+                    sprintf(__('%s: Model does not define meta.table'), $this->model));
+            elseif (!$meta['pk'])
+                throw new OrmConfigurationException(
+                    sprintf(__('%s: Model does not define meta.pk'), $this->model));
+        }
 
         // Ensure other supported fields are set and are arrays
         foreach (array('pk', 'ordering', 'defer', 'select_related') as $f) {
@@ -93,10 +94,32 @@ class ModelMeta implements ArrayAccess {
         return $meta + $this->base + self::$base;
     }
 
+    /**
+     * Adds some more information to a declared relationship. If the
+     * relationship is a reverse relation, then the information from the
+     * reverse relation is loaded into the local definition
+     *
+     * Compiled-Join-Structure:
+     * 'constraint' => array(local => array(foreign_field, foreign_class)),
+     *      Constraint used to construct a JOIN in an SQL query
+     * 'list' => boolean
+     *      TRUE if an InstrumentedList should be employed to fetch a list
+     *      of related items
+     * 'broker' => Handler for the 'list' property. Usually a subclass of
+     *      'InstrumentedList'
+     * 'null' => boolean
+     *      TRUE if relation is nullable
+     * 'fkey' => array(class, pk)
+     *      Classname and field of the first item in the constraint that
+     *      points to a PK field of a foreign model
+     * 'local' => string
+     *      The local field corresponding to the 'fkey' property
+     */
     function processJoin(&$j) {
         $constraint = array();
         if (isset($j['reverse'])) {
             list($fmodel, $key) = explode('.', $j['reverse']);
+            // NOTE: It's ok if the forein meta data is not yet inspected.
             $info = $fmodel::$meta['joins'][$key];
             if (!is_array($info['constraint']))
                 throw new OrmConfigurationException(sprintf(__(
@@ -211,11 +234,8 @@ class VerySimpleModel {
     function get($field, $default=false) {
         if (array_key_exists($field, $this->ht))
             return $this->ht[$field];
-        elseif (isset(static::$meta['joins'][$field])) {
-            // Make sure joins were inspected
-            if (!static::$meta instanceof ModelMeta)
-                static::_inspect();
-            $j = static::$meta['joins'][$field];
+        elseif (($joins = static::getMeta('joins')) && isset($joins[$field])) {
+            $j = $joins[$field];
             // Support instrumented lists and such
             if (isset($j['list']) && $j['list']) {
                 $class = $j['fkey'][0];
@@ -296,11 +316,9 @@ class VerySimpleModel {
     function set($field, $value) {
         // Update of foreign-key by assignment to model instance
         $related = false;
-        if (isset(static::$meta['joins'][$field])) {
-            // XXX: This is likely not necessary
-            if (!isset(static::$meta['joins'][$field]['fkey']))
-                static::_inspect();
-            $j = static::$meta['joins'][$field];
+        $joins = static::getMeta('joins');
+        if (isset($joins[$field])) {
+            $j = $joins[$field];
             if ($j['list'] && ($value instanceof InstrumentedList)) {
                 // Magic list property
                 $this->ht[$field] = $value;
@@ -365,12 +383,17 @@ class VerySimpleModel {
     static function __oninspect() {}
 
     static function _inspect() {
-        if (!static::$meta instanceof ModelMeta) {
-            static::$meta = new ModelMeta(get_called_class());
+        static::$meta = new ModelMeta(get_called_class());
 
-            // Let the model participate
-            static::__oninspect();
-        }
+        // Let the model participate
+        static::__oninspect();
+    }
+
+    static function getMeta($key=false) {
+        if (!static::$meta instanceof ModelMeta)
+            static::_inspect();
+        $M = static::$meta;
+        return ($key) ? $M->offsetGet($key) : $M;
     }
 
     /**
@@ -412,14 +435,12 @@ class VerySimpleModel {
      * no such instance exists.
      */
     static function lookup($criteria) {
-        // Autoinsepct model
-        static::_inspect();
-
         // Model::lookup(1), where >1< is the pk value
         if (!is_array($criteria)) {
             $criteria = array();
+            $pk = static::getMeta('pk');
             foreach (func_get_args() as $i=>$f)
-                $criteria[static::$meta['pk'][$i]] = $f;
+                $criteria[$pk[$i]] = $f;
 
             // Only consult cache for PK lookup, which is assumed if the
             // values are passed as args rather than an array
@@ -455,14 +476,13 @@ class VerySimpleModel {
         if ($this->__deleted__)
             throw new OrmException('Trying to update a deleted object');
 
-        $pk = static::$meta['pk'];
+        $pk = static::getMeta('pk');
         $wasnew = $this->__new__;
 
         // First, if any foreign properties of this object are connected to
         // another *new* object, then save those objects first and set the
         // local foreign key field values
-        static::_inspect();
-        foreach (static::$meta['joins'] as $prop => $j) {
+        foreach (static::getMeta('joins') as $prop => $j) {
             if (isset($this->ht[$prop])
                 && ($foreign = $this->ht[$prop])
                 && $foreign instanceof VerySimpleModel
@@ -520,7 +540,7 @@ class VerySimpleModel {
         if ($wasnew) {
             // Attempt to update foreign, unsaved objects with the PK of
             // this newly created object
-            foreach (static::$meta['joins'] as $prop => $j) {
+            foreach (static::getMeta('joins') as $prop => $j) {
                 if (isset($this->ht[$prop])
                     && ($foreign = $this->ht[$prop])
                     && in_array($j['local'], $pk)
@@ -557,7 +577,7 @@ class VerySimpleModel {
 
     private function getPk() {
         $pk = array();
-        foreach ($this::$meta['pk'] as $f)
+        foreach ($this::getMeta('pk') as $f)
             $pk[$f] = $this->ht[$f];
         return $pk;
     }
@@ -658,6 +678,8 @@ class SqlCase extends SqlFunction {
     }
 
     function when($expr, $result) {
+        if (is_array($expr))
+            $expr = new Q($expr);
         $this->cases[] = array($expr, $result);
         return $this;
     }
@@ -816,14 +838,15 @@ class SqlAggregate extends SqlFunction {
         list($field, $rmodel) = $compiler->getField($E, $model, $options);
         if ($this->distinct) {
             $pk = false;
-            foreach ($rmodel::$meta['pk'] as $f) {
+            $fpk  = $rmodel::getMeta('pk');
+            foreach ($fpk as $f) {
                 $pk |= false !== strpos($field, $f);
             }
             if (!$pk) {
                 // Try and use the foriegn primary key
-                if (count($rmodel::$meta['pk']) == 1) {
+                if (count($fpk) == 1) {
                     list($field) = $compiler->getField(
-                        $this->expr . '__' . $rmodel::$meta['pk'][0],
+                        $this->expr . '__' . $fpk[0],
                         $model, $options);
                 }
                 else {
@@ -856,6 +879,7 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
     var $related = array();
     var $values = array();
     var $defer = array();
+    var $aggregated = false;
     var $annotations = array();
     var $extra = array();
     var $distinct = array();
@@ -1017,6 +1041,14 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
         return $this->_count = $compiler->compileCount($this);
     }
 
+    function toSql($compiler, $model, $alias) {
+        // FIXME: Force root model of the compiler to $model
+        $exec = $this->getQuery(array('compiler' => get_class($compiler)));
+        foreach ($exec->params as $P)
+            $compiler->params[] = $P;
+        return "({$exec})".($alias ? " AS {$alias}" : '');
+    }
+
     /**
      * exists
      *
@@ -1055,6 +1087,17 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
         return $this;
     }
 
+    function aggregate($annotations) {
+        // Aggregate works like annotate, except that it sets up values
+        // fetching which will disable model creation
+        $this->annotate($annotations);
+        $this->values_flat();
+        // Disable other fields from being fetched
+        $this->aggregated = true;
+        $this->related = false;
+        return $this;
+    }
+
     function delete() {
         $class = $this->compiler;
         $compiler = new $class();
@@ -1110,14 +1153,14 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
         // Load defaults from model
         $model = $this->model;
         $query = clone $this;
-        if (!$options['nosort'] && !$query->ordering && isset($model::$meta['ordering']))
-            $query->ordering = $model::$meta['ordering'];
-        if (false !== $query->related && !$query->values && $model::$meta['select_related'])
-            $query->related = $model::$meta['select_related'];
-        if (!$query->defer && $model::$meta['defer'])
-            $query->defer = $model::$meta['defer'];
-
-        $class = $this->compiler;
+        if (!$options['nosort'] && !$query->ordering && $model::getMeta('ordering'))
+            $query->ordering = $model::getMeta('ordering');
+        if (false !== $query->related && !$query->values && $model::getMeta('select_related'))
+            $query->related = $model::getMeta('select_related');
+        if (!$query->defer && $model::getMeta('defer'))
+            $query->defer = $model::getMeta('defer');
+
+        $class = $options['compiler'] ?: $this->compiler;
         $compiler = new $class($options);
         $this->query = $compiler->compileSelect($query);
 
@@ -1178,10 +1221,14 @@ abstract class ResultSet implements Iterator, ArrayAccess, Countable {
         $this->queryset = $queryset;
         if ($queryset) {
             $this->model = $queryset->model;
-            $this->resource = $queryset->getQuery();
         }
     }
 
+    function prime() {
+        if (!isset($this->resource) && $this->queryset)
+            $this->resource = $this->queryset->getQuery();
+    }
+
     abstract function fillTo($index);
 
     function asArray() {
@@ -1236,17 +1283,9 @@ class ModelInstanceManager extends ResultSet {
 
     static $objectCache = array();
 
-    function __construct($queryset=false) {
-        parent::__construct($queryset);
-        if ($queryset) {
-            $this->map = $this->resource->getMap();
-        }
-    }
-
     function cache($model) {
-        $model::_inspect();
         $key = sprintf('%s.%s',
-            $model::$meta->model, implode('.', $model->pk));
+            $model::$meta->model, implode('.', $model->get('pk')));
         self::$objectCache[$key] = $model;
     }
 
@@ -1264,8 +1303,8 @@ class ModelInstanceManager extends ResultSet {
     }
 
     static function checkCache($modelClass, $fields) {
-        $key = $modelClass;
-        foreach ($modelClass::$meta['pk'] as $f)
+        $key = $modelClass::$meta->model;
+        foreach ($modelClass::getMeta('pk') as $f)
             $key .= '.'.$fields[$f];
         return @self::$objectCache[$key];
     }
@@ -1287,7 +1326,7 @@ class ModelInstanceManager extends ResultSet {
     function getOrBuild($modelClass, $fields, $cache=true) {
         // Check for NULL primary key, used with related model fetching. If
         // the PK is NULL, then consider the object to also be NULL
-        foreach ($modelClass::$meta['pk'] as $pkf) {
+        foreach ($modelClass::getMeta('pk') as $pkf) {
             if (!isset($fields[$pkf])) {
                 return null;
             }
@@ -1382,30 +1421,36 @@ class ModelInstanceManager extends ResultSet {
     }
 
     function fillTo($index) {
+        $this->prime();
         $func = ($this->map) ? 'getRow' : 'getArray';
         while ($this->resource && $index >= count($this->cache)) {
             if ($row = $this->resource->{$func}()) {
                 $this->cache[] = $this->buildModel($row);
             } else {
                 $this->resource->close();
-                $this->resource = null;
+                $this->resource = false;
                 break;
             }
         }
     }
+
+    function prime() {
+        parent::prime();
+        if ($this->resource) {
+            $this->map = $this->resource->getMap();
+        }
+    }
 }
 
 class FlatArrayIterator extends ResultSet {
-    function __construct($queryset) {
-        $this->resource = $queryset->getQuery();
-    }
     function fillTo($index) {
+        $this->prime();
         while ($this->resource && $index >= count($this->cache)) {
             if ($row = $this->resource->getRow()) {
                 $this->cache[] = $row;
             } else {
                 $this->resource->close();
-                $this->resource = null;
+                $this->resource = false;
                 break;
             }
         }
@@ -1413,16 +1458,14 @@ class FlatArrayIterator extends ResultSet {
 }
 
 class HashArrayIterator extends ResultSet {
-    function __construct($queryset) {
-        $this->resource = $queryset->getQuery();
-    }
     function fillTo($index) {
+        $this->prime();
         while ($this->resource && $index >= count($this->cache)) {
             if ($row = $this->resource->getArray()) {
                 $this->cache[] = $row;
             } else {
                 $this->resource->close();
-                $this->resource = null;
+                $this->resource = false;
                 break;
             }
         }
@@ -1431,12 +1474,14 @@ class HashArrayIterator extends ResultSet {
 
 class InstrumentedList extends ModelInstanceManager {
     var $key;
-    var $model;
 
     function __construct($fkey, $queryset=false) {
         list($model, $this->key) = $fkey;
-        if (!$queryset)
+        if (!$queryset) {
             $queryset = $model::objects()->filter($this->key);
+            if ($related = $model::getMeta('select_related'))
+                $queryset->select_related($related);
+        }
         parent::__construct($queryset);
         $this->model = $model;
     }
@@ -1466,7 +1511,8 @@ class InstrumentedList extends ModelInstanceManager {
         if ($delete)
             $object->delete();
         else
-            $object->set($this->key, null);
+            foreach ($this->key as $field=>$value)
+                $object->set($field, null);
     }
 
     function reset() {
@@ -1480,10 +1526,10 @@ class InstrumentedList extends ModelInstanceManager {
      */
     function window($constraint) {
         $model = $this->model;
-        $meta = $model::$meta;
+        $fields = $model::getMeta('fields');
         $key = $this->key;
         foreach ($constraint as $field=>$value) {
-            if (!is_string($field) || false === in_array($field, $meta['fields']))
+            if (!is_string($field) || false === in_array($field, $fields))
                 throw new OrmException('InstrumentedList windowing must be performed on local fields only');
             $key[$field] = $value;
         }
@@ -1618,52 +1664,52 @@ class SqlCompiler {
             }
         }
 
-        $path = array();
+        $path = '';
         $rootModel = $model;
 
         // Call pushJoin for each segment in the join path. A new JOIN
         // fragment will need to be emitted and/or cached
         $joins = array();
-        $push = function($p, $path, $model) use (&$joins) {
-            $model::_inspect();
-            if (!($info = $model::$meta['joins'][$p])) {
+        $push = function($p, $model) use (&$joins, &$path) {
+            $J = $model::getMeta('joins');
+            if (!($info = $J[$p])) {
                 throw new OrmException(sprintf(
                    'Model `%s` does not have a relation called `%s`',
                     $model, $p));
             }
-            $crumb = implode('__', $path);
-            $tip = ($crumb) ? "{$crumb}__{$p}" : $p;
-            $joins[] = array($crumb, $tip, $model, $info);
+            $crumb = $path;
+            $path = ($path) ? "{$path}__{$p}" : $p;
+            $joins[] = array($crumb, $path, $model, $info);
             // Roll to foreign model
             return $info['fkey'];
         };
 
         foreach ($parts as $p) {
-            list($model) = $push($p, $path, $model);
-            $path[] = $p;
+            list($model) = $push($p, $model);
         }
 
         // If comparing a relationship, join the foreign table
         // This is a comparison with a relationship — use the foreign key
-        if (isset($model::$meta['joins'][$field])) {
-            list($model, $field) = $push($field, $path, $model);
-        }
-
-        // Add the conststraint as the last arg to the last join
-        if (isset($options['constraint'])) {
-            $joins[count($joins)-1][] = $options['constraint'];
+        $J = $model::getMeta('joins');
+        if (isset($J[$field])) {
+            list($model, $field) = $push($field, $model);
         }
 
         // Apply the joins list to $this->pushJoin
-        foreach ($joins as $A) {
-            $alias = call_user_func_array(array($this, 'pushJoin'), $A);
+        $last = count($joins) - 1;
+        $constraint = false;
+        foreach ($joins as $i=>$A) {
+            // Add the conststraint as the last arg to the last join
+            if ($i == $last)
+                $constraint = $options['constraint'];
+            $alias = $this->pushJoin($A[0], $A[1], $A[2], $A[3], $contraint);
         }
 
         if (!isset($alias)) {
             // Determine the alias for the root model table
             $alias = (isset($this->joins['']))
                 ? $this->joins['']['alias']
-                : $this->quote($rootModel::$meta['table']);
+                : $this->quote($rootModel::getMeta('table'));
         }
 
         if (isset($options['table']) && $options['table'])
@@ -1771,6 +1817,8 @@ class SqlCompiler {
                 }
                 if ($value === null)
                     $filter[] = sprintf('%s IS NULL', $field);
+                elseif ($value instanceof SqlField)
+                    $filter[] = sprintf($op, $field, $value->toSql($this, $model));
                 // Allow operators to be callable rather than sprintf
                 // strings
                 elseif (is_callable($op))
@@ -1959,7 +2007,7 @@ class MySqlCompiler extends SqlCompiler {
         if (isset($this->joins[$tip]))
             $table = $this->joins[$tip]['alias'];
         else
-            $table = $this->quote($model::$meta['table']);
+            $table = $this->quote($model::getMeta('table'));
         foreach ($info['constraint'] as $local => $foreign) {
             list($rmodel, $right) = $foreign;
             // Support a constant constraint with
@@ -1992,10 +2040,10 @@ class MySqlCompiler extends SqlCompiler {
         if (!isset($rmodel))
             $rmodel = $model;
         // Support inline views
-        $table = ($rmodel::$meta['view'])
+        $table = ($rmodel::getMeta('view'))
             // XXX: Support parameters from the nested query
             ? $rmodel::getQuery($this)
-            : $this->quote($rmodel::$meta['table']);
+            : $this->quote($rmodel::getMeta('table'));
         $base = "{$join}{$table} {$alias}";
         if ($constraints)
            $base .= ' ON ('.implode(' AND ', $constraints).')';
@@ -2069,8 +2117,7 @@ class MySqlCompiler extends SqlCompiler {
 
     function compileCount($queryset) {
         $model = $queryset->model;
-        $model::_inspect();
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
         list($where, $having) = $this->getWhereHavingClause($queryset);
         $joins = $this->getJoins($queryset);
         $sql = 'SELECT COUNT(*) AS count FROM '.$this->quote($table).$joins.$where;
@@ -2102,30 +2149,34 @@ class MySqlCompiler extends SqlCompiler {
                         $dir = 'DESC';
                         $sort = substr($sort, 1);
                     }
-                    list($field) = $this->getField($sort, $model);
+                    // If the field is already an annotation, then don't
+                    // compile the annotation again below. It's included in
+                    // the select clause, which is sufficient
+                    if (isset($this->annotations[$sort]))
+                        $field = $this->quote($sort);
+                    else
+                        list($field) = $this->getField($sort, $model);
                 }
-                // TODO: Throw exception if $field can be indentified as
-                //       invalid
                 if ($field instanceof SqlFunction)
                     $field = $field->toSql($this, $model);
+                // TODO: Throw exception if $field can be indentified as
+                //       invalid
 
-                $orders[] = $field.' '.$dir;
+                $orders[] = "{$field} {$dir}";
             }
             $sort = ' ORDER BY '.implode(', ', $orders);
         }
 
         // Compile the field listing
-        $fields = array();
-        $group_by = array();
-        $model::_inspect();
-        $table = $this->quote($model::$meta['table']).' '.$rootAlias;
+        $fields = $group_by = array();
+        $table = $this->quote($model::getMeta('table')).' '.$rootAlias;
         // Handle related tables
         if ($queryset->related) {
             $count = 0;
             $fieldMap = $theseFields = array();
             $defer = $queryset->defer ?: array();
             // Add local fields first
-            foreach ($model::$meta['fields'] as $f) {
+            foreach ($model::getMeta('fields') as $f) {
                 // Handle deferreds
                 if (isset($defer[$f]))
                     continue;
@@ -2147,8 +2198,7 @@ class MySqlCompiler extends SqlCompiler {
                     $theseFields = array();
                     list($alias, $fmodel) = $this->getField($full_path, $model,
                         array('table'=>true, 'model'=>true));
-                    $fmodel::_inspect();
-                    foreach ($fmodel::$meta['fields'] as $f) {
+                    foreach ($fmodel::getMeta('fields') as $f) {
                         // Handle deferreds
                         if (isset($defer[$sr . '__' . $f]))
                             continue;
@@ -2183,10 +2233,9 @@ class MySqlCompiler extends SqlCompiler {
             }
         }
         // Simple selection from one table
-        else {
+        elseif (!$queryset->aggregated) {
             if ($queryset->defer) {
-                $model::_inspect();
-                foreach ($model::$meta['fields'] as $f) {
+                foreach ($model::getMeta('fields') as $f) {
                     if (isset($queryset->defer[$f]))
                         continue;
                     $fields[$rootAlias .'.'. $this->quote($f)] = true;
@@ -2214,7 +2263,7 @@ class MySqlCompiler extends SqlCompiler {
             }
             // If no group by has been set yet, use the root model pk
             if (!$group_by) {
-                foreach ($model::$meta['pk'] as $pk)
+                foreach ($model::getMeta('pk') as $pk)
                     $group_by[] = $rootAlias .'.'. $pk;
             }
         }
@@ -2264,8 +2313,8 @@ class MySqlCompiler extends SqlCompiler {
     }
 
     function compileUpdate(VerySimpleModel $model) {
-        $pk = $model::$meta['pk'];
-        $sql = 'UPDATE '.$this->quote($model::$meta['table']);
+        $pk = $model::getMeta('pk');
+        $sql = 'UPDATE '.$this->quote($model::getMeta('table'));
         $sql .= $this->__compileUpdateSet($model, $pk);
         // Support PK updates
         $criteria = array();
@@ -2279,15 +2328,15 @@ class MySqlCompiler extends SqlCompiler {
     }
 
     function compileInsert(VerySimpleModel $model) {
-        $pk = $model::$meta['pk'];
-        $sql = 'INSERT INTO '.$this->quote($model::$meta['table']);
+        $pk = $model::getMeta('pk');
+        $sql = 'INSERT INTO '.$this->quote($model::getMeta('table'));
         $sql .= $this->__compileUpdateSet($model, $pk);
 
         return new MySqlExecutor($sql, $this->params);
     }
 
     function compileDelete($model) {
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
 
         $where = ' WHERE '.implode(' AND ',
             $this->compileConstraints(array(new Q($model->pk)), $model));
@@ -2297,7 +2346,7 @@ class MySqlCompiler extends SqlCompiler {
 
     function compileBulkDelete($queryset) {
         $model = $queryset->model;
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
         list($where, $having) = $this->getWhereHavingClause($queryset);
         $joins = $this->getJoins($queryset);
         $sql = 'DELETE '.$this->quote($table).'.* FROM '
@@ -2307,7 +2356,7 @@ class MySqlCompiler extends SqlCompiler {
 
     function compileBulkUpdate($queryset, array $what) {
         $model = $queryset->model;
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
         $set = array();
         foreach ($what as $field=>$value)
             $set[] = sprintf('%s = %s', $this->quote($field), $this->input($value));
@@ -2394,7 +2443,7 @@ class MySqlExecutor {
 
         $types = '';
         $ps = array();
-        foreach ($params as &$p) {
+        foreach ($params as $i=>&$p) {
             if (is_int($p) || is_bool($p))
                 $types .= 'i';
             elseif (is_float($p))
diff --git a/include/class.thread.php b/include/class.thread.php
index 00623c85eb1020c12bc70e7500a8bed0ea191c20..8777211cd56b641453027d5e83b6cc591637e279 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -86,15 +86,20 @@ class Thread extends VerySimpleModel {
         return $this->entries->count();
     }
 
+    var $_entries;
     function getEntries($criteria=false) {
-        $base = $this->entries->annotate(array(
-            'has_attachments' => SqlAggregate::COUNT('attachments', false,
-                new Q(array('attachments__inline'=>0)))
-        ));
-        $base->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN));
-        if ($criteria)
-            $base->filter($criteria);
-        return $base;
+        if (!isset($this->_entries)) {
+            $this->_entries = $this->entries->annotate(array(
+                'has_attachments' => SqlAggregate::COUNT(SqlCase::N()
+                    ->when(array('attachments__inline'=>0), 1)
+                    ->otherwise(null)
+                ),
+            ));
+            $this->_entries->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN));
+            if ($criteria)
+                $this->_entries->filter($criteria);
+        }
+        return $this->_entries;
     }
 
     // Collaborators
@@ -134,7 +139,7 @@ class Thread extends VerySimpleModel {
         return $collaborators;
     }
 
-    function addCollaborator($user, $vars, &$errors) {
+    function addCollaborator($user, $vars, &$errors, $event=true) {
 
         if (!$user)
             return null;
@@ -147,6 +152,16 @@ class Thread extends VerySimpleModel {
 
         $this->_collaborators = null;
 
+        if ($event)
+            $this->getEvents()->log($this->getObject(),
+                'collab',
+                array('add' => array($user->getId() => array(
+                        'name' => $user->getName()->getOriginal(),
+                        'src' => @$vars['source'],
+                    ))
+                )
+            );
+
         return $c;
     }
 
@@ -164,11 +179,9 @@ class Thread extends VerySimpleModel {
                         && $c->delete())
                      $collabs[] = $c;
             }
-
-            $this->getObject()->postThreadEntry('N',
-                    array(
-                        'title' => _S('Collaborators Removed'),
-                        'note' => implode("<br>", $collabs)));
+            $this->getEvents()->log($this->getObject(), 'collab', array(
+                'del' => array($c->user_id => array('name' => $c->getName()->getOriginal()))
+            ));
         }
 
         //statuses
@@ -205,6 +218,11 @@ class Thread extends VerySimpleModel {
         if ($type && is_array($type))
             $entries->filter(array('type__in' => $type));
 
+        // Precache all the attachments on this thread
+        AttachmentFile::objects()->filter(array(
+            'attachments__thread_entry__thread__id' => $this->id
+        ))->all();
+
         $events = $this->getEvents();
         $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
         include $inc . 'templates/thread-entries.tmpl.php';
@@ -1545,83 +1563,15 @@ class ThreadEvent extends VerySimpleModel {
     }
 
     function getDescription($mode=self::MODE_STAFF) {
-        static $descs;
-        if (!isset($descs))
-            $descs = array(
-            'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'),
-            'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'),
-            'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'),
-            'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'),
-            'collab:org' => __('Collaborators for {<Organization>data.org} organization added'),
-            'collab:del' => function($evt) {
-                $data = $evt->getData();
-                $base = __('<b>{username}</b> removed %s from the collaborators.');
-                return $data['del']
-                    ? Format::htmlchars(sprintf($base, implode(', ', $data['del'])))
-                    : 'somebody';
-            },
-            'collab:add' => function($evt) {
-                $data = $evt->getData();
-                $base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}');
-                $collabs = array();
-                if ($data['add']) {
-                    foreach ($data['add'] as $c) {
-                        if (is_array($c))
-                            $c = sprintf(__("%s via %a"
-                                /* e.g. "Me <me@company.me> via Email (to)" */),
-                                $c[0], $c[1]);
-                        $collabs[] = Format::htmlchars($c);
-                    }
-                }
-                return $collabs
-                    ? sprintf($base, implode(', ', $collabs))
-                    : 'somebody';
-            },
-            'created' => __('Created by <b>{username}</b> {timestamp}'),
-            'closed' => __('Closed by <b>{username}</b> {timestamp}'),
-            'reopened' => __('Reopened by <b>{username}</b> {timestamp}'),
-            'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'),
-            'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'),
-            'overdue' => __('Flagged as overdue by the system {timestamp}'),
-            'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'),
-            'edited:fields' => function($evt) use ($mode) {
-                $base = __('Updated by <b>{username}</b> {timestamp} — %s');
-                $data = $evt->getData();
-                $fields = $changes = array();
-                foreach (DynamicFormField::objects()->filter(array(
-                    'id__in' => array_keys($data['fields'])
-                )) as $F) {
-                    $fields[$F->id] = $F;
-                }
-                foreach ($data['fields'] as $id=>$f) {
-                    $field = $fields[$id];
-                    if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers())
-                        continue;
-                    list($old, $new) = $f;
-                    $impl = $field->getImpl($field);
-                    $before = $impl->to_php($old);
-                    $after = $impl->to_php($new);
-                    $changes[] = sprintf('<strong>%s</strong> %s',
-                        $field->getLocal('label'), $impl->whatChanged($before, $after));
-                }
-                if (!$changes)
-                    return '';
-                return sprintf($base, implode(', ', $changes));
-            },
-            'resent' => __('<b>{username}</b> resent <strong><a href="#thread-entry-{data.entry}">a previous response</a></strong> {timestamp}'),
-        );
-        $self = $this;
-        $data = $this->getData();
-        $state = $this->state;
-        if (is_array($data)) {
-            foreach (array_keys($data) as $k)
-                if (isset($descs[$state . ':' . $k]))
-                    $state .= ':' . $k;
-        }
-        $description = $descs[$state];
-        if (is_callable($description))
-            $description = $description($this);
+        // Abstract description
+        return $this->template(sprintf(
+            __('%s by {somebody} {timestamp}'),
+            $this->state
+        ));
+    }
 
+    function template($description) {
+        $self = $this;
         return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
             function ($m) use ($self) {
                 switch ($m['key']) {
@@ -1636,7 +1586,7 @@ class ThreadEvent extends VerySimpleModel {
                         $assignees[] = $T->getLocalName();
                     }
                     return implode('/', $assignees);
-                case 'username':
+                case 'somebody':
                     $name = $self->getUserName();
                     if ($url = $self->getAvatar())
                         $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name;
@@ -1680,7 +1630,7 @@ class ThreadEvent extends VerySimpleModel {
 
     function render($mode) {
         $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
-        $event = $this;
+        $event = $this->getTypedEvent();
         include $inc . 'templates/thread-event.tmpl.php';
     }
 
@@ -1711,6 +1661,22 @@ class ThreadEvent extends VerySimpleModel {
         ), $user);
         return $inst;
     }
+
+    function getTypedEvent() {
+        static $subclasses;
+
+        if (!isset($subclasses)) {
+            $parent = get_class($this);
+            $subclasses = array();
+            foreach (get_declared_classes() as $class) {
+                if (is_subclass_of($class, $parent))
+                    $subclasses[$class::$state] = $class;
+            }
+        }
+        if (!($class = $subclasses[$this->state]))
+            return $this;
+        return new $class($this->ht);
+    }
 }
 
 class ThreadEvents extends InstrumentedList {
@@ -1785,6 +1751,186 @@ class ThreadEvents extends InstrumentedList {
     }
 }
 
+class AssignmentEvent extends ThreadEvent {
+    static $icon = 'hand-right';
+    static $state = 'assigned';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        $data = $this->getData();
+        switch (true) {
+        case !is_array($data):
+        default:
+            $desc = __('Assignee changed by <b>{somebody}</b> to <strong>{assignees}</strong> {timestamp}');
+            break;
+        case isset($data['staff']):
+            $desc = __('<b>{somebody}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}');
+            break;
+        case isset($data['team']):
+            $desc = __('<b>{somebody}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}');
+            break;
+        case isset($data['claim']):
+            $desc = __('<b>{somebody}</b> claimed this {timestamp}');
+            break;
+        }
+        return $this->template($desc);
+    }
+}
+
+class CloseEvent extends ThreadEvent {
+    static $icon = 'thumbs-up-alt';
+    static $state = 'closed';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Closed by <b>{somebody}</b> {timestamp}'));
+    }
+}
+
+class CollaboratorEvent extends ThreadEvent {
+    static $icon = 'group';
+    static $state = 'collab';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        $data = $this->getData();
+        switch (true) {
+        case isset($data['org']):
+            $desc = __('Collaborators for {<Organization>data.org} organization added');
+            break;
+        case isset($data['del']):
+            $base = __('<b>{somebody}</b> removed <strong>%s</strong> from the collaborators {timestamp}');
+            $collabs = array();
+            $users = User::objects()->filter(array('id__in' => array_keys($data['del'])));
+            foreach ($data['del'] as $id=>$c) {
+                $U = false;
+                foreach ($users as $user) {
+                    if ($user->id == $id) {
+                        $U = $user;
+                        break;
+                    }
+                }
+                $collabs[] = Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c);
+            }
+            $desc = sprintf($base, implode(', ', $collabs));
+            break;
+        case isset($data['add']):
+            $base = __('<b>{somebody}</b> added <strong>%s</strong> as collaborators {timestamp}');
+            $collabs = array();
+            if ($data['add']) {
+                $users = User::objects()->filter(array('id__in' => array_keys($data['add'])));
+                foreach ($data['add'] as $id=>$c) {
+                    $U = false;
+                    foreach ($users as $user) {
+                        if ($user->id == $id) {
+                            $U = $user;
+                            break;
+                        }
+                    }
+                    $c = sprintf(__("%s via %s"
+                        /* e.g. "Me <me@company.me> via Email (to)" */),
+                        Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c),
+                        $c['src'] ?: '?'
+                    );
+                    $collabs[] = $c;
+                }
+            }
+            $desc = $collabs
+                ? sprintf($base, implode(', ', $collabs))
+                : 'somebody';
+            break;
+        }
+        return $this->template($desc);
+    }
+}
+
+class CreationEvent extends ThreadEvent {
+    static $icon = 'magic';
+    static $state = 'created';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Created by <b>{somebody}</b> {timestamp}'));
+    }
+}
+
+class EditEvent extends ThreadEvent {
+    static $icon = 'pencil';
+    static $state = 'edited';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        $data = $this->getData();
+        switch (true) {
+        case isset($data['owner']):
+            $desc = __('<b>{somebody}</b> changed ownership to {<User>data.owner} {timestamp}');
+            break;
+        case isset($data['status']):
+            $desc = __('<b>{somebody}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}');
+            break;
+        case isset($data['fields']):
+            $base = __('Updated by <b>{somebody}</b> {timestamp} — %s');
+            $fields = $changes = array();
+            foreach (DynamicFormField::objects()->filter(array(
+                'id__in' => array_keys($data['fields'])
+            )) as $F) {
+                $fields[$F->id] = $F;
+            }
+            foreach ($data['fields'] as $id=>$f) {
+                $field = $fields[$id];
+                if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers())
+                    continue;
+                list($old, $new) = $f;
+                $impl = $field->getImpl($field);
+                $before = $impl->to_php($old);
+                $after = $impl->to_php($new);
+                $changes[] = sprintf('<strong>%s</strong> %s',
+                    $field->getLocal('label'), $impl->whatChanged($before, $after));
+            }
+            $desc = $changes
+                ? sprintf($base, implode(', ', $changes)) : '';
+            break;
+        }
+
+        return $this->template($desc);
+    }
+}
+
+class OverdueEvent extends ThreadEvent {
+    static $icon = 'time';
+    static $state = 'overdue';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Flagged as overdue by the system {timestamp}'));
+    }
+}
+
+class ReopenEvent extends ThreadEvent {
+    static $icon = 'rotate-right';
+    static $state = 'reopened';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Reopened by <b>{somebody}</b> {timestamp}'));
+    }
+}
+
+class ResendEvent extends ThreadEvent {
+    static $icon = 'reply-all icon-flip-horizontal';
+    static $state = 'resent';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('<b>{somebody}</b> resent <strong><a href="#thread-entry-{data.entry}">a previous response</a></strong> {timestamp}'));
+    }
+}
+
+class TransferEvent extends ThreadEvent {
+    static $icon = 'share-alt';
+    static $state = 'transferred';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('<b>{somebody}</b> transferred this to <strong>{dept}</strong> {timestamp}'));
+    }
+}
+
+class ViewEvent extends ThreadEvent {
+    static $state = 'viewed';
+}
+
 class ThreadEntryBody /* extends SplString */ {
 
     static $types = array('text', 'html');
@@ -2125,8 +2271,6 @@ class NoteThreadEntry extends ThreadEntry {
 // Object specific thread utils.
 class ObjectThread extends Thread
 implements TemplateVariable {
-    private $_entries = array();
-
     static $types = array(
         ObjectModel::OBJECT_TYPE_TASK => 'TaskThread',
         ObjectModel::OBJECT_TYPE_TICKET => 'TicketThread',
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 8a771c3237eb81f0333b7af69855f358432bd54e..35e5f1e794f01d83b20163f449cd083c1ff535ed 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -208,7 +208,8 @@ class Ticket extends TicketModel
 implements RestrictedAccess, Threadable {
 
     static $meta = array(
-        'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread'),
+        'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread',
+            'user__default_email'),
     );
 
     var $lastMsgId;
@@ -229,12 +230,15 @@ implements RestrictedAccess, Threadable {
     function loadDynamicData() {
         if (!isset($this->_answers)) {
             $this->_answers = array();
-            foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) {
-                foreach ($form->getAnswers() as $answer) {
-                    $tag = mb_strtolower($answer->field->name)
-                        ?: 'field.' . $answer->field->id;
-                        $this->_answers[$tag] = $answer;
-                }
+            foreach (DynamicFormEntryAnswer::objects()
+                ->filter(array(
+                    'entry__object_id' => $this->getId(),
+                    'entry__object_type' => 'T'
+                )) as $answer
+            ) {
+                $tag = mb_strtolower($answer->field->name)
+                    ?: 'field.' . $answer->field->id;
+                    $this->_answers[$tag] = $answer;
             }
         }
         return $this->_answers;
@@ -804,17 +808,10 @@ implements RestrictedAccess, Threadable {
         if (!$user || $user->getId() == $this->getOwnerId())
             return null;
 
-        $vars = array_merge(array(
-                'threadId' => $this->getThreadId(),
-                'userId' => $user->getId()), $vars);
-        if (!($c=Collaborator::add($vars, $errors)))
-            return null;
-
-        $this->collaborators = null;
-        $this->recipients = null;
-
-        if ($event)
-            $this->logEvent('collab', array('add' => array($c->toString())));
+        if ($c = $this->getThread()->addCollaborator($user, $vars, $errors, $event)) {
+            $this->collaborators = null;
+            $this->recipients = null;
+        }
 
         return $c;
     }
@@ -2070,7 +2067,10 @@ implements RestrictedAccess, Threadable {
                     if ($c=$this->addCollaborator($user, $info, $errors, false))
                         // FIXME: This feels very unwise — should be a
                         // string indexed array for future
-                        $collabs[] = array((string)$c, $recipient['source']);
+                        $collabs[$c->user_id] = array(
+                            'name' => $c->getName()->getOriginal(),
+                            'src' => $recipient['source'],
+                        );
             }
             // TODO: Can collaborators add others?
             if ($collabs) {
diff --git a/include/client/faq-category.inc.php b/include/client/faq-category.inc.php
index eb6606af8b261f9daf9d3e8fed1d17c345423a90..3ce0b7230dcdff9e3ded235c902b8db55b2f9315 100644
--- a/include/client/faq-category.inc.php
+++ b/include/client/faq-category.inc.php
@@ -13,9 +13,11 @@ if(!defined('OSTCLIENTINC') || !$category || !$category->isPublic()) die('Access
 <?php
 $faqs = FAQ::objects()
     ->filter(array('category'=>$category))
-    ->exclude(array('ispublished'=>false))
-    ->annotate(array('has_attachments'=>SqlAggregate::COUNT('attachments', false,
-        array('attachments__inline'=>0))))
+    ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE))
+    ->annotate(array('has_attachments' => SqlAggregate::COUNT(SqlCase::N()
+        ->when(array('attachments__inline'=>0), 1)
+        ->otherwise(null)
+    )))
     ->order_by('-ispublished', 'question');
 
 if ($faqs->exists(true)) {
diff --git a/include/client/kb-categories.inc.php b/include/client/kb-categories.inc.php
index c6cc4e930aad3076b4237db87f8d932d6aac1df6..6129180fa8b9a01cf8a66c0e124bdc1c514d447b 100644
--- a/include/client/kb-categories.inc.php
+++ b/include/client/kb-categories.inc.php
@@ -2,10 +2,13 @@
 <div class="span8">
 <?php
     $categories = Category::objects()
-        ->exclude(Q::any(array('ispublic'=>false, 'faqs__ispublished'=>false)))
+        ->exclude(Q::any(array(
+            'ispublic'=>Category::VISIBILITY_PRIVATE,
+            'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
+        )))
         ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs')))
         ->filter(array('faq_count__gt'=>0));
-    if ($categories->all()) { ?>
+    if ($categories->exists(true)) { ?>
         <div><?php echo __('Click on the category to browse FAQs.'); ?></div>
         <ul id="kb">
 <?php
@@ -18,7 +21,7 @@
                 <?php echo Format::safe_html($C->getLocalDescriptionWithImages()); ?>
             </div>
 <?php       foreach ($C->faqs
-                    ->exclude(array('ispublished'=>false))
+                    ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE))
                     ->order_by('-views')->limit(5) as $F) { ?>
                 <div class="popular-faq"><i class="icon-file-alt"></i>
                 <a href="faq.php?id=<?php echo $F->getId(); ?>">
diff --git a/include/client/kb-search.inc.php b/include/client/kb-search.inc.php
index 5166a6616fbd5ee2ff677124e46aeaaae15b0a2c..a81f5e4fdea5f7064c1f5dacbeb88fab99fb296c 100644
--- a/include/client/kb-search.inc.php
+++ b/include/client/kb-search.inc.php
@@ -5,7 +5,7 @@
 <?php
     if ($faqs->exists(true)) {
         echo '<div id="faq">'.sprintf(__('%d FAQs matched your search criteria.'),
-            count($faqs->all()))
+            $faqs->count())
             .'<ol>';
         foreach ($faqs as $F) {
             echo sprintf(
diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php
index e339e04555ebcbddc5d823f3edd6b7472956f080..62cd3e3362509d15f13faee81508f9b51060059d 100644
--- a/include/staff/templates/thread-entry.tmpl.php
+++ b/include/staff/templates/thread-entry.tmpl.php
@@ -60,7 +60,7 @@ if ($user && ($url = $user->get_gravatar(48)))
             echo $entry->title; ?></span>
         </span>
     </div>
-    <div class="thread-body">
+    <div class="thread-body no-pjax">
         <div><?php echo $entry->getBody()->toHtml(); ?></div>
         <div class="clear"></div>
 <?php
diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php
index da1d522122e01fd806a756355ade4b2a43fa0ccf..9fe9fe5bcc6af1a06f884cd754260d26541d4208 100644
--- a/include/staff/tickets.inc.php
+++ b/include/staff/tickets.inc.php
@@ -274,23 +274,21 @@ TicketForm::ensureDynamicDataView();
 
 // Select pertinent columns
 // ------------------------------------------------------------
-$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__:priority__priority_color', 'cdata__:priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate');
+$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__:priority__priority_color', 'cdata__:priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate', 'isanswered');
 
 // Add in annotations
 $tickets->annotate(array(
-    'collab_count' => SqlAggregate::COUNT('thread__collaborators', true),
-    'attachment_count' => SqlAggregate::COUNT(SqlCase::N()
-       ->when(new SqlField('thread__entries__attachments__inline'), null)
-       ->otherwise(new SqlField('thread__entries__attachments')),
-        true
-    ),
-    'thread_count' => SqlAggregate::COUNT(SqlCase::N()
-        ->when(
-            new Q(array('thread__entries__flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)),
-            null)
-        ->otherwise(new SqlField('thread__entries__id')),
-       true
-    ),
+    'collab_count' => TicketThread::objects()
+        ->filter(array('ticket__ticket_id' => new SqlField('ticket_id')))
+        ->aggregate(array('count' => SqlAggregate::COUNT('collaborators__id'))),
+    'attachment_count' => TicketThread::objects()
+        ->filter(array('ticket__ticket_id' => new SqlField('ticket_id')))
+        ->filter(array('entries__attachments__inline' => 0))
+        ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id'))),
+    'thread_count' => TicketThread::objects()
+        ->filter(array('ticket__ticket_id' => new SqlField('ticket_id')))
+        ->filter(Q::not(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN)))
+        ->aggregate(array('count' => SqlAggregate::COUNT('entries__id'))),
 ));
 
 // Save the query to the session for exporting
diff --git a/js/osticket.js b/js/osticket.js
index 28fd56fc8fd5df9153eac06242e8116b8cdf5dc0..0da43e7d94a9836687caf0d45dcdcbe27fa26df8 100644
--- a/js/osticket.js
+++ b/js/osticket.js
@@ -133,6 +133,10 @@ $(document).ready(function(){
             // TODO: Add a hover-button to show just one image
         });
     });
+
+    $('div.thread-body a').each(function() {
+        $(this).attr('target', '_blank');
+    });
 });
 
 showImagesInline = function(urls, thread_id) {
diff --git a/scp/js/ticket.js b/scp/js/ticket.js
index 3be939612745b3aec9ae319af1903d885cbd918f..e7fd1e3693102def1502d18169471383cee5da08 100644
--- a/scp/js/ticket.js
+++ b/scp/js/ticket.js
@@ -448,6 +448,10 @@ var ticket_onload = function($) {
                 fx.end = last_entry.offset().top - 50;
         }
     });
+
+    $('div.thread-body a').each(function() {
+        $(this).attr('target', '_blank');
+    });
 };
 $(ticket_onload);
 $(document).on('pjax:success', function() { ticket_onload(jQuery); });