Browse Source

Maintenance: Split up article view and item into smaller files and make them patchable.

Co-authored-by: Florian Liebe <fl@zammad.com>
Rolf Schmidt 10 months ago
parent
commit
b214397853

+ 477 - 0
app/assets/javascripts/app/controllers/article_view/item.coffee

@@ -0,0 +1,477 @@
+class App.ArticleViewItem extends App.ControllerObserver
+  model: 'TicketArticle'
+  observe:
+    from: true
+    to: true
+    cc: true
+    subject: true
+    body: true
+    internal: true
+    preferences: true
+
+  elements:
+    '.textBubble-content':           'textBubbleContent'
+    '.textBubble-content img':       'textBubbleImages'
+    '.textBubble-overflowContainer': 'textBubbleOverflowContainer'
+
+  events:
+    'click .article-meta-permanent':  'toggleMetaWithDelay'
+    'click .textBubble':              'toggleMetaWithDelay'
+    'click .textBubble a':            'stopPropagation'
+    'click .js-toggleFold':           'toggleFold'
+    'click .richtext-content img':    'imageView'
+    'click .attachments img':         'imageView'
+    'click .file-calendar .js-preview':  'calendarView'
+    'click .js-securityRetryProcess': 'retrySecurityProcess'
+    'click .js-retryWhatsAppAttachmentDownload': 'retryWhatsAppAttachmentDownload'
+
+  constructor: ->
+    super
+    @seeMoreOpen = false
+
+    # set expand of text area only once
+    @controllerBind('ui::ticket::shown', (data) =>
+      return if data.ticket_id.toString() isnt @ticket.id.toString()
+
+      # set highlighter
+      @setHighlighter()
+
+      # set see more
+      @setSeeMore()
+    )
+
+  setHighlighter: =>
+    return if @el.is(':hidden')
+    # use delay do no ui blocking
+    #@highlighter.loadHighlights(@object_id)
+    d = =>
+      if @highlighter
+        @highlighter.loadHighlights(@object_id)
+    @delay(d, 200)
+
+  render: (article) =>
+
+    # set @el attributes
+    @el.addClass("ticket-article-item #{article.sender.name.toLowerCase()}")
+    @el.attr('data-id', article.id)
+    @el.attr('id', "article-#{article.id}")
+    if article.internal
+      @el.addClass('is-internal')
+    else
+      @el.removeClass('is-internal')
+
+    # check if email link needs to be updated
+    links = clone(article.preferences.links) || []
+    if article.type.name is 'email'
+      link =
+        name: __('Raw')
+        url: "#{@Config.get('api_path')}/ticket_article_plain/#{article.id}"
+        target: '_blank'
+      links.push link
+
+    # attachments prepare
+    attachments = App.TicketArticle.contentAttachments(article)
+    if article.attachments
+      for attachment in article.attachments
+
+        dispositionParams = ''
+        if attachment?.preferences['Content-Type'] isnt 'text/html'
+          dispositionParams = '?disposition=attachment'
+
+        attachment.url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}#{dispositionParams}"
+        attachment.preview_url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?view=preview"
+
+        if attachment && attachment.preferences && attachment.preferences['original-format'] is true
+          link =
+              url: "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment"
+              name: __('Original Formatting')
+              target: '_blank'
+          links.push link
+
+    # prepare html body
+    if article.content_type is 'text/html'
+      body = article.body
+      if article.preferences && article.preferences.signature_detection
+        signatureDetected = '<span class="js-signatureMarker"></span>'
+        body = body.replace(signatureDetected, '')
+        body = body.split('<br>')
+        body.splice(article.preferences.signature_detection, 0, signatureDetected)
+        body = body.join('<br>')
+      else
+        body = App.Utils.signatureIdentifyByHtml(body)
+      article['html'] = body
+    else
+
+      # client signature detection
+      bodyHtml = App.Utils.text2html(article.body)
+      article['html'] = App.Utils.signatureIdentifyByPlaintext(bodyHtml)
+
+      # if no signature detected or within first 25 lines, check if signature got detected in backend
+      if article['html'] is bodyHtml || (article.preferences && article.preferences.signature_detection < 25)
+        signatureDetected = false
+        body = article.body
+        if article.preferences && article.preferences.signature_detection
+          signatureDetected = '########SIGNATURE########'
+          # coffeelint: disable=no_unnecessary_double_quotes
+          body = body.split("\n")
+          body.splice(article.preferences.signature_detection, 0, signatureDetected)
+          body = body.join("\n")
+          # coffeelint: enable=no_unnecessary_double_quotes
+        if signatureDetected
+          body = App.Utils.textCleanup(body)
+          article['html'] = App.Utils.text2html(body)
+          article['html'] = article['html'].replace(signatureDetected, '<span class="js-signatureMarker"></span>')
+
+    if article.preferences.delivery_message
+      @html App.view('ticket_zoom/article_view_delivery_failed')(
+        ticket:      @ticket
+        article:     article
+        attachments: attachments
+        links:       links
+      )
+      return
+    if article.sender.name is 'System' && article.type.name isnt 'note'
+    #if article.sender.name is 'System' && article.preferences.perform_origin is 'trigger'
+      @html App.view('ticket_zoom/article_view_system')(
+        ticket:      @ticket
+        article:     article
+        attachments: attachments
+        links:       links
+      )
+      return
+
+    if article.preferences?.whatsapp
+      icon = null
+      msg  = null
+      if article.preferences?.whatsapp?.timestamp_read
+        icon = 'double-checkmark'
+        msg  = __('read by the customer')
+      else if article.preferences?.whatsapp?.timestamp_delivered
+        icon = 'double-checkmark-outline'
+        msg  = __('delivered to the customer')
+      else if article.preferences?.whatsapp?.timestamp_sent
+        icon = 'checkmark-outline'
+        msg  = __('sent to the customer')
+
+      article['delivery_status_icon']    = icon
+      article['delivery_status_message'] = msg
+
+    @html App.view('ticket_zoom/article_view')(
+      ticket:      @ticket
+      article:     article
+      attachments: App.view('generic/attachments')(attachments: attachments, has_body: !!article.html)
+      links:       links
+    )
+
+    new App.WidgetAvatar(
+      el:        @$('.js-avatar')
+      object_id: article.origin_by_id || article.created_by_id
+      size:      40
+    )
+
+    @articleActions = new App.TicketZoomArticleActions(
+      el:             @$('.js-article-actions')
+      ticket:         @ticket
+      article:        article
+      lastAttributes: @lastAttributes
+      form_id:        @form_id
+    )
+
+    # set see more
+    @shown = false
+    a = =>
+      @setSeeMore()
+    @delay(a, 50)
+
+    # set highlighter
+    @setHighlighter()
+
+  # set see more options
+  setSeeMore: =>
+    return if @el.is(':hidden')
+    return if @shown
+    @shown = true
+
+    @textBubbleImages.each (i, el) =>
+      if !el.complete
+        $(el).one 'load', @measureSeeMore
+
+    @measureSeeMore()
+
+  measureSeeMore: =>
+    maxHeight               = 560
+    minHeight               = 90
+    bubbleContent           = @textBubbleContent
+    bubbleOverflowContainer = @textBubbleOverflowContainer
+
+    # expand if see more is already clicked
+    if @seeMoreOpen
+      bubbleContent.css('height', 'auto')
+    else
+      # reset bubble height and "see more" opacity
+      bubbleContent.css('height', '')
+    bubbleOverflowContainer.css('opacity', '')
+
+    # remember offset of "see more"
+    signatureMarker = bubbleContent.find('.js-signatureMarker')
+    if !signatureMarker.get(0)
+      signatureMarker = bubbleContent.find('div [data-signature=true]')
+    offsetTop = signatureMarker.position()
+
+    # safari - workaround
+    # in safari sometimes the marker is directly on top via .top and inspector but it isn't
+    # in this case use the next element
+    if offsetTop && offsetTop.top is 0
+      offsetTop = signatureMarker.next('div, p, br').position()
+
+    # remember bubble content height
+    bubbleContentHeight = bubbleContent.height()
+
+    # get marker height
+    if offsetTop
+      markerHeight = offsetTop.top
+
+    # if signature marker exists and height is within maxHeight
+    if markerHeight && markerHeight < maxHeight
+      newHeight = markerHeight + 30
+      if newHeight < minHeight
+        newHeight = minHeight
+
+      bubbleContent.attr('data-height', bubbleContentHeight + 30)
+      bubbleContent.attr('data-height-origin', newHeight)
+      bubbleContent.css('height', "#{newHeight}px")
+      bubbleOverflowContainer.removeClass('hide')
+
+    # if height is higher then maxHeight
+    else if bubbleContentHeight > maxHeight
+      bubbleContent.attr('data-height', bubbleContentHeight + 30)
+      bubbleContent.attr('data-height-origin', maxHeight)
+      newHeight = if @seeMoreOpen then 'auto' else "#{maxHeight}px"
+      bubbleContent.css('height', newHeight)
+      bubbleOverflowContainer.toggleClass('is-open', @seeMoreOpen).find('.js-toggleFold').html(@label)
+      bubbleOverflowContainer.removeClass('hide')
+    else
+      bubbleOverflowContainer.addClass('hide')
+
+  retrySecurityProcess: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+
+    article_id = $(e.target).closest('.ticket-article-item').data('id')
+    article    = App.TicketArticle.find(article_id)
+
+    @ajax(
+      id:   'retrySecurityProcess'
+      type: 'POST'
+      url:  "#{@apiPath}/ticket_articles/#{article_id}/retry_security_process"
+      processData: true
+      success: (encryption_data, status, xhr) =>
+        for data in encryption_data
+          continue if article.preferences.security.type isnt data.type
+
+          if data.sign.success
+            @notify
+              type: 'success'
+              msg:  App.i18n.translateContent('The signature was successfully verified.')
+          else if data.sign.comment
+            comment = App.i18n.translateContent('Signature verification failed!') + ' ' + App.i18n.translateContent(data.sign.comment || '', data.sign.commentPlaceholders)
+            @notify
+              type: 'error'
+              msg: comment
+              timeout: 2000
+
+          if data.encryption.success
+            @notify
+              type: 'success'
+              msg:  App.i18n.translateContent('Decryption was successful.')
+          else if data.encryption.comment
+            comment = App.i18n.translateContent('Decryption failed!') + ' ' + App.i18n.translateContent(data.encryption.comment || '', data.encryption.commentPlaceholders)
+            @notify
+              type: 'error'
+              msg:  comment
+              timeout: 2000
+
+      error: (xhr) =>
+        @notify
+          type: 'error'
+          msg:  App.i18n.translateContent('The retried security process failed!')
+    )
+
+  retryWhatsAppAttachmentDownload: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+
+    article_id = $(e.target).closest('.ticket-article-item').data('id')
+
+    @ajax(
+      id:   'retryWhatsAppAttachmentDownload'
+      type: 'POST'
+      url:  "#{@apiPath}/ticket_articles/#{article_id}/retry_whatsapp_attachment_download"
+      processData: true
+      success: (data, status, xhr) =>
+        @notify
+          type: 'success'
+          msg:  App.i18n.translateContent('Downloading attachments…')
+
+      error: (data, status, xhr) =>
+        details = data.responseJSON || {}
+        @notify
+          type: 'error'
+          msg:  App.i18n.translateContent(details.error)
+    )
+
+  stopPropagation: (e) ->
+    e.stopPropagation()
+
+  toggleMetaWithDelay: (e) =>
+    # allow double click select
+    # by adding a delay to the toggle
+    delay = 300
+
+    article = $(e.target).closest('.ticket-article-item')
+    if @elementContainsSelection(article.get(0))
+      @stopPropagation(e)
+      return false
+
+    if @lastClick and +new Date - @lastClick < delay
+      clearTimeout(@toggleMetaTimeout)
+    else
+      @toggleMetaTimeout = setTimeout(@toggleMeta, delay, e)
+      @lastClick = +new Date
+
+  toggleMeta: (e) =>
+    e.preventDefault()
+
+    animSpeed      = 300
+    article        = $(e.target).closest('.ticket-article-item')
+    metaTopClip    = article.find('.article-meta-clip.top')
+    metaBottomClip = article.find('.article-meta-clip.bottom')
+    metaTop        = article.find('.article-content-meta.top')
+    metaBottom     = article.find('.article-content-meta.bottom')
+
+    if @elementContainsSelection(article.get(0))
+      @stopPropagation(e)
+      return false
+
+    if !metaTop.hasClass('hide')
+      article.removeClass('state--folde-out')
+
+      # scroll back up
+      article.velocity 'scroll',
+        container: article.scrollParent()
+        offset: -article.offset().top - metaTop.outerHeight()
+        duration: animSpeed
+        easing: 'easeOutQuad'
+
+      metaTop.velocity
+        properties:
+          translateY: 0
+          opacity: [ 0, 1 ]
+        options:
+          speed: animSpeed
+          easing: 'easeOutQuad'
+          complete: -> metaTop.addClass('hide')
+
+      metaBottom.velocity
+        properties:
+          translateY: [ -metaBottom.outerHeight(), 0 ]
+          opacity: [ 0, 1 ]
+        options:
+          speed: animSpeed
+          easing: 'easeOutQuad'
+          complete: -> metaBottom.addClass('hide')
+
+      metaTopClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad')
+      metaBottomClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad')
+    else
+      article.addClass('state--folde-out')
+      metaBottom.removeClass('hide')
+      metaTop.removeClass('hide')
+
+      # balance out the top meta height by scrolling down
+      article.velocity('scroll',
+        container: article.scrollParent()
+        offset: -article.offset().top + metaTop.outerHeight()
+        duration: animSpeed
+        easing: 'easeOutQuad'
+      )
+
+      metaTop.velocity
+        properties:
+          translateY: [ 0, metaTop.outerHeight() ]
+          opacity: [ 1, 0 ]
+        options:
+          speed: animSpeed
+          easing: 'easeOutQuad'
+
+      metaBottom.velocity
+        properties:
+          translateY: [ 0, -metaBottom.outerHeight() ]
+          opacity: [ 1, 0 ]
+        options:
+          speed: animSpeed
+          easing: 'easeOutQuad'
+
+      metaTopClip.velocity({ height: metaTop.outerHeight() }, animSpeed, 'easeOutQuad')
+      metaBottomClip.velocity({ height: metaBottom.outerHeight() }, animSpeed, 'easeOutQuad')
+
+  toggleFold: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+
+    bubbleContent           = @textBubbleContent
+    bubbleOverflowContainer = @textBubbleOverflowContainer
+
+    if @seeMoreOpen
+      @label = App.i18n.translateContent('See more')
+      height = bubbleContent.attr('data-height-origin')
+      @seeMoreOpen = false
+    else
+      @label = App.i18n.translateContent('See less')
+      height = bubbleContent.attr('data-height')
+      @seeMoreOpen = true
+
+    bubbleOverflowContainer.toggleClass('is-open', @seeMoreOpen).find('.js-toggleFold').html(@label)
+
+    bubbleContent.velocity
+      properties:
+        height: height
+      options:
+        duration: 300
+
+  isOrContains: (node, container) ->
+    while node
+      if node is container
+        return true
+      node = node.parentNode
+    false
+
+  elementContainsSelection: (el) ->
+    sel = window.getSelection()
+    if sel.rangeCount > 0 && sel.toString()
+      for i in [0..sel.rangeCount-1]
+        if !@isOrContains(sel.getRangeAt(i).commonAncestorContainer, el)
+          return false
+      return true
+    false
+
+  remove: =>
+    @el.remove()
+
+  imageView: (e) ->
+    # take care of images surrounded by a link
+    if e.target && e.target.parentNode && e.target.parentNode.nodeName.toLowerCase() == 'a'
+      return false
+
+    e.preventDefault()
+    e.stopPropagation()
+    new App.TicketZoomArticleImageView(image: $(e.target).get(0).outerHTML, parentElement: $(e.currentTarget))
+
+  calendarView: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+    parentElement = $(e.target).closest('.attachment.file-calendar')
+    new App.TicketZoomArticleCalendarView(calendar: parentElement.get(0).outerHTML)
+
+  updateFormId: (newFormId) ->
+    @articleActions?.form_id = newFormId

