Commit d59f8070 authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Remove the old threading implementation (#3287)

Following the introduction of the <annotation-thread> component this is
now unused.

This also removes the JWZ threading implementation since the
functionality this provided is now implemented by `build-thread.js`
parent 2810d866
###*
# @ngdoc type
# @name deepCount.DeepCountController
#
# @description
# `DeepCountController` exports a single getter / setter that can be used
# to retrieve and manipulate a set of counters. Changes to these counters
# are bubbled and aggregated by any instances higher up in the DOM. Digests
# are performed from the top down, scheduled during animation frames, and
# debounced for performance.
###
DeepCountController = [
'$element', '$scope', 'render',
($element, $scope, render) ->
cancelFrame = null
counters = {}
parent = $element.parent().controller('deepCount')
###*
# @ngdoc method
# @name deepCount.DeepCountController#count
#
# @param {string} key An aggregate key.
# @param {number} delta If provided, the amount by which the aggregate
# for the given key should be incremented.
# @return {number} The value of the aggregate for the given key.
#
# @description
# Modify an aggregate when called with an argument and return its current
# value.
###
this.count = (key, delta) ->
if delta is undefined or delta is 0 then return counters[key] or 0
counters[key] ?= 0
counters[key] += delta
unless counters[key] then delete counters[key]
if parent
# Bubble updates.
parent.count key, delta
else
# Debounce digests from the top.
if cancelFrame then cancelFrame()
cancelFrame = render ->
$scope.$digest()
cancelFrame = null
counters[key] or 0
this
]
###*
# @ngdoc directive
# @name deepCount
# @restrict A
# @description
# Directive that instantiates
# {@link deepCount.DeepCountController DeepCountController} and exports it
# to the current scope under the name specified by the attribute parameter.
###
module.exports = [
'$parse',
($parse) ->
controller: DeepCountController
link: (scope, elem, attrs, ctrl) ->
parsedCounterName = $parse attrs.deepCount
if parsedCounterName.assign
parsedCounterName.assign scope, angular.bind ctrl, ctrl.count
]
{module, inject} = angular.mock
describe 'thread', ->
$compile = null
$element = null
$scope = null
controller = null
fakeRender = null
fakeAnnotationUI = null
sandbox = null
selectedAnnotations = []
createDirective = ->
$element = angular.element('<div thread>')
$compile($element)($scope)
$scope.$digest()
controller = $element.controller('thread')
before ->
angular.module('h', [])
.directive('thread', require('../thread'))
beforeEach module('h')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeRender = sandbox.spy()
fakeAnnotationUI = {
hasSelectedAnnotations: ->
selectedAnnotations.length > 0
isAnnotationSelected: (id) ->
selectedAnnotations.indexOf(id) != -1
}
$provide.value 'render', fakeRender
$provide.value 'annotationUI', fakeAnnotationUI
return
beforeEach inject (_$compile_, $rootScope) ->
$compile = _$compile_
$scope = $rootScope.$new()
afterEach ->
sandbox.restore()
describe 'controller', ->
it 'returns true from isNew() for a new annotation', ->
createDirective()
# When the user clicks to create a new annotation in the browser, we get
# a ThreadController with a container with a message (the annotation)
# with no id.
controller.container = {message: {}}
assert(controller.isNew())
it 'returns false from isNew() for an old annotation', ->
createDirective()
# When we create a ThreadController for an old annotation, the controller
# has a container with a message (the annotation) with an id.
controller.container = {message: {id: 123}}
assert(not controller.isNew())
it 'returns false from isNew() for a null annotation', ->
createDirective()
# The ThreadController may be an empty container.
controller.container = {}
assert(not controller.isNew())
describe '#toggleCollapsed', ->
count = null
beforeEach ->
createDirective()
count = sinon.stub().returns(0)
count.withArgs('message').returns(2)
controller.counter = {count: count}
it 'toggles whether or not the thread is collapsed', ->
before = controller.collapsed
controller.toggleCollapsed()
after = controller.collapsed
assert.equal(before, !after)
it 'defaults to collapsed if it is a top level annotation', ->
assert.isTrue(controller.collapsed)
it 'can accept an argument to force a particular state', ->
controller.toggleCollapsed(true)
assert.isTrue(controller.collapsed)
controller.toggleCollapsed(true)
assert.isTrue(controller.collapsed)
controller.toggleCollapsed(false)
assert.isFalse(controller.collapsed)
controller.toggleCollapsed(false)
assert.isFalse(controller.collapsed)
it 'allows collapsing the thread even if there are no replies', ->
count.withArgs('message').returns(1)
controller.toggleCollapsed()
assert.isFalse(controller.collapsed)
controller.toggleCollapsed()
assert.isTrue(controller.collapsed)
describe '#shouldShow()', ->
count = null
beforeEach ->
createDirective()
count = sinon.stub().returns(0)
controller.counter = {count: count}
it 'is true by default', ->
assert.isTrue(controller.shouldShow())
describe 'when the thread root is an orphan', ->
beforeEach ->
$scope.feature = -> false
controller.container =
message:
$orphan: true
it 'returns false', ->
assert.isFalse(controller.shouldShow())
describe 'when the thread filter is active', ->
beforeEach ->
controller.filter = {active: -> true}
it 'is false when there are no matches in the thread', ->
assert.isFalse(controller.shouldShow())
it 'is true when there are matches in the thread', ->
count.withArgs('match').returns(1)
assert.isTrue(controller.shouldShow())
it 'is true when there are edits in the thread', ->
count.withArgs('edit').returns(1)
assert.isTrue(controller.shouldShow())
it 'is true when the thread is new', ->
controller.container =
# message exists but there is no id field
message: {}
assert.isTrue(controller.shouldShow())
describe 'when the thread root has a group', ->
beforeEach ->
controller.container =
message:
id: 123
group: 'wibble'
describe 'filters messages based on the selection', ->
messageID = 456
beforeEach ->
controller.container =
message:
id: messageID
it 'shows all annotations when there is no selection', ->
assert.isTrue(controller.shouldShow())
it 'hides annotations that are not selected', ->
selectedAnnotations = ['some-other-message-id']
assert.isFalse(controller.shouldShow())
it 'shows annotations that are selected', ->
selectedAnnotations = [messageID]
assert.isTrue(controller.shouldShow())
describe '#shouldShowAsReply', ->
count = null
beforeEach ->
createDirective()
count = sinon.stub().returns(0)
controller.counter = {count: count}
it 'is true by default', ->
assert.isTrue(controller.shouldShowAsReply())
it 'is false when the parent thread is collapsed', ->
controller.parent = {collapsed: true}
assert.isFalse(controller.shouldShowAsReply())
describe 'when the thread contains edits', ->
beforeEach ->
count.withArgs('edit').returns(1)
it 'is true when the thread has no parent', ->
assert.isTrue(controller.shouldShowAsReply())
it 'is true when the parent thread is not collapsed', ->
controller.parent = {collapsed: false}
assert.isTrue(controller.shouldShowAsReply())
it 'is true when the parent thread is collapsed', ->
controller.parent = {collapsed: true}
assert.isTrue(controller.shouldShowAsReply())
describe 'when the thread filter is active', ->
beforeEach ->
controller.filter = {active: -> true}
it 'is false when there are no matches in the thread', ->
assert.isFalse(controller.shouldShowAsReply())
it 'is true when there are matches in the thread', ->
count.withArgs('match').returns(1)
assert.isTrue(controller.shouldShowAsReply())
describe '#numReplies', ->
beforeEach ->
createDirective()
it 'returns zero when there is no counter', ->
assert.equal(controller.numReplies(), 0)
it 'returns one less than the number of messages in the thread when there is a counter', ->
count = sinon.stub()
count.withArgs('message').returns(5)
controller.counter = {count: count}
assert.equal(controller.numReplies(), 4)
describe '#shouldShowLoadMore', ->
beforeEach ->
createDirective()
describe 'when the thread filter is not active', ->
it 'is false with an empty container', ->
assert.isFalse(controller.shouldShowLoadMore())
it 'is false when the container contains an annotation', ->
controller.container = {message: {id: 123}}
assert.isFalse(controller.shouldShowLoadMore())
describe 'when the thread filter is active', ->
beforeEach ->
controller.filter = {active: -> true}
it 'is false with an empty container', ->
assert.isFalse(controller.shouldShowLoadMore())
it 'is true when the container contains an annotation', ->
controller.container = {message: {id: 123}}
assert.isTrue(controller.shouldShowLoadMore())
describe '#loadMore', ->
beforeEach ->
createDirective()
it 'uncollapses the thread', ->
sinon.spy(controller, 'toggleCollapsed')
controller.loadMore()
assert.calledWith(controller.toggleCollapsed, false)
it 'uncollapses all the ancestors of the thread', ->
grandmother = {toggleCollapsed: sinon.stub()}
mother = {toggleCollapsed: sinon.stub()}
controller.parent = mother
controller.parent.parent = grandmother
controller.loadMore()
assert.calledWith(mother.toggleCollapsed, false)
assert.calledWith(grandmother.toggleCollapsed, false)
it 'deactivates the thread filter when present', ->
controller.filter = {active: sinon.stub()}
controller.loadMore()
assert.calledWith(controller.filter.active, false)
describe '#matchesFilter', ->
beforeEach ->
createDirective()
it 'is true by default', ->
assert.isTrue(controller.matchesFilter())
it 'checks with the thread filter to see if the annotation matches', ->
check = sinon.stub().returns(false)
controller.filter = {check: check}
controller.container = {}
assert.isFalse(controller.matchesFilter())
assert.calledWith(check, controller.container)
###*
# @ngdoc type
# @name threadFilter.ThreadFilterController
#
# @property {boolean} match True if the last checked message was a match.
#
# @description
# `ThreadFilterController` provides an API for maintaining filtering over
# a message thread.
###
ThreadFilterController = [
'viewFilter'
(viewFilter) ->
@match = false
@_active = false
@_children = []
@_filters = null
@_frozen = false
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#active
#
# @param {boolean=} active New state
# @return {boolean} state
#
# @description
# This method is a getter / setter.
#
# Activate or deactivate filtering when called with an argument and
# return the activation status.
###
this.active = (active) ->
if active is undefined then return @_active
else if @active == active then return @_active
else
child.active active for child in @_children
if @_frozen then @_active else @_active = active
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#filters
#
# @param {Object=} filters New filters
# @return {Object} filters
#
# @description
# This method is a getter / setter.
#
# Set the filter configuration when called with an argument and return
# return the configuration.
###
this.filters = (filters) ->
if filters is undefined then return @_filters
else if @filters == filters then return @_filters
else
child.filters filters for child in @_children
if @_frozen then @_filters else @_filters = filters
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#freeze
#
# @param {boolean=} frozen New state
# @return {boolean} frozen state
#
# @description
# This method is a getter / setter.
#
# Freeze or unfreeze the filter when called with an argument and
# return the frozen state. A frozen filter will not change its activation
# state or filter configuration.
###
this.freeze = (frozen=true) ->
if frozen? then @_frozen = frozen else @_frozen
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#check
#
# @param {Object} container The `mail.messageContainer` to filter.
# @return {boolean} True if the message matches the filters, it has not
# yet been saved, or if filtering is not active.
#
# @description
# Check whether a message container carries a message matching the
# configured filters. If filtering is not active then the result is
# always `true`. Updates the `match` property to reflect the result.
###
this.check = (container) ->
unless container?.message then return false
if this.active()
@match = !!viewFilter.filter([container.message], @_filters).length
else
@match = true
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#registerChild
#
# @param {Object} target The child controller instance.
#
# @description
# Add another instance of the thread filter controller to the set of
# registered children. Changes in activation status and filter configuration
# are propagated to child controllers.
###
this.registerChild = (target) ->
@_children.push target
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#unregisterChild
#
# @param {Object} target The child controller instance.
#
# @description
# Remove a previously registered instance of the thread filter controller
# from the set of registered children.
###
this.unregisterChild = (target) ->
@_children = (child for child in @_children if child isnt target)
this
]
###*
# @ngdoc directive
# @name threadFilter
# @restrict A
# @description
# Directive that instantiates
# {@link threadFilter.ThreadFilterController ThreadController}.
#
# The threadFilter directive utilizes the {@link searchFilter searchFilter}
# service to parse the expression passed in the directive attribute as a
# faceted search query and configures its controller with the resulting
# filters. It watches the `match` property of the controller and updates
# its thread's message count under the 'filter' key.
###
module.exports = [
'$parse', 'searchFilter'
($parse, searchFilter) ->
linkFn = (scope, elem, attrs, [ctrl, counter]) ->
if counter?
scope.$watch (-> ctrl.match), (match, old) ->
if match and not old
counter.count 'match', 1
else if old
counter.count 'match', -1
scope.$on '$destroy', ->
if ctrl.match then counter.count 'match', -1
if parentCtrl = elem.parent().controller('threadFilter')
ctrl.filters parentCtrl.filters()
ctrl.active parentCtrl.active()
parentCtrl.registerChild ctrl
scope.$on '$destroy', -> parentCtrl.unregisterChild ctrl
else
scope.$watch $parse(attrs.threadFilter), (query) ->
unless query then return ctrl.active false
filters = searchFilter.generateFacetedFilter(query)
ctrl.filters filters
ctrl.active true
controller: ThreadFilterController
controllerAs: 'threadFilter'
link: linkFn
require: ['threadFilter', '?^deepCount']
]
uuid = require('node-uuid')
###*
# @ngdoc type
# @name thread.ThreadController
#
# @property {Object} container The thread domain model. An instance of
# `mail.messageContainer`.
# @property {boolean} collapsed True if the thread is collapsed.
#
# @description
# `ThreadController` provides an API for the thread directive controlling
# the collapsing behavior.
###
ThreadController = [
'$scope', 'annotationUI'
($scope, annotationUI) ->
@container = null
@collapsed = true
@parent = null
@counter = null
@filter = null
###*
# @ngdoc method
# @name thread.ThreadController#toggleCollapsed
# @description
# Toggle whether or not the replies to this thread are hidden by default.
# Note that the visibility of replies is also dependent on the state of the
# thread filter, if present.
###
this.toggleCollapsed = (value) ->
newval = if value?
!!value
else
not @collapsed
@collapsed = newval
###*
# @ngdoc method
# @name thread.ThreadController#shouldShow
# @description
# Return a boolean indicating whether this thread should be shown given the
# current system state.
###
this.shouldShow = ->
# when there is a selection, hide unselected annotations
annotationID = this.container?.message?.id
if annotationUI.hasSelectedAnnotations() &&
!annotationUI.isAnnotationSelected(annotationID)
return false
if this.container?.message?.$orphan == true
return false
editing = this._count('edit') > 0
matching = this._count('match') > 0
if this._isFilterActive() and not (this.isNew() or editing or matching)
return false
return true
###*
# @ngdoc method
# @name thread.ThreadController#shouldShowAsReply
# @description
# Return a boolean indicating whether this thread should be shown if it is
# being rendered as a reply to another annotation.
###
this.shouldShowAsReply = ->
shouldShowUnfiltered = not @parent?.collapsed
shouldShowFiltered = this._count('match') > 0
# We always show replies that contain an editor
if this._count('edit') > 0
return true
if this._isFilterActive()
return shouldShowFiltered
else
return shouldShowUnfiltered
###*
# @ngdoc method
# @name thread.ThreadController#numReplies
# @description
# Returns the cumulative number of replies to the annotation at the root of
# this thread.
###
this.numReplies = ->
if @counter
this._count('message') - 1
else
0
###*
# @ngdoc method
# @name thread.ThreadController#shouldShowLoadMore
# @description
# Return a boolean indicating whether the "load more" link should be shown
# for the annotation at the root of this thread. The "load more" link can be
# shown when the thread filter is active (although it may not be visible if
# no replies are hidden in this thread).
###
this.shouldShowLoadMore = ->
this.container?.message?.id? and this._isFilterActive()
###*
# @ngdoc method
# @name thread.ThreadController#numLoadMore
# @description
# Returns the number of replies in this thread which are currently hidden as
# a result of the thread filter.
###
this.numLoadMore = ->
this._count('message') - this._count('match')
###*
# @ngdoc method
# @name thread.ThreadController#loadMore
# @description
# Makes visible any replies in this thread which have been hidden by the
# thread filter.
###
this.loadMore = ->
# If we want to show the rest of the replies in the thread, we need to
# uncollapse all parent threads.
ctrl = this
while ctrl
ctrl.toggleCollapsed(false)
ctrl = ctrl.parent
# Deactivate the thread filter for this thread.
@filter?.active(false)
###*
# @ngdoc method
# @name thread.ThreadController#matchesFilter
# @description
# Returns a boolean indicating whether the annotation at the root of this
# thread is marked as a match by the current thread filter. If there is no
# thread filter attached to this thread, it will return true.
###
this.matchesFilter = ->
if not @filter
return true
return @filter.check(@container)
###*
# @ngdoc method
# @name thread.ThreadController#isNew
# @description
# Return true if this is a newly-created annotation (e.g. the user has just
# created it by clicking the new annotation button in the browser),
# false otherwise.
###
this.isNew = ->
return (this.container?.message? and not this.container?.message?.id)
this._isFilterActive = ->
if @filter
@filter.active()
else
false
this._count = (name) ->
if @counter
@counter.count(name)
else
0
this.id = uuid.v4()
this
]
###*
# @ngdoc function
# @name isHiddenThread
# @param {Element} elem An element bound to a ThreadController
# @returns {Boolean} True if the the thread is hidden
###
isHiddenThread = (elem) ->
parent = elem.parent()
parentThread = parent.controller('thread')
if !parentThread
return false
return parentThread.collapsed || isHiddenThread(parent)
###*
# @ngdoc directive
# @name thread
# @restrict A
# @description
# Directive that instantiates {@link thread.ThreadController ThreadController}.
###
module.exports = [
'$parse', '$window', '$location', '$anchorScroll', 'render',
($parse, $window, $location, $anchorScroll, render) ->
linkFn = (scope, elem, attrs, [ctrl, counter, filter]) ->
# We would ideally use require for this, but searching parents only for a
# controller is a feature of Angular 1.3 and above only.
ctrl.parent = elem.parent().controller('thread')
ctrl.counter = counter
ctrl.filter = filter
# If annotation is a reply, it should be uncollapsed so that when
# shown, replies don't have to be individually expanded.
if ctrl.parent?
ctrl.collapsed = false
# Track the number of messages in the thread
if counter?
counter.count 'message', 1
scope.$on '$destroy', -> counter.count 'message', -1
# The watch is necessary because the computed value of the attribute
# expression may change. This won't happen when we use the thread
# directive in a repeat, since the element will be torn down whenever the
# thread might have changed, but we shouldn't assume that here, unless we
# want to advertise that the thread expression is fixed at link time and
# should not change.
scope.$watch $parse(attrs.thread), (thread) ->
# Queue a render frame to complete the binding and show the element.
# We call $digest to trigger a scope local update.
render ->
ctrl.container = thread
if ctrl.isNew()
# Scroll the sidebar to show new annotations.
# Note that only top level annotation cards have their ID set
annotationCard = document.getElementById(ctrl.id)
if annotationCard
annotationCard.scrollIntoView();
scope.$digest()
controller: ThreadController
controllerAs: 'vm'
link: linkFn
require: ['thread', '?^deepCount', '?^threadFilter']
scope: true
]
{module, inject} = angular.mock
events = require('../events')
threading = require('../threading')
describe 'Threading', ->
instance = null
$rootScope = null
before ->
angular.module('h', [])
.service('threading', require('../threading'))
beforeEach module('h')
beforeEach inject (_threading_, _$rootScope_) ->
instance = _threading_
$rootScope = _$rootScope_
describe 'when an annotation is unloaded', ->
it 'removes the annotation from the thread', ->
$rootScope.$broadcast(events.ANNOTATIONS_LOADED, [{id: 'a'}])
assert.equal(instance.root.children.length, 1)
$rootScope.$broadcast(events.ANNOTATIONS_UNLOADED, [{id: 'a'}])
assert.equal(instance.root.children.length, 0)
describe 'pruneEmpties', ->
it 'keeps public messages with no children', ->
threadA = mail.messageContainer(mail.message('subject a', 'a', []))
threadB = mail.messageContainer(mail.message('subject b', 'b', []))
threadC = mail.messageContainer(mail.message('subject c', 'c', []))
root = mail.messageContainer()
root.addChild(threadA)
root.addChild(threadB)
root.addChild(threadC)
instance.pruneEmpties(root)
assert.equal(root.children.length, 3)
it 'keeps public messages with public children', ->
threadA = mail.messageContainer(mail.message('subject a', 'a', []))
threadA1 = mail.messageContainer(mail.message('subject a1', 'a1', ['a']))
threadA2 = mail.messageContainer(mail.message('subject a2', 'a2', ['a']))
root = mail.messageContainer()
root.addChild(threadA)
threadA.addChild(threadA1)
threadA.addChild(threadA2)
instance.pruneEmpties(root)
assert.equal(root.children.length, 1)
it 'prunes private messages with no children', ->
threadA = mail.messageContainer()
threadB = mail.messageContainer()
threadC = mail.messageContainer()
root = mail.messageContainer()
root.addChild(threadA)
root.addChild(threadB)
root.addChild(threadC)
instance.pruneEmpties(root)
assert.equal(root.children.length, 0)
it 'keeps private messages with public children', ->
threadA = mail.messageContainer()
threadA1 = mail.messageContainer(mail.message('subject a1', 'a1', ['a']))
threadA2 = mail.messageContainer(mail.message('subject a2', 'a2', ['a']))
root = mail.messageContainer()
root.addChild(threadA)
threadA.addChild(threadA1)
threadA.addChild(threadA2)
instance.pruneEmpties(root)
assert.equal(root.children.length, 1)
it 'prunes private messages with private children', ->
threadA = mail.messageContainer()
threadA1 = mail.messageContainer()
threadA2 = mail.messageContainer()
root = mail.messageContainer()
root.addChild(threadA)
threadA.addChild(threadA1)
threadA.addChild(threadA2)
instance.pruneEmpties(root)
assert.equal(root.children.length, 0)
angular = require('angular')
mail = require('./vendor/jwz')
events = require('./events')
# The threading service provides the model for the currently loaded
# set of annotations, structured as a tree of annotations and replies.
#
# The service listens for events when annotations are loaded, unloaded,
# created or deleted and updates the tree model in response.
#
# The conversion of a flat list of incoming messages into a tree structure
# with replies nested under their parents
# uses an implementation of the `jwz` message threading algorithm
# (see https://www.jwz.org/doc/threading.html and the JS port
# at https://github.com/maxogden/conversationThreading-js).
#
# The 'Threading' service "inherits" from 'mail.messageThread'
#
module.exports = class Threading
root: null
this.$inject = ['$rootScope']
constructor: ($rootScope) ->
# XXX: gross hack to inherit from messageThread, which doesn't have an
# accessible prototype.
thread = new mail.messageThread()
threadInheritedProperties = {}
for key in Object.getOwnPropertyNames(thread) when not this[key]?
descriptor = Object.getOwnPropertyDescriptor(thread, key)
threadInheritedProperties[key] = descriptor
Object.defineProperties(this, threadInheritedProperties)
# Create a root container.
@root = mail.messageContainer()
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, this.beforeAnnotationCreated)
$rootScope.$on(events.ANNOTATION_CREATED, this.annotationCreated)
$rootScope.$on(events.ANNOTATION_DELETED, this.annotationDeleted)
$rootScope.$on(events.ANNOTATIONS_LOADED, this.annotationsLoaded)
$rootScope.$on(events.ANNOTATIONS_UNLOADED, this.annotationsUnloaded)
# TODO: Refactor the jwz API for progressive updates.
# Right now the idTable is wiped when `messageThread.thread()` is called and
# empty containers are pruned. We want to show empties so that replies attach
# to missing parents and threads can be updates as new data arrives.
thread: (messages) ->
for message in messages
# Get or create a thread to contain the annotation
if message.id
thread = (this.getContainer message.id)
thread.message = message
else
# XXX: relies on outside code to update the idTable if the message
# later acquires an id.
thread = mail.messageContainer(message)
prev = @root
references = message.references or []
if typeof(message.references) == 'string'
references = [references]
# Build out an ancestry from the root
for reference in references
container = this.getContainer(reference)
unless container.parent? or container.hasDescendant(prev) # no cycles
prev.addChild(container)
prev = container
# Attach the thread at its leaf location
unless thread.hasDescendant(prev) # no cycles
do ->
for child in prev.children when child.message is message
return # no dupes
prev.addChild(thread)
this.pruneEmpties(@root)
@root
# Returns a flat list of every annotation that is currently loaded
# in the thread
annotationList: ->
(message for id, {message} of @idTable when message)
pruneEmpties: (parent) ->
for container in parent.children
this.pruneEmpties(container)
if !container.message && container.children.length == 0
parent.removeChild(container)
beforeAnnotationCreated: (event, annotation) =>
this.thread([annotation])
annotationCreated: (event, annotation) =>
references = annotation.references or []
if typeof(annotation.references) == 'string' then references = []
ref = references[references.length-1]
parent = if ref then @idTable[ref] else @root
for child in (parent.children or []) when child.message is annotation
@idTable[annotation.id] = child
break
annotationDeleted: (event, annotation) =>
if this.idTable[annotation.id]
container = this.idTable[annotation.id]
container.message = null
delete this.idTable[annotation.id]
this.pruneEmpties(@root)
else
if annotation.references
refs = annotation.references
unless angular.isArray(refs) then refs = [refs]
parentRef = refs[refs.length-1]
parent = this.idTable[parentRef]
else
parent = @root
for child in parent.children when child.message is annotation
child.message = null
this.pruneEmpties(@root)
break
annotationsUnloaded: (event, annotations) =>
for annot in annotations
this.annotationDeleted(event, annot)
annotationsLoaded: (event, annotations) =>
messages = (@root.flattenChildren() or []).concat(annotations)
this.thread(messages)
// example usage:
// thread = mail.messageThread().thread(messages.map(
// function(message) {
// return mail.message(message.subject, message.messageId, message.references);
// }
// ));
// conversation = thread.getConversation(messageId);
(function() {
function message(subject, id, references) {
return function(subject, id, references) {
return {
subject: subject,
id: id,
references: references
}
}(subject, id, references);
}
function messageContainer(message) {
return function(message) {
var children = [];
function getConversation(id) {
var child = this.getSpecificChild(id);
var flattened = [];
if(child) flattened = child.flattenChildren();
if(child.message) flattened.unshift(child.message);
return flattened;
}
function flattenChildren() {
var messages = [];
this.children.forEach(function(child) {
if (child.message) messages.push(child.message);
var nextChildren = child.flattenChildren();
if (nextChildren) {
nextChildren.forEach(function(nextChild) {
messages.push(nextChild);
})
}
});
if (messages.length > 0) return messages;
}
function getSpecificChild(id) {
var instance = this;
if (instance.message && instance.message.id == id) return instance;
var specificChild = null;
instance.children.forEach(function(child) {
var found = child.getSpecificChild(id);
if (found) {
specificChild = found;
return;
}
})
return specificChild;
}
function threadParent() {
if (!this.message) return this;
var next = this.parent;
if (!next) return this;
var top = next;
while (next) {
next = next.parent;
if (next) {
if (!next.message) return top;
top = next;
}
}
return top;
}
function addChild(child) {
if(child.parent) child.parent.removeChild(child);
this.children.push(child);
child.parent = this;
}
function removeChild(child) {
this.children = this.children.filter(function (other) {
return other !== child
});
delete child.parent;
}
function hasDescendant(container) {
if (this === container) return true;
if (this.children.length < 1) return false;
var descendantPresent = false;
this.children.forEach(function(child) {
if(child.hasDescendant(container)) descendantPresent = true;
})
return descendantPresent;
}
return {
message: message,
children: children,
flattenChildren: flattenChildren,
getConversation: getConversation,
getSpecificChild: getSpecificChild,
threadParent: threadParent,
addChild: addChild,
removeChild: removeChild,
hasDescendant: hasDescendant
}
}(message);
}
function messageThread() {
return function() {
var idTable = {};
function thread(messages) {
idTable = this.createIdTable(messages);
var root = messageContainer();
Object.keys(idTable).forEach(function(id) {
var container = idTable[id];
if (typeof(container.parent) === 'undefined') {
root.addChild(container);
}
})
pruneEmpties(root);
return root;
}
function pruneEmpties(parent) {
for(var i = parent.children.length - 1; i >= 0; i--) {
var container = parent.children[i];
pruneEmpties(container);
if (!container.message && container.children.length === 0) {
parent.removeChild(container);
} else if (!container.message && container.children.length > 0) {
if (!parent.parent && container.children.length === 1) {
promoteChildren(parent, container)
} else if (!parent.parent && container.children.length > 1) {
// do nothing
} else {
promoteChildren(parent, container)
}
}
}
}
function promoteChildren(parent, container) {
for(var i = container.children.length - 1; i >= 0; i--) {
var child = container.children[i];
parent.addChild(child);
}
parent.removeChild(container);
}
function createIdTable(messages) {
idTable = {};
messages.forEach(function(message) {
var parentContainer = getContainer(message.id);
parentContainer.message = message;
var prev = null;
var references = message.references || [];
if (typeof(references) == 'string') {
references = [references]
}
references.forEach(function(reference) {
var container = getContainer(reference);
if (prev && typeof(container.parent) === 'undefined'
&& !container.hasDescendant(prev)) {
prev.addChild(container);
}
prev = container;
})
if (prev && !parentContainer.hasDescendant(prev)) {
prev.addChild(parentContainer);
}
})
return idTable;
}
function getContainer(id) {
if (typeof(idTable[id]) !== 'undefined') {
return idTable[id];
} else {
return createContainer(id);
}
}
function createContainer(id) {
var container = mail.messageContainer();
idTable[id] = container;
return container;
}
function groupBySubject(root) {
var subjectTable = {};
root.children.forEach(function(container) {
if(!container.message) {
var c = container.children[0];
} else {
var c = container;
}
if (c && c.message) {
var message = c.message;
} else {
return;
}
var subject = helpers.normalizeSubject(message.subject);
if (subject.length === 0) return;
var existing = subjectTable[subject];
if (!existing) {
subjectTable[subject] = c;
} else if (
(typeof(existing.message) !== "undefined") && (
(typeof(c.message) === "undefined") ||
(helpers.isReplyOrForward(existing.message.subject)) &&
(!helpers.isReplyOrForward(message.subject))
)
) {
subjectTable[subject] = c;
}
})
for(var i = root.children.length - 1; i >= 0; i--) {
var container = root.children[i];
if (container.message) {
var subject = container.message.subject;
} else {
var subject = container.children[0].message.subject;
}
subject = helpers.normalizeSubject(subject);
var c = subjectTable[subject];
if (!c || c === container) continue;
if (
(typeof(c.message) === "undefined") &&
(typeof(container.message) === "undefined")
) {
container.children.forEach(function(ctr) {
c.addChild(ctr);
})
container.parent.removeChild(container);
} else if (
(typeof(c.message) === "undefined") &&
(typeof(container.message) !== "undefined")
) {
c.addChild(container);
} else if (
(!helpers.isReplyOrForward(c.message.subject)) &&
(helpers.isReplyOrForward(container.message.subject))
) {
c.addChild(container);
} else {
var newContainer = mail.messageContainer();
newContainer.addChild(c);
newContainer.addChild(container);
subjectTable[subject] = newContainer;
}
}
return subjectTable;
}
return {
getContainer: getContainer,
createContainer: createContainer,
createIdTable: createIdTable,
promoteChildren: promoteChildren,
pruneEmpties: pruneEmpties,
groupBySubject: groupBySubject,
thread: thread,
get idTable() { return idTable; }
}
}();
}
var helpers = {
isReplyOrForward: function(subject) {
var pattern = /^(Re|Fwd)/i;
var match = subject.match(pattern);
return match ? true : false;
},
normalizeSubject: function(subject) {
var pattern = /((Re|Fwd)(\[[\d+]\])?:(\s)?)*([\w]*)/i;
var match = subject.match(pattern);
return match ? match[5] : false;
},
normalizeMessageId: function(messageId) {
var pattern = /<([^<>]+)>/;
var match = messageId.match(pattern);
return match ? match[1] : null;
},
parseReferences: function(references) {
if (!references) return null;
var pattern = /<[^<>]+>/g;
return references.match(pattern).map(function(match) {
return match.match(/[^<>]+/)[0];
})
}
}
var mail = this.mail = {
message: message,
messageContainer: messageContainer,
messageThread: messageThread,
helpers: helpers
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = mail;
}
})();
<a href=""
class="threadexp"
title="{{vm.collapsed && 'Expand' || 'Collapse'}}"
ng-click="vm.toggleCollapsed()">
<span ng-class="{'h-icon-arrow-right': !!vm.collapsed,
'h-icon-arrow-drop-down': !vm.collapsed}"></span>
</a>
<!-- Annotation -->
<div ng-if="vm.container && !vm.container.message" class="thread-deleted">
<p><em>Message not available.</em></p>
</div>
<annotation class="annotation thread-message {{vm.collapsed && 'collapsed'}}"
name="annotation"
annotation="vm.container.message"
is-last-reply="$last"
is-sidebar="::isSidebar"
reply-count="vm.numReplies()"
is-collapsed="vm.collapsed"
on-reply-count-click="vm.toggleCollapsed()"
ng-if="vm.container.message"
ng-show="vm.matchesFilter()">
</annotation>
<div class="thread-load-more" ng-show="vm.shouldShowLoadMore()">
<a class="load-more small"
href=""
ng-click="vm.loadMore()"
ng-pluralize
count="vm.numLoadMore()"
when="{'0': '',
one: 'View one more in conversation',
other: 'View {} more in conversation'}"
></a>
</div>
<!-- Replies -->
<ul class="thread-replies">
<li class="thread"
deep-count="count"
thread="child" thread-filter
ng-include="'thread.html'"
ng-repeat="child in vm.container.children
| orderBy : 'message.updated' : true"
ng-show="vm.shouldShowAsReply()">
</li>
</ul>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment