227:64acb7b50eee
Anton Shestakov <av6@dwimlabs.net>, Fri, 12 Aug 2016 22:26:01 +0800
pipelines: re-enable idempotence testing

next change 254:ff8f2e348f73
previous change 206:d0bde0e3cc62

coffee/index.coffee

Permissions: -rw-r--r--

Other formats: Feeds:
Strophe.addNamespace('CAPS', 'http://jabber.org/protocol/caps')
Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2')
Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates')
Strophe.addNamespace('CSI', 'urn:xmpp:csi:0')
Strophe.addNamespace('DELAY', 'urn:xmpp:delay')
Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0')
Strophe.addNamespace('LAST', 'jabber:iq:last')
Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0')
Strophe.addNamespace('PRIVATE', 'jabber:iq:private')
Strophe.addNamespace('TIME', 'urn:xmpp:time')
window.contacts = new Tram.Contacts()
window.contactsApp = new Tram.ContactsApp(el: $('[data-app="contacts"]'), collection: contacts)
window.calls = new Tram.Calls()
window.callsApp = new Tram.CallsApp(el: $('[data-app="calls"]'), collection: calls)
window.settings = new Tram.ClientSettings()
window.serverInfo = new Tram.ServerInfo()
window.clientState = new Tram.ClientState()
window.faviconApp = new Tram.FaviconApp(model: clientState)
window.sidebarApp = new Tram.SidebarApp(el: $('[data-app="sidebar"]'), model: clientState)
window.progressApp = new Tram.ProgressApp(el: $('[data-app="progress"]'), model: clientState)
window.chats = {}
window.capscache = {}
window.vcardcache = {}
contacts.on 'change:show', (model) ->
if model.get('type') is 'self'
clientState.set('show', model.get('show'))
contacts.on 'change:chatstate/self', (model, cs) ->
if settings.get('chatstates') and Strophe.NS.CHATSTATES in (model?.get('features') ? [])
msg = $msg(to: model.get('jid'), from: X.conn.jid, type: 'chat')
.c(cs, xmlns: Strophe.NS.CHATSTATES)
X.conn.send(msg.tree())
contacts.on 'change:presence', (model) ->
dupes = contacts.where(bjid: model.get('bjid'))
offline = (c for c in dupes when c.get('presence') is 'unavailable')
if offline.length is dupes.length
offline = (c for c in offline when c.get('jid') isnt model.get('jid'))
contacts.remove(offline)
clientState.on 'action/show', (show, status) ->
X.sendPresence(show: show, status: status, priority: 1)
clientState.on 'action/disconnect', ->
X.disconnect('Logged out')
clientState.on 'change:contact', ->
previous = clientState.previous('contact')
if previous?
previous.set('chatstate/self', 'inactive')
previous.unset('active')
contact = clientState.get('contact')
if contact?
contact.set('chatstate/self', 'active')
contact.set('active', true)
$logs = $('[data-app="logs"]')
$logs.children().detach()
$logs.append(contact.chat.render().el)
contact.chat.scroll()
$('[data-form="send"]').toggleClass('uk-hidden', not contact?)
clientState.on 'change:csi', ->
state = clientState.get('csi')
X.conn.send($build(state, xmlns: Strophe.NS.CSI).tree())
onConnected = ->
X.conn.addHandler(onPresence, null, 'presence')
X.conn.addHandler(onChatMessage, null, 'message', 'chat')
X.conn.addHandler(onChatState, Strophe.NS.CHATSTATES, 'message')
X.conn.addHandler(onWebRTC, Tram.NS.WEBRTC, 'message', 'chat')
X.conn.addHandler(onAnyError, null, null, 'error')
X.conn.addHandler(onGetLast, Strophe.NS.LAST, 'iq', 'get')
X.conn.addHandler(onGetTime, Strophe.NS.TIME, 'iq', 'get')
X.conn.addHandler(onGetVersion, Strophe.NS.VERSION, 'iq', 'get')
X.conn.ping.addPingHandler(onPing)
X.conn.disco.addIdentity('client', 'web', Tram.info.client)
X.conn.disco.addFeature(Strophe.NS.CAPS)
X.conn.disco.addFeature(Strophe.NS.CARBONS)
X.conn.disco.addFeature(Strophe.NS.CHATSTATES)
X.conn.disco.addFeature(Strophe.NS.CSI)
X.conn.disco.addFeature(Strophe.NS.DISCO_INFO)
X.conn.disco.addFeature(Strophe.NS.LAST)
X.conn.disco.addFeature(Strophe.NS.MESSAGE_CORRECT)
X.conn.disco.addFeature(Strophe.NS.PING)
X.conn.disco.addFeature(Strophe.NS.PRIVATE)
X.conn.disco.addFeature(Strophe.NS.TIME)
X.conn.disco.addFeature(Strophe.NS.VERSION)
X.conn.disco.addFeature(Strophe.NS.XHTML_IM)
X.conn.disco.addFeature(Tram.NS.WEBRTC)
dSettings = $.Deferred()
dCaps = $.Deferred()
dVersion = $.Deferred()
dInfo = $.Deferred()
dCarbons = $.Deferred()
dRoster = $.Deferred()
loadSettings(dSettings.resolve, dSettings.reject)
loadCapsCache(dCaps.resolve, dCaps.reject)
getServerVersion(dVersion.resolve, dVersion.reject)
X.conn.roster.get(dRoster.resolve)
$.when(dCaps).always ->
getServerInfo(dInfo.resolve, dInfo.reject)
$.when(dInfo).always ->
if Strophe.NS.CARBONS in serverInfo.get('features')
okcb = ->
serverInfo.set('carbons', true)
dCarbons.resolve()
failcb = (stanza) ->
serverInfo.set('carbons', false)
dCarbons.reject()
enableCarbons(okcb, failcb)
else
dCarbons.reject()
hasCSI = X.conn.features?.getElementsByTagNameNS(Strophe.NS.CSI, 'csi')[0]?
serverInfo.set('csi', hasCSI)
if hasCSI
enableCSI()
$.when(dSettings, dVersion, dInfo, dRoster, dCarbons).always ->
X.sendPresence(status: 'Online', priority: 1)
onDisconnected = ->
location.reload()
getStamp = (stanza) ->
delay = stanza.getElementsByTagNameNS(Strophe.NS.DELAY, 'delay')[0]
return new Date(delay?.getAttribute('stamp') ? Date.now())
getText = (el) ->
if not el
return null
str = ''
if el.childNodes.length is 0 and el.nodeType is Strophe.ElementType.TEXT
str += el.nodeValue
for node in el.childNodes
if node.nodeType is Strophe.ElementType.TEXT
str += node.nodeValue
return str
getServerVersion = (doneCallback, failCallback) ->
iq = $iq(type: 'get', id: X.conn.getUniqueId('version'), to: X.conn.domain)
.c('query', xmlns: Strophe.NS.VERSION)
okcb = (stanza) ->
serverInfo.set
name: getText(stanza.getElementsByTagName('name')[0])
version: getText(stanza.getElementsByTagName('version')[0])
os: getText(stanza.getElementsByTagName('os')[0])
doneCallback?()
failcb = (stanza) ->
console.error("couldn't get version", stanza?.innerHTML)
failCallback?()
X.conn.sendIQ(iq.tree(), okcb, failcb, Tram.config.iqTimeout)
enableCarbons = (doneCallback, failCallback) ->
iq = $iq(type: 'set', id: X.conn.getUniqueId('enable'))
.c('enable', xmlns: Strophe.NS.CARBONS)
okcb = (stanza) ->
serverInfo.set('carbons', true)
doneCallback?()
failcb = (stanza) ->
serverInfo.set('carbons', false)
failCallback?()
X.conn.sendIQ(iq.tree(), okcb, failcb, Tram.config.iqTimeout)
enableCSI = ->
csiTimer = null
$(window).on 'focus.tram', ->
if csiTimer?
window.clearTimeout(csiTimer)
csiTimer = null
clientState.set('csi', 'active')
$(window).on 'blur.tram', ->
if csiTimer?
window.clearTimeout(csiTimer)
csiTimer = window.setTimeout ->
clientState.set('csi', 'inactive')
, Tram.config.inactiveTime
loadSettings = (doneCallback, failCallback) ->
iq = $iq(type: 'get', id: X.conn.getUniqueId('settings'))
.c('query', xmlns: Strophe.NS.PRIVATE)
.c('settings', xmlns: Tram.NS.PRIVATE)
okcb = (stanza) ->
el = stanza.getElementsByTagName('settings')[0]
if el?.firstChild?
try
data = JSON.parse(el.firstChild.nodeValue)
catch
failCallback?()
return
if data?
settings.set(data)
doneCallback?()
failcb = (stanza) ->
console.error("couldn't load settings", stanza)
failCallback?()
X.conn.sendIQ(iq.tree(), okcb, failcb, Tram.config.iqTimeout)
_saveSettings = (doneCallback, failCallback) ->
payload = JSON.stringify(settings.toJSON())
iq = $iq(type: 'set', id: X.conn.getUniqueId('settings'))
.c('query', xmlns: Strophe.NS.PRIVATE)
.c('settings', xmlns: Tram.NS.PRIVATE).t(payload)
okcb = (stanza) ->
doneCallback?()
failcb = (stanza) ->
console.warn("couldn't save settings", stanza)
failCallback?()
X.conn.sendIQ(iq.tree(), okcb, failcb, Tram.config.iqTimeout)
saveSettings = _(_saveSettings).debounce(1000)
loadCapsCache = (doneCallback, failCallback) ->
iq = $iq(type: 'get', id: X.conn.getUniqueId('capscache'))
.c('query', xmlns: Strophe.NS.PRIVATE)
.c('capscache', xmlns: Tram.NS.PRIVATE)
okcb = (stanza) ->
el = stanza.getElementsByTagName('capscache')[0]
if el?.firstChild?
try
data = JSON.parse(el.firstChild.nodeValue)
catch
failCallback?()
return
if data?
_(capscache).defaults(data)
doneCallback?()
failcb = (stanza) ->
console.error("couldn't load caps cache", stanza)
failCallback?()
X.conn.sendIQ(iq.tree(), okcb, failcb, Tram.config.iqTimeout)
_saveCapsCache = (doneCallback, failCallback) ->
payload = JSON.stringify(capscache)
iq = $iq(type: 'set', id: X.conn.getUniqueId('capscache'))
.c('query', xmlns: Strophe.NS.PRIVATE)
.c('capscache', xmlns: Tram.NS.PRIVATE).t(payload)
okcb = (stanza) ->
doneCallback?()
failcb = (stanza) ->
console.warn("couldn't save caps cache", stanza)
failCallback?()
X.conn.sendIQ(iq.tree(), okcb, failcb, Tram.config.iqTimeout)
saveCapsCache = _(_saveCapsCache).debounce(1000)
onAnyError = (stanza) ->
console.warn('got an error:', stanza, stanza.outerHTML)
return true
onGetLast = (stanza) ->
id = stanza.getAttribute('id')
from = stanza.getAttribute('from')
iq = $iq(to: from, type: 'result', id: id)
.c('query', xmlns: Strophe.NS.LAST, seconds: '0')
X.conn.send(iq.tree())
return true
onGetTime = (stanza) ->
now = moment()
id = stanza.getAttribute('id')
from = stanza.getAttribute('from')
iq = $iq(to: from, type: 'result', id: id)
.c('time', xmlns: Strophe.NS.TIME)
.c('tzo').t(now.format('Z')).up()
.c('utc').t(now.toISOString())
X.conn.send(iq.tree())
return true
onGetVersion = (stanza) ->
id = stanza.getAttribute('id')
from = stanza.getAttribute('from')
iq = $iq(to: from, type: 'result', id: id)
.c('query', xmlns: Strophe.NS.VERSION)
.c('name').t(Tram.info.client).up()
.c('version').t(Tram.info.version)
X.conn.send(iq.tree())
return true
onPing = (stanza) ->
X.conn.ping.pong(stanza)
return true
getChat = (jid) ->
chats[jid] ?= new Tram.LogApp(collection: new Tram.Messages())
getContact = (from, node, ver) ->
contact = contacts.get(from)
if contact?
return contact
self = from is X.conn.jid
if self
contacts.each (model) ->
model.set('type', 'contact')
bjid = Strophe.getBareJidFromJid(from)
contact = contacts.add
jid: from
bjid: bjid
type: if self then 'self' else 'contact'
contact.chat = getChat(bjid)
contact.on 'action/chat', ->
clientState.set('contact', contact)
contact.on 'action/authorize', ->
X.conn.roster.authorize(bjid)
contact.on 'action/unauthorize', ->
X.conn.roster.unauthorize(bjid)
contacts.remove(contact)
contact.on 'action/remove', ->
if X.conn.roster.findItem(bjid)
X.conn.roster.remove bjid, ->
contacts.remove(contacts.where(bjid: bjid))
else
if contacts.find(bjid: bjid, presence: 'subscribe')?
X.conn.roster.unauthorize(bjid)
contacts.remove(contacts.where(bjid: bjid))
contact.w = new Tram.WebRTCInterface(contact)
contact.on 'action/call', (media) ->
contact.set('callstate', 'outgoing')
contact.w.init(true, audio: 'a' in media, video: 'v' in media)
contact.on 'action/accept', (media) ->
contact.w.init(false, audio: 'a' in media, video: 'v' in media)
contact.on 'action/decline action/hangup', ->
contact.w.sendIntent('terminate')
contact.w.disconnect()
contact.unset('callstate')
getContactProfile(contact)
if Strophe.getResourceFromJid(from)?
getClientInfo(contact, node, ver)
return contact
getContactProfile = (contact) ->
bjid = contact.get('bjid')
if bjid is Strophe.getBareJidFromJid(X.conn.jid)
bjid = null
if vcardcache[bjid]?
data = vcardcache[bjid]
contact.set(data)
if not data.avatar?
contact.unset('avatar')
return
okcb = (stanza) ->
vcard = stanza.getElementsByTagNameNS(Strophe.NS.VCARD, 'vCard')[0]
if not vcard?
console.warn("no vcard in response", stanza)
return
data =
nickname: getText(vcard.querySelector('NICKNAME'))
fullname: getText(vcard.querySelector('FN'))
firstname: getText(vcard.querySelector('N > GIVEN'))
lastname: getText(vcard.querySelector('N > FAMILY'))
avatar =
mime: getText(vcard.querySelector('PHOTO > TYPE'))
data: getText(vcard.querySelector('PHOTO > BINVAL'))
if avatar.mime and avatar.data
data.avatar = avatar
vcardcache[bjid] = data
contact.set(data)
if not data.avatar?
contact.unset('avatar')
failcb = (stanza) ->
console.warn("couldn't get vcard", stanza)
X.conn.vcard.get(okcb, bjid, failcb)
getClientInfo = (contact, node, ver) ->
jid = contact.get('jid')
if node? and ver?
clientId = "#{ node }##{ ver }"
if capscache[clientId]?
return contact.set('features', capscache[clientId])
okcb = (stanza) ->
features = (el.getAttribute('var') for el in stanza.getElementsByTagName('feature'))
contact.set('features', features)
if clientId?
capscache[clientId] = features
saveCapsCache()
failcb = (stanza) ->
console.warn("couldn't get client info", stanza)
X.conn.disco.info(jid, clientId, okcb, failcb, Tram.config.iqTimeout)
getServerInfo = (doneCallback, failCallback) ->
caps = X.conn.features.getElementsByTagNameNS(Strophe.NS.CAPS, 'c')[0]
node = caps?.getAttribute('node')
ver = caps?.getAttribute('ver')
if node? and ver?
serverId = "#{ node }##{ ver }"
if capscache[serverId]?
serverInfo.set('features', capscache[serverId])
doneCallback?()
return
okcb = (stanza) ->
features = (el.getAttribute('var') for el in stanza.getElementsByTagName('feature'))
serverInfo.set('features', features)
capscache[serverId] = features
doneCallback?()
failcb = (stanza) ->
console.warn("couldn't get server info", stanza)
failCallback?()
X.conn.disco.info(X.conn.domain, serverId, okcb, failcb, Tram.config.iqTimeout)
onPresence = (stanza) ->
type = stanza.getAttribute('type') ? 'available'
from = stanza.getAttribute('from')
switch type
when 'unsubscribe'
# Upon receiving the presence stanza of type "unsubscribe", the contact
# SHOULD acknowledge receipt of that subscription state notification
# through either "affirming" it by sending a presence stanza of type
# "unsubscribed" to the user or ...
X.conn.roster.unauthorize(from)
return true
when 'subscribed'
# Upon receiving the presence stanza of type "subscribed", the contact
# SHOULD acknowledge receipt of that subscription state notification
# through either "affirming" it by sending a presence stanza of type
# "subscribe" to the user or ...
X.conn.roster.subscribe(from)
return true
when 'unsubscribed'
# Upon receiving the presence stanza of type "unsubscribed", the user
# SHOULD acknowledge receipt of that subscription state notification
# through either "affirming" it by sending a presence stanza of type
# "unsubscribe" to the contact or ...
X.conn.roster.unsubscribe(from)
return true
when 'error'
return true
caps = stanza.getElementsByTagNameNS(Strophe.NS.CAPS, 'c')[0]
ver = caps?.getAttribute('ver')
node = caps?.getAttribute('node')
contact = getContact(from, node, ver)
show = getText(stanza.getElementsByTagName('show')[0])
status = getText(stanza.getElementsByTagName('status')[0])
priority = getText(stanza.getElementsByTagName('priority')[0])
switch type
when 'available'
show ?= 'online'
priority ?= '0'
when 'unavailable'
show = 'offline'
priority = '0'
contact.set
presence: type
show: show
status: status
priority: priority
if type is 'unavailable'
contact.w.disconnect()
if type in ['available', 'unavailable'] and contact.get('type') isnt 'self'
stamp = getStamp(stanza)
contact.chat.collection.add
id: stanza.getAttribute('id')
type: 'presence'
cls: 'presence'
from: from
stamp: stamp
contact: contact
presence: type
show: show
status: status
priority: priority
text: status ? "is now #{ show }"
return true
onChatMessage = (stanza) ->
sent = stanza.getElementsByTagNameNS(Strophe.NS.CARBONS, 'sent')[0]
received = stanza.getElementsByTagNameNS(Strophe.NS.CARBONS, 'received')[0]
carbon = sent? or received?
if sent?
forwarded = sent.getElementsByTagNameNS(Strophe.NS.FORWARD, 'forwarded')[0]
else if received?
forwarded = received.getElementsByTagNameNS(Strophe.NS.FORWARD, 'forwarded')[0]
if forwarded?
message = forwarded.getElementsByTagName('message')[0]
if carbon
onChatState(message)
else
message = stanza
text = getText(message.getElementsByTagName('body')[0])
xhtml = message.getElementsByTagNameNS(Strophe.NS.XHTML_IM, 'html')[0]
if not (text or xhtml)
return true
id = message.getAttribute('id')
to = message.getAttribute('to')
from = message.getAttribute('from')
type = message.getAttribute('type') ? 'normal'
thread = getText(message.getElementsByTagName('thread')[0])
subject = getText(message.getElementsByTagName('subject')[0])
cls = type
stamp = getStamp(stanza)
if type is 'error'
return true
contact = getContact(from)
fromSelf = contact.get('bjid') is Strophe.getBareJidFromJid(X.conn.jid)
if xhtml? and (fromSelf or X.conn.roster.findItem(contact.get('bjid')))
body = xhtml.getElementsByTagNameNS(Strophe.NS.XHTML, 'body')[0]
html = Strophe.createHtml(body).innerHTML
if forwarded? and fromSelf
chat = getContact(to).chat
cls = 'self'
else
chat = contact.chat
replace = stanza.getElementsByTagNameNS(Strophe.NS.MESSAGE_CORRECT, 'replace')[0]
rid = replace?.getAttribute('id')
if rid?
msg = chat.collection.get(rid)
if msg? and msg.get('from') is from
msg.set
id: id
corrected: true
carbon: carbon
thread: thread
subject: subject
text: text
html: html
return true
chat.collection.add
id: id
type: type
cls: cls
to: to
from: from
contact: contact
stamp: stamp
carbon: carbon
thread: thread
subject: subject
text: text
html: html
return true
onChatState = (stanza) ->
delay = stanza.getElementsByTagNameNS(Strophe.NS.DELAY, 'delay')[0]
if delay?
return true
from = stanza.getAttribute('from')
contact = getContact(from)
elements = stanza.getElementsByTagNameNS(Strophe.NS.CHATSTATES, '*')
if elements[0]?
contact.set('chatstate', elements[0].tagName.toLowerCase())
return true
onWebRTC = (stanza) ->
from = stanza.getAttribute('from')
contact = getContact(from)
intent = stanza.getElementsByTagNameNS(Tram.NS.WEBRTC, 'intent')[0]
payload = stanza.getElementsByTagNameNS(Tram.NS.WEBRTC, 'payload')[0]
stamp = getStamp(stanza)
if intent?
switch getText(intent)
when 'initiate'
contact.set('callstate', 'incoming')
contact.chat.collection.add
id: stanza.getAttribute('id')
type: 'call'
cls: 'call'
from: from
stamp: stamp
contact: contact
text: 'incoming call'
when 'terminate'
contact.w.disconnect()
contact.unset('callstate')
if payload?
contact.w.onPayload(stanza)
return true
window.X = new Tram.XMPPInterface()
X.on 'connecting', ->
connForm.unset('auth-errors')
X.on 'authfail', ->
connForm.set('auth-errors', ['Invalid username or password.'])
X.on 'disconnected', ->
$('[data-step="login"]').removeClass('uk-hidden')
$('[data-step="main"]').addClass('uk-hidden')
onDisconnected()
X.on 'connected attached', ->
$('[data-step="login"]').addClass('uk-hidden')
$('[data-step="main"]').removeClass('uk-hidden')
onConnected()
X.on 'status', (status) ->
switch status
when Strophe.Status.CONNECTING
clientState.set('progress', 0)
when Strophe.Status.CONNECTED
clientState.set('progress', 100)
else
clientState.unset('progress')
$(window).on 'beforeunload unload', ->
X.disconnect('Window closed')
window.connForm = new Tram.ConnectionForm()
connectfn = ->
if connForm.isValid()
X.connect(connForm.get('username').trim(), connForm.get('password'))
$form = $('[data-form="connect"]')
$form.streamline()
window.connRivet = rivets.bind($form, form: connForm, connect: connectfn)
sendMessage = ->
text = $('#msg').val()
if text isnt '' and clientState.has('contact')
contact = clientState.get('contact')
msg = $msg(to: contact.get('jid'), from: X.conn.jid, type: 'chat').c('body').t(text).up()
if settings.get('chatstates') and Strophe.NS.CHATSTATES in (contact.get('features') ? [])
msg.c('active', xmlns: Strophe.NS.CHATSTATES)
contact.set('chatstate/self', 'active', silent: true)
X.conn.send(msg.tree())
contact.chat.collection.add
type: 'chat'
cls: 'self'
from: X.conn.jid
to: contact.get('jid')
contact: contacts.findWhere(type: 'self')
text: text
$('#msg').val('')
$('body').on 'dragover dragenter', (event) ->
event.stopPropagation()
event.preventDefault()
$('body').on 'drop', (event) ->
event.stopPropagation()
event.preventDefault()
$('[data-send-button]').on('click', sendMessage)
$('#msg').on 'keypress', (e) ->
if e.keyCode is 13
sendMessage()
else
clientState.get('contact')?.set('chatstate/self', 'composing')
$('#msg').on 'blur', ->
if clientState.get('contact')?.get('chatstate/self') is 'composing'
clientState.get('contact')?.set('chatstate/self', 'paused')
$('[data-add-button]').on 'click', ->
jid = $('#new-contact').val().trim()
if jid is ''
return
if '@' not in jid
jid = "#{ jid }@#{ Tram.config.domain }"
$('#new-contact').val('')
X.conn.roster.add jid, null, [], ->
X.conn.roster.subscribe(jid, 'I want to chat', null)
$('#new-contact').on 'keypress', (e) ->
if e.keyCode is 13
$('[data-add-button]').trigger('click')
$('[data-unregister-button]').on 'click', ->
X.unregister()
$('#settings-modal').one 'show.uk.modal', ->
window.settingsRivet = rivets.bind($('[data-app="settings"]'), settings: settings)
window.serverRivet = rivets.bind($('[data-app="server-info"]'), server: serverInfo)
settings.on('change', saveSettings)
$('#profile-modal').one 'show.uk.modal', ->
window.profileRivet = rivets.bind($('[data-app="profile"]'), contact: contacts.get(X.conn.jid))
window.setInterval ->
$('time[datetime]').each ->
$this = $(this)
$this.text(moment($this.attr('datetime')).fromNow())
, 15 * 1000