chat.coffee 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
  1. do($ = window.jQuery, window) ->
  2. scripts = document.getElementsByTagName('script')
  3. myScript = scripts[scripts.length - 1]
  4. scriptHost = myScript.src.match('.*://([^:/]*).*')[1]
  5. scriptProtocol = myScript.src.match('(.*)://[^:/]*.*')[1]
  6. # Define the plugin class
  7. class Base
  8. defaults:
  9. debug: false
  10. constructor: (options) ->
  11. @options = $.extend {}, @defaults, options
  12. @log = new Log(debug: @options.debug, logPrefix: @options.logPrefix || @logPrefix)
  13. class Log
  14. defaults:
  15. debug: false
  16. constructor: (options) ->
  17. @options = $.extend {}, @defaults, options
  18. debug: (items...) =>
  19. return if !@options.debug
  20. @log('debug', items)
  21. notice: (items...) =>
  22. @log('notice', items)
  23. error: (items...) =>
  24. @log('error', items)
  25. log: (level, items) =>
  26. items.unshift('||')
  27. items.unshift(level)
  28. items.unshift(@options.logPrefix)
  29. console.log.apply console, items
  30. return if !@options.debug
  31. logString = ''
  32. for item in items
  33. logString += ' '
  34. if typeof item is 'object'
  35. logString += JSON.stringify(item)
  36. else if item && item.toString
  37. logString += item.toString()
  38. else
  39. logString += item
  40. $('.js-chatLogDisplay').prepend('<div>' + logString + '</div>')
  41. class Timeout extends Base
  42. timeoutStartedAt: null
  43. logPrefix: 'timeout'
  44. defaults:
  45. debug: false
  46. timeout: 4
  47. timeoutIntervallCheck: 0.5
  48. constructor: (options) ->
  49. super(options)
  50. start: =>
  51. @stop()
  52. timeoutStartedAt = new Date
  53. check = =>
  54. timeLeft = new Date - new Date(timeoutStartedAt.getTime() + @options.timeout * 1000 * 60)
  55. @log.debug "Timeout check for #{@options.timeout} minutes (left #{timeLeft/1000} sec.)"#, new Date
  56. return if timeLeft < 0
  57. @stop()
  58. @options.callback()
  59. @log.debug "Start timeout in #{@options.timeout} minutes"#, new Date
  60. @intervallId = setInterval(check, @options.timeoutIntervallCheck * 1000 * 60)
  61. stop: =>
  62. return if !@intervallId
  63. @log.debug "Stop timeout of #{@options.timeout} minutes"#, new Date
  64. clearInterval(@intervallId)
  65. class Io extends Base
  66. logPrefix: 'io'
  67. constructor: (options) ->
  68. super(options)
  69. set: (params) =>
  70. for key, value of params
  71. @options[key] = value
  72. connect: =>
  73. @log.debug "Connecting to #{@options.host}"
  74. @ws = new window.WebSocket("#{@options.host}")
  75. @ws.onopen = (e) =>
  76. @log.debug 'onOpen', e
  77. @options.onOpen(e)
  78. @ping()
  79. @ws.onmessage = (e) =>
  80. pipes = JSON.parse(e.data)
  81. @log.debug 'onMessage', e.data
  82. for pipe in pipes
  83. if pipe.event is 'pong'
  84. @ping()
  85. if @options.onMessage
  86. @options.onMessage(pipes)
  87. @ws.onclose = (e) =>
  88. @log.debug 'close websocket connection', e
  89. if @pingDelayId
  90. clearTimeout(@pingDelayId)
  91. if @manualClose
  92. @log.debug 'manual close, onClose callback'
  93. @manualClose = false
  94. if @options.onClose
  95. @options.onClose(e)
  96. else
  97. @log.debug 'error close, onError callback'
  98. if @options.onError
  99. @options.onError('Connection lost...')
  100. @ws.onerror = (e) =>
  101. @log.debug 'onError', e
  102. if @options.onError
  103. @options.onError(e)
  104. close: =>
  105. @log.debug 'close websocket manually'
  106. @manualClose = true
  107. @ws.close()
  108. reconnect: =>
  109. @log.debug 'reconnect'
  110. @close()
  111. @connect()
  112. send: (event, data = {}) =>
  113. @log.debug 'send', event, data
  114. msg = JSON.stringify
  115. event: event
  116. data: data
  117. @ws.send msg
  118. ping: =>
  119. localPing = =>
  120. @send('ping')
  121. @pingDelayId = setTimeout(localPing, 29000)
  122. class ZammadChat extends Base
  123. defaults:
  124. chatId: undefined
  125. show: true
  126. target: $('body')
  127. host: ''
  128. debug: false
  129. flat: false
  130. lang: undefined
  131. cssAutoload: true
  132. cssUrl: undefined
  133. fontSize: undefined
  134. buttonClass: 'open-zammad-chat'
  135. inactiveClass: 'is-inactive'
  136. title: '<strong>Chat</strong> with us!'
  137. scrollHint: 'Scrolle nach unten um neue Nachrichten zu sehen'
  138. idleTimeout: 6
  139. idleTimeoutIntervallCheck: 0.5
  140. inactiveTimeout: 8
  141. inactiveTimeoutIntervallCheck: 0.5
  142. waitingListTimeout: 4
  143. waitingListTimeoutIntervallCheck: 0.5
  144. logPrefix: 'chat'
  145. _messageCount: 0
  146. isOpen: false
  147. blinkOnlineInterval: null
  148. stopBlinOnlineStateTimeout: null
  149. showTimeEveryXMinutes: 2
  150. lastTimestamp: null
  151. lastAddedType: null
  152. inputTimeout: null
  153. isTyping: false
  154. state: 'offline'
  155. initialQueueDelay: 10000
  156. translations:
  157. 'de':
  158. '<strong>Chat</strong> with us!': '<strong>Chatte</strong> mit uns!'
  159. 'Scroll down to see new messages': 'Scrolle nach unten um neue Nachrichten zu sehen'
  160. 'Online': 'Online'
  161. 'Online': 'Online'
  162. 'Offline': 'Offline'
  163. 'Connecting': 'Verbinden'
  164. 'Connection re-established': 'Verbindung wiederhergestellt'
  165. 'Today': 'Heute'
  166. 'Send': 'Senden'
  167. 'Compose your message...': 'Ihre Nachricht...'
  168. 'All colleagues are busy.': 'Alle Kollegen sind belegt.'
  169. 'You are on waiting list position <strong>%s</strong>.': 'Sie sind in der Warteliste an der Position <strong>%s</strong>.'
  170. 'Start new conversation': 'Neue Konversation starten'
  171. 'Since you didn\'t respond in the last %s minutes your conversation with <strong>%s</strong> got closed.': 'Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit <strong>%s</strong> geschlossen.'
  172. 'Since you didn\'t respond in the last %s minutes your conversation got closed.': 'Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen.'
  173. 'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': 'Es tut uns leid, es dauert länger als erwartet, um einen freien Platz zu erhalten. Bitte versuchen Sie es zu einem späteren Zeitpunkt noch einmal oder schicken Sie uns eine E-Mail. Vielen Dank!'
  174. 'fr':
  175. '<strong>Chat</strong> with us!': '<strong>Chattez</strong> avec nous!'
  176. 'Scroll down to see new messages': 'Faites défiler pour lire les nouveaux messages'
  177. 'Online': 'En-ligne'
  178. 'Online': 'En-ligne'
  179. 'Offline': 'Hors-ligne'
  180. 'Connecting': 'Connexion en cours'
  181. 'Connection re-established': 'Connexion rétablie'
  182. 'Today': 'Aujourdhui'
  183. 'Send': 'Envoyer'
  184. 'Compose your message...': 'Composez votre message...'
  185. 'All colleagues are busy.': 'Tous les collègues sont actuellement occupés.'
  186. 'You are on waiting list position <strong>%s</strong>.': 'Vous êtes actuellement en <strong>%s</strong> position dans la file d\'attente.'
  187. 'Start new conversation': 'Démarrer une nouvelle conversation'
  188. 'Since you didn\'t respond in the last %s minutes your conversation with <strong>%s</strong> got closed.': 'Si vous ne répondez pas dans les <strong>%s</strong> minutes, votre conversation avec %s va être fermée.'
  189. 'Since you didn\'t respond in the last %s minutes your conversation got closed.': 'Si vous ne répondez pas dans les %s minutes, votre conversation va être fermée.'
  190. 'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': 'Nous sommes désolés, il faut plus de temps que prévu pour obtenir un emplacement vide. Veuillez réessayer ultérieurement ou nous envoyer un courriel. Je vous remercie!'
  191. 'zh-cn':
  192. '<strong>Chat</strong> with us!': '发起<strong>即时对话</strong>!'
  193. 'Scroll down to see new messages': '向下滚动以查看新消息'
  194. 'Online': '在线'
  195. 'Online': '在线'
  196. 'Offline': '离线'
  197. 'Connecting': '连接中'
  198. 'Connection re-established': '正在重新建立连接'
  199. 'Today': '今天'
  200. 'Send': '发送'
  201. 'Compose your message...': '正在输入信息...'
  202. 'All colleagues are busy.': '所有工作人员都在忙碌中.'
  203. 'You are on waiting list position <strong>%s</strong>.': '您目前的等候位置是第 <strong>%s</strong> 位.'
  204. 'Start new conversation': '开始新的会话'
  205. 'Since you didn\'t respond in the last %s minutes your conversation with <strong>%s</strong> got closed.': '由于您超过 %s 分钟没有回复, 您与 <strong>%s</strong> 的会话已被关闭.'
  206. 'Since you didn\'t respond in the last %s minutes your conversation got closed.': '由于您超过 %s 分钟没有任何回复, 该对话已被关闭.'
  207. 'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': '非常抱歉, 目前需要等候更长的时间才能接入对话, 请稍后重试或向我们发送电子邮件. 谢谢!'
  208. 'zh-tw':
  209. '<strong>Chat</strong> with us!': '開始<strong>即時對话</strong>!'
  210. 'Scroll down to see new messages': '向下滑動以查看新訊息'
  211. 'Online': '線上'
  212. 'Online': '線上'
  213. 'Offline': '离线'
  214. 'Connecting': '連線中'
  215. 'Connection re-established': '正在重新建立連線中'
  216. 'Today': '今天'
  217. 'Send': '發送'
  218. 'Compose your message...': '正在輸入訊息...'
  219. 'All colleagues are busy.': '所有服務人員都在忙碌中.'
  220. 'You are on waiting list position <strong>%s</strong>.': '你目前的等候位置是第 <strong>%s</strong> 順位.'
  221. 'Start new conversation': '開始新的對話'
  222. 'Since you didn\'t respond in the last %s minutes your conversation with <strong>%s</strong> got closed.': '由於你超過 %s 分鐘沒有回應, 你與 <strong>%s</strong> 的對話已被關閉.'
  223. 'Since you didn\'t respond in the last %s minutes your conversation got closed.': '由於你超過 %s 分鐘沒有任何回應, 該對話已被關閉.'
  224. 'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': '非常抱歉, 當前需要等候更長的時間方可排入對話程序, 請稍後重試或向我們寄送電子郵件. 謝謝!'
  225. sessionId: undefined
  226. scrolledToBottom: true
  227. scrollSnapTolerance: 10
  228. T: (string, items...) =>
  229. if @options.lang && @options.lang isnt 'en'
  230. if !@translations[@options.lang]
  231. @log.notice "Translation '#{@options.lang}' needed!"
  232. else
  233. translations = @translations[@options.lang]
  234. if !translations[string]
  235. @log.notice "Translation needed for '#{string}'"
  236. string = translations[string] || string
  237. if items
  238. for item in items
  239. string = string.replace(/%s/, item)
  240. string
  241. view: (name) =>
  242. return (options) =>
  243. if !options
  244. options = {}
  245. options.T = @T
  246. options.background = @options.background
  247. options.flat = @options.flat
  248. options.fontSize = @options.fontSize
  249. return window.zammadChatTemplates[name](options)
  250. constructor: (options) ->
  251. @options = $.extend {}, @defaults, options
  252. super(@options)
  253. # fullscreen
  254. @isFullscreen = (window.matchMedia and window.matchMedia('(max-width: 768px)').matches)
  255. @scrollRoot = $(@getScrollRoot())
  256. # check prerequisites
  257. if !$
  258. @state = 'unsupported'
  259. @log.notice 'Chat: no jquery found!'
  260. return
  261. if !window.WebSocket or !sessionStorage
  262. @state = 'unsupported'
  263. @log.notice 'Chat: Browser not supported!'
  264. return
  265. if !@options.chatId
  266. @state = 'unsupported'
  267. @log.error 'Chat: need chatId as option!'
  268. return
  269. # detect language
  270. if !@options.lang
  271. @options.lang = $('html').attr('lang')
  272. if @options.lang
  273. if !@translations[@options.lang]
  274. @log.debug "lang: No #{@options.lang} found, try first two letters"
  275. @options.lang = @options.lang.replace(/-.+?$/, '') # replace "-xx" of xx-xx
  276. @log.debug "lang: #{@options.lang}"
  277. # detect host
  278. @detectHost() if !@options.host
  279. @loadCss()
  280. @io = new Io(@options)
  281. @io.set(
  282. onOpen: @render
  283. onClose: @onWebSocketClose
  284. onMessage: @onWebSocketMessage
  285. onError: @onError
  286. )
  287. @io.connect()
  288. getScrollRoot: ->
  289. return document.scrollingElement if 'scrollingElement' of document
  290. html = document.documentElement
  291. start = html.scrollTop
  292. html.scrollTop = start + 1
  293. end = html.scrollTop
  294. html.scrollTop = start
  295. return if end > start then html else document.body
  296. render: =>
  297. if !@el || !$('.zammad-chat').get(0)
  298. @renderBase()
  299. # disable open button
  300. $(".#{ @options.buttonClass }").addClass @inactiveClass
  301. @setAgentOnlineState 'online'
  302. @log.debug 'widget rendered'
  303. @startTimeoutObservers()
  304. @idleTimeout.start()
  305. # get current chat status
  306. @sessionId = sessionStorage.getItem('sessionId')
  307. @send 'chat_status_customer',
  308. session_id: @sessionId
  309. url: window.location.href
  310. renderBase: ->
  311. @el = $(@view('chat')(
  312. title: @options.title,
  313. scrollHint: @options.scrollHint
  314. ))
  315. @options.target.append @el
  316. @input = @el.find('.zammad-chat-input')
  317. # start bindings
  318. @el.find('.js-chat-open').click @open
  319. @el.find('.js-chat-toggle').click @toggle
  320. @el.find('.zammad-chat-controls').on 'submit', @onSubmit
  321. @el.find('.zammad-chat-body').on 'scroll', @detectScrolledtoBottom
  322. @el.find('.zammad-scroll-hint').click @onScrollHintClick
  323. @input.on
  324. keydown: @checkForEnter
  325. input: @onInput
  326. $(window).on('beforeunload', =>
  327. @onLeaveTemporary()
  328. )
  329. $(window).bind('hashchange', =>
  330. if @isOpen
  331. if @sessionId
  332. @send 'chat_session_notice',
  333. session_id: @sessionId
  334. message: window.location.href
  335. return
  336. @idleTimeout.start()
  337. )
  338. if @isFullscreen
  339. @input.on
  340. focus: @onFocus
  341. focusout: @onFocusOut
  342. checkForEnter: (event) =>
  343. if not event.shiftKey and event.keyCode is 13
  344. event.preventDefault()
  345. @sendMessage()
  346. send: (event, data = {}) =>
  347. data.chat_id = @options.chatId
  348. @io.send(event, data)
  349. onWebSocketMessage: (pipes) =>
  350. for pipe in pipes
  351. @log.debug 'ws:onmessage', pipe
  352. switch pipe.event
  353. when 'chat_error'
  354. @log.notice pipe.data
  355. if pipe.data && pipe.data.state is 'chat_disabled'
  356. @destroy(remove: true)
  357. when 'chat_session_message'
  358. return if pipe.data.self_written
  359. @receiveMessage pipe.data
  360. when 'chat_session_typing'
  361. return if pipe.data.self_written
  362. @onAgentTypingStart()
  363. when 'chat_session_start'
  364. @onConnectionEstablished pipe.data
  365. when 'chat_session_queue'
  366. @onQueueScreen pipe.data
  367. when 'chat_session_closed'
  368. @onSessionClosed pipe.data
  369. when 'chat_session_left'
  370. @onSessionClosed pipe.data
  371. when 'chat_status_customer'
  372. switch pipe.data.state
  373. when 'online'
  374. @sessionId = undefined
  375. if !@options.cssAutoload || @cssLoaded
  376. @onReady()
  377. else
  378. @socketReady = true
  379. when 'offline'
  380. @onError 'Zammad Chat: No agent online'
  381. when 'chat_disabled'
  382. @onError 'Zammad Chat: Chat is disabled'
  383. when 'no_seats_available'
  384. @onError "Zammad Chat: Too many clients in queue. Clients in queue: #{pipe.data.queue}"
  385. when 'reconnect'
  386. @onReopenSession pipe.data
  387. onReady: ->
  388. @log.debug 'widget ready for use'
  389. $(".#{ @options.buttonClass }").click(@open).removeClass(@inactiveClass)
  390. if @options.show
  391. @show()
  392. onError: (message) =>
  393. @log.debug message
  394. @addStatus(message)
  395. $(".#{ @options.buttonClass }").hide()
  396. if @isOpen
  397. @disableInput()
  398. @destroy(remove: false)
  399. else
  400. @destroy(remove: true)
  401. onReopenSession: (data) =>
  402. @log.debug 'old messages', data.session
  403. @inactiveTimeout.start()
  404. unfinishedMessage = sessionStorage.getItem 'unfinished_message'
  405. # rerender chat history
  406. if data.agent
  407. @onConnectionEstablished(data)
  408. for message in data.session
  409. @renderMessage
  410. message: message.content
  411. id: message.id
  412. from: if message.created_by_id then 'agent' else 'customer'
  413. if unfinishedMessage
  414. @input.html(unfinishedMessage)
  415. # show wait list
  416. if data.position
  417. @onQueue data
  418. @show()
  419. @open()
  420. @scrollToBottom()
  421. if unfinishedMessage
  422. @input.focus()
  423. onInput: =>
  424. # remove unread-state from messages
  425. @el.find('.zammad-chat-message--unread')
  426. .removeClass 'zammad-chat-message--unread'
  427. sessionStorage.setItem 'unfinished_message', @input.html()
  428. @onTyping()
  429. onFocus: =>
  430. $(window).scrollTop(10)
  431. keyboardShown = $(window).scrollTop() > 0
  432. $(window).scrollTop(0)
  433. if keyboardShown
  434. @log.notice 'virtual keyboard shown'
  435. # on keyboard shown
  436. # can't measure visible area height :(
  437. onFocusOut: ->
  438. # on keyboard hidden
  439. onTyping: ->
  440. # send typing start event only every 1.5 seconds
  441. return if @isTyping && @isTyping > new Date(new Date().getTime() - 1500)
  442. @isTyping = new Date()
  443. @send 'chat_session_typing',
  444. session_id: @sessionId
  445. @inactiveTimeout.start()
  446. onSubmit: (event) =>
  447. event.preventDefault()
  448. @sendMessage()
  449. sendMessage: ->
  450. message = @input.html()
  451. return if !message
  452. @inactiveTimeout.start()
  453. sessionStorage.removeItem 'unfinished_message'
  454. messageElement = @view('message')
  455. message: message
  456. from: 'customer'
  457. id: @_messageCount++
  458. unreadClass: ''
  459. @maybeAddTimestamp()
  460. # add message before message typing loader
  461. if @el.find('.zammad-chat-message--typing').get(0)
  462. @lastAddedType = 'typing-placeholder'
  463. @el.find('.zammad-chat-message--typing').before messageElement
  464. else
  465. @lastAddedType = 'message--customer'
  466. @el.find('.zammad-chat-body').append messageElement
  467. @input.html('')
  468. @scrollToBottom()
  469. # send message event
  470. @send 'chat_session_message',
  471. content: message
  472. id: @_messageCount
  473. session_id: @sessionId
  474. receiveMessage: (data) =>
  475. @inactiveTimeout.start()
  476. # hide writing indicator
  477. @onAgentTypingEnd()
  478. @maybeAddTimestamp()
  479. @renderMessage
  480. message: data.message.content
  481. id: data.id
  482. from: 'agent'
  483. @scrollToBottom showHint: true
  484. renderMessage: (data) =>
  485. @lastAddedType = "message--#{ data.from }"
  486. data.unreadClass = if document.hidden then ' zammad-chat-message--unread' else ''
  487. @el.find('.zammad-chat-body').append @view('message')(data)
  488. open: =>
  489. if @isOpen
  490. @log.debug 'widget already open, block'
  491. return
  492. @isOpen = true
  493. @log.debug 'open widget'
  494. if !@sessionId
  495. @showLoader()
  496. @el.addClass('zammad-chat-is-open')
  497. if !@inputInitialized
  498. @inputInitialized = true
  499. @input.autoGrow
  500. extraLine: false
  501. remainerHeight = @el.height() - @el.find('.zammad-chat-header').outerHeight()
  502. @el.css 'bottom', -remainerHeight
  503. if !@sessionId
  504. @el.animate { bottom: 0 }, 500, @onOpenAnimationEnd
  505. @send('chat_session_init'
  506. url: window.location.href
  507. )
  508. else
  509. @el.css 'bottom', 0
  510. @onOpenAnimationEnd()
  511. onOpenAnimationEnd: =>
  512. @idleTimeout.stop()
  513. if @isFullscreen
  514. @disableScrollOnRoot()
  515. sessionClose: =>
  516. # send close
  517. @send 'chat_session_close',
  518. session_id: @sessionId
  519. # stop timer
  520. @inactiveTimeout.stop()
  521. @waitingListTimeout.stop()
  522. # delete input store
  523. sessionStorage.removeItem 'unfinished_message'
  524. # stop delay of initial queue position
  525. if @onInitialQueueDelayId
  526. clearTimeout(@onInitialQueueDelayId)
  527. @setSessionId undefined
  528. toggle: (event) =>
  529. if @isOpen
  530. @close(event)
  531. else
  532. @open(event)
  533. close: (event) =>
  534. if !@isOpen
  535. @log.debug 'can\'t close widget, it\'s not open'
  536. return
  537. if @initDelayId
  538. clearTimeout(@initDelayId)
  539. if !@sessionId
  540. @log.debug 'can\'t close widget without sessionId'
  541. return
  542. @log.debug 'close widget'
  543. event.stopPropagation() if event
  544. @sessionClose()
  545. if @isFullscreen
  546. @enableScrollOnRoot()
  547. # close window
  548. remainerHeight = @el.height() - @el.find('.zammad-chat-header').outerHeight()
  549. @el.animate { bottom: -remainerHeight }, 500, @onCloseAnimationEnd
  550. onCloseAnimationEnd: =>
  551. @el.css 'bottom', ''
  552. @el.removeClass('zammad-chat-is-open')
  553. @showLoader()
  554. @el.find('.zammad-chat-welcome').removeClass('zammad-chat-is-hidden')
  555. @el.find('.zammad-chat-agent').addClass('zammad-chat-is-hidden')
  556. @el.find('.zammad-chat-agent-status').addClass('zammad-chat-is-hidden')
  557. @isOpen = false
  558. @io.reconnect()
  559. onWebSocketClose: =>
  560. return if @isOpen
  561. if @el
  562. @el.removeClass('zammad-chat-is-shown')
  563. @el.removeClass('zammad-chat-is-loaded')
  564. show: ->
  565. return if @state is 'offline'
  566. @el.addClass('zammad-chat-is-loaded')
  567. @el.addClass('zammad-chat-is-shown')
  568. disableInput: ->
  569. @input.prop('disabled', true)
  570. @el.find('.zammad-chat-send').prop('disabled', true)
  571. enableInput: ->
  572. @input.prop('disabled', false)
  573. @el.find('.zammad-chat-send').prop('disabled', false)
  574. hideModal: ->
  575. @el.find('.zammad-chat-modal').html ''
  576. onQueueScreen: (data) =>
  577. @setSessionId data.session_id
  578. # delay initial queue position, show connecting first
  579. show = =>
  580. @onQueue data
  581. @waitingListTimeout.start()
  582. if @initialQueueDelay && !@onInitialQueueDelayId
  583. @onInitialQueueDelayId = setTimeout(show, @initialQueueDelay)
  584. return
  585. # stop delay of initial queue position
  586. if @onInitialQueueDelayId
  587. clearTimeout(@onInitialQueueDelayId)
  588. # show queue position
  589. show()
  590. onQueue: (data) =>
  591. @log.notice 'onQueue', data.position
  592. @inQueue = true
  593. @el.find('.zammad-chat-modal').html @view('waiting')
  594. position: data.position
  595. onAgentTypingStart: =>
  596. if @stopTypingId
  597. clearTimeout(@stopTypingId)
  598. @stopTypingId = setTimeout(@onAgentTypingEnd, 3000)
  599. # never display two typing indicators
  600. return if @el.find('.zammad-chat-message--typing').get(0)
  601. @maybeAddTimestamp()
  602. @el.find('.zammad-chat-body').append @view('typingIndicator')()
  603. # only if typing indicator is shown
  604. return if !@isVisible(@el.find('.zammad-chat-message--typing'), true)
  605. @scrollToBottom()
  606. onAgentTypingEnd: =>
  607. @el.find('.zammad-chat-message--typing').remove()
  608. onLeaveTemporary: =>
  609. return if !@sessionId
  610. @send 'chat_session_leave_temporary',
  611. session_id: @sessionId
  612. maybeAddTimestamp: ->
  613. timestamp = Date.now()
  614. if !@lastTimestamp or (timestamp - @lastTimestamp) > @showTimeEveryXMinutes * 60000
  615. label = @T('Today')
  616. time = new Date().toTimeString().substr 0,5
  617. if @lastAddedType is 'timestamp'
  618. # update last time
  619. @updateLastTimestamp label, time
  620. @lastTimestamp = timestamp
  621. else
  622. # add new timestamp
  623. @el.find('.zammad-chat-body').append @view('timestamp')
  624. label: label
  625. time: time
  626. @lastTimestamp = timestamp
  627. @lastAddedType = 'timestamp'
  628. @scrollToBottom()
  629. updateLastTimestamp: (label, time) ->
  630. return if !@el
  631. @el.find('.zammad-chat-body')
  632. .find('.zammad-chat-timestamp')
  633. .last()
  634. .replaceWith @view('timestamp')
  635. label: label
  636. time: time
  637. addStatus: (status) ->
  638. return if !@el
  639. @maybeAddTimestamp()
  640. @el.find('.zammad-chat-body').append @view('status')
  641. status: status
  642. @scrollToBottom()
  643. detectScrolledtoBottom: =>
  644. scrollBottom = @el.find('.zammad-chat-body').scrollTop() + @el.find('.zammad-chat-body').outerHeight()
  645. @scrolledToBottom = Math.abs(scrollBottom - @el.find('.zammad-chat-body').prop('scrollHeight')) <= @scrollSnapTolerance
  646. @el.find('.zammad-scroll-hint').addClass('is-hidden') if @scrolledToBottom
  647. showScrollHint: ->
  648. @el.find('.zammad-scroll-hint').removeClass('is-hidden')
  649. # compensate scroll
  650. @el.find('.zammad-chat-body').scrollTop(@el.find('.zammad-chat-body').scrollTop() + @el.find('.zammad-scroll-hint').outerHeight())
  651. onScrollHintClick: =>
  652. # animate scroll
  653. @el.find('.zammad-chat-body').animate({scrollTop: @el.find('.zammad-chat-body').prop('scrollHeight')}, 300)
  654. scrollToBottom: ({ showHint } = { showHint: false }) ->
  655. if @scrolledToBottom
  656. @el.find('.zammad-chat-body').scrollTop($('.zammad-chat-body').prop('scrollHeight'))
  657. else if showHint
  658. @showScrollHint()
  659. destroy: (params = {}) =>
  660. @log.debug 'destroy widget', params
  661. @setAgentOnlineState 'offline'
  662. if params.remove && @el
  663. @el.remove()
  664. # stop all timer
  665. if @waitingListTimeout
  666. @waitingListTimeout.stop()
  667. if @inactiveTimeout
  668. @inactiveTimeout.stop()
  669. if @idleTimeout
  670. @idleTimeout.stop()
  671. # stop ws connection
  672. @io.close()
  673. reconnect: =>
  674. # set status to connecting
  675. @log.notice 'reconnecting'
  676. @disableInput()
  677. @lastAddedType = 'status'
  678. @setAgentOnlineState 'connecting'
  679. @addStatus @T('Connection lost')
  680. onConnectionReestablished: =>
  681. # set status back to online
  682. @lastAddedType = 'status'
  683. @setAgentOnlineState 'online'
  684. @addStatus @T('Connection re-established')
  685. onSessionClosed: (data) ->
  686. @addStatus @T('Chat closed by %s', data.realname)
  687. @disableInput()
  688. @setAgentOnlineState 'offline'
  689. @inactiveTimeout.stop()
  690. setSessionId: (id) =>
  691. @sessionId = id
  692. if id is undefined
  693. sessionStorage.removeItem 'sessionId'
  694. else
  695. sessionStorage.setItem 'sessionId', id
  696. onConnectionEstablished: (data) =>
  697. # stop delay of initial queue position
  698. if @onInitialQueueDelayId
  699. clearTimeout @onInitialQueueDelayId
  700. @inQueue = false
  701. if data.agent
  702. @agent = data.agent
  703. if data.session_id
  704. @setSessionId data.session_id
  705. # empty old messages
  706. @el.find('.zammad-chat-body').html('')
  707. @el.find('.zammad-chat-agent').html @view('agent')
  708. agent: @agent
  709. @enableInput()
  710. @hideModal()
  711. @el.find('.zammad-chat-welcome').addClass('zammad-chat-is-hidden')
  712. @el.find('.zammad-chat-agent').removeClass('zammad-chat-is-hidden')
  713. @el.find('.zammad-chat-agent-status').removeClass('zammad-chat-is-hidden')
  714. @input.focus() if not @isFullscreen
  715. @setAgentOnlineState 'online'
  716. @waitingListTimeout.stop()
  717. @idleTimeout.stop()
  718. @inactiveTimeout.start()
  719. showCustomerTimeout: ->
  720. @el.find('.zammad-chat-modal').html @view('customer_timeout')
  721. agent: @agent.name
  722. delay: @options.inactiveTimeout
  723. reload = ->
  724. location.reload()
  725. @el.find('.js-restart').click reload
  726. @sessionClose()
  727. showWaitingListTimeout: ->
  728. @el.find('.zammad-chat-modal').html @view('waiting_list_timeout')
  729. delay: @options.watingListTimeout
  730. reload = ->
  731. location.reload()
  732. @el.find('.js-restart').click reload
  733. @sessionClose()
  734. showLoader: ->
  735. @el.find('.zammad-chat-modal').html @view('loader')()
  736. setAgentOnlineState: (state) =>
  737. @state = state
  738. return if !@el
  739. capitalizedState = state.charAt(0).toUpperCase() + state.slice(1)
  740. @el
  741. .find('.zammad-chat-agent-status')
  742. .attr('data-status', state)
  743. .text @T(capitalizedState)
  744. detectHost: ->
  745. protocol = 'ws://'
  746. if scriptProtocol is 'https'
  747. protocol = 'wss://'
  748. @options.host = "#{ protocol }#{ scriptHost }/ws"
  749. loadCss: ->
  750. return if !@options.cssAutoload
  751. url = @options.cssUrl
  752. if !url
  753. url = @options.host
  754. .replace(/^wss/i, 'https')
  755. .replace(/^ws/i, 'http')
  756. .replace(/\/ws/i, '')
  757. url += '/assets/chat/chat.css'
  758. @log.debug "load css from '#{url}'"
  759. styles = "@import url('#{url}');"
  760. newSS = document.createElement('link')
  761. newSS.onload = @onCssLoaded
  762. newSS.rel = 'stylesheet'
  763. newSS.href = 'data:text/css,' + escape(styles)
  764. document.getElementsByTagName('head')[0].appendChild(newSS)
  765. onCssLoaded: =>
  766. if @socketReady
  767. @onReady()
  768. else
  769. @cssLoaded = true
  770. startTimeoutObservers: =>
  771. @idleTimeout = new Timeout(
  772. logPrefix: 'idleTimeout'
  773. debug: @options.debug
  774. timeout: @options.idleTimeout
  775. timeoutIntervallCheck: @options.idleTimeoutIntervallCheck
  776. callback: =>
  777. @log.debug 'Idle timeout reached, hide widget', new Date
  778. @destroy(remove: true)
  779. )
  780. @inactiveTimeout = new Timeout(
  781. logPrefix: 'inactiveTimeout'
  782. debug: @options.debug
  783. timeout: @options.inactiveTimeout
  784. timeoutIntervallCheck: @options.inactiveTimeoutIntervallCheck
  785. callback: =>
  786. @log.debug 'Inactive timeout reached, show timeout screen.', new Date
  787. @showCustomerTimeout()
  788. @destroy(remove: false)
  789. )
  790. @waitingListTimeout = new Timeout(
  791. logPrefix: 'waitingListTimeout'
  792. debug: @options.debug
  793. timeout: @options.waitingListTimeout
  794. timeoutIntervallCheck: @options.waitingListTimeoutIntervallCheck
  795. callback: =>
  796. @log.debug 'Waiting list timeout reached, show timeout screen.', new Date
  797. @showWaitingListTimeout()
  798. @destroy(remove: false)
  799. )
  800. disableScrollOnRoot: ->
  801. @rootScrollOffset = @scrollRoot.scrollTop()
  802. @scrollRoot.css
  803. overflow: 'hidden'
  804. position: 'fixed'
  805. enableScrollOnRoot: ->
  806. @scrollRoot.scrollTop @rootScrollOffset
  807. @scrollRoot.css
  808. overflow: ''
  809. position: ''
  810. # based on https://github.com/customd/jquery-visible/blob/master/jquery.visible.js
  811. # to have not dependency, port to coffeescript
  812. isVisible: (el, partial, hidden, direction) ->
  813. return if el.length < 1
  814. $w = $(window)
  815. $t = if el.length > 1 then el.eq(0) else el
  816. t = $t.get(0)
  817. vpWidth = $w.width()
  818. vpHeight = $w.height()
  819. direction = if direction then direction else 'both'
  820. clientSize = if hidden is true then t.offsetWidth * t.offsetHeight else true
  821. if typeof t.getBoundingClientRect is 'function'
  822. # Use this native browser method, if available.
  823. rec = t.getBoundingClientRect()
  824. tViz = rec.top >= 0 && rec.top < vpHeight
  825. bViz = rec.bottom > 0 && rec.bottom <= vpHeight
  826. lViz = rec.left >= 0 && rec.left < vpWidth
  827. rViz = rec.right > 0 && rec.right <= vpWidth
  828. vVisible = if partial then tViz || bViz else tViz && bViz
  829. hVisible = if partial then lViz || rViz else lViz && rViz
  830. if direction is 'both'
  831. return clientSize && vVisible && hVisible
  832. else if direction is 'vertical'
  833. return clientSize && vVisible
  834. else if direction is 'horizontal'
  835. return clientSize && hVisible
  836. else
  837. viewTop = $w.scrollTop()
  838. viewBottom = viewTop + vpHeight
  839. viewLeft = $w.scrollLeft()
  840. viewRight = viewLeft + vpWidth
  841. offset = $t.offset()
  842. _top = offset.top
  843. _bottom = _top + $t.height()
  844. _left = offset.left
  845. _right = _left + $t.width()
  846. compareTop = if partial is true then _bottom else _top
  847. compareBottom = if partial is true then _top else _bottom
  848. compareLeft = if partial is true then _right else _left
  849. compareRight = if partial is true then _left else _right
  850. if direction is 'both'
  851. return !!clientSize && ((compareBottom <= viewBottom) && (compareTop >= viewTop)) && ((compareRight <= viewRight) && (compareLeft >= viewLeft))
  852. else if direction is 'vertical'
  853. return !!clientSize && ((compareBottom <= viewBottom) && (compareTop >= viewTop))
  854. else if direction is 'horizontal'
  855. return !!clientSize && ((compareRight <= viewRight) && (compareLeft >= viewLeft))
  856. window.ZammadChat = ZammadChat