(function(){var n=this,t=n._,r={},e=Array.prototype,u=Object.prototype,i=Function.prototype,a=e.push,o=e.slice,c=e.concat,l=u.toString,f=u.hasOwnProperty,s=e.forEach,p=e.map,h=e.reduce,v=e.reduceRight,d=e.filter,g=e.every,m=e.some,y=e.indexOf,b=e.lastIndexOf,x=Array.isArray,_=Object.keys,j=i.bind,w=function(n){return n instanceof w?n:this instanceof w?(this._wrapped=n,void 0):new w(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=w),exports._=w):n._=w,w.VERSION="1.4.4";var A=w.each=w.forEach=function(n,t,e){if(null!=n)if(s&&n.forEach===s)n.forEach(t,e);else if(n.length===+n.length){for(var u=0,i=n.length;i>u;u++)if(t.call(e,n[u],u,n)===r)return}else for(var a in n)if(w.has(n,a)&&t.call(e,n[a],a,n)===r)return};w.map=w.collect=function(n,t,r){var e=[];return null==n?e:p&&n.map===p?n.map(t,r):(A(n,function(n,u,i){e[e.length]=t.call(r,n,u,i)}),e)};var O="Reduce of empty array with no initial value";w.reduce=w.foldl=w.inject=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),h&&n.reduce===h)return e&&(t=w.bind(t,e)),u?n.reduce(t,r):n.reduce(t);if(A(n,function(n,i,a){u?r=t.call(e,r,n,i,a):(r=n,u=!0)}),!u)throw new TypeError(O);return r},w.reduceRight=w.foldr=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),v&&n.reduceRight===v)return e&&(t=w.bind(t,e)),u?n.reduceRight(t,r):n.reduceRight(t);var i=n.length;if(i!==+i){var a=w.keys(n);i=a.length}if(A(n,function(o,c,l){c=a?a[--i]:--i,u?r=t.call(e,r,n[c],c,l):(r=n[c],u=!0)}),!u)throw new TypeError(O);return r},w.find=w.detect=function(n,t,r){var e;return E(n,function(n,u,i){return t.call(r,n,u,i)?(e=n,!0):void 0}),e},w.filter=w.select=function(n,t,r){var e=[];return null==n?e:d&&n.filter===d?n.filter(t,r):(A(n,function(n,u,i){t.call(r,n,u,i)&&(e[e.length]=n)}),e)},w.reject=function(n,t,r){return w.filter(n,function(n,e,u){return!t.call(r,n,e,u)},r)},w.every=w.all=function(n,t,e){t||(t=w.identity);var u=!0;return null==n?u:g&&n.every===g?n.every(t,e):(A(n,function(n,i,a){return(u=u&&t.call(e,n,i,a))?void 0:r}),!!u)};var E=w.some=w.any=function(n,t,e){t||(t=w.identity);var u=!1;return null==n?u:m&&n.some===m?n.some(t,e):(A(n,function(n,i,a){return u||(u=t.call(e,n,i,a))?r:void 0}),!!u)};w.contains=w.include=function(n,t){return null==n?!1:y&&n.indexOf===y?n.indexOf(t)!=-1:E(n,function(n){return n===t})},w.invoke=function(n,t){var r=o.call(arguments,2),e=w.isFunction(t);return w.map(n,function(n){return(e?t:n[t]).apply(n,r)})},w.pluck=function(n,t){return w.map(n,function(n){return n[t]})},w.where=function(n,t,r){return w.isEmpty(t)?r?null:[]:w[r?"find":"filter"](n,function(n){for(var r in t)if(t[r]!==n[r])return!1;return!0})},w.findWhere=function(n,t){return w.where(n,t,!0)},w.max=function(n,t,r){if(!t&&w.isArray(n)&&n[0]===+n[0]&&65535>n.length)return Math.max.apply(Math,n);if(!t&&w.isEmpty(n))return-1/0;var e={computed:-1/0,value:-1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;a>=e.computed&&(e={value:n,computed:a})}),e.value},w.min=function(n,t,r){if(!t&&w.isArray(n)&&n[0]===+n[0]&&65535>n.length)return Math.min.apply(Math,n);if(!t&&w.isEmpty(n))return 1/0;var e={computed:1/0,value:1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;e.computed>a&&(e={value:n,computed:a})}),e.value},w.shuffle=function(n){var t,r=0,e=[];return A(n,function(n){t=w.random(r++),e[r-1]=e[t],e[t]=n}),e};var k=function(n){return w.isFunction(n)?n:function(t){return t[n]}};w.sortBy=function(n,t,r){var e=k(t);return w.pluck(w.map(n,function(n,t,u){return{value:n,index:t,criteria:e.call(r,n,t,u)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||r===void 0)return 1;if(e>r||e===void 0)return-1}return n.indexi;){var o=i+a>>>1;u>r.call(e,n[o])?i=o+1:a=o}return i},w.toArray=function(n){return n?w.isArray(n)?o.call(n):n.length===+n.length?w.map(n,w.identity):w.values(n):[]},w.size=function(n){return null==n?0:n.length===+n.length?n.length:w.keys(n).length},w.first=w.head=w.take=function(n,t,r){return null==n?void 0:null==t||r?n[0]:o.call(n,0,t)},w.initial=function(n,t,r){return o.call(n,0,n.length-(null==t||r?1:t))},w.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:o.call(n,Math.max(n.length-t,0))},w.rest=w.tail=w.drop=function(n,t,r){return o.call(n,null==t||r?1:t)},w.compact=function(n){return w.filter(n,w.identity)};var R=function(n,t,r){return A(n,function(n){w.isArray(n)?t?a.apply(r,n):R(n,t,r):r.push(n)}),r};w.flatten=function(n,t){return R(n,t,[])},w.without=function(n){return w.difference(n,o.call(arguments,1))},w.uniq=w.unique=function(n,t,r,e){w.isFunction(t)&&(e=r,r=t,t=!1);var u=r?w.map(n,r,e):n,i=[],a=[];return A(u,function(r,e){(t?e&&a[a.length-1]===r:w.contains(a,r))||(a.push(r),i.push(n[e]))}),i},w.union=function(){return w.uniq(c.apply(e,arguments))},w.intersection=function(n){var t=o.call(arguments,1);return w.filter(w.uniq(n),function(n){return w.every(t,function(t){return w.indexOf(t,n)>=0})})},w.difference=function(n){var t=c.apply(e,o.call(arguments,1));return w.filter(n,function(n){return!w.contains(t,n)})},w.zip=function(){for(var n=o.call(arguments),t=w.max(w.pluck(n,"length")),r=Array(t),e=0;t>e;e++)r[e]=w.pluck(n,""+e);return r},w.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},w.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=w.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}if(y&&n.indexOf===y)return n.indexOf(t,r);for(;u>e;e++)if(n[e]===t)return e;return-1},w.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=null!=r;if(b&&n.lastIndexOf===b)return e?n.lastIndexOf(t,r):n.lastIndexOf(t);for(var u=e?r:n.length;u--;)if(n[u]===t)return u;return-1},w.range=function(n,t,r){1>=arguments.length&&(t=n||0,n=0),r=arguments[2]||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=0,i=Array(e);e>u;)i[u++]=n,n+=r;return i},w.bind=function(n,t){if(n.bind===j&&j)return j.apply(n,o.call(arguments,1));var r=o.call(arguments,2);return function(){return n.apply(t,r.concat(o.call(arguments)))}},w.partial=function(n){var t=o.call(arguments,1);return function(){return n.apply(this,t.concat(o.call(arguments)))}},w.bindAll=function(n){var t=o.call(arguments,1);return 0===t.length&&(t=w.functions(n)),A(t,function(t){n[t]=w.bind(n[t],n)}),n},w.memoize=function(n,t){var r={};return t||(t=w.identity),function(){var e=t.apply(this,arguments);return w.has(r,e)?r[e]:r[e]=n.apply(this,arguments)}},w.delay=function(n,t){var r=o.call(arguments,2);return setTimeout(function(){return n.apply(null,r)},t)},w.defer=function(n){return w.delay.apply(w,[n,1].concat(o.call(arguments,1)))},w.throttle=function(n,t){var r,e,u,i,a=0,o=function(){a=new Date,u=null,i=n.apply(r,e)};return function(){var c=new Date,l=t-(c-a);return r=this,e=arguments,0>=l?(clearTimeout(u),u=null,a=c,i=n.apply(r,e)):u||(u=setTimeout(o,l)),i}},w.debounce=function(n,t,r){var e,u;return function(){var i=this,a=arguments,o=function(){e=null,r||(u=n.apply(i,a))},c=r&&!e;return clearTimeout(e),e=setTimeout(o,t),c&&(u=n.apply(i,a)),u}},w.once=function(n){var t,r=!1;return function(){return r?t:(r=!0,t=n.apply(this,arguments),n=null,t)}},w.wrap=function(n,t){return function(){var r=[n];return a.apply(r,arguments),t.apply(this,r)}},w.compose=function(){var n=arguments;return function(){for(var t=arguments,r=n.length-1;r>=0;r--)t=[n[r].apply(this,t)];return t[0]}},w.after=function(n,t){return 0>=n?t():function(){return 1>--n?t.apply(this,arguments):void 0}},w.keys=_||function(n){if(n!==Object(n))throw new TypeError("Invalid object");var t=[];for(var r in n)w.has(n,r)&&(t[t.length]=r);return t},w.values=function(n){var t=[];for(var r in n)w.has(n,r)&&t.push(n[r]);return t},w.pairs=function(n){var t=[];for(var r in n)w.has(n,r)&&t.push([r,n[r]]);return t},w.invert=function(n){var t={};for(var r in n)w.has(n,r)&&(t[n[r]]=r);return t},w.functions=w.methods=function(n){var t=[];for(var r in n)w.isFunction(n[r])&&t.push(r);return t.sort()},w.extend=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]=t[r]}),n},w.pick=function(n){var t={},r=c.apply(e,o.call(arguments,1));return A(r,function(r){r in n&&(t[r]=n[r])}),t},w.omit=function(n){var t={},r=c.apply(e,o.call(arguments,1));for(var u in n)w.contains(r,u)||(t[u]=n[u]);return t},w.defaults=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)null==n[r]&&(n[r]=t[r])}),n},w.clone=function(n){return w.isObject(n)?w.isArray(n)?n.slice():w.extend({},n):n},w.tap=function(n,t){return t(n),n};var I=function(n,t,r,e){if(n===t)return 0!==n||1/n==1/t;if(null==n||null==t)return n===t;n instanceof w&&(n=n._wrapped),t instanceof w&&(t=t._wrapped);var u=l.call(n);if(u!=l.call(t))return!1;switch(u){case"[object String]":return n==t+"";case"[object Number]":return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case"[object Date]":case"[object Boolean]":return+n==+t;case"[object RegExp]":return n.source==t.source&&n.global==t.global&&n.multiline==t.multiline&&n.ignoreCase==t.ignoreCase}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]==n)return e[i]==t;r.push(n),e.push(t);var a=0,o=!0;if("[object Array]"==u){if(a=n.length,o=a==t.length)for(;a--&&(o=I(n[a],t[a],r,e)););}else{var c=n.constructor,f=t.constructor;if(c!==f&&!(w.isFunction(c)&&c instanceof c&&w.isFunction(f)&&f instanceof f))return!1;for(var s in n)if(w.has(n,s)&&(a++,!(o=w.has(t,s)&&I(n[s],t[s],r,e))))break;if(o){for(s in t)if(w.has(t,s)&&!a--)break;o=!a}}return r.pop(),e.pop(),o};w.isEqual=function(n,t){return I(n,t,[],[])},w.isEmpty=function(n){if(null==n)return!0;if(w.isArray(n)||w.isString(n))return 0===n.length;for(var t in n)if(w.has(n,t))return!1;return!0},w.isElement=function(n){return!(!n||1!==n.nodeType)},w.isArray=x||function(n){return"[object Array]"==l.call(n)},w.isObject=function(n){return n===Object(n)},A(["Arguments","Function","String","Number","Date","RegExp"],function(n){w["is"+n]=function(t){return l.call(t)=="[object "+n+"]"}}),w.isArguments(arguments)||(w.isArguments=function(n){return!(!n||!w.has(n,"callee"))}),"function"!=typeof/./&&(w.isFunction=function(n){return"function"==typeof n}),w.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},w.isNaN=function(n){return w.isNumber(n)&&n!=+n},w.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"==l.call(n)},w.isNull=function(n){return null===n},w.isUndefined=function(n){return n===void 0},w.has=function(n,t){return f.call(n,t)},w.noConflict=function(){return n._=t,this},w.identity=function(n){return n},w.times=function(n,t,r){for(var e=Array(n),u=0;n>u;u++)e[u]=t.call(r,u);return e},w.random=function(n,t){return null==t&&(t=n,n=0),n+Math.floor(Math.random()*(t-n+1))};var M={escape:{"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"}};M.unescape=w.invert(M.escape);var S={escape:RegExp("["+w.keys(M.escape).join("")+"]","g"),unescape:RegExp("("+w.keys(M.unescape).join("|")+")","g")};w.each(["escape","unescape"],function(n){w[n]=function(t){return null==t?"":(""+t).replace(S[n],function(t){return M[n][t]})}}),w.result=function(n,t){if(null==n)return null;var r=n[t];return w.isFunction(r)?r.call(n):r},w.mixin=function(n){A(w.functions(n),function(t){var r=w[t]=n[t];w.prototype[t]=function(){var n=[this._wrapped];return a.apply(n,arguments),D.call(this,r.apply(w,n))}})};var N=0;w.uniqueId=function(n){var t=++N+"";return n?n+t:t},w.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var T=/(.)^/,q={"'":"'","\\":"\\","\r":"r","\n":"n"," ":"t","\u2028":"u2028","\u2029":"u2029"},B=/\\|'|\r|\n|\t|\u2028|\u2029/g;w.template=function(n,t,r){var e;r=w.defaults({},r,w.templateSettings);var u=RegExp([(r.escape||T).source,(r.interpolate||T).source,(r.evaluate||T).source].join("|")+"|$","g"),i=0,a="__p+='";n.replace(u,function(t,r,e,u,o){return a+=n.slice(i,o).replace(B,function(n){return"\\"+q[n]}),r&&(a+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'"),e&&(a+="'+\n((__t=("+e+"))==null?'':__t)+\n'"),u&&(a+="';\n"+u+"\n__p+='"),i=o+t.length,t}),a+="';\n",r.variable||(a="with(obj||{}){\n"+a+"}\n"),a="var __t,__p='',__j=Array.prototype.join,"+"print=function(){__p+=__j.call(arguments,'');};\n"+a+"return __p;\n";try{e=Function(r.variable||"obj","_",a)}catch(o){throw o.source=a,o}if(t)return e(t,w);var c=function(n){return e.call(this,n,w)};return c.source="function("+(r.variable||"obj")+"){\n"+a+"}",c},w.chain=function(n){return w(n).chain()};var D=function(n){return this._chain?w(n).chain():n};w.mixin(w),A(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=e[n];w.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!=n&&"splice"!=n||0!==r.length||delete r[0],D.call(this,r)}}),A(["concat","join","slice"],function(n){var t=e[n];w.prototype[n]=function(){return D.call(this,t.apply(this._wrapped,arguments))}}),w.extend(w.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}})}).call(this); this["FirechatDefaultTemplates"] = this["FirechatDefaultTemplates"] || {}; this["FirechatDefaultTemplates"]["templates/layout-full.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
\n
\n
\n\n\nChat Rooms\n\n\n
    \n
    \n\n\nVisitors\n\n\n
    \n
    \n
    \n\n\n
    \n
    \n
      \n
      \n
      \n
      \n
      \n
      \n
        \n
        \n
        \n
        ';}return __p}; this["FirechatDefaultTemplates"]["templates/layout-popout.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
        \n
        \n
          \n
          \n
          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/message-context-menu.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
          \n\n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/message.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
          \n
          \n'; if (!disableActions) { ;__p += '\n\n'; } ;__p += '
          \n
          \n' +((__t = ( message )) == null ? '' : __t) +'\n
          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt-alert.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          ' +__e( message ) +'
          \n

          \n\n

          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt-create-room.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          Give your chat room a name:
          \n\n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt-invitation.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          ' +__e( fromUserName ) +'
          \n

          invited you to join

          \n
          ' +__e( toRoomName ) +'
          \n

          \n\n\n

          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt-invite-private.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          Invite ' +__e( userName ) +' to ' +__e( roomName ) +'?
          \n

          \n\n\n

          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt-invite-reply.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
          \n
          ' +__e( toUserName ) +'
          \n

          \n'; if (status === 'accepted') { ;__p += ' accepted your invite. '; } else { ;__p += ' declined your invite. '; } ;__p += '\n

          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt-user-mute.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          ' +__e( userName ) +'
          \n

          \n\n\n

          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/prompt.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          \n' +__e( title ) +'\nX\n
          \n
          \n' +((__t = ( content )) == null ? '' : __t) +'\n
          \n
          \n
          ';}return __p}; this["FirechatDefaultTemplates"]["templates/room-list-item.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
        • \n\n' +__e( name ) +'\n\n
        • ';}return __p}; this["FirechatDefaultTemplates"]["templates/room-user-list-item.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
        • \n\n' +__e( name ) +''; if (!disableActions) { ;__p += '\n \n \n'; } ;__p += '\n\n
        • ';}return __p}; this["FirechatDefaultTemplates"]["templates/room-user-search-list-item.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
        • \n\n'; if (disableActions) { ;__p += '\n' +__e( name ) +'\n'; } else { ;__p += '\n' +__e( name ) +'\n+\n'; } ;__p += '\n\n
        • ';}return __p}; this["FirechatDefaultTemplates"]["templates/tab-content.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
          \n
          \n\n\nIn Room\n\n\n
          \n
            \n
            \n
            \n\n+\nInvite\n\n
            \n
            \n
            \n\n\n
            \n
            \n
              \n
              \n
              \n
              \n
              \n\n\n
              \n
              ';}return __p}; this["FirechatDefaultTemplates"]["templates/tab-menu-item.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape;with (obj) {__p += '
            • \n' +__e( name ) +'\n
            • ';}return __p}; this["FirechatDefaultTemplates"]["templates/user-search-list-item.html"] = function(obj) {obj || (obj = {});var __t, __p = '', __e = _.escape, __j = Array.prototype.join;function print() { __p += __j.call(arguments, '') }with (obj) {__p += '
            • \n\n'; if (disableActions) { ;__p += '\n' +__e( name ) +'\n'; } else { ;__p += '\n' +__e( name ) +'\n \n'; } ;__p += '\n\n
            • ';}return __p}; (function($) { // Shim for Function.bind(...) - (Required by IE < 9, FF < 4, SF < 6) if (!Function.prototype.bind) { Function.prototype.bind = function(oThis) { if (typeof this !== "function") { throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); } var aArgs = Array.prototype.slice.call(arguments, 1), fToBind = this, fNOP = function() {}, fBound = function() { return fToBind.apply(this instanceof fNOP && oThis ? this : oThis, aArgs.concat(Array.prototype.slice.call(arguments))); }; fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); return fBound; }; } // Shim for Object.keys(...) - (Required by IE < 9, FF < 4) Object.keys = Object.keys || function(oObj) { var result = []; for (var name in oObj) { if (oObj.hasOwnProperty(name)) { result.push(name); } } return result; }; })(); // Firechat is a simple, easily-extensible data layer for multi-user, // multi-room chat, built entirely on [Firebase](https://firebase.com). // // The Firechat object is the primary conduit for all underlying data events. // It exposes a number of methods for binding event listeners, creating, // entering, or leaving chat rooms, initiating chats, sending messages, // and moderator actions such as warning, kicking, or suspending users. // // Firechat.js 1.0.0 // https://firebase.com // (c) 2014 Firebase // License: MIT // Setup // -------------- (function(Firebase) { // Establish a reference to the `window` object, and save the previous value // of the `Firechat` variable. var root = this, previousFirechat = root.Firechat; function Firechat(firebaseRef, options) { // Instantiate a new connection to Firebase. this._firebase = firebaseRef; // User-specific instance variables. this._user = null; this._userId = null; this._userName = null; this._isModerator = false; // A unique id generated for each session. this._sessionId = null; // A mapping of event IDs to an array of callbacks. this._events = {}; // A mapping of room IDs to a boolean indicating presence. this._rooms = {}; // A mapping of operations to re-queue on disconnect. this._presenceBits = {}; // Commonly-used Firebase references. this._userRef = null; this._messageRef = this._firebase.child('room-messages'); this._roomRef = this._firebase.child('room-metadata'); this._privateRoomRef = this._firebase.child('room-private-metadata'); this._moderatorsRef = this._firebase.child('moderators'); this._suspensionsRef = this._firebase.child('suspensions'); this._usersOnlineRef = this._firebase.child('user-names-online'); // Setup and establish default options. this._options = options || {}; // The number of historical messages to load per room. this._options.numMaxMessages = this._options.numMaxMessages || 50; } // Run Firechat in *noConflict* mode, returning the `Firechat` variable to // its previous owner, and returning a reference to the Firechat object. Firechat.noConflict = function noConflict() { root.Firechat = previousFirechat; return Firechat; }; // Export the Firechat object as a global. root.Firechat = Firechat; // Firechat Internal Methods // -------------- Firechat.prototype = { // Load the initial metadata for the user's account and set initial state. _loadUserMetadata: function(onComplete) { var self = this; // Update the user record with a default name on user's first visit. this._userRef.transaction(function(current) { if (!current || !current.id || !current.name) { return { id: self._userId, name: self._userName }; } }, function(error, committed, snapshot) { self._user = snapshot.val(); self._moderatorsRef.child(self._userId).once('value', function(snapshot) { self._isModerator = !!snapshot.val(); root.setTimeout(onComplete, 0); }); }); }, // Initialize Firebase listeners and callbacks for the supported bindings. _setupDataEvents: function() { // Monitor connection state so we can requeue disconnect operations if need be. this._firebase.root().child('.info/connected').on('value', function(snapshot) { if (snapshot.val() === true) { // We're connected (or reconnected)! Set up our presence state. for (var i = 0; i < this._presenceBits; i++) { var op = this._presenceBits[i], ref = this._firebase.root().child(op.ref); ref.onDisconnect().set(op.offlineValue); ref.set(op.onlineValue); } } }, this); // Generate a unique session id for the visit. var sessionRef = this._userRef.child('sessions').push(); this._sessionId = sessionRef.name(); this._queuePresenceOperation(sessionRef, true, null); // Register our username in the public user listing. var usernameRef = this._usersOnlineRef.child(this._userName.toLowerCase()); var usernameSessionRef = usernameRef.child(this._sessionId); this._queuePresenceOperation(usernameSessionRef, { id: this._userId, name: this._userName }, null); // Listen for state changes for the given user. this._userRef.on('value', this._onUpdateUser, this); // Listen for chat invitations from other users. this._userRef.child('invites').on('child_added', this._onFirechatInvite, this); // Listen for messages from moderators and adminstrators. this._userRef.child('notifications').on('child_added', this._onNotification, this); }, // Append the new callback to our list of event handlers. _addEventCallback: function(eventId, callback) { this._events[eventId] = this._events[eventId] || []; this._events[eventId].push(callback); }, // Retrieve the list of event handlers for a given event id. _getEventCallbacks: function(eventId) { if (this._events.hasOwnProperty(eventId)) { return this._events[eventId]; } return []; }, // Invoke each of the event handlers for a given event id with specified data. _invokeEventCallbacks: function(eventId) { var args = [], callbacks = this._getEventCallbacks(eventId); Array.prototype.push.apply(args, arguments); args = args.slice(1); for (var i = 0; i < callbacks.length; i += 1) { callbacks[i].apply(null, args); } }, // Keep track of on-disconnect events so they can be requeued if we disconnect the reconnect. _queuePresenceOperation: function(ref, onlineValue, offlineValue) { ref.onDisconnect().set(offlineValue); ref.set(onlineValue); this._presenceBits[ref.toString()] = { ref: ref, onlineValue: onlineValue, offlineValue: offlineValue }; }, // Remove an on-disconnect event from firing upon future disconnect and reconnect. _removePresenceOperation: function(path, value) { var ref = new Firebase(path); ref.onDisconnect().cancel(); ref.set(value); delete this._presenceBits[path]; }, // Event to monitor current user state. _onUpdateUser: function(snapshot) { this._user = snapshot.val(); this._invokeEventCallbacks('user-update', this._user); }, // Event to monitor current auth + user state. _onAuthRequired: function() { this._invokeEventCallbacks('auth-required'); }, // Events to monitor room entry / exit and messages additional / removal. _onEnterRoom: function(room) { this._invokeEventCallbacks('room-enter', room); }, _onNewMessage: function(roomId, snapshot) { var message = snapshot.val(); message.id = snapshot.name(); this._invokeEventCallbacks('message-add', roomId, message); }, _onRemoveMessage: function(roomId, snapshot) { var messageId = snapshot.name(); this._invokeEventCallbacks('message-remove', roomId, messageId); }, _onLeaveRoom: function(roomId) { this._invokeEventCallbacks('room-exit', roomId); }, // Event to listen for notifications from administrators and moderators. _onNotification: function(snapshot) { var notification = snapshot.val(); if (!notification.read) { if (notification.notificationType !== 'suspension' || notification.data.suspendedUntil < new Date().getTime()) { snapshot.ref().child('read').set(true); } this._invokeEventCallbacks('notification', notification); } }, // Events to monitor chat invitations and invitation replies. _onFirechatInvite: function(snapshot) { var self = this, invite = snapshot.val(); // Skip invites we've already responded to. if (invite.status) { return; } invite.id = invite.id || snapshot.name(); self.getRoom(invite.roomId, function(room) { invite.toRoomName = room.name; self._invokeEventCallbacks('room-invite', invite); }); }, _onFirechatInviteResponse: function(snapshot) { var self = this, invite = snapshot.val(); invite.id = invite.id || snapshot.name(); this._invokeEventCallbacks('room-invite-response', invite); } }; // Firechat External Methods // -------------- // Initialize the library and setup data listeners. Firechat.prototype.setUser = function(userId, userName, callback) { var self = this; self._firebase.root().child('.info/authenticated').on('value', function(snapshot) { var authenticated = snapshot.val(); if (authenticated) { self._firebase.root().child('.info/authenticated').off(); self._userId = userId.toString(); self._userName = userName.toString(); self._userRef = self._firebase.child('users').child(self._userId); self._loadUserMetadata(function() { root.setTimeout(function() { callback(self._user); self._setupDataEvents(); }, 0); }); } else { self.warn('Firechat requires an authenticated Firebase reference. Pass an authenticated reference before loading.'); } }); }; // Resumes the previous session by automatically entering rooms. Firechat.prototype.resumeSession = function() { this._userRef.child('rooms').once('value', function(snapshot) { var rooms = snapshot.val(); for (var roomId in rooms) { this.enterRoom(rooms[roomId].id); } }, /* onError */ function(){}, /* context */ this); }; // Callback registration. Supports each of the following events: Firechat.prototype.on = function(eventType, cb) { this._addEventCallback(eventType, cb); }; // Create and automatically enter a new chat room. Firechat.prototype.createRoom = function(roomName, roomType, callback) { var self = this, newRoomRef = this._roomRef.push(); var newRoom = { id: newRoomRef.name(), name: roomName, type: roomType || 'public', createdByUserId: this._userId, createdAt: Firebase.ServerValue.TIMESTAMP }; if (roomType === 'private') { newRoom.authorizedUsers = {}; newRoom.authorizedUsers[this._userId] = true; } newRoomRef.set(newRoom, function(error) { if (!error) { self.enterRoom(newRoomRef.name()); } if (callback) { callback(newRoomRef.name()); } }); }; // Enter a chat room. Firechat.prototype.enterRoom = function(roomId) { var self = this; self.getRoom(roomId, function(room) { var roomName = room.name; if (!roomId || !roomName) return; // Skip if we're already in this room. if (self._rooms[roomId]) { return; } self._rooms[roomId] = true; if (self._user) { // Save entering this room to resume the session again later. self._userRef.child('rooms').child(roomId).set({ id: roomId, name: roomName, active: true }); // Set presence bit for the room and queue it for removal on disconnect. var presenceRef = self._firebase.child('room-users').child(roomId).child(self._userId).child(self._sessionId); self._queuePresenceOperation(presenceRef, { id: self._userId, name: self._userName }, null); } // Invoke our callbacks before we start listening for new messages. self._onEnterRoom({ id: roomId, name: roomName }); // Setup message listeners self._roomRef.child(roomId).once('value', function(snapshot) { self._messageRef.child(roomId).limit(self._options.numMaxMessages).on('child_added', function(snapshot) { self._onNewMessage(roomId, snapshot); }, /* onCancel */ function() { // Turns out we don't have permission to access these messages. self.leaveRoom(roomId); }, /* context */ self); self._messageRef.child(roomId).limit(self._options.numMaxMessages).on('child_removed', function(snapshot) { self._onRemoveMessage(roomId, snapshot); }, /* onCancel */ function(){}, /* context */ self); }, /* onFailure */ function(){}, self); }); }; // Leave a chat room. Firechat.prototype.leaveRoom = function(roomId) { var self = this, userRoomRef = self._firebase.child('room-users').child(roomId); // Remove listener for new messages to this room. self._messageRef.child(roomId).off(); if (self._user) { var presenceRef = userRoomRef.child(self._userId).child(self._sessionId); // Remove presence bit for the room and cancel on-disconnect removal. self._removePresenceOperation(presenceRef.toString(), null); // Remove session bit for the room. self._userRef.child('rooms').child(roomId).remove(); } delete self._rooms[roomId]; // Invoke event callbacks for the room-exit event. self._onLeaveRoom(roomId); }; Firechat.prototype.sendMessage = function(roomId, messageContent, messageType, cb) { var self = this, message = { userId: self._userId, name: self._userName, timestamp: Firebase.ServerValue.TIMESTAMP, message: messageContent, type: messageType || 'default' }, newMessageRef; if (!self._user) { self._onAuthRequired(); if (cb) { cb(new Error('Not authenticated or user not set!')); } return; } newMessageRef = self._messageRef.child(roomId).push(); newMessageRef.setWithPriority(message, Firebase.ServerValue.TIMESTAMP, cb); }; Firechat.prototype.deleteMessage = function(roomId, messageId, cb) { var self = this; self._messageRef.child(roomId).child(messageId).remove(cb); }; // Mute or unmute a given user by id. This list will be stored internally and // all messages from the muted clients will be filtered client-side after // receipt of each new message. Firechat.prototype.toggleUserMute = function(userId, cb) { var self = this; if (!self._user) { self._onAuthRequired(); if (cb) { cb(new Error('Not authenticated or user not set!')); } return; } self._userRef.child('muted').child(userId).transaction(function(isMuted) { return (isMuted) ? null : true; }, cb); }; // Send a moderator notification to a specific user. Firechat.prototype.sendSuperuserNotification = function(userId, notificationType, data, cb) { var self = this, userNotificationsRef = self._firebase.child('users').child(userId).child('notifications'); userNotificationsRef.push({ fromUserId: self._userId, timestamp: Firebase.ServerValue.TIMESTAMP, notificationType: notificationType, data: data || {} }, cb); }; // Warn a user for violating the terms of service or being abusive. Firechat.prototype.warnUser = function(userId) { var self = this; self.sendSuperuserNotification(userId, 'warning'); }; // Suspend a user by putting the user into read-only mode for a period. Firechat.prototype.suspendUser = function(userId, timeLengthSeconds, cb) { var self = this, suspendedUntil = new Date().getTime() + 1000*timeLengthSeconds; self._suspensionsRef.child(userId).set(suspendedUntil, function(error) { if (error && cb) { return cb(error); } else { self.sendSuperuserNotification(userId, 'suspension', { suspendedUntil: suspendedUntil }); return cb(null); } }); }; // Invite a user to a specific chat room. Firechat.prototype.inviteUser = function(userId, roomId) { var self = this, sendInvite = function() { var inviteRef = self._firebase.child('users').child(userId).child('invites').push(); inviteRef.set({ id: inviteRef.name(), fromUserId: self._userId, fromUserName: self._userName, roomId: roomId }); // Handle listen unauth / failure in case we're kicked. inviteRef.on('value', self._onFirechatInviteResponse, function(){}, self); }; if (!self._user) { self._onAuthRequired(); return; } self.getRoom(roomId, function(room) { if (room.type === 'private') { var authorizedUserRef = self._roomRef.child(roomId).child('authorizedUsers'); authorizedUserRef.child(userId).set(true, function(error) { if (!error) { sendInvite(); } }); } else { sendInvite(); } }); }; Firechat.prototype.acceptInvite = function(inviteId, cb) { var self = this; self._userRef.child('invites').child(inviteId).once('value', function(snapshot) { var invite = snapshot.val(); if (invite === null && cb) { return cb(new Error('acceptInvite(' + inviteId + '): invalid invite id')); } else { self.enterRoom(invite.roomId); self._userRef.child('invites').child(inviteId).update({ 'status': 'accepted', 'toUserName': self._userName }, cb); } }, self); }; Firechat.prototype.declineInvite = function(inviteId, cb) { var self = this, updates = { 'status': 'declined', 'toUserName': self._userName }; self._userRef.child('invites').child(inviteId).update(updates, cb); }; Firechat.prototype.getRoomList = function(cb) { var self = this; self._roomRef.once('value', function(snapshot) { cb(snapshot.val()); }); }; Firechat.prototype.getUsersByRoom = function() { var self = this, roomId = arguments[0], query = self._firebase.child('room-users').child(roomId), cb = arguments[arguments.length - 1], limit = null; if (arguments.length > 2) { limit = arguments[1]; } query = (limit) ? query.limit(limit) : query; query.once('value', function(snapshot) { var usernames = snapshot.val() || {}, usernamesUnique = {}; for (var username in usernames) { for (var session in usernames[username]) { // Skip all other sessions for this user as we only need one. usernamesUnique[username] = usernames[username][session]; break; } } root.setTimeout(function() { cb(usernamesUnique); }, 0); }); }; Firechat.prototype.getUsersByPrefix = function(prefix, startAt, endAt, limit, cb) { var self = this, query = this._usersOnlineRef, prefixLower = prefix.toLowerCase(); if (startAt) { query = query.startAt(null, startAt); } else if (endAt) { query = query.endAt(null, endAt); } else { query = (prefixLower) ? query.startAt(null, prefixLower) : query.startAt(); } query = (limit) ? query.limit(limit) : query; query.once('value', function(snapshot) { var usernames = snapshot.val() || {}, usernamesFiltered = {}; for (var userNameKey in usernames) { var sessions = usernames[userNameKey], userName, userId, usernameClean; // Grab the user data from the first registered active session. for (var sessionId in sessions) { userName = sessions[sessionId].name; userId = sessions[sessionId].id; // Skip all other sessions for this user as we only need one. break; } // Filter out any usernames that don't match our prefix and break. if ((prefix.length > 0) && (userName.toLowerCase().indexOf(prefixLower) !== 0)) continue; usernamesFiltered[userName] = { name: userName, id: userId }; } root.setTimeout(function() { cb(usernamesFiltered); }, 0); }); }; // Miscellaneous helper methods. Firechat.prototype.getRoom = function(roomId, callback) { this._roomRef.child(roomId).once('value', function(snapshot) { callback(snapshot.val()); }); }; Firechat.prototype.userIsModerator = function() { return this._isModerator; }; Firechat.prototype.warn = function(msg) { if (console) { msg = 'Firechat Warning: ' + msg; if (typeof console.warn === 'function') { console.warn(msg); } else if (typeof console.log === 'function') { console.log(msg); } } }; })(Firebase); (function($) { if (!$ || (parseInt($().jquery.replace(/\./g, ""), 10) < 170)) { throw new Error("jQuery 1.7 or later required!"); } var root = this, previousFirechatUI = root.FirechatUI; root.FirechatUI = FirechatUI; if (!self.FirechatDefaultTemplates) { throw new Error("Unable to find chat templates!"); } function FirechatUI(firebaseRef, el, options) { var self = this; if (!firebaseRef) { throw new Error('FirechatUI: Missing required argument `firebaseRef`'); } if (!el) { throw new Error('FirechatUI: Missing required argument `el`'); } options = options || {}; this._options = options; this._el = el; this._user = null; this._chat = new Firechat(firebaseRef, options); // A list of rooms to enter once we've made room for them (once we've hit the max room limit). this._roomQueue = []; // Define some constants regarding maximum lengths, client-enforced. this.maxLengthUsername = 15; this.maxLengthUsernameDisplay = 15; this.maxLengthRoomName = 24; this.maxLengthMessage = 120; this.maxUserSearchResults = 100; // Define some useful regexes. this.urlPattern = /\b(?:https?|ftp):\/\/[a-z0-9-+&@#\/%?=~_|!:,.;]*[a-z0-9-+&@#\/%=~_|]/gim; this.pseudoUrlPattern = /(^|[^\/])(www\.[\S]+(\b|$))/gim; this._renderLayout(); // Grab shortcuts to commonly used jQuery elements. this.$wrapper = $('#firechat'); this.$roomList = $('#firechat-room-list'); this.$tabList = $('#firechat-tab-list'); this.$tabContent = $('#firechat-tab-content'); this.$messages = {}; // Rate limit messages from a given user with some defaults. this.$rateLimit = { limitCount: 10, // max number of events limitInterval: 10000, // max interval for above count in milliseconds limitWaitTime: 30000, // wait time if a user hits the wait limit history: {} }; // Setup UI bindings for chat controls. this._bindUIEvents(); // Setup bindings to internal methods this._bindDataEvents(); } // Run FirechatUI in *noConflict* mode, returning the `FirechatUI` variable to // its previous owner, and returning a reference to the FirechatUI object. FirechatUI.noConflict = function noConflict() { root.FirechatUI = previousFirechatUI; return FirechatUI; }; FirechatUI.prototype = { _bindUIEvents: function() { // Chat-specific custom interactions and functionality. this._bindForHeightChange(); this._bindForTabControls(); this._bindForRoomList(); this._bindForUserRoomList(); this._bindForUserSearch(); this._bindForUserMuting(); this._bindForChatInvites(); this._bindForRoomListing(); // Generic, non-chat-specific interactive elements. this._setupTabs(); this._setupDropdowns(); this._bindTextInputFieldLimits(); }, _bindDataEvents: function() { this._chat.on('user-update', this._onUpdateUser.bind(this)); // Bind events for new messages, enter / leaving rooms, and user metadata. this._chat.on('room-enter', this._onEnterRoom.bind(this)); this._chat.on('room-exit', this._onLeaveRoom.bind(this)); this._chat.on('message-add', this._onNewMessage.bind(this)); this._chat.on('message-remove', this._onRemoveMessage.bind(this)); // Bind events related to chat invitations. this._chat.on('room-invite', this._onChatInvite.bind(this)); this._chat.on('room-invite-response', this._onChatInviteResponse.bind(this)); // Binds events related to admin or moderator notifications. this._chat.on('notification', this._onNotification.bind(this)); }, _renderLayout: function() { var template = FirechatDefaultTemplates["templates/layout-full.html"]; $(this._el).html(template({ maxLengthUsername: this.maxLengthUsername })); }, _onUpdateUser: function(user) { // Update our current user state and render latest user name. this._user = user; // Update our interface to reflect which users are muted or not. var mutedUsers = this._user.muted || {}; $('[data-event="firechat-user-mute-toggle"]').each(function(i, el) { var userId = $(this).closest('[data-user-id]').data('user-id'); $(this).toggleClass('red', !!mutedUsers[userId]); }); // Ensure that all messages from muted users are removed. for (var userId in mutedUsers) { $('.message[data-user-id="' + userId + '"]').fadeOut(); } }, _onEnterRoom: function(room) { this.attachTab(room.id, room.name); }, _onLeaveRoom: function(roomId) { this.removeTab(roomId); // Auto-enter rooms in the queue if ((this._roomQueue.length > 0)) { this._chat.enterRoom(this._roomQueue.shift(roomId)); } }, _onNewMessage: function(roomId, message) { var userId = message.userId; if (!this._user || !this._user.muted || !this._user.muted[userId]) { this.showMessage(roomId, message); } }, _onRemoveMessage: function(roomId, messageId) { this.removeMessage(roomId, messageId); }, // Events related to chat invitations. _onChatInvite: function(invitation) { var self = this; var template = FirechatDefaultTemplates["templates/prompt-invitation.html"]; var $prompt = this.prompt('Invite', template(invitation)); $prompt.find('a.close').click(function() { $prompt.remove(); self._chat.declineInvite(invitation.id); return false; }); $prompt.find('[data-toggle=accept]').click(function() { $prompt.remove(); self._chat.acceptInvite(invitation.id); return false; }); $prompt.find('[data-toggle=decline]').click(function() { $prompt.remove(); self._chat.declineInvite(invitation.id); return false; }); }, _onChatInviteResponse: function(invitation) { if (!invitation.status) return; var self = this, template = FirechatDefaultTemplates["templates/prompt-invite-reply.html"], $prompt; if (invitation.status && invitation.status === 'accepted') { $prompt = this.prompt('Accepted', template(invitation)); this._chat.getRoom(invitation.roomId, function(room) { self.attachTab(invitation.roomId, room.name); }); } else { $prompt = this.prompt('Declined', template(invitation)); } $prompt.find('a.close').click(function() { $prompt.remove(); return false; }); }, // Events related to admin or moderator notifications. _onNotification: function(notification) { if (notification.notificationType === 'warning') { this.renderAlertPrompt('Warning', 'You are being warned for inappropriate messaging. Further violation may result in temporary or permanent ban of service.'); } else if (notification.notificationType === 'suspension') { var suspendedUntil = notification.data.suspendedUntil, secondsLeft = Math.round((suspendedUntil - new Date().getTime()) / 1000), timeLeft = ''; if (secondsLeft > 0) { if (secondsLeft > 2*3600) { var hours = Math.floor(secondsLeft / 3600); timeLeft = hours + ' hours, '; secondsLeft -= 3600*hours; } timeLeft += Math.floor(secondsLeft / 60) + ' minutes'; this.renderAlertPrompt('Suspended', 'A moderator has suspended you for violating site rules. You cannot send messages for another ' + timeLeft + '.'); } } } }; /** * Initialize an authenticated session with a user id and name. * This method assumes that the underlying Firebase reference has * already been authenticated. */ FirechatUI.prototype.setUser = function(userId, userName) { var self = this; // Initialize data events self._chat.setUser(userId, userName, function(user) { self._user = user; if (self._chat.userIsModerator()) { self._bindSuperuserUIEvents(); } self._chat.resumeSession(); }); }; /** * Exposes internal chat bindings via this external interface. */ FirechatUI.prototype.on = function(eventType, cb) { var self = this; this._chat.on(eventType, cb); }; /** * Binds a custom context menu to messages for superusers to warn or ban * users for violating terms of service. */ FirechatUI.prototype._bindSuperuserUIEvents = function() { var self = this, parseMessageVars = function(event) { var $this = $(this), messageId = $this.closest('[data-message-id]').data('message-id'), userId = $('[data-message-id="' + messageId + '"]').closest('[data-user-id]').data('user-id'), roomId = $('[data-message-id="' + messageId + '"]').closest('[data-room-id]').data('room-id'); return { messageId: messageId, userId: userId, roomId: roomId }; }, clearMessageContextMenus = function() { // Remove any context menus currently showing. $('[data-toggle="firechat-contextmenu"]').each(function() { $(this).remove(); }); // Remove any messages currently highlighted. $('#firechat .message.highlighted').each(function() { $(this).removeClass('highlighted'); }); }, showMessageContextMenu = function(event) { var $this = $(this), $message = $this.closest('[data-message-id]'), template = FirechatDefaultTemplates["templates/message-context-menu.html"], messageVars = parseMessageVars.call(this, event), $template; event.preventDefault(); // Clear existing menus. clearMessageContextMenus(); // Highlight the relevant message. $this.addClass('highlighted'); self._chat.getRoom(messageVars.roomId, function(room) { // Show the context menu. $template = $(template({ id: $message.data('message-id') })); $template.css({ left: event.clientX, top: event.clientY }).appendTo(self.$wrapper); }); }; // Handle dismissal of message context menus (any non-right-click click event). $(document).bind('click', { self: this }, function(event) { if (!event.button || event.button != 2) { clearMessageContextMenus(); } }); // Handle display of message context menus (via right-click on a message). $(document).delegate('[data-class="firechat-message"]', 'contextmenu', showMessageContextMenu); // Handle click of the 'Warn User' contextmenu item. $(document).delegate('[data-event="firechat-user-warn"]', 'click', function(event) { var messageVars = parseMessageVars.call(this, event); self._chat.warnUser(messageVars.userId); }); // Handle click of the 'Suspend User (1 Hour)' contextmenu item. $(document).delegate('[data-event="firechat-user-suspend-hour"]', 'click', function(event) { var messageVars = parseMessageVars.call(this, event); self._chat.suspendUser(messageVars.userId, /* 1 Hour = 3600s */ 60*60); }); // Handle click of the 'Suspend User (1 Day)' contextmenu item. $(document).delegate('[data-event="firechat-user-suspend-day"]', 'click', function(event) { var messageVars = parseMessageVars.call(this, event); self._chat.suspendUser(messageVars.userId, /* 1 Day = 86400s */ 24*60*60); }); // Handle click of the 'Delete Message' contextmenu item. $(document).delegate('[data-event="firechat-message-delete"]', 'click', function(event) { var messageVars = parseMessageVars.call(this, event); self._chat.deleteMessage(messageVars.roomId, messageVars.messageId); }); }; /** * Binds to height changes in the surrounding div. */ FirechatUI.prototype._bindForHeightChange = function() { var self = this, $el = $(this._el), lastHeight = null; setInterval(function() { var height = $el.height(); if (height != lastHeight) { lastHeight = height; $('.chat').each(function(i, el) { }); } }, 500); }; /** * Binds custom inner-tab events. */ FirechatUI.prototype._bindForTabControls = function() { var self = this; // Handle click of tab close button. $(document).delegate('[data-event="firechat-close-tab"]', 'click', function(event) { var roomId = $(this).closest('[data-room-id]').data('room-id'); self._chat.leaveRoom(roomId); return false; }); }; /** * Binds room list dropdown to populate room list on-demand. */ FirechatUI.prototype._bindForRoomList = function() { var self = this; $('#firechat-btn-rooms').bind('click', function() { if ($(this).parent().hasClass('open')) { return; } var $this = $(this), template = FirechatDefaultTemplates["templates/room-list-item.html"], selectRoomListItem = function() { var parent = $(this).parent(), roomId = parent.data('room-id'), roomName = parent.data('room-name'); if (self.$messages[roomId]) { self.focusTab(roomId); } else { self._chat.enterRoom(roomId, roomName); } return false; }; self._chat.getRoomList(function(rooms) { self.$roomList.empty(); for (var roomId in rooms) { var room = rooms[roomId]; if (room.type != "public") continue; room.isRoomOpen = !!self.$messages[room.id]; var $roomItem = $(template(room)); $roomItem.children('a').bind('click', selectRoomListItem); self.$roomList.append($roomItem.toggle(true)); } }); }); }; /** * Binds user list dropdown per room to populate user list on-demand. */ FirechatUI.prototype._bindForUserRoomList = function() { var self = this; // Upon click of the dropdown, autofocus the input field and trigger list population. $(document).delegate('[data-event="firechat-user-room-list-btn"]', 'click', function(event) { event.stopPropagation(); var $this = $(this), roomId = $this.closest('[data-room-id]').data('room-id'), template = FirechatDefaultTemplates["templates/room-user-list-item.html"], targetId = $this.data('target'), $target = $('#' + targetId); $target.empty(); self._chat.getUsersByRoom(roomId, function(users) { for (var username in users) { user = users[username]; user.disableActions = (!self._user || user.id === self._user.id); user.nameTrimmed = self.trimWithEllipsis(user.name, self.maxLengthUsernameDisplay); user.isMuted = (self._user && self._user.muted && self._user.muted[user.id]); $target.append($(template(user))); } self.sortListLexicographically('#' + targetId); }); }); }; /** * Binds user search buttons, dropdowns, and input fields for searching all * active users currently in chat. */ FirechatUI.prototype._bindForUserSearch = function() { var self = this, handleUserSearchSubmit = function(event) { var $this = $(this), targetId = $this.data('target'), controlsId = $this.data('controls'), templateId = $this.data('template'), prefix = $this.val() || $this.data('prefix') || '', startAt = $this.data('startAt') || null, endAt = $this.data('endAt') || null; event.preventDefault(); userSearch(targetId, templateId, controlsId, prefix, startAt, endAt); }, userSearch = function(targetId, templateId, controlsId, prefix, startAt, endAt) { var $target = $('#' + targetId), $controls = $('#' + controlsId), template = FirechatDefaultTemplates[templateId]; // Query results, filtered by prefix, using the defined startAt and endAt markets. self._chat.getUsersByPrefix(prefix, startAt, endAt, self.maxUserSearchResults, function(users) { var numResults = 0, $prevBtn, $nextBtn, username, firstResult, lastResult; $target.empty(); for (username in users) { var user = users[username]; // Disable buttons for . user.disableActions = (!self._user || user.id === self._user.id); numResults += 1; $target.append(template(user)); // If we've hit our result limit, the additional value signifies we should paginate. if (numResults === 1) { firstResult = user.name.toLowerCase(); } else if (numResults >= self.maxUserSearchResults) { lastResult = user.name.toLowerCase(); break; } } if ($controls) { $prevBtn = $controls.find('[data-toggle="firechat-pagination-prev"]'); $nextBtn = $controls.find('[data-toggle="firechat-pagination-next"]'); // Sort out configuration for the 'next' button if (lastResult) { $nextBtn .data('event', 'firechat-user-search') .data('startAt', lastResult) .data('prefix', prefix) .removeClass('disabled').removeAttr('disabled'); } else { $nextBtn .data('event', null) .data('startAt', null) .data('prefix', null) .addClass('disabled').attr('disabled', 'disabled'); } } }); }; $(document).delegate('[data-event="firechat-user-search"]', 'keyup', handleUserSearchSubmit); $(document).delegate('[data-event="firechat-user-search"]', 'click', handleUserSearchSubmit); // Upon click of the dropdown, autofocus the input field and trigger list population. $(document).delegate('[data-event="firechat-user-search-btn"]', 'click', function(event) { event.stopPropagation(); var $input = $(this).next('div.firechat-dropdown-menu').find('input'); $input.focus(); $input.trigger(jQuery.Event('keyup')); }); // Ensure that the dropdown stays open despite clicking on the input element. $(document).delegate('[data-event="firechat-user-search"]', 'click', function(event) { event.stopPropagation(); }); }; /** * Binds user mute toggles and removes all messages for a given user upon mute. */ FirechatUI.prototype._bindForUserMuting = function() { var self = this; $(document).delegate('[data-event="firechat-user-mute-toggle"]', 'click', function(event) { var $this = $(this), userId = $this.closest('[data-user-id]').data('user-id'), userName = $this.closest('[data-user-name]').data('user-name'), isMuted = $this.hasClass('red'), template = FirechatDefaultTemplates["templates/prompt-user-mute.html"]; event.preventDefault(); // Require user confirmation for muting. if (!isMuted) { var $prompt = self.prompt('Mute User?', template({ userName: userName })); $prompt.find('a.close').first().click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=decline]').first().click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=accept]').first().click(function() { self._chat.toggleUserMute(userId); $prompt.remove(); return false; }); } else { self._chat.toggleUserMute(userId); } }); }; /** * Binds to elements with the data-event='firechat-user-(private)-invite' and * handles invitations as well as room creation and entering. */ FirechatUI.prototype._bindForChatInvites = function() { var self = this, renderInvitePrompt = function(event) { var $this = $(this), userId = $this.closest('[data-user-id]').data('user-id'), roomId = $this.closest('[data-room-id]').data('room-id'), userName = $this.closest('[data-user-name]').data('user-name'), template = FirechatDefaultTemplates["templates/prompt-invite-private.html"], $prompt; self._chat.getRoom(roomId, function(room) { $prompt = self.prompt('Invite', template({ userName: userName, roomName: room.name })); $prompt.find('a.close').click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=decline]').click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=accept]').first().click(function() { $prompt.remove(); self._chat.inviteUser(userId, roomId, room.name); return false; }); return false; }); return false; }, renderPrivateInvitePrompt = function(event) { var $this = $(this), userId = $this.closest('[data-user-id]').data('user-id'), userName = $this.closest('[data-user-name]').data('user-name'), template = FirechatDefaultTemplates["templates/prompt-invite-private.html"], $prompt; if (userId && userName) { $prompt = self.prompt('Private Invite', template({ userName: userName, roomName: 'Private Chat' })); $prompt.find('a.close').click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=decline]').click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=accept]').first().click(function() { $prompt.remove(); var roomName = 'Private Chat'; self._chat.createRoom(roomName, 'private', function(roomId) { self._chat.inviteUser(userId, roomId, roomName); }); return false; }); } return false; }; $(document).delegate('[data-event="firechat-user-chat"]', 'click', renderPrivateInvitePrompt); $(document).delegate('[data-event="firechat-user-invite"]', 'click', renderInvitePrompt); }; /** * Binds to room dropdown button, menu items, and create room button. */ FirechatUI.prototype._bindForRoomListing = function() { var self = this, $createRoomPromptButton = $('#firechat-btn-create-room-prompt'), $createRoomButton = $('#firechat-btn-create-room'), renderRoomList = function(event) { var type = $(this).data('room-type'); self.sortListLexicographically('#firechat-room-list'); }; // Handle click of the create new room prompt-button. $createRoomPromptButton.bind('click', function(event) { self.promptCreateRoom(); return false; }); // Handle click of the create new room button. $createRoomButton.bind('click', function(event) { var roomName = $('#firechat-input-room-name').val(); $('#firechat-prompt-create-room').remove(); self._chat.createRoom(roomName); return false; }); }; /** * A stripped-down version of bootstrap-tab.js. * * Original bootstrap-tab.js Copyright 2012 Twitter, Inc.,licensed under the Apache v2.0 */ FirechatUI.prototype._setupTabs = function() { var self = this, show = function($el) { var $this = $el, $ul = $this.closest('ul:not(.firechat-dropdown-menu)'), selector = $this.attr('data-target'), previous = $ul.find('.active:last a')[0], $target, e; if (!selector) { selector = $this.attr('href'); selector = selector && selector.replace(/.*(?=#[^\s]*$)/, ''); } if ($this.parent('li').hasClass('active')) return; e = $.Event('show', { relatedTarget: previous }); $this.trigger(e); if (e.isDefaultPrevented()) return; $target = $(selector); activate($this.parent('li'), $ul); activate($target, $target.parent(), function () { $this.trigger({ type: 'shown', relatedTarget: previous }); }); }, activate = function (element, container, callback) { var $active = container.find('> .active'), transition = callback && $.support.transition && $active.hasClass('fade'); function next() { $active .removeClass('active') .find('> .firechat-dropdown-menu > .active') .removeClass('active'); element.addClass('active'); if (transition) { element.addClass('in'); } else { element.removeClass('fade'); } if (element.parent('.firechat-dropdown-menu')) { element.closest('li.firechat-dropdown').addClass('active'); } if (callback) { callback(); } } if (transition) { $active.one($.support.transition.end, next); } else { next(); } $active.removeClass('in'); }; $(document).delegate('[data-toggle="firechat-tab"]', 'click', function(event) { event.preventDefault(); show($(this)); }); }; /** * A stripped-down version of bootstrap-dropdown.js. * * Original bootstrap-dropdown.js Copyright 2012 Twitter, Inc., licensed under the Apache v2.0 */ FirechatUI.prototype._setupDropdowns = function() { var self = this, toggle = '[data-toggle=firechat-dropdown]', toggleDropdown = function(event) { var $this = $(this), $parent = getParent($this), isActive = $parent.hasClass('open'); if ($this.is('.disabled, :disabled')) return; clearMenus(); if (!isActive) { $parent.toggleClass('open'); } $this.focus(); return false; }, clearMenus = function() { $('[data-toggle=firechat-dropdown]').each(function() { getParent($(this)).removeClass('open'); }); }, getParent = function($this) { var selector = $this.attr('data-target'), $parent; if (!selector) { selector = $this.attr('href'); selector = selector && /#/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, ''); } $parent = selector && $(selector); if (!$parent || !$parent.length) $parent = $this.parent(); return $parent; }; $(document) .bind('click', clearMenus) .delegate('.firechat-dropdown-menu', 'click', function(event) { event.stopPropagation(); }) .delegate('[data-toggle=firechat-dropdown]', 'click', toggleDropdown); }; /** * Binds to any text input fields with data-provide='limit' and * data-counter='', and upon value change updates the selector * content to reflect the number of characters remaining, as the 'maxlength' * attribute less the current value length. */ FirechatUI.prototype._bindTextInputFieldLimits = function() { $('body').delegate('input[data-provide="limit"], textarea[data-provide="limit"]', 'keyup', function(event) { var $this = $(this), $target = $($this.data('counter')), limit = $this.attr('maxlength'), count = $this.val().length; $target.html(Math.max(0, limit - count)); }); }; /** * Given a title and message content, show an alert prompt to the user. * * @param {string} title * @param {string} message */ FirechatUI.prototype.renderAlertPrompt = function(title, message) { var template = FirechatDefaultTemplates["templates/prompt-alert.html"], $prompt = this.prompt(title, template({ message: message })); $prompt.find('.close').click(function() { $prompt.remove(); return false; }); return; }; /** * Toggle input field s if we want limit / unlimit input fields. */ FirechatUI.prototype.toggleInputs = function(isEnabled) { $('#firechat-tab-content textarea').each(function() { var $this = $(this); if (isEnabled) { $(this).val(''); } else { $(this).val('You have exceeded the message limit, please wait before sending.'); } $this.prop('disabled', !isEnabled); }); $('#firechat-input-name').prop('disabled', !isEnabled); }; /** * Given a room id and name, attach the tab to the interface and setup events. * * @param {string} roomId * @param {string} roomName */ FirechatUI.prototype.attachTab = function(roomId, roomName) { var self = this; // If this tab already exists, give it focus. if (this.$messages[roomId]) { this.focusTab(roomId); return; } var room = { id: roomId, name: roomName }; // Populate and render the tab content template. var tabTemplate = FirechatDefaultTemplates["templates/tab-content.html"]; var $tabContent = $(tabTemplate(room)); this.$tabContent.prepend($tabContent); var $messages = $('#firechat-messages' + roomId); // Keep a reference to the message listing for later use. this.$messages[roomId] = $messages; // Attach on-enter event to textarea. var $textarea = $tabContent.find('textarea').first(); $textarea.bind('keydown', function(e) { var message = self.trimWithEllipsis($textarea.val(), self.maxLengthMessage); if ((e.which === 13) && (message !== '')) { $textarea.val(''); self._chat.sendMessage(roomId, message); return false; } }); // Populate and render the tab menu template. var tabListTemplate = FirechatDefaultTemplates["templates/tab-menu-item.html"]; var $tab = $(tabListTemplate(room)); this.$tabList.prepend($tab); // Attach on-shown event to move tab to front and scroll to bottom. $tab.bind('shown', function(event) { $messages.scrollTop($messages[0].scrollHeight); }); // Dynamically update the width of each tab based upon the number open. var tabs = this.$tabList.children('li'); var tabWidth = Math.floor($('#firechat-tab-list').width() / tabs.length); this.$tabList.children('li').css('width', tabWidth); // Update the room listing to reflect that we're now in the room. this.$roomList.children('[data-room-id=' + roomId + ']').children('a').addClass('highlight'); // Sort each item in the user list alphabetically on click of the dropdown. $('#firechat-btn-room-user-list-' + roomId).bind('click', function() { self.sortListLexicographically('#firechat-room-user-list-' + roomId); return false; }); // Automatically select the new tab. this.focusTab(roomId); }; /** * Given a room id, focus the given tab. * * @param {string} roomId */ FirechatUI.prototype.focusTab = function(roomId) { if (this.$messages[roomId]) { var $tabLink = this.$tabList.find('[data-room-id=' + roomId + ']').find('a'); if ($tabLink.length) { $tabLink.first().trigger('click'); } } }; /** * Given a room id, remove the tab and all child elements from the interface. * * @param {string} roomId */ FirechatUI.prototype.removeTab = function(roomId) { delete this.$messages[roomId]; // Remove the inner tab content. this.$tabContent.find('[data-room-id=' + roomId + ']').remove(); // Remove the tab from the navigation menu. this.$tabList.find('[data-room-id=' + roomId + ']').remove(); // Dynamically update the width of each tab based upon the number open. var tabs = this.$tabList.children('li'); var tabWidth = Math.floor($('#firechat-tab-list').width() / tabs.length); this.$tabList.children('li').css('width', tabWidth); // Automatically select the next tab if there is one. this.$tabList.find('[data-toggle="firechat-tab"]').first().trigger('click'); // Update the room listing to reflect that we're now in the room. this.$roomList.children('[data-room-id=' + roomId + ']').children('a').removeClass('highlight'); }; /** * Render a new message in the specified chat room. * * @param {string} roomId * @param {string} message */ FirechatUI.prototype.showMessage = function(roomId, rawMessage) { var self = this; // Setup defaults var message = { id : rawMessage.id, localtime : self.formatTime(rawMessage.timestamp), message : rawMessage.message || '', userId : rawMessage.userId, name : rawMessage.name, type : rawMessage.type || 'default', isSelfMessage : (self._user && rawMessage.userId == self._user.id), disableActions : (!self._user || rawMessage.userId == self._user.id) }; // While other data is escaped in the Underscore.js templates, escape and // process the message content here to add additional functionality (add links). // Also trim the message length to some client-defined maximum. var messageConstructed = ''; message.message = _.map(message.message.split(' '), function(token) { if (self.urlPattern.test(token) || self.pseudoUrlPattern.test(token)) { return self.linkify(encodeURI(token)); } else { return _.escape(token); } }).join(' '); message.message = self.trimWithEllipsis(message.message, self.maxLengthMessage); // Populate and render the message template. var template = FirechatDefaultTemplates["templates/message.html"]; var $message = $(template(message)); var $messages = self.$messages[roomId]; if ($messages) { var scrollToBottom = false; if ($messages.scrollTop() / ($messages[0].scrollHeight - $messages[0].offsetHeight) >= 0.95) { // Pinned to bottom scrollToBottom = true; } else if ($messages[0].scrollHeight <= $messages.height()) { // Haven't added the scrollbar yet scrollToBottom = true; } $messages.append($message); if (scrollToBottom) { $messages.scrollTop($messages[0].scrollHeight); } } }; /** * Remove a message by id. * * @param {string} roomId * @param {string} messageId */ FirechatUI.prototype.removeMessage = function(roomId, messageId) { $('.message[data-message-id="' + messageId + '"]').remove(); }; /** * Given a selector for a list element, sort the items alphabetically. * * @param {string} selector */ FirechatUI.prototype.sortListLexicographically = function(selector) { $(selector).children("li").sort(function(a, b) { var upA = $(a).text().toUpperCase(); var upB = $(b).text().toUpperCase(); return (upA < upB) ? -1 : (upA > upB) ? 1 : 0; }).appendTo(selector); }; /** * Remove leading and trailing whitespace from a string and shrink it, with * added ellipsis, if it exceeds a specified length. * * @param {string} str * @param {number} length * @return {string} */ FirechatUI.prototype.trimWithEllipsis = function(str, length) { str = str.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); return (length && str.length <= length) ? str : str.substring(0, length) + '...'; }; /** * Given a timestamp, format it in the form hh:mm am/pm. Defaults to now * if the timestamp is undefined. * * @param {Number} timestamp * @param {string} date */ FirechatUI.prototype.formatTime = function(timestamp) { var date = (timestamp) ? new Date(timestamp) : new Date(), hours = date.getHours() || 12, minutes = '' + date.getMinutes(), ampm = (date.getHours() >= 12) ? 'pm' : 'am'; hours = (hours > 12) ? hours - 12 : hours; minutes = (minutes.length < 2) ? '0' + minutes : minutes; return '' + hours + ':' + minutes + ampm; }; /** * Launch a prompt to allow the user to create a new room. */ FirechatUI.prototype.promptCreateRoom = function() { var self = this; var template = FirechatDefaultTemplates["templates/prompt-create-room.html"]; var $prompt = this.prompt('Create Public Room', template({ maxLengthRoomName: this.maxLengthRoomName, isModerator: self._chat.userIsModerator() })); $prompt.find('a.close').first().click(function() { $prompt.remove(); return false; }); $prompt.find('[data-toggle=submit]').first().click(function() { var name = $prompt.find('[data-input=firechat-room-name]').first().val(); if (name !== '') { self._chat.createRoom(name, 'public'); $prompt.remove(); } return false; }); $prompt.find('[data-input=firechat-room-name]').first().focus(); $prompt.find('[data-input=firechat-room-name]').first().bind('keydown', function(e) { if (e.which === 13) { var name = $prompt.find('[data-input=firechat-room-name]').first().val(); if (name !== '') { self._chat.createRoom(name, 'public'); $prompt.remove(); return false; } } }); }; /** * Inner method to launch a prompt given a specific title and HTML content. * @param {string} title * @param {string} content */ FirechatUI.prototype.prompt = function(title, content) { var template = FirechatDefaultTemplates["templates/prompt.html"], $prompt; $prompt = $(template({ title: title, content: content })).css({ top: this.$wrapper.position().top + (0.333 * this.$wrapper.height()), left: this.$wrapper.position().left + (0.125 * this.$wrapper.width()), width: 0.75 * this.$wrapper.width() }); this.$wrapper.append($prompt.removeClass('hidden')); return $prompt; }; // see http://stackoverflow.com/questions/37684/how-to-replace-plain-urls-with-links FirechatUI.prototype.linkify = function(str) { var self = this; return str .replace(self.urlPattern, '$&') .replace(self.pseudoUrlPattern, '$1$2'); }; })(jQuery);