From 791c0f934cc5148fdd267dbe8776c5488160d7df Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 10 Nov 2015 15:01:04 +0100 Subject: [PATCH] First version of customer chat. --- .../javascripts/app/controllers/chat.coffee | 383 ++++++++++++++++++ .../app/views/customer_chat/index.jst.eco | 24 ++ .../app/views/navigation/menu.jst.eco | 2 +- app/models/chat.rb | 133 ++++++ db/migrate/20151109000001_create_chat.rb | 93 +++++ lib/sessions/event.rb | 18 + lib/sessions/event/chat_agent_state.rb | 38 ++ lib/sessions/event/chat_session_close.rb | 46 +++ lib/sessions/event/chat_session_init.rb | 55 +++ lib/sessions/event/chat_session_message.rb | 68 ++++ lib/sessions/event/chat_session_start.rb | 60 +++ lib/sessions/event/chat_session_typing.rb | 61 +++ lib/sessions/event/chat_status.rb | 31 ++ lib/sessions/event/chat_status_agent.rb | 27 ++ public/assets/chat/chat.coffee | 101 +++-- public/assets/chat/chat.js | 256 +++++++----- public/assets/chat/chat.min.js | 2 +- public/assets/chat/index.html | 6 +- script/websocket-server.rb | 11 +- 19 files changed, 1256 insertions(+), 159 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/chat.coffee create mode 100644 app/assets/javascripts/app/views/customer_chat/index.jst.eco create mode 100644 app/models/chat.rb create mode 100644 db/migrate/20151109000001_create_chat.rb create mode 100644 lib/sessions/event.rb create mode 100644 lib/sessions/event/chat_agent_state.rb create mode 100644 lib/sessions/event/chat_session_close.rb create mode 100644 lib/sessions/event/chat_session_init.rb create mode 100644 lib/sessions/event/chat_session_message.rb create mode 100644 lib/sessions/event/chat_session_start.rb create mode 100644 lib/sessions/event/chat_session_typing.rb create mode 100644 lib/sessions/event/chat_status.rb create mode 100644 lib/sessions/event/chat_status_agent.rb diff --git a/app/assets/javascripts/app/controllers/chat.coffee b/app/assets/javascripts/app/controllers/chat.coffee new file mode 100644 index 000000000..9eb72f8ee --- /dev/null +++ b/app/assets/javascripts/app/controllers/chat.coffee @@ -0,0 +1,383 @@ +class App.CustomerChat extends App.Controller + @extend Spine.Events + + events: + 'click .js-acceptChat': 'acceptChat' + + sounds: + chat_new: new Audio('assets/sounds/chat_new.mp3') + + constructor: -> + super + + @i = 0 + @chatWindows = {} + @totalQuestions = 7 + @answered = 0 + @correct = 0 + @wrong = 0 + @maxChats = 4 + + @messageCounter = 0 + @meta = + active: false + waiting_chat_count: 0 + running_chat_count: 0 + active_agents: 0 + + @render() + + App.Event.bind( + 'chat_status_agent' + (data) => + @meta = data + @updateMeta() + ) + App.Event.bind( + 'chat_session_start' + (data) => + App.WebSocket.send(event:'chat_status_agent') + if data.session + @addChat(data.session) + ) + + App.WebSocket.send(event:'chat_status_agent') + + @interval(@pushState, 16000) + + pushState: => + App.WebSocket.send( + event:'chat_agent_state' + data: + active: @meta.active + ) + + render: -> + @html App.view('customer_chat/index')() + + show: (params) => + @navupdate '#customer_chat' + + counter: => + if @meta.waiting_chat_count + if @waitingChatCountLast isnt @meta.waiting_chat_count + @sounds.chat_new.play() + @waitingChatCountLast = @meta.waiting_chat_count + + return @messageCounter + @meta.waiting_chat_count + @messageCounter + + switch: (state = undefined) => + + # read state + if state is undefined + return @meta.active + + @meta.active = state + + # write state + App.WebSocket.send( + event:'chat_agent_state' + data: + active: @meta.active + ) + + updateNavMenu: => + delay = -> + App.Event.trigger('menu:render') + @delay(delay, 200) + + updateMeta: => + if @meta.waiting_chat_count + @$('.js-acceptChat').addClass('is-clickable is-blinking') + else + @$('.js-acceptChat').removeClass('is-clickable is-blinking') + @$('.js-badgeWaitingCustomers').text(@meta.waiting_chat_count) + @$('.js-badgeChattingCustomers').text(@meta.running_chat_count) + @$('.js-badgeActiveAgents').text(@meta.active_agents) + + if @meta.active_sessions + for session in @meta.active_sessions + @addChat(session) + + @updateNavMenu() + + addChat: (session) -> + return if @chatWindows[session.session_id] + chat = new chatWindow + name: "#{session.created_at}" + session: session + callback: @removeChat + + @on 'layout-has-changed', @propagateLayoutChange + + @$('.chat-workspace').append(chat.el) + @chatWindows[session.session_id] = chat + + removeChat: (session_id) => + console.log('removeChat', session_id, @chatWindows[session_id]) + delete @chatWindows[session_id] + + propagateLayoutChange: (event) => + + # adjust scroll position on layoutChange + for session_id, chat of @chatWindows + chat.trigger 'layout-changed' + + acceptChat: => + currentChats = 0 + for key, value of @chatWindows + if @chatWindows[key] + currentChats += 1 + return if currentChats >= @maxChats + + App.WebSocket.send(event:'chat_session_start') + +class CustomerChatRouter extends App.ControllerPermanent + constructor: (params) -> + super + + # check authentication + return if !@authenticate() + + App.TaskManager.execute( + key: 'CustomerChat' + controller: 'CustomerChat' + params: {} + show: true + persistent: true + ) + +class chatWindow extends App.Controller + @extend Spine.Events + + className: 'chat-window' + + events: + 'keydown .js-customerChatInput': 'onKeydown' + 'focus .js-customerChatInput': 'clearUnread' + 'click': 'clearUnread' + 'click .js-send': 'sendMessage' + 'click .js-close': 'close' + + elements: + '.js-customerChatInput': 'input' + '.js-status': 'status' + '.js-body': 'body' + '.js-scrollHolder': 'scrollHolder' + + sounds: + message: new Audio('assets/sounds/chat_message.mp3') + + constructor: -> + super + + @showTimeEveryXMinutes = 1 + @lastTimestamp + @lastAddedType + @isTyping = false + @render() + + @on 'layout-change', @scrollToBottom + + App.Event.bind( + 'chat_session_typing' + (data) => + return if data.session_id isnt @session.session_id + return if data.self_written + @showWritingLoader() + ) + App.Event.bind( + 'chat_session_message' + (data) => + return if data.session_id isnt @session.session_id + return if data.self_written + @receiveMessage(data.message.content) + ) + + render: -> + @html App.view('layout_ref/customer_chat_window') + name: @options.name + + @el.one 'transitionend', @onTransitionend + + # make sure animation will run + setTimeout (=> @el.addClass('is-open')), 0 + + # @addMessage 'Hello. My name is Roger, how can I help you?', 'agent' + if @session && @session.messages + for message in @session.messages + if message.created_by_id + @addMessage message.content, 'agent' + else + @addMessage message.content, 'customer' + + # set focus + @input.get(0).focus() + + onTransitionend: (event) => + # chat window is done with animation - adjust scroll-bars + # of sibling chat windows + @trigger 'layout-has-changed' + + if event.data and event.data.callback + event.data.callback() + + close: => + @el.one 'transitionend', { callback: @release }, @onTransitionend + @el.removeClass('is-open') + App.WebSocket.send( + event:'chat_session_close' + data: + session_id: @session.session_id + ) + if @callback + @callback(@session.session_id) + + release: => + @trigger 'closed' + super + + clearUnread: => + @$('.chat-message--new').removeClass('chat-message--new') + @updateModified(false) + + onKeydown: (event) => + TABKEY = 9; + ENTERKEY = 13; + + if event.keyCode isnt TABKEY && event.keyCode isnt ENTERKEY + App.WebSocket.send( + event:'chat_session_typing' + data: + session_id: @session.session_id + ) + + switch event.keyCode + when TABKEY + allChatInputs = $('.js-customerChatInput').not('[disabled="disabled"]') + chatCount = allChatInputs.size() + index = allChatInputs.index(@input) + + if chatCount > 1 + switch index + when chatCount-1 + if !event.shiftKey + # State: tab without shift on last input + # Jump to first input + event.preventDefault() + allChatInputs.eq(0).focus() + when 0 + if event.shiftKey + # State: tab with shift on first input + # Jump to last input + event.preventDefault() + allChatInputs.eq(chatCount-1).focus() + + when ENTERKEY + if !event.shiftKey + event.preventDefault() + @sendMessage() + + sendMessage: => + content = @input.html() + return if !content + + #@trigger "answer", @input.html() + App.WebSocket.send( + event:'chat_session_message' + data: + content: content + session_id: @session.session_id + ) + + @addMessage content, 'agent' + @input.html('') + + updateModified: (state) => + @status.toggleClass('is-modified', state) + + receiveMessage: (message) => + isFocused = @input.is(':focus') + + @removeWritingLoader() + @addMessage(message, 'customer', !isFocused) + + if !isFocused + @updateModified(true) + @sounds.message.play() + + addMessage: (message, sender, isNew) => + @maybeAddTimestamp() + + @lastAddedType = sender + + @body.append App.view('layout_ref/customer_chat_message') + message: message + sender: sender + isNew: isNew + timestamp: Date.now() + + @scrollToBottom() + + showWritingLoader: => + if !@isTyping + @isTyping = true + @maybeAddTimestamp() + @body.append App.view('layout_ref/customer_chat_loader')() + @scrollToBottom() + + # clear old delay, set new + @delay(@removeWritingLoader, 1800, 'typing') + + removeWritingLoader: => + @isTyping = false + @$('.js-loader').remove() + + goOffline: => + @addStatusMessage("#{ @options.name }'s connection got closed") + @status.attr('data-status', 'offline') + @el.addClass('is-offline') + @input.attr('disabled', true) + + maybeAddTimestamp: -> + timestamp = Date.now() + + if !@lastTimestamp or timestamp - @lastTimestamp > @showTimeEveryXMinutes * 60000 + label = 'Today' + time = new Date().toTimeString().substr(0,5) + if @lastAddedType is 'timestamp' + # update last time + @updateLastTimestamp label, time + @lastTimestamp = timestamp + else + @addTimestamp label, time + @lastTimestamp = timestamp + @lastAddedType = 'timestamp' + + addTimestamp: (label, time) => + @body.append App.view('layout_ref/customer_chat_timestamp') + label: label + time: time + + updateLastTimestamp: (label, time) -> + @body + .find('.js-timestamp') + .last() + .replaceWith App.view('layout_ref/customer_chat_timestamp') + label: label + time: time + + addStatusMessage: (message) -> + @body.append App.view('layout_ref/customer_chat_status_message') + message: message + + @scrollToBottom() + + scrollToBottom: -> + @scrollHolder.scrollTop(@scrollHolder.prop('scrollHeight')) + + +App.Config.set( 'customer_chat', CustomerChatRouter, 'Routes' ) +App.Config.set( 'CustomerChat', { controller: 'CustomerChat', authentication: true }, 'permanentTask' ) +App.Config.set( 'CustomerChat', { prio: 1200, parent: '', name: 'Customer Chat', target: '#customer_chat', key: 'CustomerChat', role: ['Chat'], class: 'chat' }, 'NavBar' ) diff --git a/app/assets/javascripts/app/views/customer_chat/index.jst.eco b/app/assets/javascripts/app/views/customer_chat/index.jst.eco new file mode 100644 index 000000000..c13088449 --- /dev/null +++ b/app/assets/javascripts/app/views/customer_chat/index.jst.eco @@ -0,0 +1,24 @@ +
+ +
+
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/navigation/menu.jst.eco b/app/assets/javascripts/app/views/navigation/menu.jst.eco index ef2f79320..e78b7f188 100644 --- a/app/assets/javascripts/app/views/navigation/menu.jst.eco +++ b/app/assets/javascripts/app/views/navigation/menu.jst.eco @@ -26,7 +26,7 @@ <%- @T(item.name) %> - <% if item.counter isnt undefined: %> + <% if item.counter isnt undefined && item.counter isnt 0: %> <%= item.counter %> <% end %> <% if item.switch isnt undefined: %> diff --git a/app/models/chat.rb b/app/models/chat.rb new file mode 100644 index 000000000..8ec1536a9 --- /dev/null +++ b/app/models/chat.rb @@ -0,0 +1,133 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class Chat < ApplicationModel + has_many :chat_topics + validates :name, presence: true + + def state + return { state: 'chat_disabled' } if !Setting.get('chat') + + if Chat::Agent.where(active: true).where('updated_at > ?', Time.zone.now - 2.minutes).count > 0 + if active_chat_count >= max_queue + return { + state: 'no_seats_available', + queue: seads_available, + } + else + return { state: 'online' } + end + end + + { state: 'offline' } + end + + def self.agent_state(user_id) + return { state: 'chat_disabled' } if !Setting.get('chat') + actice_sessions = [] + Chat::Session.where(state: 'running').order('created_at ASC').each {|session| + session_attributes = session.attributes + session_attributes['messages'] = [] + Chat::Message.where(chat_session_id: session.id).each { |message| + session_attributes['messages'].push message.attributes + } + actice_sessions.push session_attributes + } + { + waiting_chat_count: waiting_chat_count, + running_chat_count: running_chat_count, + #available_agents: available_agents, + active_sessions: actice_sessions, + active: Chat::Agent.state(user_id) + } + end + + def self.waiting_chat_count + Chat::Session.where(state: ['waiting']).count + end + + def self.running_chat_count + Chat::Session.where(state: ['waiting']).count + end + + def active_chat_count + Chat::Session.where(state: %w(waiting running)).count + end + + def available_agents(diff = 2.minutes) + agents = {} + Chat::Agent.where(active: true).where('updated_at > ?', Time.zone.now - diff).each {|record| + agents[record.updated_by_id] = record.concurrent + } + agents + end + + def seads_total(diff = 2.minutes) + total = 0 + available_agents(diff).each {|_record, concurrent| + total += concurrent + } + total + end + + def seads_available(diff = 2.minutes) + seads_total(diff) - active_chat_count + end +end + +class Chat::Topic < ApplicationModel +end + +class Chat::Agent < ApplicationModel + + def seads_available + concurrent - active_chat_count + end + + def active_chat_count + Chat::Session.where(state: %w(waiting running), user_id: updated_by_id).count + end + + def self.state(user_id, state = nil) + chat_agent = Chat::Agent.find_by( + updated_by_id: user_id + ) + if state.nil? + return false if !chat_agent + return chat_agent.active + end + if chat_agent + chat_agent.active = state + chat_agent.updated_at = Time.zone.now + chat_agent.save + else + Chat::Agent.create( + active: state, + updated_by_id: user_id, + created_by_id: user_id, + ) + end + end + + def self.create_or_update(params) + chat_agent = Chat::Agent.find_by( + updated_by_id: params[:updated_by_id] + ) + if chat_agent + chat_agent.update_attributes(params) + else + Chat::Agent.create(params) + end + end +end + +class Chat::Session < ApplicationModel + before_create :generate_session_id + store :preferences + + def generate_session_id + self.session_id = Digest::MD5.hexdigest(Time.zone.now.to_s + rand(99_999_999_999_999).to_s) + end +end + +class Chat::Message < ApplicationModel +end diff --git a/db/migrate/20151109000001_create_chat.rb b/db/migrate/20151109000001_create_chat.rb new file mode 100644 index 000000000..85992f14c --- /dev/null +++ b/db/migrate/20151109000001_create_chat.rb @@ -0,0 +1,93 @@ +class CreateChat < ActiveRecord::Migration + def up +=begin + ActiveRecord::Migration.drop_table :chats + ActiveRecord::Migration.drop_table :chat_topics + ActiveRecord::Migration.drop_table :chat_sessions + ActiveRecord::Migration.drop_table :chat_messages + ActiveRecord::Migration.drop_table :chat_agents +=end + create_table :chats do |t| + t.string :name, limit: 250, null: true + t.integer :max_queue, null: false, default: 5 + t.string :note, limit: 250, null: true + t.boolean :active, null: false, default: true + t.integer :updated_by_id, null: false + t.integer :created_by_id, null: false + t.timestamps null: false + end + add_index :chats, [:name], unique: true + + create_table :chat_topics do |t| + t.integer :chat_id, null: false + t.string :name, limit: 250, null: false + t.string :note, limit: 250, null: true + t.integer :updated_by_id, null: false + t.integer :created_by_id, null: false + t.timestamps null: false + end + add_index :chat_topics, [:name], unique: true + + create_table :chat_sessions do |t| + t.integer :chat_id, null: false + t.string :session_id, null: false + t.string :name, limit: 250, null: true + t.string :state, limit: 50, null: false, default: 'waiting' # running, closed + t.integer :user_id, null: true + t.text :preferences, limit: 100.kilobytes + 1, null: true + t.integer :updated_by_id, null: true + t.integer :created_by_id, null: true + t.timestamps null: false + end + add_index :chat_sessions, [:state] + add_index :chat_sessions, [:user_id] + + create_table :chat_messages do |t| + t.integer :chat_session_id, null: false + t.string :content, limit: 5000, null: false + t.integer :created_by_id, null: true + t.timestamps null: false + end + + create_table :chat_agents do |t| + t.boolean :active, null: false, default: true + t.integer :concurrent, null: false, default: 5 + t.integer :updated_by_id, null: false + t.integer :created_by_id, null: false + t.timestamps null: false + end + add_index :chat_agents, [:created_by_id], unique: true + + Role.create_if_not_exists( + name: 'Chat', + note: 'Access to chat feature.', + updated_by_id: 1, + created_by_id: 1 + ) + + chat = Chat.create( + name: 'default', + max_queue: 5, + note: '', + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + + chat_topic = Chat::Topic.create( + chat_id: chat.id, + name: 'default', + updated_by_id: 1, + created_by_id: 1, + ) + + end + + def down + drop_table :chat_topics + drop_table :chat_sessions + drop_table :chat_messages + drop_table :chat_agents + drop_table :chats + end +end diff --git a/lib/sessions/event.rb b/lib/sessions/event.rb new file mode 100644 index 000000000..685c41104 --- /dev/null +++ b/lib/sessions/event.rb @@ -0,0 +1,18 @@ +class Sessions::Event + include ApplicationLib + + def self.run(event, data, session, client_id) + adapter = "Sessions::Event::#{event.to_classname}" + begin + backend = load_adapter(adapter) + rescue => e + return { error: "No such event #{event}" } + end + + ActiveRecord::Base.establish_connection + result = backend.run(data, session, client_id) + ActiveRecord::Base.remove_connection + result + end + +end \ No newline at end of file diff --git a/lib/sessions/event/chat_agent_state.rb b/lib/sessions/event/chat_agent_state.rb new file mode 100644 index 000000000..f63900a90 --- /dev/null +++ b/lib/sessions/event/chat_agent_state.rb @@ -0,0 +1,38 @@ +class Sessions::Event::ChatAgentState + + def self.run(data, session, _client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_agent_state', + data: { + state: 'chat_disabled', + }, + } + end + + # only agents can do this + chat_id = 1 + chat = Chat.find_by(id: chat_id) + if !session['id'] + return { + event: 'chat_agent_state', + data: { + state: 'failed', + message: 'No such user_id.' + }, + } + end + + Chat::Agent.state(session['id'], data['data']['active']) + + { + event: 'chat_agent_state', + data: { + state: 'ok', + active: data['data']['active'], + }, + } + end +end diff --git a/lib/sessions/event/chat_session_close.rb b/lib/sessions/event/chat_session_close.rb new file mode 100644 index 000000000..6a5a0a535 --- /dev/null +++ b/lib/sessions/event/chat_session_close.rb @@ -0,0 +1,46 @@ +class Sessions::Event::ChatSessionClose + + def self.run(data, _session, _client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_status_close', + data: { + state: 'chat_disabled', + }, + } + end + + if !data['data'] || !data['data']['session_id'] + return { + event: 'chat_status_close', + data: { + state: 'Need session_id.', + }, + } + end + + chat_session = Chat::Session.find_by(session_id: data['data']['session_id']) + if !chat_session + return { + event: 'chat_status_close', + data: { + state: "No such session id #{data['data']['session_id']}", + }, + } + end + + chat_session.state = 'closed' + chat_session.save + + # return new session + { + event: 'chat_status_close', + data: { + state: 'ok', + session_id: chat_session.session_id, + }, + } + end +end diff --git a/lib/sessions/event/chat_session_init.rb b/lib/sessions/event/chat_session_init.rb new file mode 100644 index 000000000..f2bd37068 --- /dev/null +++ b/lib/sessions/event/chat_session_init.rb @@ -0,0 +1,55 @@ +class Sessions::Event::ChatSessionInit + + def self.run(data, _session, client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_session_init', + data: { + state: 'chat_disabled', + }, + } + end + + chat_id = 1 + chat = Chat.find_by(id: chat_id) + if !chat + return { + event: 'chat_session_init', + data: { + state: 'no_such_chat', + }, + } + end + + # create chat session + chat_session = Chat::Session.create( + chat_id: chat_id, + name: '', + state: 'waiting', + preferences: { + participants: [client_id], + }, + ) + + # send update to agents + User.where(active: true).each {|user| + data = { + event: 'chat_status_agent', + data: Chat.agent_state(user.id), + } + Sessions.send_to(user.id, data) + } + + # return new session + { + event: 'chat_session_init', + data: { + state: 'queue', + position: Chat.waiting_chat_count, + session_id: chat_session.session_id, + }, + } + end +end diff --git a/lib/sessions/event/chat_session_message.rb b/lib/sessions/event/chat_session_message.rb new file mode 100644 index 000000000..f410943cd --- /dev/null +++ b/lib/sessions/event/chat_session_message.rb @@ -0,0 +1,68 @@ +class Sessions::Event::ChatSessionMessage + + def self.run(data, session, client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_session_message', + data: { + state: 'chat_disabled', + }, + } + end + + if !data['data'] || !data['data']['session_id'] + return { + event: 'chat_session_message', + data: { + state: 'Need session_id.', + }, + } + end + + chat_session = Chat::Session.find_by(session_id: data['data']['session_id']) + if !chat_session + return { + event: 'chat_session_message', + data: { + state: "No such session id #{data['data']['session_id']}", + }, + } + end + + user_id = nil + if session + user_id = session['id'] + end + chat_message = Chat::Message.create( + chat_session_id: chat_session.id, + content: data['data']['content'], + created_by_id: user_id, + ) + message = { + event: 'chat_session_message', + data: { + session_id: chat_session.session_id, + message: chat_message, + }, + } + + # send to participents + chat_session.preferences[:participants].each {|local_client_id| + next if local_client_id == client_id + Sessions.send(local_client_id, message) + } + + # send chat_session_init to agent + { + event: 'chat_session_message', + data: { + session_id: chat_session.session_id, + message: chat_message, + self_written: true, + }, + } + + end +end diff --git a/lib/sessions/event/chat_session_start.rb b/lib/sessions/event/chat_session_start.rb new file mode 100644 index 000000000..c7880e259 --- /dev/null +++ b/lib/sessions/event/chat_session_start.rb @@ -0,0 +1,60 @@ +class Sessions::Event::ChatSessionStart + + def self.run(data, session, client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_session_start', + data: { + state: 'chat_disabled', + }, + } + end + + # find first in waiting list + chat_session = Chat::Session.where(state: 'waiting').order('created_at ASC').first + if !chat_session + return { + event: 'chat_session_start', + data: { + state: 'failed', + message: 'No session available.', + }, + } + end + chat_session.user_id = session['id'] + chat_session.state = 'running' + chat_session.preferences[:participants].push client_id + chat_session.save + + # send chat_session_init to client + chat_user = User.find(chat_session.user_id) + user = { + name: chat_user.fullname, + avatar: chat_user.image, + } + data = { + event: 'chat_session_start', + data: { + state: 'ok', + agent: user, + session_id: chat_session.session_id, + }, + } + + chat_session.preferences[:participants].each {|local_client_id| + next if local_client_id == client_id + Sessions.send(local_client_id, data) + } + + # send chat_session_init to agent + { + event: 'chat_session_start', + data: { + state: 'ok', + session: chat_session, + }, + } + end +end diff --git a/lib/sessions/event/chat_session_typing.rb b/lib/sessions/event/chat_session_typing.rb new file mode 100644 index 000000000..660818c61 --- /dev/null +++ b/lib/sessions/event/chat_session_typing.rb @@ -0,0 +1,61 @@ +class Sessions::Event::ChatSessionTyping + + def self.run(data, session, client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_session_typing', + data: { + state: 'chat_disabled', + }, + } + end + + if !data['data'] || !data['data']['session_id'] + return { + event: 'chat_session_typing', + data: { + state: 'Need session_id.', + }, + } + end + + chat_session = Chat::Session.find_by(session_id: data['data']['session_id']) + if !chat_session + return { + event: 'chat_session_typing', + data: { + state: "No such session id #{data['data']['session_id']}", + }, + } + end + + user_id = nil + if session + user_id = session['id'] + end + message = { + event: 'chat_session_typing', + data: { + session_id: chat_session.session_id, + user_id: user_id, + }, + } + + # send to participents + chat_session.preferences[:participants].each {|local_client_id| + next if local_client_id == client_id + Sessions.send(local_client_id, message) + } + + # send chat_session_init to agent + { + event: 'chat_session_typing', + data: { + session_id: chat_session.session_id, + self_written: true, + }, + } + end +end diff --git a/lib/sessions/event/chat_status.rb b/lib/sessions/event/chat_status.rb new file mode 100644 index 000000000..40576a53c --- /dev/null +++ b/lib/sessions/event/chat_status.rb @@ -0,0 +1,31 @@ +class Sessions::Event::ChatStatus + + def self.run(_data, _session, _client_id) + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_status', + data: { + state: 'chat_disabled', + }, + } + end + + chat_id = 1 + chat = Chat.find_by(id: chat_id) + if !chat + return { + event: 'chat_status', + data: { + state: 'no_such_chat', + }, + } + end + + { + event: 'chat_status', + data: chat.state, + } + end +end diff --git a/lib/sessions/event/chat_status_agent.rb b/lib/sessions/event/chat_status_agent.rb new file mode 100644 index 000000000..8890fddd7 --- /dev/null +++ b/lib/sessions/event/chat_status_agent.rb @@ -0,0 +1,27 @@ +class Sessions::Event::ChatStatusAgent + + def self.run(_data, session, _client_id) + + # check if user has permissions + + # check if feature is enabled + if !Setting.get('chat') + return { + event: 'chat_status_agent', + data: { + state: 'chat_disabled', + }, + } + end + + # renew timestamps + state = Chat::Agent.state(session['id']) + Chat::Agent.state(session['id'], state) + + { + event: 'chat_status_agent', + data: Chat.agent_state(session['id']), + } + end + +end diff --git a/public/assets/chat/chat.coffee b/public/assets/chat/chat.coffee index 27338e4f7..2a5e11ba8 100644 --- a/public/assets/chat/chat.coffee +++ b/public/assets/chat/chat.coffee @@ -51,6 +51,8 @@ do($ = window.jQuery, window) -> input: @onInput ).autoGrow { extraLine: false } + @session_id = undefined + if !window.WebSocket console.log('Zammad Chat: Browser not supported') return @@ -61,7 +63,7 @@ do($ = window.jQuery, window) -> @ws.onopen = => console.log('ws connected') - @send "chat_status" + @send 'chat_status' @ws.onmessage = @onWebSocketMessage @@ -76,40 +78,52 @@ do($ = window.jQuery, window) -> event.preventDefault() @sendMessage() - send: (action, data) => + send: (event, data) => + console.log 'debug', 'ws:send', event, data pipe = JSON.stringify - action: action + event: event data: data - @ws.send pipe onWebSocketMessage: (e) => - pipe = JSON.parse( e.data ) - console.log 'debug', 'ws:onmessage', pipe + pipes = JSON.parse( e.data ) + console.log 'debug', 'ws:onmessage', pipes - switch pipe.action - when 'chat_message' - @receiveMessage pipe.data - when 'chat_typing_start' - @onAgentTypingStart() - when 'chat_typing_end' - @onAgentTypingEnd() - when 'chat_init' - switch pipe.data.state - when 'ok' - @onConnectionEstablished pipe.data.agent - when 'queue' - @onQueue pipe.data.position - when 'chat_status' - switch pipe.data.state - when 'ok' - @onReady() - when 'offline' - console.log 'Zammad Chat: No agent online' - when 'chat_disabled' - console.log 'Zammad Chat: Chat is disabled' - when 'no_seats_available' - console.log 'Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue + for pipe in pipes + switch pipe.event + when 'chat_session_message' + return if pipe.data.self_written + @receiveMessage pipe.data + when 'chat_session_typing' + return if pipe.data.self_written + @onAgentTypingStart() + if @stopTypingId + clearTimeout(@stopTypingId) + delay = => + @onAgentTypingEnd() + @stopTypingId = setTimeout(delay, 3000) + when 'chat_session_start' + switch pipe.data.state + when 'ok' + @onConnectionEstablished pipe.data.agent + when 'chat_session_init' + switch pipe.data.state + when 'ok' + @onConnectionEstablished pipe.data.agent + when 'queue' + @onQueue pipe.data.position + @session_id = pipe.data.session_id + when 'chat_status' + switch pipe.data.state + when 'online' + @onReady() + console.log 'Zammad Chat: ready' + when 'offline' + console.log 'Zammad Chat: No agent online' + when 'chat_disabled' + console.log 'Zammad Chat: Chat is disabled' + when 'no_seats_available' + console.log 'Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue onReady: => @show() if @options.show @@ -119,22 +133,22 @@ do($ = window.jQuery, window) -> @el.find('.zammad-chat-message--unread') .removeClass 'zammad-chat-message--unread' - clearTimeout(@inputTimeout) if @inputTimeout - - # fire typingEnd after 5 seconds - @inputTimeout = setTimeout @onTypingEnd, 5000 - - @onTypingStart() if @isTyping + @onTypingStart() onTypingStart: -> + + clearTimeout(@isTypingTimeout) if @isTypingTimeout + + # fire typingEnd after 5 seconds + @isTypingTimeout = setTimeout @onTypingEnd, 1500 + # send typing start event - @isTyping = true - @send 'typing_start' + if !@isTyping + @isTyping = true + @send 'chat_session_typing', {session_id: @session_id} onTypingEnd: => - # send typing end event @isTyping = false - @send 'typing_end' onSubmit: (event) => event.preventDefault() @@ -167,9 +181,10 @@ do($ = window.jQuery, window) -> @isTyping = false # send message event - @send 'message', - body: message + @send 'chat_session_message', + content: message id: @_messageCount + session_id: @session_id receiveMessage: (data) => # hide writing indicator @@ -180,7 +195,7 @@ do($ = window.jQuery, window) -> @lastAddedType = 'message--agent' unread = document.hidden ? " zammad-chat-message--unread" : "" @el.find('.zammad-chat-body').append @view('message') - message: data.body + message: data.message.content id: data.id from: 'agent' @scrollToBottom() @@ -275,7 +290,7 @@ do($ = window.jQuery, window) -> @el.find('.zammad-chat-body').scrollTop($('.zammad-chat-body').prop('scrollHeight')) connect: -> - @send('chat_init') + @send('chat_session_init') reconnect: => # set status to connecting diff --git a/public/assets/chat/chat.js b/public/assets/chat/chat.js index 6ef8a0949..d6b5109d6 100644 --- a/public/assets/chat/chat.js +++ b/public/assets/chat/chat.js @@ -1,66 +1,3 @@ -if (!window.zammadChatTemplates) { - window.zammadChatTemplates = {}; -} -window.zammadChatTemplates["agent"] = function (__obj) { - if (!__obj) __obj = {}; - var __out = [], __capture = function(callback) { - var out = __out, result; - __out = []; - callback.call(this); - result = __out.join(''); - __out = out; - return __safe(result); - }, __sanitize = function(value) { - if (value && value.ecoSafe) { - return value; - } else if (typeof value !== 'undefined' && value != null) { - return __escape(value); - } else { - return ''; - } - }, __safe, __objSafe = __obj.safe, __escape = __obj.escape; - __safe = __obj.safe = function(value) { - if (value && value.ecoSafe) { - return value; - } else { - if (!(typeof value !== 'undefined' && value != null)) value = ''; - var result = new String(value); - result.ecoSafe = true; - return result; - } - }; - if (!__escape) { - __escape = __obj.escape = function(value) { - return ('' + value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }; - } - (function() { - (function() { - __out.push('\n\n '); - - __out.push(__sanitize(this.agent.name)); - - __out.push(' '); - - __out.push(this.agentPhrase); - - __out.push('\n'); - - }).call(this); - - }).call(__obj); - __obj.safe = __objSafe, __obj.escape = __escape; - return __out.join(''); -}; - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; (function($, window) { @@ -151,6 +88,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }).autoGrow({ extraLine: false }); + this.session_id = void 0; if (!window.WebSocket) { console.log('Zammad Chat: Browser not supported'); return; @@ -161,7 +99,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); this.ws.onopen = (function(_this) { return function() { console.log('ws connected'); - return _this.send("chat_status"); + return _this.send('chat_status'); }; })(this); this.ws.onmessage = this.onWebSocketMessage; @@ -184,45 +122,76 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); } }; - ZammadChat.prototype.send = function(action, data) { + ZammadChat.prototype.send = function(event, data) { var pipe; + console.log('debug', 'ws:send', event, data); pipe = JSON.stringify({ - action: action, + event: event, data: data }); return this.ws.send(pipe); }; ZammadChat.prototype.onWebSocketMessage = function(e) { - var pipe; - pipe = JSON.parse(e.data); - console.log('debug', 'ws:onmessage', pipe); - switch (pipe.action) { - case 'chat_message': - return this.receiveMessage(pipe.data); - case 'chat_typing_start': - return this.onAgentTypingStart(); - case 'chat_typing_end': - return this.onAgentTypingEnd(); - case 'chat_init': - switch (pipe.data.state) { - case 'ok': - return this.onConnectionEstablished(pipe.data.agent); - case 'queue': - return this.onQueue(pipe.data.position); - } - break; - case 'chat_status': - switch (pipe.data.state) { - case 'ok': - return this.onReady(); - case 'offline': - return console.log('Zammad Chat: No agent online'); - case 'chat_disabled': - return console.log('Zammad Chat: Chat is disabled'); - case 'no_seats_available': - return console.log('Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue); - } + var delay, i, len, pipe, pipes; + pipes = JSON.parse(e.data); + console.log('debug', 'ws:onmessage', pipes); + for (i = 0, len = pipes.length; i < len; i++) { + pipe = pipes[i]; + switch (pipe.event) { + case 'chat_session_message': + if (pipe.data.self_written) { + return; + } + this.receiveMessage(pipe.data); + break; + case 'chat_session_typing': + if (pipe.data.self_written) { + return; + } + this.onAgentTypingStart(); + if (this.stopTypingId) { + clearTimeout(this.stopTypingId); + } + delay = (function(_this) { + return function() { + return _this.onAgentTypingEnd(); + }; + })(this); + this.stopTypingId = setTimeout(delay, 3000); + break; + case 'chat_session_start': + switch (pipe.data.state) { + case 'ok': + this.onConnectionEstablished(pipe.data.agent); + } + break; + case 'chat_session_init': + switch (pipe.data.state) { + case 'ok': + this.onConnectionEstablished(pipe.data.agent); + break; + case 'queue': + this.onQueue(pipe.data.position); + this.session_id = pipe.data.session_id; + } + break; + case 'chat_status': + switch (pipe.data.state) { + case 'online': + this.onReady(); + console.log('Zammad Chat: ready'); + break; + case 'offline': + console.log('Zammad Chat: No agent online'); + break; + case 'chat_disabled': + console.log('Zammad Chat: Chat is disabled'); + break; + case 'no_seats_available': + console.log('Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue); + } + } } }; @@ -234,23 +203,24 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); ZammadChat.prototype.onInput = function() { this.el.find('.zammad-chat-message--unread').removeClass('zammad-chat-message--unread'); - if (this.inputTimeout) { - clearTimeout(this.inputTimeout); - } - this.inputTimeout = setTimeout(this.onTypingEnd, 5000); - if (this.isTyping) { - return this.onTypingStart(); - } + return this.onTypingStart(); }; ZammadChat.prototype.onTypingStart = function() { - this.isTyping = true; - return this.send('typing_start'); + if (this.isTypingTimeout) { + clearTimeout(this.isTypingTimeout); + } + this.isTypingTimeout = setTimeout(this.onTypingEnd, 1500); + if (!this.isTyping) { + this.isTyping = true; + return this.send('chat_session_typing', { + session_id: this.session_id + }); + } }; ZammadChat.prototype.onTypingEnd = function() { - this.isTyping = false; - return this.send('typing_end'); + return this.isTyping = false; }; ZammadChat.prototype.onSubmit = function(event) { @@ -280,9 +250,10 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); this.el.find('.zammad-chat-input').val(''); this.scrollToBottom(); this.isTyping = false; - return this.send('message', { - body: message, - id: this._messageCount + return this.send('chat_session_message', { + content: message, + id: this._messageCount, + session_id: this.session_id }); }; @@ -295,7 +266,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); " zammad-chat-message--unread": "" }; this.el.find('.zammad-chat-body').append(this.view('message')({ - message: data.body, + message: data.message.content, id: data.id, from: 'agent' })); @@ -404,7 +375,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; ZammadChat.prototype.connect = function() { - return this.send('chat_init'); + return this.send('chat_session_init'); }; ZammadChat.prototype.reconnect = function() { @@ -456,6 +427,69 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }); })(window.jQuery, window); +if (!window.zammadChatTemplates) { + window.zammadChatTemplates = {}; +} +window.zammadChatTemplates["agent"] = function (__obj) { + if (!__obj) __obj = {}; + var __out = [], __capture = function(callback) { + var out = __out, result; + __out = []; + callback.call(this); + result = __out.join(''); + __out = out; + return __safe(result); + }, __sanitize = function(value) { + if (value && value.ecoSafe) { + return value; + } else if (typeof value !== 'undefined' && value != null) { + return __escape(value); + } else { + return ''; + } + }, __safe, __objSafe = __obj.safe, __escape = __obj.escape; + __safe = __obj.safe = function(value) { + if (value && value.ecoSafe) { + return value; + } else { + if (!(typeof value !== 'undefined' && value != null)) value = ''; + var result = new String(value); + result.ecoSafe = true; + return result; + } + }; + if (!__escape) { + __escape = __obj.escape = function(value) { + return ('' + value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + } + (function() { + (function() { + __out.push('\n\n '); + + __out.push(__sanitize(this.agent.name)); + + __out.push(' '); + + __out.push(this.agentPhrase); + + __out.push('\n'); + + }).call(this); + + }).call(__obj); + __obj.safe = __objSafe, __obj.escape = __escape; + return __out.join(''); +}; + /*! * ---------------------------------------------------------------------------- * "THE BEER-WARE LICENSE" (Revision 42): diff --git a/public/assets/chat/chat.min.js b/public/assets/chat/chat.min.js index 898670000..ec3d2050f 100644 --- a/public/assets/chat/chat.min.js +++ b/public/assets/chat/chat.min.js @@ -1 +1 @@ -window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('\n\n '),n.push(a(this.agent.name)),n.push(" "),n.push(this.agentPhrase),n.push("\n")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")};var bind=function(t,e){return function(){return t.apply(e,arguments)}};!function(t,e){var n;return n=function(){function n(n,a){this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.toggle=bind(this.toggle,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onTypingEnd=bind(this.onTypingEnd,this),this.onInput=bind(this.onInput,this),this.onReady=bind(this.onReady,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.view=bind(this.view,this),this.T=bind(this.T,this);var s;return this.options=t.extend({},this.defaults,a),this.el=t(this.view("chat")(this.options)),this.options.target.append(this.el),this.setAgentOnlineState(this.isOnline),this.el.find(".zammad-chat-header").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-input").on({keydown:this.checkForEnter,input:this.onInput}).autoGrow({extraLine:!1}),e.WebSocket?(s="ws://localhost:6042",this.ws=new e.WebSocket(s),console.log("Connecting to "+s),this.ws.onopen=function(t){return function(){return console.log("ws connected"),t.send("chat_status")}}(this),this.ws.onmessage=this.onWebSocketMessage,this.ws.onclose=function(t){return function(t){return console.log("debug","close websocket connection")}}(this),void(this.ws.onerror=function(t){return function(t){return console.log("debug","ws:onerror",t)}}(this))):void console.log("Zammad Chat: Browser not supported")}return n.prototype.defaults={invitationPhrase:"Chat with us!",agentPhrase:" is helping you",show:!0,target:t("body")},n.prototype._messageCount=0,n.prototype.isOpen=!1,n.prototype.blinkOnlineInterval=null,n.prototype.stopBlinOnlineStateTimeout=null,n.prototype.showTimeEveryXMinutes=1,n.prototype.lastTimestamp=null,n.prototype.lastAddedType=null,n.prototype.inputTimeout=null,n.prototype.isTyping=!1,n.prototype.isOnline=!0,n.prototype.strings={Online:"Online",Offline:"Offline",Connecting:"Connecting","Connection re-established":"Connection re-established",Today:"Today"},n.prototype.T=function(t){return this.strings[t]},n.prototype.view=function(t){return function(n){return function(a){return a||(a={}),a.T=n.T,e.zammadChatTemplates[t](a)}}(this)},n.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},n.prototype.send=function(t,e){var n;return n=JSON.stringify({action:t,data:e}),this.ws.send(n)},n.prototype.onWebSocketMessage=function(t){var e;switch(e=JSON.parse(t.data),console.log("debug","ws:onmessage",e),e.action){case"chat_message":return this.receiveMessage(e.data);case"chat_typing_start":return this.onAgentTypingStart();case"chat_typing_end":return this.onAgentTypingEnd();case"chat_init":switch(e.data.state){case"ok":return this.onConnectionEstablished(e.data.agent);case"queue":return this.onQueue(e.data.position)}break;case"chat_status":switch(e.data.state){case"ok":return this.onReady();case"offline":return console.log("Zammad Chat: No agent online");case"chat_disabled":return console.log("Zammad Chat: Chat is disabled");case"no_seats_available":return console.log("Zammad Chat: Too many clients in queue. Clients in queue: ",e.data.queue)}}},n.prototype.onReady=function(){return this.options.show?this.show():void 0},n.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),this.inputTimeout&&clearTimeout(this.inputTimeout),this.inputTimeout=setTimeout(this.onTypingEnd,5e3),this.isTyping?this.onTypingStart():void 0},n.prototype.onTypingStart=function(){return this.isTyping=!0,this.send("typing_start")},n.prototype.onTypingEnd=function(){return this.isTyping=!1,this.send("typing_end")},n.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},n.prototype.sendMessage=function(){var t,e;return(t=this.el.find(".zammad-chat-input").val())?(e=this.view("message")({message:t,from:"customer",id:this._messageCount++}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").size()?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.el.find(".zammad-chat-input").val(""),this.scrollToBottom(),this.isTyping=!1,this.send("message",{body:t,id:this._messageCount})):void 0},n.prototype.receiveMessage=function(t){var e,n;return this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.lastAddedType="message--agent",n=null!=(e=document.hidden)?e:{" zammad-chat-message--unread":""},this.el.find(".zammad-chat-body").append(this.view("message")({message:t.body,id:t.id,from:"agent"})),this.scrollToBottom()},n.prototype.toggle=function(){return this.isOpen?this.close():this.open()},n.prototype.open=function(){return this.showLoader(),this.el.addClass("zammad-chat-is-open").animate({bottom:0},500,this.onOpenAnimationEnd)},n.prototype.onOpenAnimationEnd=function(){return this.isOpen=!0,this.connect()},n.prototype.close=function(){var t;return t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-t},500,this.onCloseAnimationEnd)},n.prototype.onCloseAnimationEnd=function(){return this.el.removeClass("zammad-chat-is-open"),this.disconnect(),this.isOpen=!1},n.prototype.hide=function(){return this.el.removeClass("zammad-chat-is-visible")},n.prototype.show=function(){var t;return this.el.addClass("zammad-chat-is-visible"),t=this.el.outerHeight()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t)},n.prototype.onQueue=function(t){return console.log("onQueue",t),this.inQueue=!0,this.el.find(".zammad-chat-body").html(this.view("waiting")({position:t}))},n.prototype.onAgentTypingStart=function(){return this.el.find(".zammad-chat-message--typing").size()?void 0:(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.scrollToBottom())},n.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},n.prototype.maybeAddTimestamp=function(){var t,e,n;return n=Date.now(),!this.lastTimestamp||n-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=n):(this.addStatus(t,e),this.lastTimestamp=n,this.lastAddedType="timestamp")):void 0},n.prototype.updateLastTimestamp=function(t,e){return this.el.find(".zammad-chat-body").find(".zammad-chat-status").last().replaceWith(this.view("status")({label:t,time:e}))},n.prototype.addStatus=function(t,e){return this.el.find(".zammad-chat-body").append(this.view("status")({label:t,time:e}))},n.prototype.scrollToBottom=function(){return this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight"))},n.prototype.connect=function(){return this.send("chat_init")},n.prototype.reconnect=function(){return this.lastAddedType="status",this.el.find(".zammad-chat-agent-status").attr("data-status","connecting").text(this.T("Connecting")),this.addStatus(this.T("Connection lost"))},n.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.el.find(".zammad-chat-agent-status").attr("data-status","online").text(this.T("Online")),this.addStatus(this.T("Connection re-established"))},n.prototype.disconnect=function(){return this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden")},n.prototype.onConnectionEstablished=function(t){return this.inQueue=!1,this.agent=t,this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:t})),this.el.find(".zammad-chat-body").empty(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-input").focus()},n.prototype.showLoader=function(){return this.el.find(".zammad-chat-body").html(this.view("loader")())},n.prototype.setAgentOnlineState=function(t){return this.isOnline=t,this.el.find(".zammad-chat-agent-status").toggleClass("zammad-chat-is-online",t).text(t?this.T("Online"):this.T("Offline"))},n}(),t(document).ready(function(){return e.zammadChat=new n})}(window.jQuery,window),jQuery.fn.autoGrow=function(t){return this.each(function(){var e=jQuery.extend({extraLine:!0},t),n=function(t){return jQuery(t).after('
'),jQuery(t).next(".autogrow-textarea-mirror")[0]},a=function(t){i.innerHTML=String(t.value).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">").replace(/ /g," ").replace(/\n/g,"
")+(e.extraLine?".
.":""),jQuery(t).height()!=jQuery(i).height()&&jQuery(t).height(jQuery(i).height())},s=function(){a(this)},i=n(this);i.style.display="none",i.style.wordWrap="break-word",i.style.whiteSpace="normal",i.style.padding=jQuery(this).css("paddingTop")+" "+jQuery(this).css("paddingRight")+" "+jQuery(this).css("paddingBottom")+" "+jQuery(this).css("paddingLeft"),i.style.width=jQuery(this).css("width"),i.style.fontFamily=jQuery(this).css("font-family"),i.style.fontSize=jQuery(this).css("font-size"),i.style.lineHeight=jQuery(this).css("line-height"),this.style.overflow="hidden",this.style.minHeight=this.rows+"em",this.onkeyup=s,a(this)})},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,n=[],a=t.safe,s=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},s||(s=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n
\n
\n Online\n \n \n \n \n
\n
\n \n
\n
\n \n '),n.push(this.invitationPhrase),n.push('\n
\n
\n
\n
\n \n \n
\n
')}).call(this)}.call(t),t.safe=a,t.escape=s,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.loader=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n \n \n \n \n \n '),n.push(a(this.T("Connecting"))),n.push("\n
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.message=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n '),n.push(this.message),n.push("\n
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.status=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
'),n.push(a(this.label)),n.push(""),n.push(a(this.time)),n.push("
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.typingIndicator=function(t){t||(t={});var e,n=[],a=t.safe,s=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},s||(s=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n \n \n \n \n \n \n \n
')}).call(this)}.call(t),t.safe=a,t.escape=s,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n
\n \n \n \n \n \n Leider sind gerade alle Mitarbeiter belegt.
\n Warteliste-Position: '),n.push(a(this.position)),n.push("\n
\n
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")}; \ No newline at end of file +var bind=function(t,e){return function(){return t.apply(e,arguments)}};!function(t,e){var n;return n=function(){function n(n,a){this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.toggle=bind(this.toggle,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onTypingEnd=bind(this.onTypingEnd,this),this.onInput=bind(this.onInput,this),this.onReady=bind(this.onReady,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.view=bind(this.view,this),this.T=bind(this.T,this);var s;return this.options=t.extend({},this.defaults,a),this.el=t(this.view("chat")(this.options)),this.options.target.append(this.el),this.setAgentOnlineState(this.isOnline),this.el.find(".zammad-chat-header").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-input").on({keydown:this.checkForEnter,input:this.onInput}).autoGrow({extraLine:!1}),this.session_id=void 0,e.WebSocket?(s="ws://localhost:6042",this.ws=new e.WebSocket(s),console.log("Connecting to "+s),this.ws.onopen=function(t){return function(){return console.log("ws connected"),t.send("chat_status")}}(this),this.ws.onmessage=this.onWebSocketMessage,this.ws.onclose=function(t){return function(t){return console.log("debug","close websocket connection")}}(this),void(this.ws.onerror=function(t){return function(t){return console.log("debug","ws:onerror",t)}}(this))):void console.log("Zammad Chat: Browser not supported")}return n.prototype.defaults={invitationPhrase:"Chat with us!",agentPhrase:" is helping you",show:!0,target:t("body")},n.prototype._messageCount=0,n.prototype.isOpen=!1,n.prototype.blinkOnlineInterval=null,n.prototype.stopBlinOnlineStateTimeout=null,n.prototype.showTimeEveryXMinutes=1,n.prototype.lastTimestamp=null,n.prototype.lastAddedType=null,n.prototype.inputTimeout=null,n.prototype.isTyping=!1,n.prototype.isOnline=!0,n.prototype.strings={Online:"Online",Offline:"Offline",Connecting:"Connecting","Connection re-established":"Connection re-established",Today:"Today"},n.prototype.T=function(t){return this.strings[t]},n.prototype.view=function(t){return function(n){return function(a){return a||(a={}),a.T=n.T,e.zammadChatTemplates[t](a)}}(this)},n.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},n.prototype.send=function(t,e){var n;return console.log("debug","ws:send",t,e),n=JSON.stringify({event:t,data:e}),this.ws.send(n)},n.prototype.onWebSocketMessage=function(t){var e,n,a,s,i;for(i=JSON.parse(t.data),console.log("debug","ws:onmessage",i),n=0,a=i.length;a>n;n++)switch(s=i[n],s.event){case"chat_session_message":if(s.data.self_written)return;this.receiveMessage(s.data);break;case"chat_session_typing":if(s.data.self_written)return;this.onAgentTypingStart(),this.stopTypingId&&clearTimeout(this.stopTypingId),e=function(t){return function(){return t.onAgentTypingEnd()}}(this),this.stopTypingId=setTimeout(e,3e3);break;case"chat_session_start":switch(s.data.state){case"ok":this.onConnectionEstablished(s.data.agent)}break;case"chat_session_init":switch(s.data.state){case"ok":this.onConnectionEstablished(s.data.agent);break;case"queue":this.onQueue(s.data.position),this.session_id=s.data.session_id}break;case"chat_status":switch(s.data.state){case"online":this.onReady(),console.log("Zammad Chat: ready");break;case"offline":console.log("Zammad Chat: No agent online");break;case"chat_disabled":console.log("Zammad Chat: Chat is disabled");break;case"no_seats_available":console.log("Zammad Chat: Too many clients in queue. Clients in queue: ",s.data.queue)}}},n.prototype.onReady=function(){return this.options.show?this.show():void 0},n.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),this.onTypingStart()},n.prototype.onTypingStart=function(){return this.isTypingTimeout&&clearTimeout(this.isTypingTimeout),this.isTypingTimeout=setTimeout(this.onTypingEnd,1500),this.isTyping?void 0:(this.isTyping=!0,this.send("chat_session_typing",{session_id:this.session_id}))},n.prototype.onTypingEnd=function(){return this.isTyping=!1},n.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},n.prototype.sendMessage=function(){var t,e;return(t=this.el.find(".zammad-chat-input").val())?(e=this.view("message")({message:t,from:"customer",id:this._messageCount++}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").size()?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.el.find(".zammad-chat-input").val(""),this.scrollToBottom(),this.isTyping=!1,this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.session_id})):void 0},n.prototype.receiveMessage=function(t){var e,n;return this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.lastAddedType="message--agent",n=null!=(e=document.hidden)?e:{" zammad-chat-message--unread":""},this.el.find(".zammad-chat-body").append(this.view("message")({message:t.message.content,id:t.id,from:"agent"})),this.scrollToBottom()},n.prototype.toggle=function(){return this.isOpen?this.close():this.open()},n.prototype.open=function(){return this.showLoader(),this.el.addClass("zammad-chat-is-open").animate({bottom:0},500,this.onOpenAnimationEnd)},n.prototype.onOpenAnimationEnd=function(){return this.isOpen=!0,this.connect()},n.prototype.close=function(){var t;return t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-t},500,this.onCloseAnimationEnd)},n.prototype.onCloseAnimationEnd=function(){return this.el.removeClass("zammad-chat-is-open"),this.disconnect(),this.isOpen=!1},n.prototype.hide=function(){return this.el.removeClass("zammad-chat-is-visible")},n.prototype.show=function(){var t;return this.el.addClass("zammad-chat-is-visible"),t=this.el.outerHeight()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t)},n.prototype.onQueue=function(t){return console.log("onQueue",t),this.inQueue=!0,this.el.find(".zammad-chat-body").html(this.view("waiting")({position:t}))},n.prototype.onAgentTypingStart=function(){return this.el.find(".zammad-chat-message--typing").size()?void 0:(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.scrollToBottom())},n.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},n.prototype.maybeAddTimestamp=function(){var t,e,n;return n=Date.now(),!this.lastTimestamp||n-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=n):(this.addStatus(t,e),this.lastTimestamp=n,this.lastAddedType="timestamp")):void 0},n.prototype.updateLastTimestamp=function(t,e){return this.el.find(".zammad-chat-body").find(".zammad-chat-status").last().replaceWith(this.view("status")({label:t,time:e}))},n.prototype.addStatus=function(t,e){return this.el.find(".zammad-chat-body").append(this.view("status")({label:t,time:e}))},n.prototype.scrollToBottom=function(){return this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight"))},n.prototype.connect=function(){return this.send("chat_session_init")},n.prototype.reconnect=function(){return this.lastAddedType="status",this.el.find(".zammad-chat-agent-status").attr("data-status","connecting").text(this.T("Connecting")),this.addStatus(this.T("Connection lost"))},n.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.el.find(".zammad-chat-agent-status").attr("data-status","online").text(this.T("Online")),this.addStatus(this.T("Connection re-established"))},n.prototype.disconnect=function(){return this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden")},n.prototype.onConnectionEstablished=function(t){return this.inQueue=!1,this.agent=t,this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:t})),this.el.find(".zammad-chat-body").empty(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-input").focus()},n.prototype.showLoader=function(){return this.el.find(".zammad-chat-body").html(this.view("loader")())},n.prototype.setAgentOnlineState=function(t){return this.isOnline=t,this.el.find(".zammad-chat-agent-status").toggleClass("zammad-chat-is-online",t).text(t?this.T("Online"):this.T("Offline"))},n}(),t(document).ready(function(){return e.zammadChat=new n})}(window.jQuery,window),window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('\n\n '),n.push(a(this.agent.name)),n.push(" "),n.push(this.agentPhrase),n.push("\n")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},jQuery.fn.autoGrow=function(t){return this.each(function(){var e=jQuery.extend({extraLine:!0},t),n=function(t){return jQuery(t).after('
'),jQuery(t).next(".autogrow-textarea-mirror")[0]},a=function(t){i.innerHTML=String(t.value).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">").replace(/ /g," ").replace(/\n/g,"
")+(e.extraLine?".
.":""),jQuery(t).height()!=jQuery(i).height()&&jQuery(t).height(jQuery(i).height())},s=function(){a(this)},i=n(this);i.style.display="none",i.style.wordWrap="break-word",i.style.whiteSpace="normal",i.style.padding=jQuery(this).css("paddingTop")+" "+jQuery(this).css("paddingRight")+" "+jQuery(this).css("paddingBottom")+" "+jQuery(this).css("paddingLeft"),i.style.width=jQuery(this).css("width"),i.style.fontFamily=jQuery(this).css("font-family"),i.style.fontSize=jQuery(this).css("font-size"),i.style.lineHeight=jQuery(this).css("line-height"),this.style.overflow="hidden",this.style.minHeight=this.rows+"em",this.onkeyup=s,a(this)})},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,n=[],a=t.safe,s=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},s||(s=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n
\n
\n Online\n \n \n \n \n
\n
\n \n
\n
\n \n '),n.push(this.invitationPhrase),n.push('\n
\n
\n
\n
\n \n \n
\n
')}).call(this)}.call(t),t.safe=a,t.escape=s,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.loader=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n \n \n \n \n \n '),n.push(a(this.T("Connecting"))),n.push("\n
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.message=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n '),n.push(this.message),n.push("\n
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.status=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
'),n.push(a(this.label)),n.push(""),n.push(a(this.time)),n.push("
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.typingIndicator=function(t){t||(t={});var e,n=[],a=t.safe,s=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},s||(s=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n \n \n \n \n \n \n \n
')}).call(this)}.call(t),t.safe=a,t.escape=s,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting=function(t){t||(t={});var e,n=[],a=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?i(t):""},s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
\n
\n \n \n \n \n \n Leider sind gerade alle Mitarbeiter belegt.
\n Warteliste-Position: '),n.push(a(this.position)),n.push("\n
\n
")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")}; \ No newline at end of file diff --git a/public/assets/chat/index.html b/public/assets/chat/index.html index a04ef6f00..7514b78f6 100644 --- a/public/assets/chat/index.html +++ b/public/assets/chat/index.html @@ -94,8 +94,11 @@ + + +