forked from Meteor-Community-Packages/meteor-user-status
-
Notifications
You must be signed in to change notification settings - Fork 0
/
status.coffee
239 lines (201 loc) · 7.38 KB
/
status.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
###
Apparently, the new api.export takes care of issues here. No need to attach to global namespace.
See http://shiggyenterprises.wordpress.com/2013/09/09/meteor-packages-in-coffeescript-0-6-5/
We may want to make UserSessions a server collection to take advantage of indices.
Will implement if someone has enough online users to warrant it.
###
UserConnections = new Mongo.Collection("user_status_sessions", { connection: null })
statusEvents = new (Npm.require('events').EventEmitter)()
###
Multiplex login/logout events to status.online
###
statusEvents.on "connectionLogin", (advice) ->
update =
$set: {
'status.online': true,
'status.lastLogin': {
date: advice.loginTime
ipAddr: advice.ipAddr
userAgent: advice.userAgent
}
}
# State change if ALL existing connections were idle, but this one isn't
conns = UserConnections.find(userId: advice.userId).fetch()
unless _.every(conns, (c) -> c.idle)
update.$unset =
'status.idle': null
'status.lastActivity': null
Meteor.users.update advice.userId, update
return
statusEvents.on "connectionLogout", (advice) ->
conns = UserConnections.find(userId: advice.userId).fetch()
if conns.length is 0
# Go offline if we are the last connection for this user
# This includes removing all idle information
Meteor.users.update advice.userId,
$set: {'status.online': false }
$unset:
'status.idle': null
'status.lastActivity': null
else if _.every(conns, (c) -> c.idle)
###
All remaining connections are idle:
- If the last active connection quit, then we should go idle with the most recent activity
- If an idle connection quit, nothing should happen; specifically, if the
most recently active idle connection quit, we shouldn't tick the value backwards.
This may result in a no-op so we can be smart and skip the update.
###
return if advice.lastActivity? # The dropped connection was already idle
Meteor.users.update advice.userId,
$set:
'status.idle': true
'status.lastActivity': _.max(_.pluck conns, "lastActivity")
return
###
Multiplex idle/active events to status.idle
TODO: Hopefully this is quick because it's all in memory, but we can use indices if it turns out to be slow
TODO: There is a race condition when switching between tabs, leaving the user inactive while idle goes from one tab to the other.
It can probably be smoothed out.
###
statusEvents.on "connectionIdle", (advice) ->
conns = UserConnections.find(userId: advice.userId).fetch()
return unless _.every(conns, (c) -> c.idle)
# Set user to idle if all the connections are idle
# This will not be the most recent idle across a disconnection, so we use max
# TODO: the race happens here where everyone was idle when we looked for them but now one of them isn't.
Meteor.users.update advice.userId,
$set:
'status.idle': true
'status.lastActivity': _.max(_.pluck conns, "lastActivity")
return
statusEvents.on "connectionActive", (advice) ->
Meteor.users.update advice.userId,
$unset:
'status.idle': null
'status.lastActivity': null
return
# Clear any online users on startup (they will re-add themselves)
# Having no status.online is equivalent to status.online = false (above)
# but it is unreasonable to set the entire users collection to false on startup.
Meteor.startup ->
Meteor.users.update {}
, $unset: {
"status.online": null
"status.idle": null
"status.lastActivity": null
}
, {multi: true}
###
Local session modifification functions - also used in testing
###
addSession = (connection) ->
UserConnections.upsert connection.id,
$set: {
ipAddr: connection.clientAddress
userAgent: connection.httpHeaders['user-agent']
}
return
loginSession = (connection, date, userId) ->
UserConnections.upsert connection.id,
$set: {
userId: userId
loginTime: date
}
statusEvents.emit "connectionLogin",
userId: userId
connectionId: connection.id
ipAddr: connection.clientAddress
userAgent: connection.httpHeaders['user-agent']
loginTime: date
return
# Possibly trigger a logout event if this connection was previously associated with a user ID
tryLogoutSession = (connection, date) ->
return false unless (conn = UserConnections.findOne({
_id: connection.id
userId: { $exists: true }
}))?
# Yes, this is actually a user logging out.
UserConnections.upsert connection.id,
$unset: {
userId: null
loginTime: null
}
statusEvents.emit "connectionLogout",
userId: conn.userId
connectionId: connection.id
lastActivity: conn.lastActivity # If this connection was idle, pass the last activity we saw
logoutTime: date
removeSession = (connection, date) ->
tryLogoutSession(connection, date)
UserConnections.remove(connection.id)
return
idleSession = (connection, date, userId) ->
UserConnections.update connection.id,
$set: {
idle: true
lastActivity: date
}
statusEvents.emit "connectionIdle",
userId: userId
connectionId: connection.id
lastActivity: date
return
activeSession = (connection, date, userId) ->
UserConnections.update connection.id,
$set: { idle: false }
$unset: { lastActivity: null }
statusEvents.emit "connectionActive",
userId: userId
connectionId: connection.id
lastActivity: date
return
###
Handlers for various client-side events
###
# Opening and closing of DDP connections
Meteor.onConnection (connection) ->
addSession(connection)
connection.onClose ->
removeSession(connection, new Date())
# Authentication of a DDP connection
Accounts.onLogin (info) ->
loginSession(info.connection, new Date(), info.user._id)
# pub/sub trick as referenced in http://stackoverflow.com/q/10257958/586086
# We used this in the past, but still need this to detect logouts on the same connection.
Meteor.publish null, ->
# Return null explicitly if this._session is not available, i.e.:
# https://github.com/arunoda/meteor-fast-render/issues/41
return [] unless @_session?
# We're interested in logout events - re-publishes for which a past connection exists
tryLogoutSession(@_session.connectionHandle, new Date()) unless @userId?
return []
# We can use the client's timestamp here because it was sent from a TimeSync
# value, however we should never trust it for something security dependent.
# If timestamp is not provided (probably due to a desync), use server time.
Meteor.methods
"user-status-idle": (timestamp) ->
check(timestamp, Match.OneOf(null, undefined, Date, Number) )
date = if timestamp? then new Date(timestamp) else new Date()
idleSession(@connection, date, @userId)
return
"user-status-active": (timestamp) ->
check(timestamp, Match.OneOf(null, undefined, Date, Number) )
# We only use timestamp because it's when we saw activity *on the client*
# as opposed to just being notified it. It is probably more accurate even if
# a dozen ms off due to the latency of sending it to the server.
date = if timestamp? then new Date(timestamp) else new Date()
activeSession(@connection, date, @userId)
return
# Exported variable
UserStatus =
connections: UserConnections
events: statusEvents
# Internal functions, exported for testing
StatusInternals = {
addSession,
removeSession,
loginSession,
tryLogoutSession,
idleSession,
activeSession,
}