most-box 0.2.8 → 0.3.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.
Files changed (54) hide show
  1. package/out/admin/index.html +0 -0
  2. package/out/app/index.html +0 -0
  3. package/out/assets/{FilePreviewOverlay-D8H61qbp.js → FilePreviewOverlay-CM5IqnZD.js} +1 -1
  4. package/out/assets/{MarketingHeader-CrYsQsQ5.js → MarketingHeader-B45AaM29.js} +1 -1
  5. package/out/assets/{MarketingLayout-CQurMOSD.js → MarketingLayout-A00pEOa2.js} +1 -1
  6. package/out/assets/{MilkdownEditor-DrsyaR4h.js → MilkdownEditor-BTq9bDAf.js} +1 -1
  7. package/out/assets/{OpenSidebarButton-DnEgqP0S.js → OpenSidebarButton-D8qfUGzU.js} +1 -1
  8. package/out/assets/{SidebarAccount-B4OAl0as.js → SidebarAccount-_RBE5K_o.js} +1 -1
  9. package/out/assets/{SidebarHomeLink-BdsUM4Kt.js → SidebarHomeLink-qMFEGjZt.js} +1 -1
  10. package/out/assets/{downloadValidation-DbVOwdmC.js → downloadValidation-CSXwoQKW.js} +1 -1
  11. package/out/assets/index-BHZhApbo.css +1 -0
  12. package/out/assets/index-BRhsWNj1.css +1 -0
  13. package/out/assets/{index-BZ-f0CZd.js → index-C-_ZwxuN.js} +2 -2
  14. package/out/assets/index-CYVRmppz.css +1 -0
  15. package/out/assets/index.lazy-0YYV0CR3.js +1 -0
  16. package/out/assets/index.lazy-BTYGr9Vb.js +1 -0
  17. package/out/assets/{index.lazy-B7AbDiMU.js → index.lazy-BVAtQtTa.js} +1 -1
  18. package/out/assets/{index.lazy-oFaoa4qT.js → index.lazy-BbdLA2L-.js} +1 -1
  19. package/out/assets/{index.lazy-DVeqmqaA.js → index.lazy-CbiLtziY.js} +1 -1
  20. package/out/assets/{index.lazy-CvKEcWlH.js → index.lazy-CcEdBg3i.js} +2 -2
  21. package/out/assets/index.lazy-CePHsLXG.js +1 -0
  22. package/out/assets/{index.lazy-rfgjFZNH.js → index.lazy-DG_3_VdF.js} +1 -1
  23. package/out/assets/{index.lazy-zpQXhgmn.js → index.lazy-DR0rWEWZ.js} +1 -1
  24. package/out/assets/{index.lazy-hF807bh0.js → index.lazy-DSyTSCJD.js} +1 -1
  25. package/out/assets/{index.lazy-DB9Ht9LJ.js → index.lazy-DUhsuPLv.js} +1 -1
  26. package/out/assets/{index.lazy-BsciBHf_.js → index.lazy-DVnhWbzH.js} +1 -1
  27. package/out/assets/{index.lazy-CgKPe02l.js → index.lazy-DZpkg1DJ.js} +1 -1
  28. package/out/assets/useChannelMessages-DOaWHdAP.js +4 -0
  29. package/out/assets/{useGameRoom-CRJL0kcm.js → useGameRoom-hHkV65ii.js} +1 -1
  30. package/out/assets/{userProfile-DBkH4s29.js → userProfile-DwZip0Dt.js} +1 -1
  31. package/out/chat/index.html +0 -0
  32. package/out/chat/join/index.html +0 -0
  33. package/out/download/index.html +2 -2
  34. package/out/game/gandengyan/index.html +0 -0
  35. package/out/game/index.html +0 -0
  36. package/out/game/zhajinhua/index.html +0 -0
  37. package/out/index.html +2 -2
  38. package/out/note/index.html +0 -0
  39. package/out/ping/index.html +2 -2
  40. package/out/profile/index.html +0 -0
  41. package/out/web3/index.html +0 -0
  42. package/package.json +1 -1
  43. package/server/index.js +59 -2
  44. package/server/src/http/app.js +7 -1
  45. package/server/src/http/nodeStatus.js +6 -0
  46. package/server/src/http/routes/channelRoutes.js +12 -0
  47. package/server/src/index.js +510 -5
  48. package/out/assets/index-3OD3Chi9.css +0 -1
  49. package/out/assets/index-BSOvFG3o.css +0 -1
  50. package/out/assets/index-Cde4g_dk.css +0 -1
  51. package/out/assets/index.lazy-BxoGfWEW.js +0 -1
  52. package/out/assets/index.lazy-CKbrHko5.js +0 -1
  53. package/out/assets/index.lazy-Citucq1G.js +0 -1
  54. package/out/assets/useChannelMessages-B6RMaNDH.js +0 -3
