most-box 0.2.9 → 0.3.1

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.
Files changed (140) hide show
  1. package/README.md +41 -21
  2. package/electron/main.js +18 -1
  3. package/electron/preload.cjs +8 -0
  4. package/out/admin/index.html +0 -0
  5. package/out/app/index.html +0 -0
  6. package/out/assets/AccountMenu-NjOgag0d.js +1 -0
  7. package/out/assets/FilePreviewOverlay-CZp6F1s_.js +1 -0
  8. package/out/assets/MarketingHeader-7B-OT2XX.js +1 -0
  9. package/out/assets/MarketingLayout-CyiSCygk.js +1 -0
  10. package/out/assets/{MilkdownEditor-CT6SHYKG.js → MilkdownEditor-WedhCoFH.js} +1 -1
  11. package/out/assets/OpenSidebarButton-BoSmO8CY.js +1 -0
  12. package/out/assets/SidebarHomeLink-CkhGDvZ8.js +1 -0
  13. package/out/assets/arrow-right-DUxAoZqb.js +1 -0
  14. package/out/assets/{ui-BpDGD3IR.js → avatar-BZwHW4ll.js} +2 -2
  15. package/out/assets/circle-alert-DSZ0bG_3.js +1 -0
  16. package/out/assets/copy-CK7HMulu.js +1 -0
  17. package/out/assets/download-Q1uCiwif.js +1 -0
  18. package/out/assets/downloadValidation-Ch966YTl.js +1 -0
  19. package/out/assets/external-link-DGzNJOqQ.js +1 -0
  20. package/out/assets/file-text-C3v0L_lQ.js +1 -0
  21. package/out/assets/folder-CwtLO09O.js +1 -0
  22. package/out/assets/hard-drive-B2V5USzR.js +1 -0
  23. package/out/assets/i18n-DZaOYCSO.js +1 -0
  24. package/out/assets/index-BRhsWNj1.css +1 -0
  25. package/out/assets/index-CBkHUp3F.css +1 -0
  26. package/out/assets/index-CYVRmppz.css +1 -0
  27. package/out/assets/index-DluhgSHX.js +31 -0
  28. package/out/assets/index.lazy-B5TYRvFJ.js +1 -0
  29. package/out/assets/index.lazy-BeDctVci.js +1 -0
  30. package/out/assets/index.lazy-BjmAkVtV.js +1 -0
  31. package/out/assets/index.lazy-CHJbzlK6.js +1 -0
  32. package/out/assets/index.lazy-CITrkWMQ.js +1 -0
  33. package/out/assets/index.lazy-CTGE5fUt.js +2 -0
  34. package/out/assets/index.lazy-CUxcXUMy.js +2 -0
  35. package/out/assets/index.lazy-D84xQtPb.js +3 -0
  36. package/out/assets/index.lazy-DRqAgqpV.js +1 -0
  37. package/out/assets/index.lazy-DSAmY1q_.js +1 -0
  38. package/out/assets/index.lazy-DvbVQRSL.js +1 -0
  39. package/out/assets/index.lazy-gAJi-rJV.js +1 -0
  40. package/out/assets/index.lazy-rzyMd-Gz.js +1 -0
  41. package/out/assets/key-round-Dn5tud-4.js +1 -0
  42. package/out/assets/loader-CLoDCtNy.js +1 -0
  43. package/out/assets/lock-BEWKuIqK.js +1 -0
  44. package/out/assets/message-square-C8ZiYztG.js +1 -0
  45. package/out/assets/mp-CGBtY36n.js +1 -0
  46. package/out/assets/music-BsTA8b_M.js +1 -0
  47. package/out/assets/notebook-pen-DgWzxf2y.js +1 -0
  48. package/out/assets/play-CVkbAJJU.js +1 -0
  49. package/out/assets/plus-D1kCYb8G.js +1 -0
  50. package/out/assets/save-X5c7jlCg.js +1 -0
  51. package/out/assets/search-DAV9vDOq.js +1 -0
  52. package/out/assets/send-C5JjYo-A.js +1 -0
  53. package/out/assets/shield-check-DuD6OLde.js +1 -0
  54. package/out/assets/smartphone-gVFruyjO.js +1 -0
  55. package/out/assets/trash-2-CE-3Dc63.js +1 -0
  56. package/out/assets/triangle-alert-DnOxsFpB.js +1 -0
  57. package/out/assets/upload-CsnBv3pd.js +1 -0
  58. package/out/assets/useChannelMessages-Bs_RWgO4.js +4 -0
  59. package/out/assets/useGameRoom-BxDxm3rp.js +1 -0
  60. package/out/assets/userProfile-UphvP_bG.js +1 -0
  61. package/out/assets/wallet-VDaoXwjI.js +1 -0
  62. package/out/assets/wifi-DfWbmlGE.js +1 -0
  63. package/out/chat/index.html +0 -0
  64. package/out/chat/join/index.html +0 -0
  65. package/out/download/index.html +2 -2
  66. package/out/game/gandengyan/index.html +0 -0
  67. package/out/game/index.html +0 -0
  68. package/out/game/zhajinhua/index.html +0 -0
  69. package/out/index.html +2 -2
  70. package/out/note/index.html +0 -0
  71. package/out/ping/index.html +2 -2
  72. package/out/profile/index.html +0 -0
  73. package/out/web3/index.html +0 -0
  74. package/package.json +2 -5
  75. package/server/index.js +59 -2
  76. package/server/src/http/app.js +9 -1
  77. package/server/src/http/nodeStatus.js +6 -0
  78. package/server/src/http/routePolicy.js +1 -0
  79. package/server/src/http/routes/channelRoutes.js +12 -0
  80. package/server/src/http/routes/noteVaultRoutes.js +85 -0
  81. package/server/src/index.js +476 -3
  82. package/server/src/utils/noteVault.js +327 -0
  83. package/electron/preload.js +0 -6
  84. package/out/assets/FilePreviewOverlay-BYZIAMJ-.js +0 -1
  85. package/out/assets/LanguageToggle-CSQtRZ3A.js +0 -1
  86. package/out/assets/MarketingHeader-ceWdRCpe.js +0 -1
  87. package/out/assets/MarketingLayout-Bun1d2wL.js +0 -1
  88. package/out/assets/MarketingThemeToggle-CIKP8x9W.js +0 -1
  89. package/out/assets/OpenSidebarButton-CLSCB7CG.js +0 -1
  90. package/out/assets/SidebarAccount-CNh0kvtB.js +0 -1
  91. package/out/assets/SidebarHomeLink-DXv0c9Ob.js +0 -1
  92. package/out/assets/arrow-right-BU9c_SL4.js +0 -1
  93. package/out/assets/circle-alert-iuHzciph.js +0 -1
  94. package/out/assets/copy-DEzFUzeX.js +0 -1
  95. package/out/assets/download-jEPn2njL.js +0 -1
  96. package/out/assets/downloadValidation-Bzl7cci9.js +0 -1
  97. package/out/assets/external-link-BFaoZeK0.js +0 -1
  98. package/out/assets/file-text-DPA1riJ_.js +0 -1
  99. package/out/assets/folder-Cfgc70_F.js +0 -1
  100. package/out/assets/hard-drive-Dar_AZEq.js +0 -1
  101. package/out/assets/i18n-NjvOSuV6.js +0 -1
  102. package/out/assets/index-3OD3Chi9.css +0 -1
  103. package/out/assets/index-BHZhApbo.css +0 -1
  104. package/out/assets/index-BI8oNSVC.js +0 -31
  105. package/out/assets/index-BSOvFG3o.css +0 -1
  106. package/out/assets/index.lazy-7omTRykl.js +0 -2
  107. package/out/assets/index.lazy-BOMHingE.js +0 -3
  108. package/out/assets/index.lazy-BSY5_gby.js +0 -1
  109. package/out/assets/index.lazy-C4ipXKZ7.js +0 -1
  110. package/out/assets/index.lazy-CPZbEnJZ.js +0 -1
  111. package/out/assets/index.lazy-Ch4Y17pJ.js +0 -1
  112. package/out/assets/index.lazy-CvBIIYbm.js +0 -1
  113. package/out/assets/index.lazy-Cz-OZwYL.js +0 -1
  114. package/out/assets/index.lazy-D4zNn3D6.js +0 -2
  115. package/out/assets/index.lazy-DRY3xoQp.js +0 -1
  116. package/out/assets/index.lazy-_mEd0Q_u.js +0 -1
  117. package/out/assets/index.lazy-bAp_Hgck.js +0 -1
  118. package/out/assets/index.lazy-ur0joIFl.js +0 -1
  119. package/out/assets/key-round-DNcpSuMw.js +0 -1
  120. package/out/assets/loader-BXQtdens.js +0 -1
  121. package/out/assets/lock-CLIE8cct.js +0 -1
  122. package/out/assets/message-square-Dp09WY1I.js +0 -1
  123. package/out/assets/mp-B6kdA94c.js +0 -1
  124. package/out/assets/music-B19Tg557.js +0 -1
  125. package/out/assets/notebook-pen-C8TlK5sB.js +0 -1
  126. package/out/assets/play-B-BjsTZz.js +0 -1
  127. package/out/assets/plus-4hygj-1Z.js +0 -1
  128. package/out/assets/save-BjrbPwhJ.js +0 -1
  129. package/out/assets/search-DSkiWdvX.js +0 -1
  130. package/out/assets/send-BL4d5iOt.js +0 -1
  131. package/out/assets/shield-check-DzDuWO8r.js +0 -1
  132. package/out/assets/smartphone-tRRFFO0a.js +0 -1
  133. package/out/assets/trash-2-V12tJ8lI.js +0 -1
  134. package/out/assets/triangle-alert-DGWc8ZHu.js +0 -1
  135. package/out/assets/upload-CoCptKya.js +0 -1
  136. package/out/assets/useChannelMessages-BeKF0gwo.js +0 -3
  137. package/out/assets/useGameRoom-cs9YpYt7.js +0 -1
  138. package/out/assets/userProfile-qcc60tHK.js +0 -1
  139. package/out/assets/wallet-T7ONZ3Kg.js +0 -1
  140. package/out/assets/wifi-Bgo8eHY0.js +0 -1