+ 1 - 478
app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee

@@ -14,7 +14,7 @@ class App.TicketZoomArticleView extends App.Controller
       controllerKey = ticket_article_id.toString()
       if !@articleController[controllerKey]
         el = $('<div></div>')
-        @articleController[controllerKey] = new ArticleViewItem(
+        @articleController[controllerKey] = new App.ArticleViewItem(
           ticket:     @ticket
           object_id:  ticket_article_id
           el:         el
@@ -66,480 +66,3 @@ class App.TicketZoomArticleView extends App.Controller
     for id, viewItem of @articleController
       viewItem.updateFormId(newFormId)
 
-class ArticleViewItem extends App.ControllerObserver
-  model: 'TicketArticle'
-  observe:
-    from: true
-    to: true
-    cc: true
-    subject: true
-    body: true
-    internal: true
-    preferences: true
-
-  elements:
-    '.textBubble-content':           'textBubbleContent'
-    '.textBubble-content img':       'textBubbleImages'
-    '.textBubble-overflowContainer': 'textBubbleOverflowContainer'
-
-  events:
-    'click .article-meta-permanent':  'toggleMetaWithDelay'
-    'click .textBubble':              'toggleMetaWithDelay'
-    'click .textBubble a':            'stopPropagation'
-    'click .js-toggleFold':           'toggleFold'
-    'click .richtext-content img':    'imageView'
-    'click .attachments img':         'imageView'
-    'click .file-calendar .js-preview':  'calendarView'
-    'click .js-securityRetryProcess': 'retrySecurityProcess'
-    'click .js-retryWhatsAppAttachmentDownload': 'retryWhatsAppAttachmentDownload'
-
-  constructor: ->
-    super
-    @seeMoreOpen = false
-
-    # set expand of text area only once
-    @controllerBind('ui::ticket::shown', (data) =>
-      return if data.ticket_id.toString() isnt @ticket.id.toString()
-
-      # set highlighter
-      @setHighlighter()
-
-      # set see more
-      @setSeeMore()
-    )
-
-  setHighlighter: =>
-    return if @el.is(':hidden')
-    # use delay do no ui blocking
-    #@highlighter.loadHighlights(@object_id)
-    d = =>
-      if @highlighter
-        @highlighter.loadHighlights(@object_id)
-    @delay(d, 200)
-
-  render: (article) =>
-
-    # set @el attributes
-    @el.addClass("ticket-article-item #{article.sender.name.toLowerCase()}")
-    @el.attr('data-id', article.id)
-    @el.attr('id', "article-#{article.id}")
-    if article.internal
-      @el.addClass('is-internal')
-    else
-      @el.removeClass('is-internal')
-
-    # check if email link needs to be updated
-    links = clone(article.preferences.links) || []
-    if article.type.name is 'email'
-      link =
-        name: __('Raw')
-        url: "#{@Config.get('api_path')}/ticket_article_plain/#{article.id}"
-        target: '_blank'
-      links.push link
-
-    # attachments prepare
-    attachments = App.TicketArticle.contentAttachments(article)
-    if article.attachments
-      for attachment in article.attachments
-
-        dispositionParams = ''
-        if attachment?.preferences['Content-Type'] isnt 'text/html'
-          dispositionParams = '?disposition=attachment'
-
-        attachment.url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}#{dispositionParams}"
-        attachment.preview_url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?view=preview"
-
-        if attachment && attachment.preferences && attachment.preferences['original-format'] is true
-          link =
-              url: "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment"
-              name: __('Original Formatting')
-              target: '_blank'
-          links.push link
-
-    # prepare html body
-    if article.content_type is 'text/html'
-      body = article.body
-      if article.preferences && article.preferences.signature_detection
-        signatureDetected = '<span class="js-signatureMarker"></span>'
-        body = body.replace(signatureDetected, '')
-        body = body.split('<br>')
-        body.splice(article.preferences.signature_detection, 0, signatureDetected)
-        body = body.join('<br>')
-      else
-        body = App.Utils.signatureIdentifyByHtml(body)
-      article['html'] = body
-    else
-
-      # client signature detection
-      bodyHtml = App.Utils.text2html(article.body)
-      article['html'] = App.Utils.signatureIdentifyByPlaintext(bodyHtml)
-
-      # if no signature detected or within first 25 lines, check if signature got detected in backend
-      if article['html'] is bodyHtml || (article.preferences && article.preferences.signature_detection < 25)
-        signatureDetected = false
-        body = article.body
-        if article.preferences && article.preferences.signature_detection
-          signatureDetected = '########SIGNATURE########'
-          # coffeelint: disable=no_unnecessary_double_quotes
-          body = body.split("\n")
-          body.splice(article.preferences.signature_detection, 0, signatureDetected)
-          body = body.join("\n")
-          # coffeelint: enable=no_unnecessary_double_quotes
-        if signatureDetected
-          body = App.Utils.textCleanup(body)
-          article['html'] = App.Utils.text2html(body)
-          article['html'] = article['html'].replace(signatureDetected, '<span class="js-signatureMarker"></span>')
-
-    if article.preferences.delivery_message
-      @html App.view('ticket_zoom/article_view_delivery_failed')(
-        ticket:      @ticket
-        article:     article
-        attachments: attachments
-        links:       links
-      )
-      return
-    if article.sender.name is 'System' && article.type.name isnt 'note'
-    #if article.sender.name is 'System' && article.preferences.perform_origin is 'trigger'
-      @html App.view('ticket_zoom/article_view_system')(
-        ticket:      @ticket
-        article:     article
-        attachments: attachments
-        links:       links
-      )
-      return
-
-    if article.preferences?.whatsapp
-      icon = null
-      msg  = null
-      if article.preferences?.whatsapp?.timestamp_read
-        icon = 'double-checkmark'
-        msg  = __('read by the customer')
-      else if article.preferences?.whatsapp?.timestamp_delivered
-        icon = 'double-checkmark-outline'
-        msg  = __('delivered to the customer')
-      else if article.preferences?.whatsapp?.timestamp_sent
-        icon = 'checkmark-outline'
-        msg  = __('sent to the customer')
-
-      article['delivery_status_icon']    = icon
-      article['delivery_status_message'] = msg
-
-    @html App.view('ticket_zoom/article_view')(
-      ticket:      @ticket
-      article:     article
-      attachments: App.view('generic/attachments')(attachments: attachments, has_body: !!article.html)
-      links:       links
-    )
-
-    new App.WidgetAvatar(
-      el:        @$('.js-avatar')
-      object_id: article.origin_by_id || article.created_by_id
-      size:      40
-    )
-
-    @articleActions = new App.TicketZoomArticleActions(
-      el:             @$('.js-article-actions')
-      ticket:         @ticket
-      article:        article
-      lastAttributes: @lastAttributes
-      form_id:        @form_id
-    )
-
-    # set see more
-    @shown = false
-    a = =>
-      @setSeeMore()
-    @delay(a, 50)
-
-    # set highlighter
-    @setHighlighter()
-
-  # set see more options
-  setSeeMore: =>
-    return if @el.is(':hidden')
-    return if @shown
-    @shown = true
-
-    @textBubbleImages.each (i, el) =>
-      if !el.complete
-        $(el).one 'load', @measureSeeMore
-
-    @measureSeeMore()
-
-  measureSeeMore: =>
-    maxHeight               = 560
-    minHeight               = 90
-    bubbleContent           = @textBubbleContent
-    bubbleOverflowContainer = @textBubbleOverflowContainer
-
-    # expand if see more is already clicked
-    if @seeMoreOpen
-      bubbleContent.css('height', 'auto')
-    else
-      # reset bubble height and "see more" opacity
-      bubbleContent.css('height', '')
-    bubbleOverflowContainer.css('opacity', '')
-
-    # remember offset of "see more"
-    signatureMarker = bubbleContent.find('.js-signatureMarker')
-    if !signatureMarker.get(0)
-      signatureMarker = bubbleContent.find('div [data-signature=true]')
-    offsetTop = signatureMarker.position()
-
-    # safari - workaround
-    # in safari sometimes the marker is directly on top via .top and inspector but it isn't
-    # in this case use the next element
-    if offsetTop && offsetTop.top is 0
-      offsetTop = signatureMarker.next('div, p, br').position()
-
-    # remember bubble content height
-    bubbleContentHeight = bubbleContent.height()
-
-    # get marker height
-    if offsetTop
-      markerHeight = offsetTop.top
-
-    # if signature marker exists and height is within maxHeight
-    if markerHeight && markerHeight < maxHeight
-      newHeight = markerHeight + 30
-      if newHeight < minHeight
-        newHeight = minHeight
-
-      bubbleContent.attr('data-height', bubbleContentHeight + 30)
-      bubbleContent.attr('data-height-origin', newHeight)
-      bubbleContent.css('height', "#{newHeight}px")
-      bubbleOverflowContainer.removeClass('hide')
-
-    # if height is higher then maxHeight
-    else if bubbleContentHeight > maxHeight
-      bubbleContent.attr('data-height', bubbleContentHeight + 30)
-      bubbleContent.attr('data-height-origin', maxHeight)
-      newHeight = if @seeMoreOpen then 'auto' else "#{maxHeight}px"
-      bubbleContent.css('height', newHeight)
-      bubbleOverflowContainer.toggleClass('is-open', @seeMoreOpen).find('.js-toggleFold').html(@label)
-      bubbleOverflowContainer.removeClass('hide')
-    else
-      bubbleOverflowContainer.addClass('hide')
-
-  retrySecurityProcess: (e) ->
-    e.preventDefault()
-    e.stopPropagation()
-
-    article_id = $(e.target).closest('.ticket-article-item').data('id')
-    article    = App.TicketArticle.find(article_id)
-
-    @ajax(
-      id:   'retrySecurityProcess'
-      type: 'POST'
-      url:  "#{@apiPath}/ticket_articles/#{article_id}/retry_security_process"
-      processData: true
-      success: (encryption_data, status, xhr) =>
-        for data in encryption_data
-          continue if article.preferences.security.type isnt data.type
-
-          if data.sign.success
-            @notify
-              type: 'success'
-              msg:  App.i18n.translateContent('The signature was successfully verified.')
-          else if data.sign.comment
-            comment = App.i18n.translateContent('Signature verification failed!') + ' ' + App.i18n.translateContent(data.sign.comment || '', data.sign.commentPlaceholders)
-            @notify
-              type: 'error'
-              msg: comment
-              timeout: 2000
-
-          if data.encryption.success
-            @notify
-              type: 'success'
-              msg:  App.i18n.translateContent('Decryption was successful.')
-          else if data.encryption.comment
-            comment = App.i18n.translateContent('Decryption failed!') + ' ' + App.i18n.translateContent(data.encryption.comment || '', data.encryption.commentPlaceholders)
-            @notify
-              type: 'error'
-              msg:  comment
-              timeout: 2000
-
-      error: (xhr) =>
-        @notify
-          type: 'error'
-          msg:  App.i18n.translateContent('The retried security process failed!')
-    )
-
-  retryWhatsAppAttachmentDownload: (e) ->
-    e.preventDefault()
-    e.stopPropagation()
-
-    article_id = $(e.target).closest('.ticket-article-item').data('id')
-
-    @ajax(
-      id:   'retryWhatsAppAttachmentDownload'
-      type: 'POST'
-      url:  "#{@apiPath}/ticket_articles/#{article_id}/retry_whatsapp_attachment_download"
-      processData: true
-      success: (data, status, xhr) =>
-        @notify
-          type: 'success'
-          msg:  App.i18n.translateContent('Downloading attachments…')
-
-      error: (data, status, xhr) =>
-        details = data.responseJSON || {}
-        @notify
-          type: 'error'
-          msg:  App.i18n.translateContent(details.error)
-    )
-
-  stopPropagation: (e) ->
-    e.stopPropagation()
-
-  toggleMetaWithDelay: (e) =>
-    # allow double click select
-    # by adding a delay to the toggle
-    delay = 300
-
-    article = $(e.target).closest('.ticket-article-item')
-    if @elementContainsSelection(article.get(0))
-      @stopPropagation(e)
-      return false
-
-    if @lastClick and +new Date - @lastClick < delay
-      clearTimeout(@toggleMetaTimeout)
-    else
-      @toggleMetaTimeout = setTimeout(@toggleMeta, delay, e)
-      @lastClick = +new Date
-
-  toggleMeta: (e) =>
-    e.preventDefault()
-
-    animSpeed      = 300
-    article        = $(e.target).closest('.ticket-article-item')
-    metaTopClip    = article.find('.article-meta-clip.top')
-    metaBottomClip = article.find('.article-meta-clip.bottom')
-    metaTop        = article.find('.article-content-meta.top')
-    metaBottom     = article.find('.article-content-meta.bottom')
-
-    if @elementContainsSelection(article.get(0))
-      @stopPropagation(e)
-      return false
-
-    if !metaTop.hasClass('hide')
-      article.removeClass('state--folde-out')
-
-      # scroll back up
-      article.velocity 'scroll',
-        container: article.scrollParent()
-        offset: -article.offset().top - metaTop.outerHeight()
-        duration: animSpeed
-        easing: 'easeOutQuad'
-
-      metaTop.velocity
-        properties:
-          translateY: 0
-          opacity: [ 0, 1 ]
-        options:
-          speed: animSpeed
-          easing: 'easeOutQuad'
-          complete: -> metaTop.addClass('hide')
-
-      metaBottom.velocity
-        properties:
-          translateY: [ -metaBottom.outerHeight(), 0 ]
-          opacity: [ 0, 1 ]
-        options:
-          speed: animSpeed
-          easing: 'easeOutQuad'
-          complete: -> metaBottom.addClass('hide')
-
-      metaTopClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad')
-      metaBottomClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad')
-    else
-      article.addClass('state--folde-out')
-      metaBottom.removeClass('hide')
-      metaTop.removeClass('hide')
-
-      # balance out the top meta height by scrolling down
-      article.velocity('scroll',
-        container: article.scrollParent()
-        offset: -article.offset().top + metaTop.outerHeight()
-        duration: animSpeed
-        easing: 'easeOutQuad'
-      )
-
-      metaTop.velocity
-        properties:
-          translateY: [ 0, metaTop.outerHeight() ]
-          opacity: [ 1, 0 ]
-        options:
-          speed: animSpeed
-          easing: 'easeOutQuad'
-
-      metaBottom.velocity
-        properties:
-          translateY: [ 0, -metaBottom.outerHeight() ]
-          opacity: [ 1, 0 ]
-        options:
-          speed: animSpeed
-          easing: 'easeOutQuad'
-
-      metaTopClip.velocity({ height: metaTop.outerHeight() }, animSpeed, 'easeOutQuad')
-      metaBottomClip.velocity({ height: metaBottom.outerHeight() }, animSpeed, 'easeOutQuad')
-
-  toggleFold: (e) ->
-    e.preventDefault()
-    e.stopPropagation()
-
-    bubbleContent           = @textBubbleContent
-    bubbleOverflowContainer = @textBubbleOverflowContainer
-
-    if @seeMoreOpen
-      @label = App.i18n.translateContent('See more')
-      height = bubbleContent.attr('data-height-origin')
-      @seeMoreOpen = false
-    else
-      @label = App.i18n.translateContent('See less')
-      height = bubbleContent.attr('data-height')
-      @seeMoreOpen = true
-
-    bubbleOverflowContainer.toggleClass('is-open', @seeMoreOpen).find('.js-toggleFold').html(@label)
-
-    bubbleContent.velocity
-      properties:
-        height: height
-      options:
-        duration: 300
-
-  isOrContains: (node, container) ->
-    while node
-      if node is container
-        return true
-      node = node.parentNode
-    false
-
-  elementContainsSelection: (el) ->
-    sel = window.getSelection()
-    if sel.rangeCount > 0 && sel.toString()
-      for i in [0..sel.rangeCount-1]
-        if !@isOrContains(sel.getRangeAt(i).commonAncestorContainer, el)
-          return false
-      return true
-    false
-
-  remove: =>
-    @el.remove()
-
-  imageView: (e) ->
-    # take care of images surrounded by a link
-    if e.target && e.target.parentNode && e.target.parentNode.nodeName.toLowerCase() == 'a'
-      return false
-
-    e.preventDefault()
-    e.stopPropagation()
-    new App.TicketZoomArticleImageView(image: $(e.target).get(0).outerHTML, parentElement: $(e.currentTarget))
-
-  calendarView: (e) ->
-    e.preventDefault()
-    e.stopPropagation()
-    parentElement = $(e.target).closest('.attachment.file-calendar')
-    new App.TicketZoomArticleCalendarView(calendar: parentElement.get(0).outerHTML)
-
-  updateFormId: (newFormId) ->
-    @articleActions?.form_id = newFormId

+ 13 - 13
i18n/zammad.pot

@@ -3533,7 +3533,7 @@ msgstr ""
 msgid "Decrement seconds value"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 msgid "Decryption failed!"
 msgstr ""
 
@@ -3541,7 +3541,7 @@ msgstr ""
 msgid "Decryption failed! %s"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleSecurityBadge.vue
 msgid "Decryption was successful."
 msgstr ""
@@ -4730,7 +4730,7 @@ msgstr ""
 msgid "Download and install the %s Migration Plugin on your %s instance."
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 msgid "Downloading attachments…"
 msgstr ""
 
@@ -9331,7 +9331,7 @@ msgstr ""
 msgid "Origin By"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue
 msgid "Original Formatting"
 msgstr ""
@@ -10135,7 +10135,7 @@ msgstr ""
 msgid "REST API access using the username/email address and password is currently disabled. Please contact your administrator."
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue
 msgid "Raw"
 msgstr ""
@@ -11067,15 +11067,15 @@ msgid "See HTTPLog for details."
 msgstr ""
 
 #: app/assets/javascripts/app/controllers/_ui_element/ticket_duplicate_detection.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/assets/javascripts/app/controllers/data_privacy.coffee
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleBubble.vue
 msgid "See less"
 msgstr ""
 
 #: app/assets/javascripts/app/controllers/_ui_element/ticket_duplicate_detection.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/assets/javascripts/app/controllers/data_privacy.coffee
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
 #: app/assets/javascripts/app/views/data_privacy/tasks.jst.eco
 #: app/assets/javascripts/app/views/generic/ticket_duplicate_detection/warning.jst.eco
 #: app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco
@@ -11628,7 +11628,7 @@ msgstr ""
 msgid "Signature"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 msgid "Signature verification failed!"
 msgstr ""
 
@@ -13128,7 +13128,7 @@ msgstr ""
 msgid "The retried attachment download failed."
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleSecurityBadge.vue
 msgid "The retried security process failed!"
 msgstr ""
@@ -13184,7 +13184,7 @@ msgstr ""
 msgid "The signature part of this PGP email is missing or has a wrong content type according to RFC 3156."
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleSecurityBadge.vue
 msgid "The signature was successfully verified."
 msgstr ""
@@ -16215,7 +16215,7 @@ msgstr ""
 msgid "delete"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue
 msgid "delivered to the customer"
 msgstr ""
@@ -16930,7 +16930,7 @@ msgstr ""
 msgid "reached"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue
 msgid "read by the customer"
 msgstr ""
@@ -17013,7 +17013,7 @@ msgstr ""
 msgid "select visible options"
 msgstr ""
 
-#: app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee
+#: app/assets/javascripts/app/controllers/article_view/item.coffee
 #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue
 msgid "sent to the customer"
 msgstr ""