From c62a8c1fd95419d014dccc4572254aef6c191bb6 Mon Sep 17 00:00:00 2001
From: Jared Hancock <jared@osticket.com>
Date: Tue, 28 Jul 2015 23:08:47 -0500
Subject: [PATCH] lock: Implement lazy locking system

---
 include/ajax.config.php           |   2 +-
 include/ajax.tickets.php          |  99 ++++---
 include/class.ticket.php          |  10 +-
 include/staff/ticket-view.inc.php |  26 +-
 js/redactor-osticket.js           |  32 +--
 scp/ajax.php                      |   8 +-
 scp/css/scp.css                   |  58 ++++
 scp/images/oscar-avatars.png      | Bin 0 -> 10251 bytes
 scp/js/scp.js                     | 128 ++++++++-
 scp/js/ticket.js                  | 452 +++++++++++++-----------------
 10 files changed, 468 insertions(+), 347 deletions(-)
 create mode 100644 scp/images/oscar-avatars.png

diff --git a/include/ajax.config.php b/include/ajax.config.php
index 1d9471aba..0bf0a4420 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 cf86c4930..c458db823 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 725bf154f..6a6af2ce1 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 51e1fe128..f1a4560b7 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 8c3a8d806..3b8529754 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 c2ff371d5..5df6b35b9 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 dee26d771..00f782692 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
GIT binary patch
literal 10251
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

literal 0
HcmV?d00001

diff --git a/scp/js/scp.js b/scp/js/scp.js
index 19a0e1376..6a06173c9 100644
--- a/scp/js/scp.js
+++ b/scp/js/scp.js
@@ -758,6 +758,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();
@@ -853,9 +978,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 4cfb20a7c..9a54a7cee 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
-- 
GitLab