@@ -93,6 +93,8 @@ import {
93
93
  } from './config.js'
94
94
 
95
95
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
96
+ const CHANNEL_PRESENCE_HEARTBEAT_MS = 15 * 1000
97
+ const CHANNEL_PRESENCE_TIMEOUT_MS = 45 * 1000
96
98
  const CHANNEL_WELCOME_MESSAGE = '我加入了群聊'
97
99
 
98
100
  function createMemoryDuplexPair() {
@@ -158,6 +160,9 @@ export class MostBoxEngine extends EventEmitter {
158
160
  #channelPeers = new Map()
159
161
  #channelCandidateCache = new Map()
160
162
  #channelStreams = new Set()
163
+ #channelPresenceSessions = new Map()
164
+ #channelPresenceProfiles = new Map()
165
+ #channelPresenceSweepTimer = null
161
166
 
162
167
  #accountMetadata = { profiles: {} }
163
168
 
@@ -190,6 +195,90 @@ export class MostBoxEngine extends EventEmitter {
190
195
  }
191
196
  }
192
197
 
198
+ getChannelPresence(channelKeyInput, options = {}) {
199
+ this.#ensureInitialized()
200
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
201
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
202
+ this.#pruneStaleChannelPresence()
203
+ return this.#getChannelPresenceList(channel.channelKey)
204
+ }
205
+
206
+ joinChannelPresence(channelKeyInput, options = {}) {
207
+ this.#ensureInitialized()
208
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
209
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
210
+ const event = this.#upsertChannelPresenceSession(channel, {
211
+ ...options,
212
+ address: options.ownerAddress,
213
+ local: true,
214
+ })
215
+ if (event) {
216
+ this.#broadcastChannelPresence(event)
217
+ }
218
+ return this.#getChannelPresenceList(channel.channelKey)
219
+ }
220
+
221
+ heartbeatChannelPresence(channelKeyInput, options = {}) {
222
+ this.#ensureInitialized()
223
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
224
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
225
+ const event = this.#touchChannelPresenceSession(channel, {
226
+ ...options,
227
+ address: options.ownerAddress,
228
+ local: true,
229
+ })
230
+ this.#broadcastChannelPresence(
231
+ event ||
232
+ this.#formatChannelPresence(
233
+ channel.channelKey,
234
+ options.ownerAddress,
235
+ 'heartbeat'
236
+ )
237
+ )
238
+ return this.#getChannelPresenceList(channel.channelKey)
239
+ }
240
+
241
+ updateChannelPresenceProfile(channelKeyInput, options = {}) {
242
+ this.#ensureInitialized()
243
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
244
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
245
+ const event = this.#updateChannelPresenceProfile(channel, {
246
+ ...options,
247
+ address: options.ownerAddress,
248
+ local: true,
249
+ })
250
+ if (event) {
251
+ this.#broadcastChannelPresence(event)
252
+ }
253
+ return this.#getChannelPresenceList(channel.channelKey)
254
+ }
255
+
256
+ leaveChannelPresence(channelKeyInput, options = {}) {
257
+ this.#ensureInitialized()
258
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
259
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
260
+ const events = this.#removeChannelPresenceSessions(channel.channelKey, {
261
+ ...options,
262
+ address: options.ownerAddress,
263
+ })
264
+ events.forEach(event => this.#broadcastChannelPresence(event))
265
+ return this.#getChannelPresenceList(channel.channelKey)
266
+ }
267
+
268
+ clearChannelPresenceSource(sourceId, options = {}) {
269
+ this.#ensureInitialized()
270
+ const events = this.#removeChannelPresenceSessionsBySource(sourceId)
271
+ if (options.broadcast) {
272
+ events.forEach(event => this.#broadcastChannelPresence(event))
273
+ }
274
+ return events
275
+ }
276
+
277
+ pruneChannelPresence() {
278
+ this.#ensureInitialized()
279
+ return this.#pruneStaleChannelPresence()
280
+ }
281
+
193
282
  /**
194
283
  * 初始化引擎 — 必须在调用其他方法之前调用
195
284
  */
@@ -363,6 +452,7 @@ export class MostBoxEngine extends EventEmitter {
363
452
  console.log(`[MostBox] Engine initialized successfully`)
364
453
  this.emit('ready')
365
454
  this.#resumeHoldingsInBackground()
455
+ this.#startChannelPresenceSweeper()
366
456
 
367
457
  return this
368
458
  }
@@ -419,6 +509,7 @@ export class MostBoxEngine extends EventEmitter {
419
509
  this.#channelPeers.clear()
420
510
  this.#channelCandidateCache.clear()
421
511
  this.#channelStreams.clear()
512
+ this.#clearChannelPresenceRuntime()
422
513
  this.#channels = []
423
514
 
424
515
  if (this.#store) {
@@ -3111,6 +3202,312 @@ export class MostBoxEngine extends EventEmitter {
3111
3202
  )
3112
3203
  }
3113
3204
 
3205
+ #normalizePresenceSessionId(sessionId) {
3206
+ return String(sessionId || 'default').trim().slice(0, 120) || 'default'
3207
+ }
3208
+
3209
+ #normalizePresenceSourceId(options = {}) {
3210
+ const sourceId = String(options.sourceId || '').trim()
3211
+ if (sourceId) return sourceId.slice(0, 160)
3212
+ const sourcePeerId = String(options.sourcePeerId || '').trim()
3213
+ if (sourcePeerId) return `peer:${sourcePeerId}`.slice(0, 180)
3214
+ return 'local'
3215
+ }
3216
+
3217
+ #getPresenceSessionKey(options = {}) {
3218
+ return [
3219
+ this.#normalizePresenceSourceId(options),
3220
+ normalizeOwnerAddress(options.address),
3221
+ this.#normalizePresenceSessionId(options.sessionId),
3222
+ ].join(':')
3223
+ }
3224
+
3225
+ #getChannelPresenceSessionMap(channelKey) {
3226
+ if (!this.#channelPresenceSessions.has(channelKey)) {
3227
+ this.#channelPresenceSessions.set(channelKey, new Map())
3228
+ }
3229
+ return this.#channelPresenceSessions.get(channelKey)
3230
+ }
3231
+
3232
+ #getChannelPresenceProfileMap(channelKey) {
3233
+ if (!this.#channelPresenceProfiles.has(channelKey)) {
3234
+ this.#channelPresenceProfiles.set(channelKey, new Map())
3235
+ }
3236
+ return this.#channelPresenceProfiles.get(channelKey)
3237
+ }
3238
+
3239
+ #isChannelPresenceAddressOnline(channelKey, address) {
3240
+ const normalizedAddress = normalizeOwnerAddress(address)
3241
+ if (!normalizedAddress) return false
3242
+ const sessions = this.#channelPresenceSessions.get(channelKey)
3243
+ if (!sessions) return false
3244
+ return [...sessions.values()].some(
3245
+ session => session.address === normalizedAddress
3246
+ )
3247
+ }
3248
+
3249
+ #getChannelPresenceList(channelKey) {
3250
+ const sessions = this.#channelPresenceSessions.get(channelKey)
3251
+ if (!sessions) return []
3252
+ const addresses = uniqueStrings(
3253
+ [...sessions.values()].map(session => session.address)
3254
+ )
3255
+ return addresses
3256
+ .map(address => this.#formatChannelPresence(channelKey, address))
3257
+ .filter(Boolean)
3258
+ }
3259
+
3260
+ #formatChannelPresence(channelKey, address, status = 'online') {
3261
+ const normalizedAddress = normalizeOwnerAddress(address)
3262
+ if (!normalizedAddress) return null
3263
+ const sessions = [
3264
+ ...(this.#channelPresenceSessions.get(channelKey)?.values() || []),
3265
+ ].filter(session => session.address === normalizedAddress)
3266
+ const profile = this.#channelPresenceProfiles
3267
+ .get(channelKey)
3268
+ ?.get(normalizedAddress)
3269
+ const lastSeen = Math.max(
3270
+ 0,
3271
+ Number(profile?.lastSeen) || 0,
3272
+ ...sessions.map(session => Number(session.lastSeen) || 0)
3273
+ )
3274
+ return {
3275
+ channelKey,
3276
+ channelId:
3277
+ this.#channels.find(channel => channel.channelKey === channelKey)
3278
+ ?.channelId || channelKey,
3279
+ address: normalizedAddress,
3280
+ displayName: profile?.displayName || undefined,
3281
+ avatar: profile?.avatar || undefined,
3282
+ profileUpdatedAt: profile?.profileUpdatedAt || undefined,
3283
+ lastSeen,
3284
+ online: sessions.length > 0,
3285
+ local: sessions.some(session => session.local),
3286
+ status,
3287
+ }
3288
+ }
3289
+
3290
+ #upsertChannelPresenceProfile(channelKey, address, options = {}, now = Date.now()) {
3291
+ const normalizedAddress = normalizeOwnerAddress(address)
3292
+ if (!normalizedAddress) return false
3293
+ const hasDisplayName = Object.prototype.hasOwnProperty.call(
3294
+ options,
3295
+ 'displayName'
3296
+ )
3297
+ const hasAvatar = Object.prototype.hasOwnProperty.call(options, 'avatar')
3298
+ const profileUpdatedAt = Number(options.profileUpdatedAt)
3299
+ const hasProfileUpdatedAt =
3300
+ Number.isFinite(profileUpdatedAt) && profileUpdatedAt > 0
3301
+ if (!hasDisplayName && !hasAvatar && !hasProfileUpdatedAt) return false
3302
+
3303
+ const profiles = this.#getChannelPresenceProfileMap(channelKey)
3304
+ const previous = profiles.get(normalizedAddress)
3305
+ const nextUpdatedAt = hasProfileUpdatedAt
3306
+ ? Math.floor(profileUpdatedAt)
3307
+ : now
3308
+ if (
3309
+ previous?.profileUpdatedAt &&
3310
+ hasProfileUpdatedAt &&
3311
+ nextUpdatedAt < previous.profileUpdatedAt
3312
+ ) {
3313
+ return false
3314
+ }
3315
+
3316
+ const next = {
3317
+ address: normalizedAddress,
3318
+ displayName: previous?.displayName || '',
3319
+ avatar: previous?.avatar || '',
3320
+ profileUpdatedAt: nextUpdatedAt,
3321
+ lastSeen: now,
3322
+ }
3323
+ if (hasDisplayName) {
3324
+ next.displayName = normalizeChannelDisplayName(
3325
+ options.displayName,
3326
+ normalizedAddress
3327
+ )
3328
+ }
3329
+ if (hasAvatar) {
3330
+ next.avatar = normalizeChannelAvatar(options.avatar)
3331
+ }
3332
+
3333
+ const changed =
3334
+ !previous ||
3335
+ previous.displayName !== next.displayName ||
3336
+ previous.avatar !== next.avatar ||
3337
+ previous.profileUpdatedAt !== next.profileUpdatedAt
3338
+ profiles.set(normalizedAddress, next)
3339
+ return changed
3340
+ }
3341
+
3342
+ #emitChannelPresence(channelKey, address, status) {
3343
+ const event = this.#formatChannelPresence(channelKey, address, status)
3344
+ if (event) {
3345
+ this.emit('channel:presence', event)
3346
+ }
3347
+ return event
3348
+ }
3349
+
3350
+ #upsertChannelPresenceSession(channel, options = {}) {
3351
+ const address = normalizeOwnerAddress(options.address)
3352
+ if (!address) return null
3353
+ const now = Number(options.lastSeen) || Date.now()
3354
+ const channelKey = channel.channelKey
3355
+ const wasOnline = this.#isChannelPresenceAddressOnline(channelKey, address)
3356
+ const session = {
3357
+ sessionId: this.#normalizePresenceSessionId(options.sessionId),
3358
+ sourceId: this.#normalizePresenceSourceId(options),
3359
+ address,
3360
+ channelKey,
3361
+ lastSeen: now,
3362
+ local: options.local === true,
3363
+ sourcePeerId: String(options.sourcePeerId || '').trim(),
3364
+ }
3365
+ this.#getChannelPresenceSessionMap(channelKey).set(
3366
+ this.#getPresenceSessionKey(session),
3367
+ session
3368
+ )
3369
+ const profileChanged = this.#upsertChannelPresenceProfile(
3370
+ channelKey,
3371
+ address,
3372
+ options,
3373
+ now
3374
+ )
3375
+ if (!wasOnline) {
3376
+ return this.#emitChannelPresence(channelKey, address, 'online')
3377
+ }
3378
+ if (profileChanged) {
3379
+ return this.#emitChannelPresence(channelKey, address, 'profile')
3380
+ }
3381
+ return null
3382
+ }
3383
+
3384
+ #touchChannelPresenceSession(channel, options = {}) {
3385
+ const address = normalizeOwnerAddress(options.address)
3386
+ if (!address) return null
3387
+ const channelKey = channel.channelKey
3388
+ const sessionKey = this.#getPresenceSessionKey({
3389
+ ...options,
3390
+ address,
3391
+ })
3392
+ const sessions = this.#getChannelPresenceSessionMap(channelKey)
3393
+ const existing = sessions.get(sessionKey)
3394
+ if (!existing) {
3395
+ return this.#upsertChannelPresenceSession(channel, {
3396
+ ...options,
3397
+ address,
3398
+ })
3399
+ }
3400
+ existing.lastSeen = Number(options.lastSeen) || Date.now()
3401
+ sessions.set(sessionKey, existing)
3402
+ return null
3403
+ }
3404
+
3405
+ #updateChannelPresenceProfile(channel, options = {}) {
3406
+ const address = normalizeOwnerAddress(options.address)
3407
+ if (!address) return null
3408
+ const now = Number(options.lastSeen) || Date.now()
3409
+ const changed = this.#upsertChannelPresenceProfile(
3410
+ channel.channelKey,
3411
+ address,
3412
+ options,
3413
+ now
3414
+ )
3415
+ if (changed && this.#isChannelPresenceAddressOnline(channel.channelKey, address)) {
3416
+ return this.#emitChannelPresence(channel.channelKey, address, 'profile')
3417
+ }
3418
+ return null
3419
+ }
3420
+
3421
+ #removeChannelPresenceSessions(channelKey, options = {}) {
3422
+ const address = normalizeOwnerAddress(options.address)
3423
+ const sourceId = this.#normalizePresenceSourceId(options)
3424
+ const sessionId = options.sessionId
3425
+ ? this.#normalizePresenceSessionId(options.sessionId)
3426
+ : ''
3427
+ const sessions = this.#channelPresenceSessions.get(channelKey)
3428
+ if (!sessions || (!address && !sourceId)) return []
3429
+
3430
+ const touchedAddresses = new Set()
3431
+ for (const [key, session] of [...sessions]) {
3432
+ if (address && session.address !== address) continue
3433
+ if (sourceId && session.sourceId !== sourceId) continue
3434
+ if (sessionId && session.sessionId !== sessionId) continue
3435
+ touchedAddresses.add(session.address)
3436
+ sessions.delete(key)
3437
+ }
3438
+ if (sessions.size === 0) {
3439
+ this.#channelPresenceSessions.delete(channelKey)
3440
+ }
3441
+
3442
+ return [...touchedAddresses]
3443
+ .filter(item => !this.#isChannelPresenceAddressOnline(channelKey, item))
3444
+ .map(item => this.#emitChannelPresence(channelKey, item, 'offline'))
3445
+ .filter(Boolean)
3446
+ }
3447
+
3448
+ #removeChannelPresenceSessionsBySource(sourceId) {
3449
+ const normalizedSourceId = String(sourceId || '').trim()
3450
+ if (!normalizedSourceId) return []
3451
+ const events = []
3452
+ for (const [channelKey, sessions] of [...this.#channelPresenceSessions]) {
3453
+ const touchedAddresses = new Set()
3454
+ for (const [key, session] of [...sessions]) {
3455
+ if (session.sourceId !== normalizedSourceId) continue
3456
+ touchedAddresses.add(session.address)
3457
+ sessions.delete(key)
3458
+ }
3459
+ if (sessions.size === 0) {
3460
+ this.#channelPresenceSessions.delete(channelKey)
3461
+ }
3462
+ for (const address of touchedAddresses) {
3463
+ if (!this.#isChannelPresenceAddressOnline(channelKey, address)) {
3464
+ const event = this.#emitChannelPresence(channelKey, address, 'offline')
3465
+ if (event) events.push(event)
3466
+ }
3467
+ }
3468
+ }
3469
+ return events
3470
+ }
3471
+
3472
+ #pruneStaleChannelPresence(now = Date.now()) {
3473
+ const events = []
3474
+ for (const [channelKey, sessions] of [...this.#channelPresenceSessions]) {
3475
+ const touchedAddresses = new Set()
3476
+ for (const [key, session] of [...sessions]) {
3477
+ if (now - session.lastSeen <= CHANNEL_PRESENCE_TIMEOUT_MS) continue
3478
+ touchedAddresses.add(session.address)
3479
+ sessions.delete(key)
3480
+ }
3481
+ if (sessions.size === 0) {
3482
+ this.#channelPresenceSessions.delete(channelKey)
3483
+ }
3484
+ for (const address of touchedAddresses) {
3485
+ if (!this.#isChannelPresenceAddressOnline(channelKey, address)) {
3486
+ const event = this.#emitChannelPresence(channelKey, address, 'offline')
3487
+ if (event) events.push(event)
3488
+ }
3489
+ }
3490
+ }
3491
+ return events
3492
+ }
3493
+
3494
+ #startChannelPresenceSweeper() {
3495
+ if (this.#channelPresenceSweepTimer) return
3496
+ this.#channelPresenceSweepTimer = setInterval(() => {
3497
+ this.#pruneStaleChannelPresence()
3498
+ }, CHANNEL_PRESENCE_HEARTBEAT_MS)
3499
+ this.#channelPresenceSweepTimer.unref?.()
3500
+ }
3501
+
3502
+ #clearChannelPresenceRuntime() {
3503
+ if (this.#channelPresenceSweepTimer) {
3504
+ clearInterval(this.#channelPresenceSweepTimer)
3505
+ this.#channelPresenceSweepTimer = null
3506
+ }
3507
+ this.#channelPresenceSessions.clear()
3508
+ this.#channelPresenceProfiles.clear()
3509
+ }
3510
+
3114
3511
  async #getNextChannelMessageTimestamp(channelKey) {
