diff --git a/include/ajax.config.php b/include/ajax.config.php
index 1d9471abac816ed0628a569f3cc94b49c05c9d22..0bf0a4420411304e02d14ae639f450ddb6bdbda6 100644
--- a/include/ajax.config.php
+++ b/include/ajax.config.php
@@ -38,7 +38,7 @@ class ConfigAjaxAPI extends AjaxController {
         list($primary_sl, $primary_locale) = explode('_', $primary);
 
         $config=array(
-              'lock_time'       => ($cfg->getLockTime()*3600),
+              'lock_time'       => ($cfg->getLockTime()*60),
               'html_thread'     => (bool) $cfg->isRichTextEnabled(),
               'date_format'     => $cfg->getDateFormat(true),
               'lang'            => $lang,
diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php
index cf86c4930e0d18572abb08d9ab7363c16c39b78a..c458db823946cfaab8f59808c426975a84908361 100644
--- a/include/ajax.tickets.php
+++ b/include/ajax.tickets.php
@@ -101,20 +101,23 @@ class TicketsAjaxAPI extends AjaxController {
     }
 
     function acquireLock($tid) {
-        global $cfg,$thisstaff;
+        global $cfg, $thisstaff;
 
         if(!$tid || !is_numeric($tid) || !$thisstaff || !$cfg || !$cfg->getLockTime())
             return 0;
 
-        if(!($ticket = Ticket::lookup($tid)) || !$ticket->checkStaffPerm($thisstaff))
-            return $this->json_encode(array('id'=>0, 'retry'=>false, 'msg'=>__('Lock denied!')));
+        if (!($ticket = Ticket::lookup($tid)) || !$ticket->checkStaffPerm($thisstaff))
+            return $this->encode(array('id'=>0, 'retry'=>false, 'msg'=>__('Lock denied!')));
 
         //is the ticket already locked?
-        if($ticket->isLocked() && ($lock=$ticket->getLock()) && !$lock->isExpired()) {
+        if ($ticket->isLocked() && ($lock=$ticket->getLock()) && !$lock->isExpired()) {
             /*Note: Ticket->acquireLock does the same logic...but we need it here since we need to know who owns the lock up front*/
             //Ticket is locked by someone else.??
-            if($lock->getStaffId()!=$thisstaff->getId())
-                return $this->json_encode(array('id'=>0, 'retry'=>false, 'msg'=>__('Unable to acquire lock.')));
+            if ($lock->getStaffId() != $thisstaff->getId())
+                return $this->json_encode(array('id'=>0, 'retry'=>false,
+                    'msg' => sprintf(__('Currently locked by %s'),
+                        $lock->getStaffName())
+                    ));
 
             //Ticket already locked by staff...try renewing it.
             $lock->renew(); //New clock baby!
@@ -130,54 +133,60 @@ class TicketsAjaxAPI extends AjaxController {
         ));
     }
 
-    function renewLock($tid, $id) {
+    function renewLock($id, $ticketId) {
         global $thisstaff;
 
-        if(!$tid || !is_numeric($tid) || !$id || !is_numeric($id) || !$thisstaff)
-            return $this->json_encode(array('id'=>0, 'retry'=>true));
-
-        if (!($ticket = Ticket::lookup($tid)))
-            return $this->json_encode(array('id'=>0, 'retry'=>true));
-
-        $lock = $ticket->getLock();
-        if(!$lock || !$lock->getStaffId() || $lock->isExpired()) //Said lock doesn't exist or is is expired
-            return self::acquireLock($tid); //acquire the lock
-
-        if($lock->getStaffId()!=$thisstaff->getId()) //user doesn't own the lock anymore??? sorry...try to next time.
-            return $this->json_encode(array('id'=>0, 'retry'=>false)); //Give up...
-
-        //Renew the lock.
-        $lock->renew(); //Failure here is not an issue since the lock is not expired yet.. client need to check time!
-
-        return $this->json_encode(array('id'=>$lock->getId(), 'time'=>$lock->getTime()));
+        if (!$id || !is_numeric($id) || !$thisstaff)
+            Http::response(403, $this->encode(array('id'=>0, 'retry'=>false)));
+        if (!($lock = Lock::lookup($id)))
+            Http::response(404, $this->encode(array('id'=>0, 'retry'=>'acquire')));
+        if (!($ticket = Ticket::lookup($ticketId)) || $ticket->lock_id != $lock->lock_id)
+            // Ticket / Lock mismatch
+            Http::response(400, $this->encode(array('id'=>0, 'retry'=>false)));
+
+        if (!$lock->getStaffId() || $lock->isExpired())
+            // Said lock doesn't exist or is is expired — fetch a new lock
+            return self::acquireLock($ticket->getId());
+
+        if ($lock->getStaffId() != $thisstaff->getId())
+            // user doesn't own the lock anymore??? sorry...try to next time.
+            Http::response(403, $this->encode(array('id'=>0, 'retry'=>false,
+                'msg' => sprintf(__('Currently locked by %s'),
+                    $lock->getStaffName())
+            ))); //Give up...
+
+        // Ensure staff still has access
+        if (!$ticket->checkStaffPerm($thisstaff))
+            Http::response(403, $this->encode(array('id'=>0, 'retry'=>false,
+                'msg' => sprintf(__('You no longer have access to #%s.'),
+                $ticket->getNumber())
+            )));
+
+        // Renew the lock.
+        // Failure here is not an issue since the lock is not expired yet.. client need to check time!
+        $lock->renew();
+
+        return $this->encode(array('id'=>$lock->getId(), 'time'=>$lock->getTime(),
+            'code' => $lock->getCode()));
     }
 
-    function releaseLock($tid, $id=0) {
+    function releaseLock($id) {
         global $thisstaff;
 
-        if (!($ticket = Ticket::lookup($tid))) {
+        if (!$id || !is_numeric($id) || !$thisstaff)
+            Http::response(403, $this->encode(array('id'=>0, 'retry'=>true)));
+        if (!($lock = Lock::lookup($id)))
+            Http::response(404, $this->encode(array('id'=>0, 'retry'=>true)));
+
+        // You have to own the lock
+        if ($lock->getStaffId() != $thisstaff->getId()) {
             return 0;
         }
-
-        if ($id) {
-            // Fetch the lock from the ticket
-            if (!($lock = $ticket->getLock())) {
-                return 1;
-            }
-            // Identify the lock by the ID number
-            if ($lock->getId() != $id) {
-                return 0;
-            }
-            // You have to own the lock
-            if ($lock->getStaffId() != $thisstaff->getId()) {
-                return 0;
-            }
-            // Can't be expired
-            if ($lock->isExpired()) {
-                return 1;
-            }
-            return $lock->release() ? 1 : 0;
+        // Can't be expired
+        if ($lock->isExpired()) {
+            return 1;
         }
+        return $lock->release() ? 1 : 0;
 
         return Lock::removeStaffLocks($thisstaff->getId(), $ticket) ? 1 : 0;
     }
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 725bf154fa82abc549c5127a40a3921b27257da3..6a6af2ce1adb3834489285c4644fa55ab9e97252 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -542,10 +542,16 @@ implements RestrictedAccess, Threadable {
     }
 
     function getLock() {
-        return $this->lock;
+        $lock = $this->lock;
+        if ($lock && !$lock->isExpired())
+            return $lock;
     }
 
-    function acquireLock($staffId, $lockTime) {
+    function acquireLock($staffId, $lockTime=null) {
+        global $cfg;
+
+        if (!isset($lockTime))
+            $lockTime = $cfg->getLockTime();
 
         if (!$staffId or !$lockTime) //Lockig disabled?
             return null;
diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php
index 51e1fe1289ca4621fb5f54a5fd20735e31d65c56..f1a4560b720d10bcb2617ea92c6fa8e49eb413ae 100644
--- a/include/staff/ticket-view.inc.php
+++ b/include/staff/ticket-view.inc.php
@@ -8,10 +8,6 @@ if(!@$thisstaff->isStaff() || !$ticket->checkStaffPerm($thisstaff)) die('Access
 //Re-use the post info on error...savekeyboards.org (Why keyboard? -> some people care about objects than users!!)
 $info=($_POST && $errors)?Format::input($_POST):array();
 
-//Auto-lock the ticket if locking is enabled.. If already locked by the user then it simply renews.
-if($cfg->getLockTime() && !$ticket->acquireLock($thisstaff->getId(),$cfg->getLockTime()))
-    $warn.=__('Unable to obtain a lock on the ticket');
-
 //Get the goodies.
 $dept  = $ticket->getDept();  //Dept
 $role  = $thisstaff->getRole($dept);
@@ -448,7 +444,8 @@ $tcount = $ticket->getThreadEntries($types)->count();
     <div id="msg_warning"><?php echo $warn; ?></div>
 <?php } ?>
 
-<div class="sticky bar stop actions" id="response_options">
+<div class="sticky bar stop actions" id="response_options"
+>
     <ul class="tabs">
         <?php
         if ($role->hasPerm(TicketModel::PERM_REPLY)) { ?>
@@ -470,7 +467,10 @@ $tcount = $ticket->getThreadEntries($types)->count();
     </ul>
     <?php
     if ($role->hasPerm(TicketModel::PERM_REPLY)) { ?>
-    <form id="reply" class="tab_content spellcheck" action="tickets.php?id=<?php
+    <form id="reply" class="tab_content spellcheck exclusive"
+        data-lock-object-id="ticket/<?php echo $ticket->getId(); ?>"
+        data-lock-id="<?php echo ($mylock) ? $mylock->getId() : ''; ?>"
+        action="tickets.php?id=<?php
         echo $ticket->getId(); ?>" name="reply" method="post" enctype="multipart/form-data">
         <?php csrf_token(); ?>
         <input type="hidden" name="id" value="<?php echo $ticket->getId(); ?>">
@@ -1024,19 +1024,5 @@ $(function() {
             }
         });
     });
-<?php
-    // Set the lock if one exists
-    if ($mylock) { ?>
-!function() {
-  var setLock = setInterval(function() {
-    if (typeof(window.autoLock) === 'undefined')
-      return;
-    clearInterval(setLock);
-    autoLock.setLock({
-      id:<?php echo $mylock->getId(); ?>,
-      time: <?php echo $cfg->getLockTime() * 60; ?>}, 'acquire');
-  }, 50);
-}();
-<?php } ?>
 });
 </script>
diff --git a/js/redactor-osticket.js b/js/redactor-osticket.js
index 8c3a8d806a98c199f2a81152fd847d21d5ae6740..3b8529754661338e6cbb07430e451161313ab99b 100644
--- a/js/redactor-osticket.js
+++ b/js/redactor-osticket.js
@@ -68,15 +68,6 @@ RedactorPlugins.draft = function() {
             this.$box.trigger('draft:recovered');
     },
     afterUpdateDraft: function(name, data) {
-        // Slight workaround. Signal the 'keyup' event normally signaled
-        // from typing in the <textarea>
-        if ($.autoLock
-            && this.$box.closest('form').find('input[name=lockCode]').val()
-            && this.code.get()
-        ) {
-            $.autoLock.handleEvent();
-        }
-
         // If the draft was created, a draft_id will be sent back — update
         // the URL to send updates in the future
         if (!this.opts.draftId && data.draft_id) {
@@ -145,6 +136,19 @@ RedactorPlugins.draft = function() {
   };
 };
 
+RedactorPlugins.autolock = function() {
+  return {
+    init: function() {
+      var code = this.$box.closest('form').find('[name=lockCode]'),
+          self = this;
+      if (code.length)
+        this.opts.keydownCallback = function(e) {
+          self.$box.closest('[data-lock-object-id]').exclusive('acquire');
+        };
+    }
+  };
+}
+
 RedactorPlugins.signature = function() {
   return {
     init: function() {
@@ -216,16 +220,6 @@ RedactorPlugins.signature = function() {
   }
 };
 
-RedactorPlugins.autolock = function() {
-  return {
-    init: function() {
-      var code = this.$box.closest('form').find('[name=lockCode]');
-      if ($.autoLock && code.length)
-        this.opts.keydownCallback = $.autoLock.handleEvent;
-    }
-  };
-}
-
 /* Redactor richtext init */
 $(function() {
     var captureImageSizes = function(html) {
diff --git a/scp/ajax.php b/scp/ajax.php
index c2ff371d5aa6a898a05036bc23d9c20f3221f565..5df6b35b966831f028f5e6c2e59c6653e91a55b1 100644
--- a/scp/ajax.php
+++ b/scp/ajax.php
@@ -137,15 +137,17 @@ $dispatcher = patterns('',
         url_get('^/(?P<id>\d+)/forms/manage$', 'manageForms'),
         url_post('^/(?P<id>\d+)/forms/manage$', 'updateForms')
     )),
+    url('^/lock/', patterns('ajax.tickets.php:TicketsAjaxAPI',
+        url_post('^ticket/(?P<tid>\d+)$', 'acquireLock'),
+        url_post('^(?P<id>\d+)/ticket/(?P<tid>\d+)/renew', 'renewLock'),
+        url_post('^(?P<id>\d+)/release', 'releaseLock')
+    )),
     url('^/tickets/', patterns('ajax.tickets.php:TicketsAjaxAPI',
         url_get('^(?P<tid>\d+)/change-user$', 'changeUserForm'),
         url_post('^(?P<tid>\d+)/change-user$', 'changeUser'),
         url_get('^(?P<tid>\d+)/user$', 'viewUser'),
         url_post('^(?P<tid>\d+)/user$', 'updateUser'),
         url_get('^(?P<tid>\d+)/preview', 'previewTicket'),
-        url_post('^(?P<tid>\d+)/lock$', 'acquireLock'),
-        url_post('^(?P<tid>\d+)/lock/(?P<id>\d+)/renew', 'renewLock'),
-        url_post('^(?P<tid>\d+)/lock/(?P<id>\d+)/release', 'releaseLock'),
         url_get('^(?P<tid>\d+)/forms/manage$', 'manageForms'),
         url_post('^(?P<tid>\d+)/forms/manage$', 'updateForms'),
         url_get('^(?P<tid>\d+)/canned-resp/(?P<cid>\w+).(?P<format>json|txt)', 'cannedResponse'),
diff --git a/scp/css/scp.css b/scp/css/scp.css
index f01bbcba4da8aec6bed59039d536b8b68edbd28b..37b30ef848068e9dbc9b087bd3204de4ea960b27 100644
--- a/scp/css/scp.css
+++ b/scp/css/scp.css
@@ -1831,6 +1831,11 @@ button[type=submit]:hover, input[type=submit]:hover, input[type=submit]:active {
   background-color: rgba(0, 0, 0, 0.5);
 }
 
+.button:disabled, .action-button:disabled,
+button[type=submit]:disabled, input[type=submit]:disabled {
+  opacity: 0.6;
+}
+
 .save.pending:hover {
   box-shadow: 0 0 0 2px rgba(242, 165, 0, 1) inset;
   background-color: rgba(255, 174, 0, 0.79);
@@ -2502,6 +2507,59 @@ td.indented {
   }
 }
 
+.message.bar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  padding: 9px 15px;
+  z-index: 10;
+  background-color: white;
+  box-shadow: 0 3px 10px rgba(0,0,0,0.2);
+  opacity: 0.95;
+}
+.message.bar.bottom {
+  bottom: 0;
+  top: auto;
+  box-shadow: 0 -3px 10px rgba(0,0,0,0.2);
+}
+.message.bar .avatar {
+  display: inline-block;
+  width: 36px;
+  height: 36px;
+  margin-right: 10px;
+  background-image: url(../images/oscar-avatars.png);
+  background-repeat: no-repeat;
+  background-size: 180px 72px;
+}
+.avatar.oscar-boy {
+  background-position: -72px 0;
+}
+.avatar.oscar-borg {
+  background-position: 0 -36px;
+}
+.message.bar .title {
+  font-weight: bold;
+  font-size: 1.1em;
+}
+.message.bar .body {
+  margin-left: 42px;
+}
+.message.bar.warning {
+  border-bottom: 3px solid orange;
+}
+.message.bar.bottom.warning {
+  border-bottom: none;
+  border-top: 3px solid orange;
+}
+.message.bar.danger {
+  border-bottom: 3px solid red;
+}
+.message.bar.bottom.danger {
+  border-bottom: none;
+  border-top: 3px solid red;
+}
+
 #thread-items::before {
   border-left: 2px dotted #ddd;
   border-bottom-color: rgba(0,0,0,0.1);
diff --git a/scp/images/oscar-avatars.png b/scp/images/oscar-avatars.png
new file mode 100644
index 0000000000000000000000000000000000000000..624c0420ab336fb6391b7288b869526bf9bc55df
Binary files /dev/null and b/scp/images/oscar-avatars.png differ
diff --git a/scp/js/scp.js b/scp/js/scp.js
index 3eca5cee3195241d5377f05db857a6d66bb5d6ab..9d72b96378aa90bebc0560349d0f42f9133fe45b 100644
--- a/scp/js/scp.js
+++ b/scp/js/scp.js
@@ -762,6 +762,131 @@ $.orgLookup = function (url, cb) {
 
 $.uid = 1;
 
++function($) {
+  var MessageBar = function() {
+    this.defaults = {
+      avatar: 'oscar-boy',
+      bar: '<div class="message bar"></div>',
+      button: '<button type="button" class="inline button"></button>',
+      buttonClass: '',
+      buttonText: __('OK'),
+      classes: '',
+      dismissible: true,
+      onok: null,
+      position: 'top'
+    };
+
+    this.show = function(title, message, options) {
+      this.hide();
+      options = $.extend({}, this.defaults, options);
+      var bar = this.bar = $(options.bar).addClass(options.classes)
+        .append($('<div class="title"></div>').text(title))
+        .append($('<div class="body"></div>').text(message))
+        .addClass(options.position);
+      if (options.avatar)
+        bar.prepend($('<div class="avatar pull-left" title="Oscar"></div>')
+            .addClass(options.avatar));
+
+      if (options.onok || options.dismissible) {
+        bar
+          .prepend($('<div><div class="valign-helper"></div></div>')
+            // FIXME: This is not compatible with .rtl
+            .css({position: 'absolute', top: 0, bottom: 0, right: 0, margin: '0 15px'})
+            .append($(options.button)
+              .text(options.buttonText)
+              .click(this.dismiss.bind(this))
+              .addClass(options.buttonClass)
+            )
+          );
+      }
+      this.visible = true;
+      this.options = options;
+
+      $('body').append(bar);
+      this.height = bar.height();
+
+      // Slight slide in
+      if (options.position == 'bottom') {
+        bar.css('bottom', -this.height/2).animate({'bottom': 0});
+      }
+      // Otherwise assume TOP positioning
+      else {
+        var hovering = false,
+            y = $(window).scrollTop(),
+            targetY = (y < this.height) ? -this.height - 10 + y : 0;
+        bar.css('top', -this.height/2).animate({'top': targetY});
+
+        // Plop out on mouse hover
+        bar.hover(function() {
+          if (!hovering && this.visible && bar.css('top') != '0') {
+            bar.stop().animate({'margin-top': -parseInt(bar.css('top'), 10)}, 400, 'easeOutBounce');
+            hovering = true;
+          }
+        }.bind(this), function() {
+          if (this.visible && hovering) {
+            bar.stop().animate({'margin-top': 0});
+            hovering = false;
+          }
+        }.bind(this));
+      }
+
+      return bar;
+    };
+
+    this.scroll = function(event) {
+      // Shade on scroll to top
+      if (!this.visible || this.options.position != 'top')
+        return;
+      var y = $(window).scrollTop();
+      if (y < this.height) {
+        this.bar.css({top: -this.height -10 + y});
+        this.shading = true;
+      }
+      else if (this.bar.css('top') != '0') {
+        if (this.shading) {
+          this.bar.stop().animate({top: 0});
+          this.shading = false;
+        }
+      }
+    };
+
+    this.dismiss = function(event) {
+      if (this.options.onok) {
+        this.bar.find('button').replaceWith(
+          $('<i class="icon-spinner icon-spin icon-large"></i>')
+        );
+        if (this.options.onok(event) === false)
+          return;
+      }
+      this.hide();
+    };
+
+    this.hide = function() {
+      if (!this.bar || !this.visible)
+        return;
+      var bar = this.bar.removeAttr('style');
+      var dir = this.options.position == 'bottom' ? 'down' : 'up';
+      // NOTE: destroy() is not called here because a new bar might be
+      //       created before the animation finishes
+      bar.hide("slide", { direction: dir }, 400, function() { bar.remove(); });
+      this.visible = false;
+    };
+
+    this.destroy = function() {
+      if (!this.bar || !this.visible)
+        return;
+      this.bar.remove();
+      this.visible = false;
+    };
+
+    // Destroy on away navigation
+    $(document).on('pjax:start.messageBar', this.destroy.bind(this));
+    $(window).on('scroll.messageBar', this.scroll.bind(this));
+  };
+
+  $.messageBar = new MessageBar();
+}(window.jQuery);
+
 // Tabs
 $(document).on('click.tab', 'ul.tabs > li > a', function(e) {
     e.preventDefault();
@@ -857,9 +982,6 @@ getConfig = (function() {
 })();
 
 $(document).on('pjax:click', function(options) {
-    // Release ticket lock (maybe)
-    if ($.autoLock !== undefined)
-        $.autoLock.releaseLock();
     // Stop all animations
     $(document).stop(false, true);
 
diff --git a/scp/js/ticket.js b/scp/js/ticket.js
index 4cfb20a7cc92c1055c9e7a7d41e35f780c18d882..9a54a7cee4017ed1548834e8b990c659794358e1 100644
--- a/scp/js/ticket.js
+++ b/scp/js/ticket.js
@@ -13,282 +13,227 @@
 
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
-var autoLock = {
-
-    // Defaults
-    lockId: 0,
-    lockCode: '',
-    timerId: 0,
-    lasteventTime: 0,
-    lastcheckTime: 0,
-    lastattemptTime: 0,
-    acquireTime: 0,
-    renewTime: 0,
-    renewFreq: 10000, //renewal frequency in seconds...based on returned lock time.
-    time: 0,
-    lockAttempts: 0, //Consecutive lock attempt errors
-    maxattempts: 2, //Maximum failed lock attempts before giving up.
-    warn: true,
-    retry: true,
-
-    addEvent: function(elm, evType, fn, useCapture) {
-        if(elm.addEventListener) {
-            elm.addEventListener(evType, fn, useCapture);
-            return true;
-        }else if(elm.attachEvent) {
-            return elm.attachEvent('on' + evType, fn);
-        }else{
-            elm['on' + evType] = fn;
-        }
++function( $ ) {
+  var Lock = function(element, options) {
+    this.$element = $(element);
+    this.options = $.extend({}, $.fn.exclusive.defaults, options);
+    if (!this.$element.data('lockObjectId'))
+      return;
+    this.objectId = this.$element.data('lockObjectId');
+    this.lockId = options.lockId || this.$element.data('lockId') || undefined;
+    this.fails = 0;
+    this.setup();
+  }
+
+  Lock.prototype = {
+    constructor: Lock,
+
+    setup: function() {
+      // When something inside changes or is clicked which requires a lock,
+      // attempt to fetch one (lazily)
+      $(':input', this.$element).on('keyup, change', this.acquire.bind(this));
+      $(':submit', this.$element).click(this.ensureLocked.bind(this));
+
+      // If lock already held, assume full time of lock remains, but warn
+      // user about pending expiration
+      if (this.lockId) {
+        getConfig().then(function(c) {
+          this.lockTimeout(c.lock_time - 20);
+        }.bind(this));
+      }
     },
 
-    removeEvent: function(elm, evType, fn, useCapture) {
-        if(elm.removeEventListener) {
-            elm.removeEventListener(evType, fn, useCapture);
-            return true;
-        }else if(elm.detachEvent) {
-            return elm.detachEvent('on' + evType, fn);
-        }else {
-            elm['on' + evType] = null;
-        }
-    },
-
-    //Incoming event...
-    handleEvent: function(e) {
-
-        if(autoLock.lockId && !autoLock.lasteventTime) { //I hate nav away warnings..but
-            $(document).on('pjax:beforeSend.changed', function(e) {
-                return confirm(__("Any changes or info you've entered will be discarded!"));
-            });
-            $(window).bind('beforeunload', function(e) {
-                return __("Any changes or info you've entered will be discarded!");
-             });
-        }
-        // Handle events only every few seconds
-        var now = new Date().getTime(),
-            renewFreq = autoLock.renewFreq;
-
-        if (autoLock.lasteventTime && now - autoLock.lasteventTime < renewFreq)
-            return;
-
-        autoLock.lasteventTime = now;
-
-        if (!autoLock.lockId) {
-            // Retry every 5 seconds??
-            if (autoLock.retry)
-                autoLock.acquireLock(e,autoLock.warn);
-        } else {
-            autoLock.renewLock(e);
-        }
-
+    acquire: function() {
+      if (this.lockId)
+        return this.renew();
+      if (this.nextRenew && new Date().getTime() < this.nextRenew)
+        return this.locked;
+      if (this.ajaxActive)
+        return this.locked;
+
+      this.ajaxActive = $.ajax({
+        type: "POST",
+        url: 'ajax.php/lock/'+this.objectId,
+        dataType: 'json',
+        cache: false,
+        success: $.proxy(this.update, this),
+        error: $.proxy(this.retry, this, this.acquire),
+        complete: $.proxy(function() { this.ajaxActive = false; }, this)
+      });
+      return this.locked = $.Deferred();
     },
 
-    //Watch activity on individual form.
-    watchForm: function(fObj,fn) {
-        if(!fObj || !fObj.length)
-            return;
-
-        //Watch onSubmit event on the form.
-        autoLock.addEvent(fObj,'submit',autoLock.onSubmit,true);
-        //Watch activity on text + textareas + select fields.
-        for (var i=0; i<fObj.length; i++) {
-            switch(fObj[i].type) {
-                case 'textarea':
-                case 'text':
-                    autoLock.addEvent(fObj[i],'keyup',autoLock.handleEvent,true);
-                    break;
-                case 'select-one':
-                case 'select-multiple':
-                    if(fObj.name!='reply') //Bug on double ajax call since select make it's own ajax call. TODO: fix it
-                        autoLock.addEvent(fObj[i],'change',autoLock.handleEvent,true);
-                    break;
-                default:
-            }
-        }
+    renew: function() {
+      if (!this.lockId)
+        return;
+      if (this.nextRenew && new Date().getTime() < this.nextRenew)
+        return this.locked;
+      if (this.ajaxActive)
+        return this.locked;
+
+      this.ajaxActive = $.ajax({
+        type: "POST",
+        url: 'ajax.php/lock/{0}/{1}/renew'.replace('{0}',this.lockId).replace('{1}',this.objectId),
+        dataType: 'json',
+        cache: false,
+        success: $.proxy(this.update, this),
+        error: $.proxy(this.retry, this, this.renew),
+        complete: $.proxy(function() { this.ajaxActive = false; }, this)
+      });
+      return this.locked = $.Deferred();
     },
 
-    //Watch all the forms on the document.
-    watchDocument: function() {
-
-        //Watch forms of interest only.
-        for (var i=0; i<document.forms.length; i++) {
-            if(!document.forms[i].id.value || parseInt(document.forms[i].id.value)!=autoLock.tid)
-                continue;
-            autoLock.watchForm(document.forms[i],autoLock.checkLock);
-        }
+    wakeup: function(e) {
+      // Click handler from message bar. Bar will be manually hidden when
+      // lock is re-acquired
+      this.renew();
+      return false;
     },
 
-    Init: function(config) {
-
-        //make sure we are on ticket view page & locking is enabled!
-        var fObj=$('form#note');
-        if(!fObj
-                || !$(':input[name=id]',fObj).length
-                || !$(':input[name=locktime]',fObj).length
-                || $(':input[name=locktime]',fObj).val()==0) {
-            return;
-        }
-
-        void(autoLock.tid=parseInt($(':input[name=id]',fObj).val()));
-        void(autoLock.lockTime=parseInt($(':input[name=locktime]',fObj).val()));
-
-        autoLock.watchDocument();
-        autoLock.addEvent(window,'unload',autoLock.releaseLock,true); //Release lock regardless of any activity.
+    retry: function(func, xhr, textStatus, response) {
+      var json = xhr ? xhr.responseJSON : response;
+
+      if ((typeof json == 'object' && !json.retry) || !this.options.retry)
+        return this.fail(json.msg);
+      if (typeof json == 'object' && json.retry == 'acquire') {
+        // Lock no longer exists server-side
+        this.destroy();
+        setTimeout(this.acquire.bind(this), 2);
+      }
+      if (++this.fails > this.options.maxRetries)
+        // Attempt to acquire a new lock ?
+        return this.fail(json ? json.msg : null);
+      this.retryTimer = setTimeout($.proxy(func, this), this.options.retryInterval * 1000);
     },
 
-
-    onSubmit: function(e) {
-        if(e.type=='submit') { //Submit. double check!
-            //remove nav away warning if any.
-            $(window).unbind('beforeunload');
-            //Only warn if we had a failed lock attempt.
-            if(autoLock.warn && !autoLock.lockId && autoLock.lasteventTime) {
-                var answer=confirm(__('Unable to acquire a lock on the ticket. Someone else could be working on the same ticket.  Please confirm if you wish to continue anyways.'));
-                if(!answer) {
-                    e.returnValue=false;
-                    e.cancelBubble=true;
-                    if(e.preventDefault) {
-                        e.preventDefault();
-                    }
-                    return false;
-                }
-            }
-        }
-        return true;
+    release: function() {
+      if (!this.lockId)
+        return false;
+      if (this.ajaxActive)
+        this.ajaxActive.abort();
+
+      $.ajax({
+        type: 'POST',
+        url: 'ajax.php/lock/{0}/release'.replace('{0}', this.lockId),
+        data: 'delete',
+        async: false,
+        cache: false,
+        always: $.proxy(this.destroy, this)
+      });
     },
 
-    acquireLock: function(e,warn) {
-
-        if(!autoLock.tid) { return false; }
-
-        var warn = warn || false;
-
-        if(autoLock.lockId) {
-            autoLock.renewLock(e);
-        } else {
-            $.ajax({
-                type: "POST",
-                url: 'ajax.php/tickets/'+autoLock.tid+'/lock',
-                dataType: 'json',
-                cache: false,
-                success: function(lock){
-                    autoLock.setLock(lock,'acquire',warn);
-                }
-            });
-        }
-
-        return autoLock.lockId;
+    destroy: function() {
+      clearTimeout(this.warning);
+      clearTimeout(this.retryTimer);
+      $(window).off('.exclusive');
+      delete this.lockId;
+      $(this.options.lockInput, this.$element).val('');
     },
 
-    //Renewal only happens on form activity..
-    renewLock: function(e) {
-
-        if (!autoLock.lockId)
-            return false;
-
-        var now = new Date().getTime(),
-            renewFreq = autoLock.renewFreq;
-
-        if (autoLock.lastcheckTime && now - autoLock.lastcheckTime < renewFreq)
-            return;
-
-        autoLock.lastcheckTime = now;
-        $.ajax({
-            type: 'POST',
-            url: 'ajax.php/tickets/'+autoLock.tid+'/lock/'+autoLock.lockId+'/renew',
-            dataType: 'json',
-            cache: false,
-            success: function(lock){
-                autoLock.setLock(lock,'renew',autoLock.warn);
-            }
-        });
+    update: function(lock) {
+      if (typeof lock != 'object' || lock.retry === true) {
+        // Non-json response, or retry requested server-side
+        return this.retry(this.renew, this.activeAjax, false, lock);
+      }
+      if (!lock.id) {
+        // Response did not include a lock id number
+        return this.fail(lock.msg);
+      }
+      if (!this.lockId) {
+        // Set up release on away navigation
+        $(window).off('.exclusive');
+        $(window).on('pjax:click.exclusive', $.proxy(this.release, this));
+      }
+
+      this.lockId = lock.id;
+      this.fails = 0;
+      $.messageBar.hide();
+      this.errorBar = false;
+
+      // If there is an input with the name 'lockCode', then set the value
+      // to the lock.code retrieved (if any)
+      if (lock.code)
+        $(this.options.lockInput, this.$element).val(lock.code);
+
+      // Deadband renew to every 30 seconds
+      this.nextRenew = new Date().getTime() + 30000;
+
+      // Warn 10 seconds before expiration
+      this.lockTimeout(lock.time - 10);
+
+      if (this.locked)
+        this.locked.resolve(lock);
     },
 
-    releaseLock: function(e) {
-        if (!autoLock.tid || !autoLock.lockId) { return false; }
-
-        $.ajax({
-            type: 'POST',
-            url: 'ajax.php/tickets/'+autoLock.tid+'/lock/'+autoLock.lockId+'/release',
-            data: 'delete',
-            async: false,
-            cache: false,
-            success: function() {
-                autoLock.destroy();
-            }
-        });
+    lockTimeout: function(time) {
+      if (this.warning)
+        clearTimeout(this.warning);
+      this.warning = setTimeout(this.warn.bind(this), time * 1000);
     },
 
-    setLock: function(lock, action, warn) {
-        var warn = warn || false;
-
-        if (!lock)
-            return false;
-
-        autoLock.lockId=lock.id; //override the lockid.
-
-        if (lock.code) {
-            autoLock.lockCode = lock.code;
-            // Update the lock code for the upcoming POST
-            var el = $('input[name=lockCode]').val(lock.code);
-        }
-
-        switch(action){
-            case 'renew':
-                if(!lock.id && lock.retry) {
-                    autoLock.lockAttempts=1; //reset retries.
-                    autoLock.acquireLock(e,false); //We lost the lock?? ..try to re acquire now.
-                }
-                break;
-            case 'acquire':
-                if(!lock.id) {
-                    autoLock.lockAttempts++;
-                    if(warn && (!lock.retry || autoLock.lockAttempts>=autoLock.maxattempts)) {
-                        autoLock.retry=false;
-                        alert(__('Unable to lock the ticket. Someone else could be working on the same ticket.'));
-                    }
-                }
-                break;
-        }
-
-        if (lock.id && lock.time) {
-            autoLock.resetTimer((lock.time - 10) * 1000);
-        }
+    ensureLocked: function(e) {
+      // Make sure a lock code has been fetched first
+      if (!$(this.options.lockInput, this.$element).val()) {
+        var $target = $(e.target),
+            text = $target.text() || $target.val();
+        $target.prop('disabled', true).text(__('Acquiring Lock')).val(__('Acquiring Lock'));
+        this.acquire().always(function(lock) {
+          $target.text(text).val(text).prop('disabled', false);
+          if (typeof lock == 'object' && lock.code)
+            $target.trigger(e.type, e);
+        }.bind(this));
+        return false;
+      }
     },
 
-    discardWarning: function(e) {
-        e.returnValue=__("Any changes or info you've entered will be discarded!");
+    warn: function() {
+      $.messageBar.show(
+        __('Your lock is expiring soon.'),
+        __('The lock you hold on this ticket will expire soon. Would you like to renew the lock?'),
+        {onok: this.wakeup.bind(this), buttonText: __("Renew")}
+      ).addClass('warning');
     },
 
-    //TODO: Monitor events and elapsed time and warn user when the lock is about to expire.
-    monitorEvents: function() {
-        $.sysAlert(
-            __('Your lock is expiring soon'),
-            __('The lock you hold on this ticket will expire soon. Would you like to renew the lock?'),
-            function() {
-                autoLock.renewLock();
-            }
-        );
-    },
+    fail: function(msg) {
+      // Don't retry for 5 seconds
+      this.nextRenew = new Date().getTime() + 5000;
+      // Resolve anything awaiting
+      if (this.locked)
+        this.locked.rejectWith(msg);
+      // No longer locked
+      this.destroy();
+      // Flash the error bar if it's already on the screen
+      if (this.errorBar && $.messageBar.visible)
+          return this.errorBar.effect('highlight');
+      // Add the error bar to the screen
+      this.errorBar = $.messageBar.show(
+        msg || __('Unable to lock the ticket.'),
+        __('Someone else could be working on the same ticket.'),
+        {avatar: 'oscar-borg', buttonClass: 'red', dismissible: true}
+      ).addClass('danger');
+    }
+  };
+
+  $.fn.exclusive = function ( option ) {
+    return this.each(function () {
+      var $this = $(this),
+        data = $this.data('exclusive'),
+        options = typeof option == 'object' && option;
+      if (!data) $this.data('exclusive', (data = new Lock(this, options)));
+      if (typeof option == 'string') data[option]();
+    });
+  };
 
-    clearTimer: function() {
-        clearTimeout(autoLock.timerId);
-    },
+  $.fn.exclusive.defaults = {
+    lockInput: 'input[name=lockCode]',
+    maxRetries: 2,
+    retry: true,
+    retryInterval: 2
+  };
 
-    resetTimer: function(time) {
-        autoLock.clearTimer();
-        autoLock.timerId = setTimeout(
-          function () { autoLock.monitorEvents(); },
-          time || 30000
-        );
-    },
+  $.fn.exclusive.Constructor = Lock;
 
-    destroy: function() {
-        autoLock.clearTimer();
-        autoLock.lockId = 0;
-    }
-};
-$.autoLock = autoLock;
+}(window.jQuery);
 
 /*
    UI & form events
@@ -341,14 +286,13 @@ $.refreshTicketView = function(interval) {
       clearInterval(refresh);
       $.pjax({url: document.location.href, container:'#pjax-container'});
     }, interval);
-}
+};
 
 var ticket_onload = function($) {
     if (0 === $('#ticketThread').length)
         return;
 
-    //Start watching the form for activity.
-    autoLock.Init();
+    $(function(){$('.exclusive[data-lock-object-id]').exclusive();});
 
     /*** Ticket Actions **/
     //print options TODO: move to backend