@nolag/feed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -0
- package/dist/EventEmitter.d.ts +10 -0
- package/dist/FeedChannel.d.ts +43 -0
- package/dist/NoLagFeed.d.ts +35 -0
- package/dist/PostStore.d.ts +42 -0
- package/dist/PresenceManager.d.ts +40 -0
- package/dist/ReactionManager.d.ts +38 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/index.cjs +688 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.mjs +684 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +162 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +57 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { NoLag } from '@nolag/js-sdk';
|
|
2
|
+
|
|
3
|
+
class EventEmitter {
|
|
4
|
+
constructor() {
|
|
5
|
+
this._handlers = new Map();
|
|
6
|
+
}
|
|
7
|
+
on(event, handler) {
|
|
8
|
+
if (!this._handlers.has(event)) {
|
|
9
|
+
this._handlers.set(event, new Set());
|
|
10
|
+
}
|
|
11
|
+
this._handlers.get(event).add(handler);
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
off(event, handler) {
|
|
15
|
+
if (handler) {
|
|
16
|
+
this._handlers.get(event)?.delete(handler);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
this._handlers.delete(event);
|
|
20
|
+
}
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
removeAllListeners() {
|
|
24
|
+
this._handlers.clear();
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
emit(event, ...args) {
|
|
28
|
+
const handlers = this._handlers.get(event);
|
|
29
|
+
if (!handlers)
|
|
30
|
+
return;
|
|
31
|
+
for (const handler of handlers) {
|
|
32
|
+
try {
|
|
33
|
+
handler(...args);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
listenerCount(event) {
|
|
41
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bounded, deduplicated post cache ordered by timestamp.
|
|
47
|
+
*/
|
|
48
|
+
class PostStore {
|
|
49
|
+
constructor(maxSize) {
|
|
50
|
+
this._posts = [];
|
|
51
|
+
this._ids = new Set();
|
|
52
|
+
this._maxSize = maxSize;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Add a post. Returns true if the post was new (not a duplicate).
|
|
56
|
+
*/
|
|
57
|
+
add(post) {
|
|
58
|
+
if (this._ids.has(post.id)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
this._ids.add(post.id);
|
|
62
|
+
this._posts.push(post);
|
|
63
|
+
// Keep sorted by timestamp (newest last)
|
|
64
|
+
if (this._posts.length > 1 &&
|
|
65
|
+
post.timestamp < this._posts[this._posts.length - 2].timestamp) {
|
|
66
|
+
this._posts.sort((a, b) => a.timestamp - b.timestamp);
|
|
67
|
+
}
|
|
68
|
+
// Trim if over capacity (remove oldest)
|
|
69
|
+
while (this._posts.length > this._maxSize) {
|
|
70
|
+
const removed = this._posts.shift();
|
|
71
|
+
this._ids.delete(removed.id);
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get a post by ID.
|
|
77
|
+
*/
|
|
78
|
+
get(id) {
|
|
79
|
+
return this._posts.find((p) => p.id === id);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get all posts in timestamp order (oldest first).
|
|
83
|
+
*/
|
|
84
|
+
getAll() {
|
|
85
|
+
return [...this._posts];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Update the like count (and likedByMe flag) for a post in-place.
|
|
89
|
+
*/
|
|
90
|
+
updateLikeCount(postId, count, likedByMe) {
|
|
91
|
+
const post = this._posts.find((p) => p.id === postId);
|
|
92
|
+
if (post) {
|
|
93
|
+
post.likeCount = count;
|
|
94
|
+
post.likedByMe = likedByMe;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Increment the comment count for a post in-place.
|
|
99
|
+
*/
|
|
100
|
+
incrementCommentCount(postId) {
|
|
101
|
+
const post = this._posts.find((p) => p.id === postId);
|
|
102
|
+
if (post) {
|
|
103
|
+
post.commentCount++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check if a post ID exists.
|
|
108
|
+
*/
|
|
109
|
+
has(id) {
|
|
110
|
+
return this._ids.has(id);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get post count.
|
|
114
|
+
*/
|
|
115
|
+
get size() {
|
|
116
|
+
return this._posts.length;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Clear all posts.
|
|
120
|
+
*/
|
|
121
|
+
clear() {
|
|
122
|
+
this._posts = [];
|
|
123
|
+
this._ids.clear();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Tracks like/unlike state per post with per-user deduplication.
|
|
129
|
+
*
|
|
130
|
+
* Internal: Map<postId, Set<userId>>
|
|
131
|
+
*/
|
|
132
|
+
class ReactionManager {
|
|
133
|
+
constructor() {
|
|
134
|
+
this._likes = new Map();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Record a like from userId on postId.
|
|
138
|
+
* Returns the updated likeCount and whether this was a new like.
|
|
139
|
+
*/
|
|
140
|
+
like(postId, userId) {
|
|
141
|
+
if (!this._likes.has(postId)) {
|
|
142
|
+
this._likes.set(postId, new Set());
|
|
143
|
+
}
|
|
144
|
+
const likers = this._likes.get(postId);
|
|
145
|
+
const isNew = !likers.has(userId);
|
|
146
|
+
likers.add(userId);
|
|
147
|
+
return { postId, likeCount: likers.size, isNew };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Record an unlike from userId on postId.
|
|
151
|
+
* Returns the updated likeCount and whether the like was removed.
|
|
152
|
+
*/
|
|
153
|
+
unlike(postId, userId) {
|
|
154
|
+
const likers = this._likes.get(postId);
|
|
155
|
+
if (!likers) {
|
|
156
|
+
return { postId, likeCount: 0, wasLiked: false };
|
|
157
|
+
}
|
|
158
|
+
const wasLiked = likers.has(userId);
|
|
159
|
+
likers.delete(userId);
|
|
160
|
+
return { postId, likeCount: likers.size, wasLiked };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Check if a userId has liked a postId.
|
|
164
|
+
*/
|
|
165
|
+
isLikedBy(postId, userId) {
|
|
166
|
+
return this._likes.get(postId)?.has(userId) ?? false;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Get the total like count for a post.
|
|
170
|
+
*/
|
|
171
|
+
getLikeCount(postId) {
|
|
172
|
+
return this._likes.get(postId)?.size ?? 0;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Clear all reaction state.
|
|
176
|
+
*/
|
|
177
|
+
clear() {
|
|
178
|
+
this._likes.clear();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Maps actorTokenId <-> FeedUser, filtering self.
|
|
184
|
+
*/
|
|
185
|
+
class PresenceManager {
|
|
186
|
+
constructor(localActorId) {
|
|
187
|
+
this._users = new Map();
|
|
188
|
+
this._actorToUserId = new Map();
|
|
189
|
+
this._localActorId = localActorId;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Add or update a user from presence data.
|
|
193
|
+
* Returns the FeedUser if it is a remote user, null if it is self.
|
|
194
|
+
*/
|
|
195
|
+
addFromPresence(actorTokenId, presence, joinedAt) {
|
|
196
|
+
const isLocal = actorTokenId === this._localActorId;
|
|
197
|
+
// Skip self
|
|
198
|
+
if (isLocal)
|
|
199
|
+
return null;
|
|
200
|
+
const existing = this._actorToUserId.get(actorTokenId);
|
|
201
|
+
const userId = presence.userId || existing || actorTokenId;
|
|
202
|
+
const user = {
|
|
203
|
+
userId,
|
|
204
|
+
actorTokenId,
|
|
205
|
+
username: presence.username,
|
|
206
|
+
avatar: presence.avatar,
|
|
207
|
+
metadata: presence.metadata,
|
|
208
|
+
joinedAt: joinedAt || Date.now(),
|
|
209
|
+
isLocal: false,
|
|
210
|
+
};
|
|
211
|
+
this._users.set(userId, user);
|
|
212
|
+
this._actorToUserId.set(actorTokenId, userId);
|
|
213
|
+
return user;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Remove a user by actorTokenId.
|
|
217
|
+
* Returns the removed user, or null if not found / is self.
|
|
218
|
+
*/
|
|
219
|
+
removeByActorId(actorTokenId) {
|
|
220
|
+
if (actorTokenId === this._localActorId)
|
|
221
|
+
return null;
|
|
222
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
223
|
+
if (!userId)
|
|
224
|
+
return null;
|
|
225
|
+
const user = this._users.get(userId) || null;
|
|
226
|
+
this._users.delete(userId);
|
|
227
|
+
this._actorToUserId.delete(actorTokenId);
|
|
228
|
+
return user;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Get a user by userId.
|
|
232
|
+
*/
|
|
233
|
+
getUser(userId) {
|
|
234
|
+
return this._users.get(userId);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Get a user by actorTokenId.
|
|
238
|
+
*/
|
|
239
|
+
getUserByActorId(actorTokenId) {
|
|
240
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
241
|
+
return userId ? this._users.get(userId) : undefined;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get all remote users.
|
|
245
|
+
*/
|
|
246
|
+
getAll() {
|
|
247
|
+
return Array.from(this._users.values());
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get the users Map (readonly view).
|
|
251
|
+
*/
|
|
252
|
+
get users() {
|
|
253
|
+
return this._users;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Clear all tracked users.
|
|
257
|
+
*/
|
|
258
|
+
clear() {
|
|
259
|
+
this._users.clear();
|
|
260
|
+
this._actorToUserId.clear();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function generateId() {
|
|
265
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
266
|
+
return crypto.randomUUID();
|
|
267
|
+
}
|
|
268
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
269
|
+
}
|
|
270
|
+
function createLogger(prefix, enabled) {
|
|
271
|
+
if (!enabled) {
|
|
272
|
+
return (..._args) => { };
|
|
273
|
+
}
|
|
274
|
+
return (...args) => { console.log(`[${prefix}]`, ...args); };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const DEFAULT_APP_NAME = 'feed';
|
|
278
|
+
const DEFAULT_MAX_POST_CACHE = 200;
|
|
279
|
+
const DEFAULT_MAX_COMMENT_CACHE = 100;
|
|
280
|
+
const TOPIC_POSTS = 'posts';
|
|
281
|
+
const TOPIC_REACTIONS = 'reactions';
|
|
282
|
+
const TOPIC_COMMENTS = 'comments';
|
|
283
|
+
const LOBBY_ID = 'online';
|
|
284
|
+
|
|
285
|
+
class FeedChannel extends EventEmitter {
|
|
286
|
+
constructor(name, roomContext, localUser, options, log) {
|
|
287
|
+
super();
|
|
288
|
+
this._comments = new Map();
|
|
289
|
+
this._unreadCount = 0;
|
|
290
|
+
this._active = false;
|
|
291
|
+
this.name = name;
|
|
292
|
+
this._roomContext = roomContext;
|
|
293
|
+
this._localUser = localUser;
|
|
294
|
+
this._options = options;
|
|
295
|
+
this._log = log;
|
|
296
|
+
this._presenceManager = new PresenceManager(localUser.actorTokenId);
|
|
297
|
+
this._postStore = new PostStore(options.maxPostCache);
|
|
298
|
+
this._reactionManager = new ReactionManager();
|
|
299
|
+
}
|
|
300
|
+
get posts() { return this._postStore.getAll(); }
|
|
301
|
+
get unreadCount() { return this._unreadCount; }
|
|
302
|
+
get active() { return this._active; }
|
|
303
|
+
createPost(opts) {
|
|
304
|
+
const post = {
|
|
305
|
+
id: generateId(), userId: this._localUser.userId, username: this._localUser.username,
|
|
306
|
+
avatar: this._localUser.avatar, content: opts.content, media: opts.media, data: opts.data,
|
|
307
|
+
likeCount: 0, commentCount: 0, likedByMe: false, timestamp: Date.now(), status: 'sending', isReplay: false,
|
|
308
|
+
};
|
|
309
|
+
this._postStore.add(post);
|
|
310
|
+
this.emit('postSent', post);
|
|
311
|
+
this._roomContext.emit(TOPIC_POSTS, {
|
|
312
|
+
id: post.id, userId: post.userId, username: post.username, avatar: post.avatar,
|
|
313
|
+
content: post.content, media: post.media, data: post.data, timestamp: post.timestamp,
|
|
314
|
+
}, { echo: false });
|
|
315
|
+
post.status = 'sent';
|
|
316
|
+
return post;
|
|
317
|
+
}
|
|
318
|
+
getPosts() { return this._postStore.getAll(); }
|
|
319
|
+
likePost(postId) {
|
|
320
|
+
const { likeCount, isNew } = this._reactionManager.like(postId, this._localUser.userId);
|
|
321
|
+
if (isNew) {
|
|
322
|
+
this._postStore.updateLikeCount(postId, likeCount, true);
|
|
323
|
+
this._roomContext.emit(TOPIC_REACTIONS, { postId, userId: this._localUser.userId, type: 'like', timestamp: Date.now() }, { echo: false });
|
|
324
|
+
this.emit('postLiked', { postId, userId: this._localUser.userId, likeCount });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
unlikePost(postId) {
|
|
328
|
+
const { likeCount, wasLiked } = this._reactionManager.unlike(postId, this._localUser.userId);
|
|
329
|
+
if (wasLiked) {
|
|
330
|
+
this._postStore.updateLikeCount(postId, likeCount, false);
|
|
331
|
+
this._roomContext.emit(TOPIC_REACTIONS, { postId, userId: this._localUser.userId, type: 'unlike', timestamp: Date.now() }, { echo: false });
|
|
332
|
+
this.emit('postUnliked', { postId, userId: this._localUser.userId, likeCount });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
addComment(postId, text) {
|
|
336
|
+
const comment = {
|
|
337
|
+
id: generateId(), postId, userId: this._localUser.userId, username: this._localUser.username,
|
|
338
|
+
avatar: this._localUser.avatar, text, timestamp: Date.now(), isReplay: false,
|
|
339
|
+
};
|
|
340
|
+
if (!this._comments.has(postId))
|
|
341
|
+
this._comments.set(postId, []);
|
|
342
|
+
this._comments.get(postId).push(comment);
|
|
343
|
+
this._postStore.incrementCommentCount(postId);
|
|
344
|
+
this.emit('commentSent', comment);
|
|
345
|
+
this._roomContext.emit(TOPIC_COMMENTS, {
|
|
346
|
+
id: comment.id, postId, userId: comment.userId, username: comment.username,
|
|
347
|
+
avatar: comment.avatar, text: comment.text, timestamp: comment.timestamp,
|
|
348
|
+
}, { echo: false });
|
|
349
|
+
return comment;
|
|
350
|
+
}
|
|
351
|
+
getComments(postId) {
|
|
352
|
+
return this._comments.get(postId) ?? [];
|
|
353
|
+
}
|
|
354
|
+
markRead() {
|
|
355
|
+
if (this._unreadCount !== 0) {
|
|
356
|
+
this._unreadCount = 0;
|
|
357
|
+
this.emit('unreadChanged', { channel: this.name, count: 0 });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
getUsers() { return this._presenceManager.getAll(); }
|
|
361
|
+
_subscribe() {
|
|
362
|
+
this._roomContext.subscribe(TOPIC_POSTS);
|
|
363
|
+
this._roomContext.subscribe(TOPIC_REACTIONS);
|
|
364
|
+
this._roomContext.subscribe(TOPIC_COMMENTS);
|
|
365
|
+
this._roomContext.on(TOPIC_POSTS, (data, meta) => this._handleIncomingPost(data, meta));
|
|
366
|
+
this._roomContext.on(TOPIC_REACTIONS, (data) => this._handleIncomingReaction(data));
|
|
367
|
+
this._roomContext.on(TOPIC_COMMENTS, (data, meta) => this._handleIncomingComment(data, meta));
|
|
368
|
+
}
|
|
369
|
+
_activate() {
|
|
370
|
+
this._active = true;
|
|
371
|
+
this._markRead();
|
|
372
|
+
this._setPresence();
|
|
373
|
+
this._roomContext.fetchPresence().then((actors) => {
|
|
374
|
+
for (const actor of actors) {
|
|
375
|
+
if (actor.presence) {
|
|
376
|
+
const user = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
|
|
377
|
+
if (user)
|
|
378
|
+
this.emit('subscriberJoined', user);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}).catch(() => { });
|
|
382
|
+
}
|
|
383
|
+
_deactivate() { this._active = false; this._presenceManager.clear(); }
|
|
384
|
+
_handlePresenceJoin(actorTokenId, presenceData) {
|
|
385
|
+
const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
386
|
+
if (user)
|
|
387
|
+
this.emit('subscriberJoined', user);
|
|
388
|
+
}
|
|
389
|
+
_handlePresenceLeave(actorTokenId) {
|
|
390
|
+
const user = this._presenceManager.removeByActorId(actorTokenId);
|
|
391
|
+
if (user)
|
|
392
|
+
this.emit('subscriberLeft', user);
|
|
393
|
+
}
|
|
394
|
+
_handlePresenceUpdate(actorTokenId, presenceData) {
|
|
395
|
+
this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
396
|
+
}
|
|
397
|
+
_handleReplayStart(count) { this.emit('replayStart', { count }); }
|
|
398
|
+
_handleReplayEnd(replayed) { this.emit('replayEnd', { replayed }); }
|
|
399
|
+
_updateLocalPresence() { this._setPresence(); }
|
|
400
|
+
_cleanup() {
|
|
401
|
+
this._roomContext.unsubscribe(TOPIC_POSTS);
|
|
402
|
+
this._roomContext.unsubscribe(TOPIC_REACTIONS);
|
|
403
|
+
this._roomContext.unsubscribe(TOPIC_COMMENTS);
|
|
404
|
+
this._roomContext.off(TOPIC_POSTS);
|
|
405
|
+
this._roomContext.off(TOPIC_REACTIONS);
|
|
406
|
+
this._roomContext.off(TOPIC_COMMENTS);
|
|
407
|
+
this._postStore.clear();
|
|
408
|
+
this._reactionManager.clear();
|
|
409
|
+
this._comments.clear();
|
|
410
|
+
this._presenceManager.clear();
|
|
411
|
+
this.removeAllListeners();
|
|
412
|
+
}
|
|
413
|
+
_handleIncomingPost(data, meta) {
|
|
414
|
+
const raw = data;
|
|
415
|
+
const post = {
|
|
416
|
+
id: raw.id, userId: raw.userId, username: raw.username,
|
|
417
|
+
avatar: raw.avatar, content: raw.content,
|
|
418
|
+
media: raw.media, data: raw.data,
|
|
419
|
+
likeCount: 0, commentCount: 0, likedByMe: false,
|
|
420
|
+
timestamp: raw.timestamp, status: 'delivered', isReplay: meta.isReplay ?? false,
|
|
421
|
+
};
|
|
422
|
+
if (this._postStore.add(post)) {
|
|
423
|
+
this.emit('postCreated', post);
|
|
424
|
+
if (!this._active && !post.isReplay) {
|
|
425
|
+
this._unreadCount++;
|
|
426
|
+
this.emit('unreadChanged', { channel: this.name, count: this._unreadCount });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
_handleIncomingReaction(data) {
|
|
431
|
+
const raw = data;
|
|
432
|
+
if (raw.type === 'like') {
|
|
433
|
+
const { likeCount } = this._reactionManager.like(raw.postId, raw.userId);
|
|
434
|
+
const likedByMe = this._reactionManager.isLikedBy(raw.postId, this._localUser.userId);
|
|
435
|
+
this._postStore.updateLikeCount(raw.postId, likeCount, likedByMe);
|
|
436
|
+
this.emit('postLiked', { postId: raw.postId, userId: raw.userId, likeCount });
|
|
437
|
+
}
|
|
438
|
+
else if (raw.type === 'unlike') {
|
|
439
|
+
const { likeCount } = this._reactionManager.unlike(raw.postId, raw.userId);
|
|
440
|
+
const likedByMe = this._reactionManager.isLikedBy(raw.postId, this._localUser.userId);
|
|
441
|
+
this._postStore.updateLikeCount(raw.postId, likeCount, likedByMe);
|
|
442
|
+
this.emit('postUnliked', { postId: raw.postId, userId: raw.userId, likeCount });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
_handleIncomingComment(data, meta) {
|
|
446
|
+
const raw = data;
|
|
447
|
+
const comment = {
|
|
448
|
+
id: raw.id, postId: raw.postId, userId: raw.userId,
|
|
449
|
+
username: raw.username, avatar: raw.avatar,
|
|
450
|
+
text: raw.text, timestamp: raw.timestamp, isReplay: meta.isReplay ?? false,
|
|
451
|
+
};
|
|
452
|
+
if (!this._comments.has(comment.postId))
|
|
453
|
+
this._comments.set(comment.postId, []);
|
|
454
|
+
this._comments.get(comment.postId).push(comment);
|
|
455
|
+
this._postStore.incrementCommentCount(comment.postId);
|
|
456
|
+
this.emit('commentAdded', comment);
|
|
457
|
+
}
|
|
458
|
+
_markRead() {
|
|
459
|
+
if (this._unreadCount !== 0) {
|
|
460
|
+
this._unreadCount = 0;
|
|
461
|
+
this.emit('unreadChanged', { channel: this.name, count: 0 });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
_setPresence() {
|
|
465
|
+
this._roomContext.setPresence({
|
|
466
|
+
userId: this._localUser.userId, username: this._localUser.username,
|
|
467
|
+
avatar: this._localUser.avatar, metadata: this._localUser.metadata,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
class NoLagFeed extends EventEmitter {
|
|
473
|
+
constructor(token, options) {
|
|
474
|
+
super();
|
|
475
|
+
this._client = null;
|
|
476
|
+
this._localUser = null;
|
|
477
|
+
this._channels = new Map();
|
|
478
|
+
this._lobby = null;
|
|
479
|
+
this._onlineUsers = new Map();
|
|
480
|
+
this._actorToUserId = new Map();
|
|
481
|
+
this._activeChannel = null;
|
|
482
|
+
this._token = token;
|
|
483
|
+
this._userId = generateId();
|
|
484
|
+
this._options = {
|
|
485
|
+
username: options.username, avatar: options.avatar, metadata: options.metadata,
|
|
486
|
+
appName: options.appName ?? DEFAULT_APP_NAME, url: options.url,
|
|
487
|
+
maxPostCache: options.maxPostCache ?? DEFAULT_MAX_POST_CACHE,
|
|
488
|
+
maxCommentCache: options.maxCommentCache ?? DEFAULT_MAX_COMMENT_CACHE,
|
|
489
|
+
debug: options.debug ?? false, reconnect: options.reconnect ?? true, channels: options.channels ?? [],
|
|
490
|
+
};
|
|
491
|
+
this._log = createLogger('NoLagFeed', this._options.debug);
|
|
492
|
+
}
|
|
493
|
+
get connected() { return this._client?.connected ?? false; }
|
|
494
|
+
get localUser() { return this._localUser; }
|
|
495
|
+
get channels() { return this._channels; }
|
|
496
|
+
async connect() {
|
|
497
|
+
const clientOptions = { debug: this._options.debug, reconnect: this._options.reconnect };
|
|
498
|
+
if (this._options.url)
|
|
499
|
+
clientOptions.url = this._options.url;
|
|
500
|
+
this._client = NoLag(this._token, clientOptions);
|
|
501
|
+
this._client.on('connect', () => { if (this._channels.size > 0) {
|
|
502
|
+
this._restoreChannels();
|
|
503
|
+
this.emit('reconnected');
|
|
504
|
+
} });
|
|
505
|
+
this._client.on('disconnect', (reason) => this.emit('disconnected', reason));
|
|
506
|
+
this._client.on('reconnect', () => { });
|
|
507
|
+
this._client.on('error', (error) => this.emit('error', error));
|
|
508
|
+
this._client.on('replay:start', (data) => { for (const ch of this._channels.values())
|
|
509
|
+
ch._handleReplayStart(data.count); });
|
|
510
|
+
this._client.on('replay:end', (data) => { for (const ch of this._channels.values())
|
|
511
|
+
ch._handleReplayEnd(data.replayed); });
|
|
512
|
+
await this._client.connect();
|
|
513
|
+
this._client.on('presence:join', (data) => this._handleRoomPresenceJoin(data));
|
|
514
|
+
this._client.on('presence:leave', (data) => this._handleRoomPresenceLeave(data));
|
|
515
|
+
this._client.on('presence:update', (data) => this._handleRoomPresenceUpdate(data));
|
|
516
|
+
this._localUser = {
|
|
517
|
+
userId: this._userId, actorTokenId: this._client.actorId, username: this._options.username,
|
|
518
|
+
avatar: this._options.avatar, metadata: this._options.metadata, joinedAt: Date.now(), isLocal: true,
|
|
519
|
+
};
|
|
520
|
+
await this._setupLobby();
|
|
521
|
+
for (const name of this._options.channels)
|
|
522
|
+
this._subscribeChannel(name);
|
|
523
|
+
this.emit('connected');
|
|
524
|
+
setTimeout(() => { if (this._lobby && this._client?.connected)
|
|
525
|
+
this._lobby.fetchPresence().then((s) => this._hydrateOnlineUsers(s)).catch(() => { }); }, 2000);
|
|
526
|
+
}
|
|
527
|
+
disconnect() {
|
|
528
|
+
for (const name of [...this._channels.keys()])
|
|
529
|
+
this.leaveChannel(name);
|
|
530
|
+
this._lobby?.unsubscribe();
|
|
531
|
+
this._lobby = null;
|
|
532
|
+
this._client?.disconnect();
|
|
533
|
+
this._client = null;
|
|
534
|
+
this._onlineUsers.clear();
|
|
535
|
+
this._actorToUserId.clear();
|
|
536
|
+
this._localUser = null;
|
|
537
|
+
}
|
|
538
|
+
joinChannel(name) {
|
|
539
|
+
if (!this._client || !this._localUser)
|
|
540
|
+
throw new Error('Not connected — call connect() first');
|
|
541
|
+
if (this._activeChannel && this._activeChannel !== name) {
|
|
542
|
+
this._channels.get(this._activeChannel)?._deactivate();
|
|
543
|
+
}
|
|
544
|
+
let ch = this._channels.get(name);
|
|
545
|
+
if (!ch)
|
|
546
|
+
ch = this._subscribeChannel(name);
|
|
547
|
+
this._activeChannel = name;
|
|
548
|
+
ch._activate();
|
|
549
|
+
return ch;
|
|
550
|
+
}
|
|
551
|
+
leaveChannel(name) {
|
|
552
|
+
const ch = this._channels.get(name);
|
|
553
|
+
if (!ch)
|
|
554
|
+
return;
|
|
555
|
+
ch._cleanup();
|
|
556
|
+
this._channels.delete(name);
|
|
557
|
+
if (this._activeChannel === name)
|
|
558
|
+
this._activeChannel = null;
|
|
559
|
+
}
|
|
560
|
+
getOnlineUsers() { return Array.from(this._onlineUsers.values()); }
|
|
561
|
+
_subscribeChannel(name) {
|
|
562
|
+
if (!this._client || !this._localUser)
|
|
563
|
+
throw new Error('Not connected');
|
|
564
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
565
|
+
const ch = new FeedChannel(name, roomContext, this._localUser, this._options, createLogger(`FeedChannel:${name}`, this._options.debug));
|
|
566
|
+
this._channels.set(name, ch);
|
|
567
|
+
ch._subscribe();
|
|
568
|
+
return ch;
|
|
569
|
+
}
|
|
570
|
+
_handleRoomPresenceJoin(data) {
|
|
571
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
572
|
+
return;
|
|
573
|
+
const pd = data.presence;
|
|
574
|
+
if (!pd?.userId)
|
|
575
|
+
return;
|
|
576
|
+
const user = this._presenceToUser(data.actorTokenId, pd);
|
|
577
|
+
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
578
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
579
|
+
this._onlineUsers.set(user.userId, user);
|
|
580
|
+
this.emit('userOnline', user);
|
|
581
|
+
}
|
|
582
|
+
const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
583
|
+
if (room)
|
|
584
|
+
room._handlePresenceJoin(data.actorTokenId, pd);
|
|
585
|
+
}
|
|
586
|
+
_handleRoomPresenceLeave(data) {
|
|
587
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
588
|
+
return;
|
|
589
|
+
const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
590
|
+
if (room)
|
|
591
|
+
room._handlePresenceLeave(data.actorTokenId);
|
|
592
|
+
}
|
|
593
|
+
_handleRoomPresenceUpdate(data) {
|
|
594
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
595
|
+
return;
|
|
596
|
+
const pd = data.presence;
|
|
597
|
+
if (!pd?.userId)
|
|
598
|
+
return;
|
|
599
|
+
const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
600
|
+
if (room)
|
|
601
|
+
room._handlePresenceUpdate(data.actorTokenId, pd);
|
|
602
|
+
}
|
|
603
|
+
async _setupLobby() {
|
|
604
|
+
if (!this._client)
|
|
605
|
+
return;
|
|
606
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
607
|
+
const lh = (type) => (data) => {
|
|
608
|
+
const e = data;
|
|
609
|
+
if (type === 'join')
|
|
610
|
+
this._handleLobbyJoin(e);
|
|
611
|
+
else if (type === 'leave')
|
|
612
|
+
this._handleLobbyLeave(e);
|
|
613
|
+
};
|
|
614
|
+
this._client.on('lobbyPresence:join', lh('join'));
|
|
615
|
+
this._client.on('lobbyPresence:leave', lh('leave'));
|
|
616
|
+
this._client.on('lobbyPresence:update', lh('update'));
|
|
617
|
+
try {
|
|
618
|
+
const s = await this._lobby.subscribe();
|
|
619
|
+
this._hydrateOnlineUsers(s);
|
|
620
|
+
}
|
|
621
|
+
catch { }
|
|
622
|
+
}
|
|
623
|
+
_handleLobbyJoin(event) {
|
|
624
|
+
const { actorId, data } = event;
|
|
625
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
626
|
+
return;
|
|
627
|
+
const pd = data;
|
|
628
|
+
if (!pd.userId)
|
|
629
|
+
return;
|
|
630
|
+
const user = this._presenceToUser(actorId, pd);
|
|
631
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
632
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
633
|
+
this._onlineUsers.set(user.userId, user);
|
|
634
|
+
this.emit('userOnline', user);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
_handleLobbyLeave(event) {
|
|
638
|
+
const { actorId, data } = event;
|
|
639
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
640
|
+
return;
|
|
641
|
+
const pd = data;
|
|
642
|
+
const userId = pd?.userId || this._actorToUserId.get(actorId);
|
|
643
|
+
if (userId) {
|
|
644
|
+
const user = this._onlineUsers.get(userId);
|
|
645
|
+
if (user) {
|
|
646
|
+
this._onlineUsers.delete(userId);
|
|
647
|
+
this._actorToUserId.delete(actorId);
|
|
648
|
+
this.emit('userOffline', user);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
_hydrateOnlineUsers(state) {
|
|
653
|
+
for (const roomId of Object.keys(state)) {
|
|
654
|
+
for (const actorId of Object.keys(state[roomId])) {
|
|
655
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
656
|
+
continue;
|
|
657
|
+
const raw = state[roomId][actorId];
|
|
658
|
+
const pd = (raw?.presence ?? raw);
|
|
659
|
+
if (pd?.userId) {
|
|
660
|
+
const user = this._presenceToUser(actorId, pd);
|
|
661
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
662
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
663
|
+
this._onlineUsers.set(user.userId, user);
|
|
664
|
+
this.emit('userOnline', user);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
_presenceToUser(actorTokenId, data) {
|
|
671
|
+
return { userId: data.userId, actorTokenId, username: data.username, avatar: data.avatar, metadata: data.metadata, joinedAt: Date.now(), isLocal: false };
|
|
672
|
+
}
|
|
673
|
+
_restoreChannels() {
|
|
674
|
+
if (this._activeChannel) {
|
|
675
|
+
const ch = this._channels.get(this._activeChannel);
|
|
676
|
+
if (ch)
|
|
677
|
+
ch._updateLocalPresence();
|
|
678
|
+
}
|
|
679
|
+
this._lobby?.fetchPresence().then((s) => { this._onlineUsers.clear(); this._actorToUserId.clear(); this._hydrateOnlineUsers(s); }).catch(() => { });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export { EventEmitter, FeedChannel, NoLagFeed };
|
|
684
|
+
//# sourceMappingURL=index.mjs.map
|