3115
3512
  const coresMap = this.#channelCores.get(channelKey)
3116
3513
  let maxTimestamp = 0
@@ -4473,6 +4870,40 @@ export class MostBoxEngine extends EventEmitter {
4473
4870
  }
4474
4871
  }
4475
4872
 
4873
+ #sendChannelPresence(stream, event) {
4874
+ if (!stream || stream.destroyed || stream.writableEnded || !event) {
4875
+ this.#channelStreams.delete(stream)
4876
+ return false
4877
+ }
4878
+ try {
4879
+ stream.write(
4880
+ `${JSON.stringify({
4881
+ type: 'channel-presence',
4882
+ peerId: this.getNodeId(),
4883
+ channelId: event.channelId,
4884
+ channelKey: event.channelKey,
4885
+ address: event.address,
4886
+ status: event.status,
4887
+ displayName: event.displayName,
4888
+ avatar: event.avatar,
4889
+ profileUpdatedAt: event.profileUpdatedAt,
4890
+ lastSeen: event.lastSeen || Date.now(),
4891
+ sessionId: event.sessionId || 'default',
4892
+ })}\n`
4893
+ )
4894
+ return true
4895
+ } catch {
4896
+ this.#channelStreams.delete(stream)
4897
+ return false
4898
+ }
4899
+ }
4900
+
4901
+ #broadcastChannelPresence(event) {
4902
+ for (const stream of [...this.#channelStreams]) {
4903
+ this.#sendChannelPresence(stream, event)
4904
+ }
4905
+ }
4906
+
4476
4907
  async #processChannelHelloMessage(msg) {