@@ -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) {
@@ -2588,7 +2679,7 @@ export class MostBoxEngine extends EventEmitter {
2588
2679
  /**
2589
2680
  * 获取频道内在线用户
2590
2681
  * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2591
- * @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
2682
+ * @returns {Array<{ peerId: string, authorName: string, lastSeen: number, memberAddresses: string[] }>}
2592
2683
  */
2593
2684
  getChannelPeers(channelKeyInput, options = {}) {
2594
2685
  this.#ensureInitialized()
@@ -2603,6 +2694,9 @@ export class MostBoxEngine extends EventEmitter {
2603
2694
  return [...peers.values()].map(p => ({
2604
2695
  peerId: p.peerId,
2605
2696
  authorName: p.authorName,
2697
+ memberAddresses: uniqueStrings(p.memberAddresses).map(address =>
2698
+ normalizeOwnerAddress(address)
2699
+ ).filter(Boolean),
2606
2700
  lastSeen: p.lastSeen,
2607
2701
  }))
2608
2702
  }
@@ -3101,6 +3195,319 @@ export class MostBoxEngine extends EventEmitter {
3101
3195
  )
3102
3196
  }
3103
3197
 
3198
+ #getChannelMemberAddresses(channel) {
3199
+ const members = Array.isArray(channel?.members) ? channel.members : []
3200
+ return uniqueStrings(
3201
+ members.map(member => normalizeOwnerAddress(member?.address))
3202
+ )
3203
+ }
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
+
3104
3511
  async #getNextChannelMessageTimestamp(channelKey) {
3105
3512
  const coresMap = this.#channelCores.get(channelKey)
3106
3513
  let maxTimestamp = 0
@@ -4429,6 +4836,7 @@ export class MostBoxEngine extends EventEmitter {
4429
4836
  type: channel.type,
4430
4837
  createdAt: channel.createdAt,
4431
4838
  lastMessageAt: channel.lastMessageAt || '',
4839
+ memberAddresses: this.#getChannelMemberAddresses(channel),
4432
4840
  writerCoreKeys: uniqueStrings([
4433
4841
  ...(channel.writerCoreKeys || []),
4434
4842
  this.#channelLocalCoreKey.get(channel.channelKey),
@@ -4462,9 +4870,44 @@ export class MostBoxEngine extends EventEmitter {
4462
4870
  }
4463
4871
  }
4464
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
+
4465
4907
  async #processChannelHelloMessage(msg) {
4466
4908
  if (msg.type !== 'channel-hello') return null
4467
4909
 
4910
+ const onlineChannels = []
4468
4911
  const remoteChannels = Array.isArray(msg.channels)
4469
4912
  ? msg.channels
4470
4913
  .filter(channel => channel && typeof channel === 'object')
@@ -4480,6 +4923,13 @@ export class MostBoxEngine extends EventEmitter {
4480
4923
  typeof channel.lastMessageAt === 'string'
4481
4924
  ? channel.lastMessageAt
4482
4925
  : '',
4926
+ memberAddresses: uniqueStrings(
4927
+ Array.isArray(channel.memberAddresses)
4928
+ ? channel.memberAddresses.map(address =>
4929
+ normalizeOwnerAddress(address)
4930
+ )
4931
+ : []
4932
+ ),
4483
4933
  writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4484
4934
  }
4485
4935
  })
@@ -4503,9 +4953,15 @@ export class MostBoxEngine extends EventEmitter {
4503
4953
  peers.set(msg.peerId, {
4504
4954
  peerId: msg.peerId,
4505
4955
  authorName: msg.authorName,
4956
+ memberAddresses: remoteChannel.memberAddresses,
4506
4957
  lastSeen: Date.now(),
4507
4958
  })
4508
4959
  }
4960
+ onlineChannels.push({
4961
+ channelKey: localChannel.channelKey,
4962
+ channelId: localChannel.channelId,
4963
+ memberAddresses: remoteChannel.memberAddresses,
4964
+ })
4509
4965
 
4510
4966
  for (const writerCoreKey of remoteChannel.writerCoreKeys) {
4511
4967
  if (
@@ -4523,11 +4979,51 @@ export class MostBoxEngine extends EventEmitter {
4523
4979
  this.emit('channel:peer:online', {
4524
4980
  peerId: msg.peerId,
4525
4981
  authorName: msg.authorName,
4982
+ channels: onlineChannels,
4526
4983
  })
4527
4984
 
4528
4985
  return msg.peerId
4529
4986
  }
4530
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
+
4531
5027
  async #handleChannelConnection(conn) {
4532
5028
  const stream = conn
4533
5029
  let connectedPeerId = null
@@ -4546,9 +5042,11 @@ export class MostBoxEngine extends EventEmitter {
4546
5042
  newlineIndex = readBuffer.indexOf('\n')
4547
5043
  if (!line) continue
4548
5044
  try {
4549
- const peerId = await this.#processChannelHelloMessage(
4550
- JSON.parse(line)
4551
- )
5045
+ const message = JSON.parse(line)
5046
+ const peerId =
5047
+ message.type === 'channel-presence'
5048
+ ? this.#processChannelPresenceMessage(message)
5049
+ : await this.#processChannelHelloMessage(message)
4552
5050
  if (peerId) connectedPeerId = peerId
4553
5051
  } catch (err) {
4554
5052
  console.warn(`[MostBox] Failed to process channel data:`, err.message)
@@ -4561,16 +5059,23 @@ export class MostBoxEngine extends EventEmitter {
4561
5059
  closed = true
4562
5060
  this.#channelStreams.delete(stream)
4563
5061
  if (connectedPeerId) {
4564
- for (const [, peers] of this.#channelPeers) {
5062
+ for (const [channelKey, peers] of this.#channelPeers) {
4565
5063
  if (peers.has(connectedPeerId)) {
4566
5064
  const peer = peers.get(connectedPeerId)
4567
5065
  peers.delete(connectedPeerId)
5066
+ const channel = this.#channels.find(
5067
+ item => item.channelKey === channelKey
5068
+ )
4568
5069
  this.emit('channel:peer:offline', {
4569
5070
  peerId: connectedPeerId,
4570
5071
  authorName: peer?.authorName,
5072
+ channelKey,
5073
+ channelId: channel?.channelId || '',
5074
+ memberAddresses: peer?.memberAddresses || [],
4571
5075
  })
4572
5076
  }
4573
5077
  }
5078
+ this.#removeChannelPresenceSessionsBySource(`peer:${connectedPeerId}`)
4574
5079
  }
4575
5080
  }
4576
5081
 
@@ -1 +0,0 @@
1
- ._page_1yyew_1{background:radial-gradient(circle at 35% 25%, #34c75924, transparent 32%), radial-gradient(circle at 78% 18%, #ff95001f, transparent 28%), var(--bg-primary);flex:1;min-height:0;padding:20px;overflow:auto}._headerActions_1yyew_12{align-items:center;gap:8px;display:flex}._entry_1yyew_18{grid-template-columns:minmax(0,1fr) 360px;align-items:center;gap:48px;max-width:1040px;min-height:calc(100vh - 116px);margin:0 auto;display:grid}._entryBrand_1yyew_28{align-items:center;gap:22px;display:flex}._cardMark_1yyew_34{width:112px;height:152px;box-shadow:var(--shadow-lg);color:#fff;background:linear-gradient(135deg,#1f8f5f,#284f7a);border:8px solid #fff;border-radius:8px;flex:0 0 112px;place-items:center;display:grid}._entryBrand_1yyew_28 h1{color:var(--text-primary);margin:0;font-size:64px;line-height:.95}._entryBrand_1yyew_28 p{max-width:520px;color:var(--text-secondary);margin:18px 0 0;font-size:18px;line-height:1.7}._entryPanel_1yyew_62{border:1px solid var(--glass-border);background:var(--glass-bg);box-shadow:var(--glass-shadow);border-radius:8px;gap:16px;padding:22px;display:grid}._accountLine_1yyew_72{color:var(--text-secondary);justify-content:space-between;align-items:center;gap:10px;font-size:14px;display:flex}._accountLine_1yyew_72 strong{max-width:190px;color:var(--text-primary);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}._joinForm_1yyew_89,._joinForm_1yyew_89 label{gap:8px;display:grid}._joinForm_1yyew_89 label{color:var(--text-secondary);font-size:14px}._joinForm_1yyew_89 input{border:1px solid var(--glass-border);background:var(--bg-primary);width:100%;min-height:40px;color:var(--text-primary);border-radius:8px;padding:0 12px}._status_1yyew_110{color:var(--text-secondary);margin:0;font-size:14px}._board_1yyew_116{grid-template-columns:minmax(0,1fr) minmax(320px,380px);gap:18px;min-height:100%;display:grid}._table_1yyew_123{background:linear-gradient(145deg, #0a533ee6, #163550eb), var(--glass-bg);min-height:560px;box-shadow:var(--shadow-lg);border:1px solid #ffffff2e;border-radius:8px;position:relative;overflow:hidden}._table_1yyew_123:before{content:"";pointer-events:none;border:2px solid #ffffff2e;border-radius:8px;position:absolute;inset:24px}._tableCenter_1yyew_144{color:#fff;background:#00000038;border:1px solid #ffffff38;border-radius:50%;flex-direction:column;justify-content:center;align-items:center;gap:5px;width:150px;height:150px;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);box-shadow:inset 0 0 24px #00000029}._tableCenter_1yyew_144 span,._tableCenter_1yyew_144 small{opacity:.78;font-size:12px}._tableCenter_1yyew_144 strong{font-size:32px}._finishBanner_1yyew_173{z-index:2;color:#fff;white-space:nowrap;background:linear-gradient(135deg,#30d158e6,#34c759e6);border-radius:20px;align-items:center;gap:8px;padding:10px 20px;font-size:15px;font-weight:700;display:flex;position:absolute;top:16px;left:50%;transform:translate(-50%);box-shadow:0 4px 16px #30d15866}._finishBannerIcon_1yyew_192{font-size:20px}._seats_1yyew_196{z-index:1;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:34px;display:grid;position:relative}._seat_1yyew_196{color:#fff;background:#ffffff1a;border:1px solid #ffffff29;border-radius:8px;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:10px;min-height:88px;padding:12px;display:grid}._seatTurn_1yyew_218{border-color:#ffd666eb;box-shadow:0 0 0 2px #ffd6662e}._seatWinner_1yyew_223{border-color:#30d158eb;box-shadow:0 0 0 2px #30d15833}._seatFolded_1yyew_228{opacity:.5}._seatAvatar_1yyew_232{color:#fff;background:#17211b;border-radius:8px;place-items:center;width:44px;height:44px;font-size:15px;font-weight:900;display:grid;overflow:hidden}._seatAvatar_1yyew_232 img{object-fit:cover;width:100%;height:100%}._seatMain_1yyew_251,._seatMeta_1yyew_252{flex-direction:column;gap:4px;min-width:0;display:flex}._seatMain_1yyew_251 strong,._seatMain_1yyew_251 span,._seatMeta_1yyew_252 span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}._seatMain_1yyew_251 strong{font-size:14px}._seatMain_1yyew_251 span,._seatMeta_1yyew_252{opacity:.76;font-size:12px}._seatMeta_1yyew_252{flex-direction:row;grid-column:1/-1;justify-content:space-between}._panel_1yyew_283{flex-direction:column;gap:14px;min-width:0;display:flex}._panelSection_1yyew_290{border:1px solid var(--glass-border);background:var(--glass-bg);-webkit-backdrop-filter:var(--blur-md);box-shadow:var(--glass-shadow);border-radius:8px;padding:16px}._panelTitle_1yyew_300{color:var(--text-primary);align-items:center;gap:8px;margin-bottom:12px;font-size:14px;font-weight:600;display:flex}._hand_1yyew_310{grid-template-columns:repeat(3,62px);justify-content:center;gap:10px;display:grid}._card_1yyew_34{color:#17211b;text-align:left;background:#fff;border:1px solid #dbe1dc;border-radius:8px;grid-template-rows:auto 1fr;width:62px;height:92px;padding:7px;display:grid;box-shadow:0 8px 18px #0e16111f}._card_1yyew_34 span{font-size:15px;font-weight:900}._card_1yyew_34 i{place-self:end;font-size:22px;font-style:normal;font-weight:900}._red_1yyew_344{color:#c93d32}._black_1yyew_348{color:#17211b}._cardBack_1yyew_352{color:#fff;background:repeating-linear-gradient(45deg,#ffffff1f 0 6px,#0000 6px 12px),linear-gradient(135deg,#243f7a,#752f4d);border:1px solid #dbe1dc;border-radius:8px;justify-content:center;align-items:center;width:62px;height:92px;display:flex;box-shadow:0 8px 18px #0e16111f}._handLabel_1yyew_373{text-align:center;color:var(--text-secondary);margin-top:10px;font-size:13px}._actionGrid_1yyew_380,._raiseRow_1yyew_381{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px;display:grid}._raiseRow_1yyew_381{grid-template-columns:minmax(0,1fr) 96px}._statusGrid_1yyew_392{grid-template-columns:76px minmax(0,1fr);align-items:start;gap:8px 12px;font-size:13px;display:grid}._statusGrid_1yyew_392 span{color:var(--text-secondary)}._statusGrid_1yyew_392 strong{color:var(--text-primary);overflow-wrap:anywhere;font-weight:600}._muted_1yyew_410{color:var(--text-secondary);margin:0;font-size:13px;line-height:1.6}._compareOverlay_1yyew_417{z-index:100;-webkit-backdrop-filter:blur(4px);background:#0009;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}._compareBox_1yyew_429{background:var(--glass-bg);border:1px solid var(--glass-border);box-shadow:var(--shadow-lg);border-radius:16px;flex-direction:column;align-items:center;gap:20px;width:90%;max-width:560px;padding:28px 36px;display:flex}._compareTitle_1yyew_443{color:var(--text-primary);font-size:18px;font-weight:700}._comparePlayers_1yyew_449{align-items:center;gap:20px;width:100%;display:flex}._compareSide_1yyew_456{background:#ffffff0d;border:2px solid #0000;border-radius:12px;flex-direction:column;flex:1;align-items:center;gap:12px;padding:16px;display:flex}._compareWinner_1yyew_468{background:#30d1581a;border-color:#30d158cc}._compareName_1yyew_473{color:var(--text-primary);text-align:center;text-overflow:ellipsis;white-space:nowrap;max-width:100%;font-size:14px;font-weight:600;overflow:hidden}._compareCards_1yyew_484{justify-content:center;gap:8px;display:flex}._compareCards_1yyew_484 ._card_1yyew_34{width:52px;height:76px;padding:5px}._compareCards_1yyew_484 ._card_1yyew_34 span{font-size:13px}._compareCards_1yyew_484 ._card_1yyew_34 i{font-size:18px}._compareCards_1yyew_484 ._cardBack_1yyew_352{width:52px;height:76px}._compareLabel_1yyew_509{color:var(--text-secondary);font-size:12px;font-weight:500}._compareVs_1yyew_515{color:var(--text-secondary);opacity:.6;font-size:20px;font-weight:900}._compareResult_1yyew_522{color:#30d158;font-size:16px;font-weight:700}@media (width<=980px){._entry_1yyew_18{grid-template-columns:1fr;align-content:center}._board_1yyew_116{grid-template-columns:1fr}._table_1yyew_123{min-height:420px}}@media (width<=640px){._page_1yyew_1{padding:12px}._entryBrand_1yyew_28{flex-direction:column;align-items:flex-start}._entryBrand_1yyew_28 h1{font-size:42px}._cardMark_1yyew_34{border-width:6px;flex-basis:100px;width:74px;height:100px}._seats_1yyew_196{grid-template-columns:1fr;padding:22px}._tableCenter_1yyew_144{margin:24px auto 0;position:static;transform:none}._table_1yyew_123:before{inset:14px}._hand_1yyew_310{grid-template-columns:repeat(3,minmax(58px,72px))}._actionGrid_1yyew_380,._raiseRow_1yyew_381{grid-template-columns:1fr}}
@@ -1 +0,0 @@
1
- ._page_p7qf6_1{background:var(--bg-primary);min-height:calc(100vh - 64px);padding:18px}._headerActions_p7qf6_7{align-items:center;gap:8px;display:flex}._entry_p7qf6_13{grid-template-columns:minmax(0,1fr) 360px;align-items:center;gap:48px;max-width:1040px;min-height:calc(100vh - 116px);margin:0 auto;display:grid}._entryBrand_p7qf6_23{align-items:center;gap:22px;display:flex}._cardMark_p7qf6_29{width:112px;height:152px;box-shadow:var(--shadow-lg);color:#fff;background:#c93d32;border:8px solid #fff;border-radius:8px;flex:0 0 112px;place-items:center;font-size:70px;font-weight:900;display:grid}._entryBrand_p7qf6_23 h1{color:var(--text-primary);margin:0;font-size:64px;line-height:.95}._entryBrand_p7qf6_23 p{max-width:520px;color:var(--text-secondary);margin:18px 0 0;font-size:18px;line-height:1.7}._entryPanel_p7qf6_59,._panel_p7qf6_60,._handPanel_p7qf6_61{border:1px solid var(--glass-border);background:var(--glass-bg);box-shadow:var(--glass-shadow);border-radius:8px}._entryPanel_p7qf6_59{gap:16px;padding:22px;display:grid}._accountLine_p7qf6_74,._scoreMeta_p7qf6_75,._roomBar_p7qf6_76,._badges_p7qf6_77,._actions_p7qf6_78{align-items:center;gap:10px;display:flex}._accountLine_p7qf6_74{color:var(--text-secondary);justify-content:space-between;font-size:14px}._accountLine_p7qf6_74 strong{max-width:190px;color:var(--text-primary);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}._joinForm_p7qf6_98,._joinForm_p7qf6_98 label{gap:8px;display:grid}._joinForm_p7qf6_98 label{color:var(--text-secondary);font-size:14px}._joinForm_p7qf6_98 input{border:1px solid var(--glass-border);background:var(--bg-primary);width:100%;min-height:40px;color:var(--text-primary);border-radius:8px;padding:0 12px}._status_p7qf6_119{color:var(--text-secondary);margin:0;font-size:14px}._gameGrid_p7qf6_125{grid-template:"table side"minmax(360px,1fr)"hand side"/minmax(0,1fr) 320px;gap:14px;min-height:calc(100vh - 104px);display:grid}._tablePanel_p7qf6_136{color:#fff;background:radial-gradient(circle,#ffffff2e,#0000 46%),#247658;border-radius:8px;grid-area:table;grid-template-rows:auto auto 1fr auto;gap:14px;min-height:360px;padding:16px;display:grid}._roomBar_p7qf6_76{justify-content:space-between}._roomBar_p7qf6_76 div:first-child{gap:2px;display:grid}._roomBar_p7qf6_76 span,._badges_p7qf6_77 span{color:#ffffffc7;font-size:13px}._badges_p7qf6_77{flex-wrap:wrap;justify-content:flex-end}._seats_p7qf6_170{grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:10px;display:grid}._player_p7qf6_176{color:#17211b;background:#ffffffeb;border:1px solid #17211b1f;border-radius:8px;align-items:center;gap:10px;min-width:0;padding:8px;display:flex}._tablePanel_p7qf6_136 ._player_p7qf6_176{color:#fff;background:#ffffff2e}._active_p7qf6_193{outline:3px solid #f3cf5a}._winner_p7qf6_197{outline:3px solid #7be39e}._avatar_p7qf6_201{color:#fff;background:#17211b;border-radius:50%;flex:0 0 40px;place-items:center;width:40px;height:40px;font-size:13px;font-weight:900;display:grid;overflow:hidden}._avatar_p7qf6_201 img{object-fit:cover;width:100%;height:100%}._player_p7qf6_176 strong,._player_p7qf6_176 span{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}._player_p7qf6_176 span{opacity:.76;max-width:190px;font-size:12px}._centerTable_p7qf6_235{grid-template-columns:120px minmax(0,1fr) 120px;align-items:center;gap:14px;display:grid}._deckBox_p7qf6_242,._playedBox_p7qf6_243{text-align:center;background:#0b221942;border-radius:8px}._deckBox_p7qf6_242{gap:6px;padding:18px 8px;display:grid}._deckBox_p7qf6_242 span{opacity:.75;font-size:13px}._deckBox_p7qf6_242 strong{font-size:30px}._playedBox_p7qf6_243{place-items:center;min-height:150px;padding:16px;display:grid}._playedCards_p7qf6_271{flex-wrap:wrap;justify-content:center;gap:8px;margin-top:10px;display:flex}._notice_p7qf6_279{text-align:center;background:#0000002e;border-radius:8px;padding:10px 12px;font-weight:800}._resultDetail_p7qf6_287{gap:4px;font-weight:600;display:grid}._resultDetail_p7qf6_287 p{margin:0;font-size:14px}._sidePanel_p7qf6_298{grid-area:side;align-content:start;gap:12px;display:grid}._panel_p7qf6_60{gap:12px;padding:14px;display:grid}._panel_p7qf6_60 h3{color:var(--text-primary);margin:0;font-size:15px}._scoreMeta_p7qf6_75{color:var(--text-secondary);justify-content:space-between;font-size:13px}._scoreRow_p7qf6_323{border-top:1px solid var(--glass-border);color:var(--text-primary);justify-content:space-between;padding-top:8px;display:flex}._logPanel_p7qf6_331{max-height:280px;overflow:auto}._logPanel_p7qf6_331 p{color:var(--text-secondary);margin:0;font-size:13px;line-height:1.5}._handPanel_p7qf6_61{grid-area:hand;gap:12px;padding:12px;display:grid}._hand_p7qf6_61{gap:8px;min-height:118px;padding:4px 2px 10px;display:flex;overflow-x:auto}._cardButton_p7qf6_358{background:0 0;border:0;border-radius:8px;flex:none;min-height:auto;padding:0;transition:transform .16s}._picked_p7qf6_368{transform:translateY(-14px)}._card_p7qf6_29{color:#17211b;text-align:left;background:#fff;border:1px solid #dbe1dc;border-radius:8px;grid-template-rows:auto 1fr;width:62px;height:92px;padding:7px;display:grid;box-shadow:0 8px 18px #0e16111f}._card_p7qf6_29 span{font-size:15px;font-weight:900}._card_p7qf6_29 i{place-self:end;font-size:22px;font-style:normal;font-weight:900}._red_p7qf6_399{color:#c93d32}._black_p7qf6_403{color:#17211b}._small_p7qf6_407{width:46px;height:66px;padding:5px}._small_p7qf6_407 span{font-size:12px}._small_p7qf6_407 i{font-size:15px}._actions_p7qf6_78{flex-wrap:wrap}._preview_p7qf6_425{border:1px solid var(--glass-border);background:var(--bg-primary);min-height:46px;color:var(--text-secondary);border-radius:8px;flex:260px;padding:7px 10px;display:grid}._preview_p7qf6_425 strong{color:var(--text-primary);font-size:14px}._preview_p7qf6_425 span{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}._valid_p7qf6_448{border-color:#176b4d70}@media (width<=980px){._entry_p7qf6_13{grid-template-columns:1fr;align-content:center}._gameGrid_p7qf6_125{grid-template-columns:1fr;grid-template-areas:"table""hand""side"}}@media (width<=640px){._page_p7qf6_1{padding:10px}._entryBrand_p7qf6_23{flex-direction:column;align-items:flex-start}._entryBrand_p7qf6_23 h1{font-size:42px}._cardMark_p7qf6_29{border-width:6px;flex-basis:100px;width:74px;height:100px;font-size:46px}._centerTable_p7qf6_235{grid-template-columns:1fr}._actions_p7qf6_78>button{flex:110px}}