4477
4908
  if (msg.type !== 'channel-hello') return null
4478
4909
 
@@ -4554,6 +4985,45 @@ export class MostBoxEngine extends EventEmitter {
4554
4985
  return msg.peerId
4555
4986
  }
4556
4987
 
4988
+ #processChannelPresenceMessage(msg) {
4989
+ if (msg.type !== 'channel-presence') return null
4990
+ const peerId = String(msg.peerId || '').trim()
4991
+ if (!peerId || peerId === this.getNodeId()) return null
4992
+ const channelId = normalizeChannelId(msg.channelId || msg.channelKey)
4993
+ const channelKey = buildChannelKey(channelId)
4994
+ const localChannel = this.#channels.find(
4995
+ channel => channel.channelKey === channelKey
4996
+ )
4997
+ if (!localChannel) return peerId
4998
+
4999
+ const address = normalizeOwnerAddress(msg.address)
5000
+ if (!address) return peerId
5001
+
5002
+ const status = String(msg.status || '').trim()
5003
+ const options = {
5004
+ address,
5005
+ sessionId: msg.sessionId,
5006
+ sourcePeerId: peerId,
5007
+ local: false,
5008
+ displayName: msg.displayName,
5009
+ avatar: msg.avatar,
5010
+ profileUpdatedAt: msg.profileUpdatedAt,
5011
+ lastSeen: Number(msg.lastSeen) || Date.now(),
5012
+ }
5013
+
5014
+ if (status === 'online') {
5015
+ this.#upsertChannelPresenceSession(localChannel, options)
5016
+ } else if (status === 'heartbeat') {
5017
+ this.#touchChannelPresenceSession(localChannel, options)
5018
+ } else if (status === 'profile') {
5019
+ this.#updateChannelPresenceProfile(localChannel, options)
5020
+ } else if (status === 'offline') {
5021
+ this.#removeChannelPresenceSessions(localChannel.channelKey, options)
5022
+ }
5023
+
5024
+ return peerId
5025
+ }
5026
+
4557
5027
  async #handleChannelConnection(conn) {
4558
5028
  const stream = conn
4559
5029
  let connectedPeerId = null
@@ -4572,9 +5042,11 @@ export class MostBoxEngine extends EventEmitter {
4572
5042
  newlineIndex = readBuffer.indexOf('\n')
4573
5043
  if (!line) continue
4574
5044
  try {
4575
- const peerId = await this.#processChannelHelloMessage(
4576
- JSON.parse(line)
4577
- )
5045
+ const message = JSON.parse(line)
5046
+ const peerId =
5047
+ message.type === 'channel-presence'
5048
+ ? this.#processChannelPresenceMessage(message)
5049
+ : await this.#processChannelHelloMessage(message)
4578
5050
  if (peerId) connectedPeerId = peerId
4579
5051
  } catch (err) {
4580
5052
  console.warn(`[MostBox] Failed to process channel data:`, err.message)
@@ -4603,6 +5075,7 @@ export class MostBoxEngine extends EventEmitter {
4603
5075
  })
4604
5076
  }
4605
5077
  }
5078
+ this.#removeChannelPresenceSessionsBySource(`peer:${connectedPeerId}`)
4606
5079
  }
4607
5080
  }
4608
5081