most-box 0.2.0 → 0.2.2

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 (166) hide show
  1. package/README.md +69 -9
  2. package/electron/deepLink.js +29 -0
  3. package/electron/main.js +87 -20
  4. package/out/admin/index.html +0 -0
  5. package/out/app/index.html +0 -0
  6. package/out/assets/AppShell-BOtfY12t.js +1 -0
  7. package/out/assets/FilePreviewOverlay-C5qK9HAE.js +1 -0
  8. package/out/assets/LanguageToggle-Di5b88mK.js +1 -0
  9. package/out/assets/LogoIcon-B2fFe0l1.js +1 -0
  10. package/out/assets/MarketingHeader-BOytKcCc.js +1 -0
  11. package/out/assets/MarketingLayout-B8m1Q7Pa.js +1 -0
  12. package/out/assets/{MarketingThemeToggle-DBaC9bjz.js → MarketingThemeToggle-C6fggkl7.js} +1 -1
  13. package/out/assets/{MilkdownEditor-BqJzntYE.js → MilkdownEditor-Cfze75zl.js} +1 -1
  14. package/out/assets/OpenSidebarButton-DDuALgJ2.js +1 -0
  15. package/out/assets/SidebarAccount-bo1ypzrJ.js +1 -0
  16. package/out/assets/arrow-right-RldN7Rwi.js +1 -0
  17. package/out/assets/circle-alert-BQoSDxUe.js +1 -0
  18. package/out/assets/cloud-DHoTMeZY.js +1 -0
  19. package/out/assets/copy-BtJHJnqH.js +1 -0
  20. package/out/assets/download-Bg-OdoxM.js +1 -0
  21. package/out/assets/downloadValidation-CUvbvj9f.js +1 -0
  22. package/out/assets/external-link-m8ZIQe4p.js +1 -0
  23. package/out/assets/file-text-DG1orIkZ.js +1 -0
  24. package/out/assets/game-s6irY8hS.js +1 -0
  25. package/out/assets/hard-drive-BB-sllXA.js +1 -0
  26. package/out/assets/index-3OD3Chi9.css +1 -0
  27. package/out/assets/index-BSOvFG3o.css +1 -0
  28. package/out/assets/index-C0xqKeu-.css +1 -0
  29. package/out/assets/index-CrAXrmfP.js +31 -0
  30. package/out/assets/index.lazy-0Njp0U6I.js +1 -0
  31. package/out/assets/index.lazy-BTndBeBF.js +1 -0
  32. package/out/assets/index.lazy-Bq39jYTl.js +1 -0
  33. package/out/assets/index.lazy-Bu93oGzJ.js +2 -0
  34. package/out/assets/index.lazy-BvdBwgHx.js +1 -0
  35. package/out/assets/index.lazy-CIx8ist6.js +3 -0
  36. package/out/assets/index.lazy-DK7R297q.js +1 -0
  37. package/out/assets/index.lazy-DVOcrcEB.js +2 -0
  38. package/out/assets/index.lazy-DejOniAm.js +1 -0
  39. package/out/assets/index.lazy-DfsgyiMN.js +1 -0
  40. package/out/assets/index.lazy-DpNIiSXF.js +1 -0
  41. package/out/assets/index.lazy-Vnvz-t7T.js +1 -0
  42. package/out/assets/index.lazy-v7nBJ-sF.js +1 -0
  43. package/out/assets/key-round-DIQ3Xt5F.js +1 -0
  44. package/out/assets/lock-tf1t2yuy.js +1 -0
  45. package/out/assets/message-square-DaQH7q-P.js +1 -0
  46. package/out/assets/mp-DvFTsIL9.js +1 -0
  47. package/out/assets/music-DaFvU2DL.js +1 -0
  48. package/out/assets/notebook-pen-CqEFOHKx.js +1 -0
  49. package/out/assets/play-B7q6F75-.js +1 -0
  50. package/out/assets/plus-H2i2mspM.js +1 -0
  51. package/out/assets/refresh-cw-roxAhABl.js +1 -0
  52. package/out/assets/save-DR0O9ReR.js +1 -0
  53. package/out/assets/search-ncblG-zw.js +1 -0
  54. package/out/assets/send-m1XCcuPn.js +1 -0
  55. package/out/assets/shield-check-BwcvTU4U.js +1 -0
  56. package/out/assets/trash-2-CNpsqYc1.js +1 -0
  57. package/out/assets/triangle-alert-DP9EP7IM.js +1 -0
  58. package/out/assets/upload-V--8p13l.js +1 -0
  59. package/out/assets/useChannelMessages-46C52EyL.js +3 -0
  60. package/out/assets/useGameRoom-BtxPpfck.js +1 -0
  61. package/out/assets/useNavigate-DWlBD_-b.js +1 -0
  62. package/out/assets/userStore-C4vdYsQp.js +4 -0
  63. package/out/assets/wallet-CozFU6yK.js +1 -0
  64. package/out/assets/wifi-DIR3g_8A.js +1 -0
  65. package/out/avatars/default/LICENSE.md +7 -0
  66. package/out/avatars/default/dolphin.svg +25 -0
  67. package/out/avatars/default/owl.svg +60 -0
  68. package/out/avatars/default/panda.svg +29 -0
  69. package/out/avatars/default/snow-mountain.svg +100 -0
  70. package/out/avatars/default/tiger.svg +55 -0
  71. package/out/avatars/default/turtle.svg +34 -0
  72. package/out/chat/index.html +0 -0
  73. package/out/chat/join/index.html +0 -0
  74. package/out/download/index.html +4 -3
  75. package/out/game/gandengyan/index.html +0 -0
  76. package/out/game/index.html +0 -0
  77. package/out/game/zhajinhua/index.html +0 -0
  78. package/out/index.html +4 -3
  79. package/out/note/index.html +0 -0
  80. package/out/ping/index.html +4 -3
  81. package/out/{demo → profile}/index.html +0 -0
  82. package/out/web3/index.html +0 -0
  83. package/package.json +9 -1
  84. package/public/avatars/default/LICENSE.md +7 -0
  85. package/public/avatars/default/dolphin.svg +25 -0
  86. package/public/avatars/default/owl.svg +60 -0
  87. package/public/avatars/default/panda.svg +29 -0
  88. package/public/avatars/default/snow-mountain.svg +100 -0
  89. package/public/avatars/default/tiger.svg +55 -0
  90. package/public/avatars/default/turtle.svg +34 -0
  91. package/server/index.js +36 -7
  92. package/server/src/core/channelIdentity.js +6 -11
  93. package/server/src/core/gameRoom.js +15 -5
  94. package/server/src/core/mostLink.js +8 -7
  95. package/server/src/core/zhajinhua.js +6 -0
  96. package/server/src/games/gandengyan.js +10 -0
  97. package/server/src/http/access.js +63 -12
  98. package/server/src/http/app.js +34 -840
  99. package/server/src/http/nodeStatus.js +101 -9
  100. package/server/src/http/routePolicy.js +2 -0
  101. package/server/src/http/routes/channelRoutes.js +163 -0
  102. package/server/src/http/routes/fileRoutes.js +345 -0
  103. package/server/src/http/routes/nodeRoutes.js +323 -0
  104. package/server/src/http/routes/seedRoutes.js +58 -0
  105. package/server/src/index.js +483 -265
  106. package/server/src/node/config.js +2 -6
  107. package/server/src/utils/avatar.js +59 -3
  108. package/server/src/utils/downloadMessages.js +0 -2
  109. package/out/assets/AppShell-CQhg6DJU.js +0 -1
  110. package/out/assets/ChatUi-BepWs-ZU.js +0 -1
  111. package/out/assets/LanguageToggle-CtzCCAYv.js +0 -1
  112. package/out/assets/LogoIcon-Dxto3Sb4.js +0 -1
  113. package/out/assets/MarketingLayout-BQw0IS2i.js +0 -1
  114. package/out/assets/MoveModal-4D9n11Kw.js +0 -1
  115. package/out/assets/Nav-9MDdvgNs.js +0 -1
  116. package/out/assets/NoteSidebar-C-rIt32H.js +0 -1
  117. package/out/assets/OpenSidebarButton-Dd0JmKuE.js +0 -1
  118. package/out/assets/PemBlock-C8dEIzu-.js +0 -1
  119. package/out/assets/SidebarAccount-ClS-N0lq.js +0 -1
  120. package/out/assets/arrow-right-urE9Rd7j.js +0 -1
  121. package/out/assets/channelApi-BwQU0-h1.js +0 -1
  122. package/out/assets/check-DUNsD2t6.js +0 -1
  123. package/out/assets/chevron-down-D6mpsfv4.js +0 -1
  124. package/out/assets/circle-alert-W0iyN4sC.js +0 -1
  125. package/out/assets/cloud-BMyOoC2x.js +0 -1
  126. package/out/assets/code-B1Cb_Icm.js +0 -1
  127. package/out/assets/copy-C1MttOli.js +0 -1
  128. package/out/assets/download-y7SZXu6E.js +0 -1
  129. package/out/assets/downloadValidation-B0p9Ai_9.js +0 -1
  130. package/out/assets/filePreview-UI9NH34f.js +0 -1
  131. package/out/assets/game-CdU3xnZo.js +0 -1
  132. package/out/assets/hard-drive-D13Qbobu.js +0 -1
  133. package/out/assets/image-DJCA16l_.js +0 -1
  134. package/out/assets/index-8eWJAjpY.css +0 -1
  135. package/out/assets/index-BZc4blbW.css +0 -1
  136. package/out/assets/index-BdaFEQG-.css +0 -1
  137. package/out/assets/index-QxXZzOUL.js +0 -33
  138. package/out/assets/index.lazy-BBTTFanX.js +0 -1
  139. package/out/assets/index.lazy-BG4ZylHD.js +0 -2
  140. package/out/assets/index.lazy-Bi-6ZXZX.js +0 -1
  141. package/out/assets/index.lazy-BixWVr0B.js +0 -1
  142. package/out/assets/index.lazy-BjFwNYy5.js +0 -3
  143. package/out/assets/index.lazy-C8EIQsXY.js +0 -2
  144. package/out/assets/index.lazy-CarNe2uu.js +0 -1
  145. package/out/assets/index.lazy-DEuGu3H3.js +0 -1
  146. package/out/assets/index.lazy-GPyILCA7.js +0 -3
  147. package/out/assets/index.lazy-I8ofndXl.js +0 -1
  148. package/out/assets/index.lazy-TxhWsA7y.js +0 -1
  149. package/out/assets/index.lazy-azfky8k7.js +0 -1
  150. package/out/assets/key-round-CZniN9lv.js +0 -1
  151. package/out/assets/lock-D5OSNhep.js +0 -1
  152. package/out/assets/log-out-B6phyZ5z.js +0 -1
  153. package/out/assets/music-CbUskKgg.js +0 -1
  154. package/out/assets/notebook-pen-DqKDQ6MJ.js +0 -1
  155. package/out/assets/play-BIl8q9eU.js +0 -1
  156. package/out/assets/plus-BxxbpH6Q.js +0 -1
  157. package/out/assets/save-DkH1n_Ov.js +0 -1
  158. package/out/assets/search-BQi5Z0E-.js +0 -1
  159. package/out/assets/send-Cl6NtD2T.js +0 -1
  160. package/out/assets/trash-2-BBjpgK_f.js +0 -1
  161. package/out/assets/triangle-alert-l98G8u9O.js +0 -1
  162. package/out/assets/upload-ByP6Ydde.js +0 -1
  163. package/out/assets/useChannelMessages-BgbYfF2c.js +0 -3
  164. package/out/assets/useGameRoom-DPmweWwe.js +0 -1
  165. package/out/assets/wallet-c7zIhNSM.js +0 -1
  166. package/out/assets/wifi-Bm4biAjc.js +0 -1
@@ -16,6 +16,7 @@ import b4a from 'b4a'
16
16
  import crypto from 'node:crypto'
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
+ import { Duplex } from 'node:stream'
19
20
 
20
21
  import { calculateCid, parseMostLink, buildMostLink } from './core/cid.js'
21
22
  import { normalizeChannelAttachment } from './core/channelAttachment.js'
@@ -28,11 +29,10 @@ import {
28
29
  normalizeChannelDisplayName,
29
30
  normalizeChannelAvatar,
30
31
  normalizeChannelId,
31
- createChannelFingerprint,
32
32
  createChannelWriterId,
33
33
  buildChannelKey,
34
34
  normalizeChannelKey,
35
- getChannelFingerprintFromKey,
35
+ isSpecialChannel,
36
36
  uniqueStrings,
37
37
  } from './core/channelIdentity.js'
38
38
  import { getPathBaseName, getDisplayPathFolder } from './core/displayPath.js'
@@ -99,6 +99,44 @@ import {
99
99
 
100
100
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
101
101
 
102
+ function createMemoryDuplexPair() {
103
+ let left
104
+ let right
105
+
106
+ left = new Duplex({
107
+ read() {},
108
+ write(chunk, _encoding, callback) {
109
+ if (!right.destroyed) right.push(chunk)
110
+ callback()
111
+ },
112
+ final(callback) {
113
+ if (!right.destroyed) right.push(null)
114
+ callback()
115
+ },
116
+ })
117
+
118
+ right = new Duplex({
119
+ read() {},
120
+ write(chunk, _encoding, callback) {
121
+ if (!left.destroyed) left.push(chunk)
122
+ callback()
123
+ },
124
+ final(callback) {
125
+ if (!left.destroyed) left.push(null)
126
+ callback()
127
+ },
128
+ })
129
+
130
+ left.on('close', () => {
131
+ if (!right.destroyed) right.destroy()
132
+ })
133
+ right.on('close', () => {
134
+ if (!left.destroyed) left.destroy()
135
+ })
136
+
137
+ return [left, right]
138
+ }
139
+
102
140
  export class MostBoxEngine extends EventEmitter {
103
141
  #store = null
104
142
  #swarm = null
@@ -123,12 +161,13 @@ export class MostBoxEngine extends EventEmitter {
123
161
  #channelIdDiscoveries = new Map()
124
162
  #channelPeers = new Map()
125
163
  #channelCandidateCache = new Map()
164
+ #channelStreams = new Set()
126
165
 
127
166
  #userSyncSessions = new Map()
128
167
  #userSyncCores = new Map()
129
168
  #userSyncCoreOffsets = new Map()
130
169
  #userSyncDiscoveries = new Map()
131
- #userSyncMetadata = { sessions: {}, clocks: {} }
170
+ #userSyncMetadata = { sessions: {}, clocks: {}, profiles: {} }
132
171
 
133
172
  #chatSwarm = null
134
173
 
@@ -387,6 +426,7 @@ export class MostBoxEngine extends EventEmitter {
387
426
  this.#channelIdDiscoveries.clear()
388
427
  this.#channelPeers.clear()
389
428
  this.#channelCandidateCache.clear()
429
+ this.#channelStreams.clear()
390
430
  this.#channels = []
391
431
 
392
432
  for (const [, coresMap] of this.#userSyncCores) {
@@ -1739,10 +1779,15 @@ export class MostBoxEngine extends EventEmitter {
1739
1779
 
1740
1780
  const left = this.#store.replicate(true, { live: true })
1741
1781
  const right = peerEngine.#store.replicate(false, { live: true })
1782
+ const [leftChat, rightChat] = createMemoryDuplexPair()
1742
1783
 
1743
1784
  left.on('error', () => {})
1744
1785
  right.on('error', () => {})
1786
+ leftChat.on('error', () => {})
1787
+ rightChat.on('error', () => {})
1745
1788
  left.pipe(right).pipe(left)
1789
+ this.#handleChannelConnection(leftChat).catch(() => {})
1790
+ peerEngine.#handleChannelConnection(rightChat).catch(() => {})
1746
1791
  this.#exchangeUserSyncSessions(peerEngine).catch(() => {})
1747
1792
  peerEngine.#exchangeUserSyncSessions(this).catch(() => {})
1748
1793
 
@@ -1750,6 +1795,8 @@ export class MostBoxEngine extends EventEmitter {
1750
1795
  close: () => {
1751
1796
  left.destroy()
1752
1797
  right.destroy()
1798
+ leftChat.destroy()
1799
+ rightChat.destroy()
1753
1800
  },
1754
1801
  }
1755
1802
  }
@@ -1837,6 +1884,58 @@ export class MostBoxEngine extends EventEmitter {
1837
1884
  }
1838
1885
  }
1839
1886
 
1887
+ getUserProfile(ownerAddressInput) {
1888
+ this.#ensureInitialized()
1889
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1890
+ if (!ownerAddress) {
1891
+ throw new ValidationError('valid owner address is required')
1892
+ }
1893
+ const profile = this.#userSyncMetadata.profiles?.[ownerAddress]
1894
+ return profile ? { ...profile } : null
1895
+ }
1896
+
1897
+ saveUserProfile(ownerAddressInput, profileInput = {}) {
1898
+ this.#ensureInitialized()
1899
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1900
+ if (!ownerAddress) {
1901
+ throw new ValidationError('valid owner address is required')
1902
+ }
1903
+ const existing = this.getUserProfile(ownerAddress)
1904
+ const profile = this.#normalizeUserProfileRecord(
1905
+ ownerAddress,
1906
+ profileInput,
1907
+ getNextSyncTimestamp(existing?.syncUpdatedAt)
1908
+ )
1909
+ if (!profile) {
1910
+ throw new ValidationError('valid profile is required')
1911
+ }
1912
+ if (existing && profile.syncUpdatedAt <= existing.syncUpdatedAt) {
1913
+ return { ...existing }
1914
+ }
1915
+
1916
+ this.#userSyncMetadata.profiles = this.#userSyncMetadata.profiles || {}
1917
+ this.#userSyncMetadata.profiles[ownerAddress] = profile
1918
+ this.#setUserSyncClock(ownerAddress, 'profile', profile.syncUpdatedAt)
1919
+ this.#appendUserSyncOpSoon(ownerAddress, 'profile:upsert', { profile })
1920
+
1921
+ const changedChannels = this.#applyUserProfileToJoinedChannels(
1922
+ ownerAddress,
1923
+ profile
1924
+ )
1925
+ if (changedChannels) {
1926
+ this.#saveChannelsMetadata()
1927
+ this.emit('user:metadata:updated', {
1928
+ ownerAddress,
1929
+ scope: 'channels',
1930
+ })
1931
+ }
1932
+ this.emit('user:metadata:updated', {
1933
+ ownerAddress,
1934
+ scope: 'profile',
1935
+ })
1936
+ return { ...profile }
1937
+ }
1938
+
1840
1939
  async cacheFile(cid, options = {}) {
1841
1940
  this.#ensureInitialized()
1842
1941
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
@@ -2050,7 +2149,7 @@ export class MostBoxEngine extends EventEmitter {
2050
2149
  // --- 频道管理 ---
2051
2150
 
2052
2151
  /**
2053
- * 创建或加入频道。channelId 是用户输入的短 ID,channelKey 是内部唯一身份。
2152
+ * 创建或加入频道。channelId 是用户输入的短 ID,channelKey 与频道名一致。
2054
2153
  * @param {string} channelIdInput - 用户可见短频道 ID
2055
2154
  * @param {string} [type='personal'] - 频道类型
2056
2155
  * @returns {Promise<object>}
@@ -2060,8 +2159,6 @@ export class MostBoxEngine extends EventEmitter {
2060
2159
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2061
2160
  const channelId = normalizeChannelId(channelIdInput)
2062
2161
  const channelType = String(type || 'personal').trim() || 'personal'
2063
- const selectedChannelKey = normalizeChannelKey(options.channelKey)
2064
- const selectedFingerprint = String(options.fingerprint || '').trim()
2065
2162
 
2066
2163
  if (channelId.includes('.') && channelType !== 'game') {
2067
2164
  throw new Error('点号为系统保留,不能用于手动频道 ID')
@@ -2079,30 +2176,6 @@ export class MostBoxEngine extends EventEmitter {
2079
2176
  throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
2080
2177
  }
2081
2178
 
2082
- if (selectedChannelKey || selectedFingerprint) {
2083
- const channelKey =
2084
- selectedChannelKey || buildChannelKey(channelId, selectedFingerprint)
2085
- const existing = this.#channels.find(c => c.channelKey === channelKey)
2086
- if (existing) {
2087
- await this.#mergeChannelWriterCoreKeys(
2088
- existing,
2089
- options.writerCoreKeys
2090
- )
2091
- if (this.#upsertChannelMember(existing, options)) {
2092
- existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2093
- this.#saveChannelsMetadata()
2094
- this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2095
- }
2096
- return this.#formatChannelForResponse(existing, ownerAddress)
2097
- }
2098
-
2099
- const candidate = this.#getCachedChannelCandidate(channelId, channelKey)
2100
- if (!candidate) {
2101
- throw new Error('未发现该频道候选,请重新搜索频道')
2102
- }
2103
- return this.#joinChannelFromCandidate(candidate, channelType, options)
2104
- }
2105
-
2106
2179
  const localCandidates = this.#getLocalChannelCandidates(channelId)
2107
2180
  const remoteCandidates = options.discover
2108
2181
  ? await this.#discoverChannelCandidates(channelId, {
@@ -2114,28 +2187,26 @@ export class MostBoxEngine extends EventEmitter {
2114
2187
  ...remoteCandidates,
2115
2188
  ])
2116
2189
 
2117
- if (candidates.length > 1) {
2118
- return {
2119
- conflict: true,
2120
- channelId,
2121
- candidates: candidates.map(candidate =>
2122
- this.#formatChannelCandidateForResponse(candidate, ownerAddress)
2123
- ),
2124
- }
2125
- }
2126
-
2127
- if (candidates.length === 1) {
2190
+ if (candidates.length > 0) {
2128
2191
  const candidate = candidates[0]
2129
2192
  if (candidate.local) {
2130
2193
  const existing = this.#channels.find(
2131
2194
  channel => channel.channelKey === candidate.channelKey
2132
2195
  )
2133
- if (existing && this.#upsertChannelMember(existing, options)) {
2134
- existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2135
- this.#saveChannelsMetadata()
2136
- this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2196
+ if (existing) {
2197
+ const writerKeysChanged = await this.#mergeChannelWriterCoreKeys(
2198
+ existing,
2199
+ candidate.writerCoreKeys
2200
+ )
2201
+ const memberChanged = this.#upsertChannelMember(existing, options)
2202
+ if (writerKeysChanged || memberChanged) {
2203
+ existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2204
+ this.#saveChannelsMetadata()
2205
+ this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2206
+ this.#broadcastChannelHello()
2207
+ }
2208
+ return this.#formatChannelForResponse(existing, ownerAddress)
2137
2209
  }
2138
- if (existing) return this.#formatChannelForResponse(existing, ownerAddress)
2139
2210
  const joined = await this.#joinChannelFromCandidate(
2140
2211
  candidate,
2141
2212
  channelType,
@@ -2188,25 +2259,32 @@ export class MostBoxEngine extends EventEmitter {
2188
2259
  ? { channelKey: String(candidateInput), channelId }
2189
2260
  : null
2190
2261
 
2191
- if (!candidate?.channelKey && !candidate?.fingerprint) {
2262
+ if (!candidate?.channelKey) {
2192
2263
  return this.createChannel(channelId, options.type || 'group', options)
2193
2264
  }
2194
2265
 
2195
- const channelKey =
2196
- normalizeChannelKey(candidate.channelKey) ||
2197
- buildChannelKey(channelId, String(candidate.fingerprint || '').trim())
2266
+ const channelKey = buildChannelKey(channelId)
2198
2267
  const existing = this.#channels.find(c => c.channelKey === channelKey)
2199
2268
  if (existing) {
2200
- await this.#mergeChannelWriterCoreKeys(existing, candidate.writerCoreKeys)
2201
- if (this.#upsertChannelMember(existing, options)) {
2269
+ const writerKeysChanged = await this.#mergeChannelWriterCoreKeys(
2270
+ existing,
2271
+ candidate.writerCoreKeys
2272
+ )
2273
+ const memberChanged = this.#upsertChannelMember(existing, options)
2274
+ if (writerKeysChanged || memberChanged) {
2202
2275
  existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2203
2276
  this.#saveChannelsMetadata()
2204
2277
  this.#appendUserSyncChannelUpsertSoon(existing, options.ownerAddress)
2278
+ this.#broadcastChannelHello()
2205
2279
  }
2206
2280
  return this.#formatChannelForResponse(existing, options.ownerAddress)
2207
2281
  }
2208
2282
 
2209
- const cached = this.#getCachedChannelCandidate(channelId, channelKey)
2283
+ const cached =
2284
+ this.#getCachedChannelCandidate(
2285
+ channelId,
2286
+ normalizeChannelKey(candidate.channelKey)
2287
+ ) || this.#getCachedChannelCandidate(channelId, channelKey)
2210
2288
  const joined = await this.#joinChannelFromCandidate(cached || candidate, 'group', {
2211
2289
  ...options,
2212
2290
  channelKey,
@@ -2379,14 +2457,13 @@ export class MostBoxEngine extends EventEmitter {
2379
2457
  }
2380
2458
 
2381
2459
  /**
2382
- * 列出所有频道
2460
+ * 列出频道;默认排除带点号的系统频道。
2383
2461
  * @returns {Array<{ channelId: string, channelKey: string, name: string, createdAt: string, lastMessageAt: string, type: string, peerCount: number, remark: string, pinned: boolean }>}
2384
2462
  */
2385
2463
  listChannels(options = {}) {
2386
2464
  this.#ensureInitialized()
2387
2465
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2388
2466
  const type = String(options.type || '').trim()
2389
- const excludeType = String(options.excludeType || '').trim()
2390
2467
 
2391
2468
  return this.#channels
2392
2469
  .filter(c => {
@@ -2395,8 +2472,7 @@ export class MostBoxEngine extends EventEmitter {
2395
2472
  })
2396
2473
  .filter(c => {
2397
2474
  if (type) return c.type === type
2398
- if (excludeType) return c.type !== excludeType
2399
- return true
2475
+ return !isSpecialChannel(c)
2400
2476
  })
2401
2477
  .map(c => this.#formatChannelForResponse(c, ownerAddress))
2402
2478
  }
@@ -2506,7 +2582,9 @@ export class MostBoxEngine extends EventEmitter {
2506
2582
  this.#upsertChannelMember(channel, {
2507
2583
  ownerAddress: options.ownerAddress,
2508
2584
  displayName: authorName,
2509
- avatar: options.avatar,
2585
+ ...(Object.prototype.hasOwnProperty.call(options, 'avatar')
2586
+ ? { avatar: options.avatar }
2587
+ : {}),
2510
2588
  })
2511
2589
  ) {
2512
2590
  this.#saveChannelsMetadata()
@@ -2517,7 +2595,9 @@ export class MostBoxEngine extends EventEmitter {
2517
2595
  author,
2518
2596
  authorName,
2519
2597
  content: trimmed,
2520
- timestamp: Date.now(),
2598
+ timestamp: await this.#getNextChannelMessageTimestamp(
2599
+ channel.channelKey
2600
+ ),
2521
2601
  }
2522
2602
  if (attachment) {
2523
2603
  message.attachment = attachment
@@ -2597,21 +2677,15 @@ export class MostBoxEngine extends EventEmitter {
2597
2677
  let channel = this.#channels.find(c => c.channelKey === value)
2598
2678
  if (channel) return channel
2599
2679
 
2600
- const matches = this.#channels.filter(c => c.channelId === value)
2601
- const visibleMatches = owner
2602
- ? matches.filter(c => this.#channelHasMember(c, owner))
2603
- : matches
2604
- if (visibleMatches.length === 1) return visibleMatches[0]
2605
- if (visibleMatches.length > 1) {
2606
- throw new Error('频道 ID 存在多个候选,请使用 channelKey')
2680
+ channel = this.#channels.find(c => c.channelId === value)
2681
+ if (channel && (!owner || this.#channelHasMember(channel, owner))) {
2682
+ return channel
2607
2683
  }
2608
2684
  throw new Error('频道不存在')
2609
2685
  }
2610
2686
 
2611
2687
  async #createLocalChannel(channelId, type = 'personal', options = {}) {
2612
- const fingerprint =
2613
- String(options.fingerprint || '').trim() || createChannelFingerprint()
2614
- const channelKey = buildChannelKey(channelId, fingerprint)
2688
+ const channelKey = buildChannelKey(channelId)
2615
2689
  const writerId = String(options.writerId || '').trim() || createChannelWriterId()
2616
2690
  const ns = this.#store.namespace(`channel-${channelKey}`)
2617
2691
  const localCore = ns.get({
@@ -2626,7 +2700,6 @@ export class MostBoxEngine extends EventEmitter {
2626
2700
  ])
2627
2701
  const channelInfo = {
2628
2702
  channelId,
2629
- fingerprint,
2630
2703
  channelKey,
2631
2704
  name: channelId,
2632
2705
  type: String(type || 'personal').trim() || 'personal',
@@ -2651,6 +2724,7 @@ export class MostBoxEngine extends EventEmitter {
2651
2724
  await this.#joinChannelDiscoveryTopics(channelInfo)
2652
2725
  this.#cacheChannelCandidate(this.#channelToCandidate(channelInfo, true))
2653
2726
  this.#saveChannelsMetadata()
2727
+ this.#broadcastChannelHello()
2654
2728
  return channelInfo
2655
2729
  }
2656
2730
 
@@ -2658,24 +2732,18 @@ export class MostBoxEngine extends EventEmitter {
2658
2732
  const channelId = normalizeChannelId(
2659
2733
  candidateInput.channelId || options.channelId
2660
2734
  )
2661
- const channelKey = normalizeChannelKey(candidateInput.channelKey)
2662
- const fingerprint =
2663
- String(candidateInput.fingerprint || '').trim() ||
2664
- getChannelFingerprintFromKey(channelId, channelKey)
2665
- if (!channelId || !fingerprint) {
2735
+ const channelKey = buildChannelKey(channelId)
2736
+ if (!channelId || !channelKey) {
2666
2737
  throw new Error('频道候选缺少身份信息')
2667
2738
  }
2668
- const expectedChannelKey = buildChannelKey(channelId, fingerprint)
2669
- if (channelKey && channelKey !== expectedChannelKey) {
2670
- throw new Error('频道候选身份格式不匹配')
2671
- }
2672
2739
 
2673
2740
  const existing = this.#channels.find(
2674
- channel => channel.channelKey === expectedChannelKey
2741
+ channel => channel.channelKey === channelKey
2675
2742
  )
2676
2743
  if (existing) {
2677
2744
  if (this.#upsertChannelMember(existing, options)) {
2678
2745
  this.#saveChannelsMetadata()
2746
+ this.#broadcastChannelHello()
2679
2747
  }
2680
2748
  return this.#formatChannelForResponse(existing, options.ownerAddress)
2681
2749
  }
@@ -2691,7 +2759,6 @@ export class MostBoxEngine extends EventEmitter {
2691
2759
  const channelInfo = await this.#createLocalChannel(channelId, candidateInput.type || type, {
2692
2760
  ...options,
2693
2761
  ownerAddress,
2694
- fingerprint,
2695
2762
  createdAt: candidateInput.createdAt,
2696
2763
  lastMessageAt: candidateInput.lastMessageAt,
2697
2764
  writerCoreKeys: candidateInput.writerCoreKeys,
@@ -2805,7 +2872,18 @@ export class MostBoxEngine extends EventEmitter {
2805
2872
  }
2806
2873
 
2807
2874
  async #discoverChannelCandidates(channelId, options = {}) {
2808
- if (this.#options.disableNetwork) return []
2875
+ const getCachedCandidates = () => {
2876
+ const now = Date.now()
2877
+ return [
2878
+ ...(this.#channelCandidateCache.get(channelId)?.values() || []),
2879
+ ].filter(
2880
+ candidate =>
2881
+ candidate.local ||
2882
+ !candidate.lastSeen ||
2883
+ now - candidate.lastSeen <= CHANNEL_CANDIDATE_TTL
2884
+ )
2885
+ }
2886
+ if (this.#options.disableNetwork) return getCachedCandidates()
2809
2887
  const timeout =
2810
2888
  Number(options.timeout) >= 0
2811
2889
  ? Number(options.timeout)
@@ -2819,15 +2897,7 @@ export class MostBoxEngine extends EventEmitter {
2819
2897
  this.#channelIdDiscoveries.set(channelId, discovery)
2820
2898
  }
2821
2899
  await sleep(timeout)
2822
- const now = Date.now()
2823
- const candidates = [
2824
- ...(this.#channelCandidateCache.get(channelId)?.values() || []),
2825
- ].filter(
2826
- candidate =>
2827
- candidate.local ||
2828
- !candidate.lastSeen ||
2829
- now - candidate.lastSeen <= CHANNEL_CANDIDATE_TTL
2830
- )
2900
+ const candidates = getCachedCandidates()
2831
2901
  if (!hadDiscovery && !this.#channels.some(c => c.channelId === channelId)) {
2832
2902
  this.#channelIdDiscoveries.delete(channelId)
2833
2903
  this.#chatSwarm
@@ -2846,7 +2916,6 @@ export class MostBoxEngine extends EventEmitter {
2846
2916
  byKey.set(candidate.channelKey, {
2847
2917
  ...candidate,
2848
2918
  writerCoreKeys: uniqueStrings(candidate.writerCoreKeys),
2849
- onlineCount: Number(candidate.onlineCount) || (candidate.local ? 0 : 1),
2850
2919
  })
2851
2920
  continue
2852
2921
  }
@@ -2858,9 +2927,6 @@ export class MostBoxEngine extends EventEmitter {
2858
2927
  ...existing.writerCoreKeys,
2859
2928
  ...(candidate.writerCoreKeys || []),
2860
2929
  ]),
2861
- onlineCount:
2862
- Math.max(Number(existing.onlineCount) || 0, 0) +
2863
- (candidate.local ? 0 : 1),
2864
2930
  })
2865
2931
  }
2866
2932
  return [...byKey.values()]
@@ -2869,66 +2935,47 @@ export class MostBoxEngine extends EventEmitter {
2869
2935
  #channelToCandidate(channel, local = false) {
2870
2936
  return {
2871
2937
  channelId: channel.channelId,
2872
- fingerprint: channel.fingerprint,
2873
2938
  channelKey: channel.channelKey,
2874
2939
  type: channel.type,
2875
2940
  createdAt: channel.createdAt,
2876
2941
  lastMessageAt: channel.lastMessageAt || '',
2877
2942
  writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
2878
2943
  local,
2879
- onlineCount: local ? 0 : 1,
2880
2944
  }
2881
2945
  }
2882
2946
 
2883
2947
  #cacheChannelCandidate(candidate) {
2884
- if (!candidate?.channelId || !candidate?.channelKey) return
2885
- if (!this.#channelCandidateCache.has(candidate.channelId)) {
2886
- this.#channelCandidateCache.set(candidate.channelId, new Map())
2887
- }
2888
- const cache = this.#channelCandidateCache.get(candidate.channelId)
2889
- const existing = cache.get(candidate.channelKey)
2890
- cache.set(candidate.channelKey, {
2948
+ const channelId = normalizeChannelId(candidate?.channelId)
2949
+ const channelKey = buildChannelKey(channelId)
2950
+ if (!channelId || !channelKey) return
2951
+ if (!this.#channelCandidateCache.has(channelId)) {
2952
+ this.#channelCandidateCache.set(channelId, new Map())
2953
+ }
2954
+ const cache = this.#channelCandidateCache.get(channelId)
2955
+ const existing = cache.get(channelKey)
2956
+ cache.set(channelKey, {
2891
2957
  ...existing,
2892
2958
  ...candidate,
2959
+ channelId,
2960
+ channelKey,
2893
2961
  writerCoreKeys: uniqueStrings([
2894
2962
  ...(existing?.writerCoreKeys || []),
2895
2963
  ...(candidate.writerCoreKeys || []),
2896
2964
  ]),
2897
- onlineCount: Math.max(Number(existing?.onlineCount) || 0, 0) + 1,
2898
2965
  lastSeen: Date.now(),
2899
2966
  })
2900
2967
  }
2901
2968
 
2902
2969
  #getCachedChannelCandidate(channelId, channelKey) {
2903
- const candidate = this.#channelCandidateCache.get(channelId)?.get(channelKey)
2970
+ const normalizedChannelId = normalizeChannelId(channelId)
2971
+ const normalizedChannelKey = buildChannelKey(normalizedChannelId)
2972
+ const cache = this.#channelCandidateCache.get(normalizedChannelId)
2973
+ const candidate = cache?.get(channelKey) || cache?.get(normalizedChannelKey)
2904
2974
  if (candidate) return candidate
2905
- const local = this.#channels.find(channel => channel.channelKey === channelKey)
2906
- return local ? this.#channelToCandidate(local, true) : null
2907
- }
2908
-
2909
- #formatChannelCandidateForResponse(candidate, ownerAddress = '') {
2910
- const owner = normalizeOwnerAddress(ownerAddress)
2911
- const localChannel = this.#channels.find(
2912
- channel => channel.channelKey === candidate.channelKey
2975
+ const local = this.#channels.find(
2976
+ channel => channel.channelKey === normalizedChannelKey
2913
2977
  )
2914
- const remark =
2915
- localChannel && owner
2916
- ? localChannel.remarks?.[owner] || ''
2917
- : candidate.local
2918
- ? ''
2919
- : `${candidate.channelId}-网络`
2920
- return {
2921
- channelId: candidate.channelId,
2922
- fingerprint: candidate.fingerprint,
2923
- channelKey: candidate.channelKey,
2924
- name: candidate.channelId,
2925
- type: candidate.type || 'public',
2926
- createdAt: candidate.createdAt || '',
2927
- lastMessageAt: candidate.lastMessageAt || '',
2928
- remark,
2929
- local: Boolean(candidate.local),
2930
- onlineCount: Number(candidate.onlineCount) || 0,
2931
- }
2978
+ return local ? this.#channelToCandidate(local, true) : null
2932
2979
  }
2933
2980
 
2934
2981
  #formatChannelForResponse(channel, ownerAddress = '') {
@@ -2936,7 +2983,6 @@ export class MostBoxEngine extends EventEmitter {
2936
2983
  return {
2937
2984
  name: channel.channelId,
2938
2985
  channelId: channel.channelId,
2939
- fingerprint: channel.fingerprint,
2940
2986
  channelKey: channel.channelKey,
2941
2987
  key: channel.channelKey,
2942
2988
  coreKey: channel.localWriterCoreKey,
@@ -2999,9 +3045,16 @@ export class MostBoxEngine extends EventEmitter {
2999
3045
  existing.displayName = displayName
3000
3046
  changed = true
3001
3047
  }
3002
- if (avatar && existing.avatar !== avatar) {
3003
- existing.avatar = avatar
3004
- changed = true
3048
+ if (Object.prototype.hasOwnProperty.call(options, 'avatar')) {
3049
+ const currentAvatar = normalizeChannelAvatar(existing.avatar)
3050
+ if (currentAvatar !== avatar) {
3051
+ if (avatar) {
3052
+ existing.avatar = avatar
3053
+ } else {
3054
+ delete existing.avatar
3055
+ }
3056
+ changed = true
3057
+ }
3005
3058
  }
3006
3059
  if (!existing.joinedAt) {
3007
3060
  existing.joinedAt = new Date().toISOString()
@@ -3046,6 +3099,31 @@ export class MostBoxEngine extends EventEmitter {
3046
3099
  )
3047
3100
  }
3048
3101
 
3102
+ async #getNextChannelMessageTimestamp(channelKey) {
3103
+ const coresMap = this.#channelCores.get(channelKey)
3104
+ let maxTimestamp = 0
3105
+
3106
+ if (coresMap) {
3107
+ for (const [, core] of coresMap) {
3108
+ for (let i = 0; i < core.length; i++) {
3109
+ try {
3110
+ const entry = await core.get(i)
3111
+ if (entry?.type === 'message') {
3112
+ maxTimestamp = Math.max(
3113
+ maxTimestamp,
3114
+ Number(entry.timestamp) || 0
3115
+ )
3116
+ }
3117
+ } catch {
3118
+ break
3119
+ }
3120
+ }
3121
+ }
3122
+ }
3123
+
3124
+ return Math.max(Date.now(), maxTimestamp + 1)
3125
+ }
3126
+
3049
3127
  #normalizeChannelMessageForResponse(channelKey, message) {
3050
3128
  const channel = this.#channels.find(item => item.channelKey === channelKey)
3051
3129
  const authorAddress = normalizeOwnerAddress(message?.author)
@@ -3054,10 +3132,23 @@ export class MostBoxEngine extends EventEmitter {
3054
3132
  item => normalizeOwnerAddress(item?.address) === authorAddress
3055
3133
  )
3056
3134
  : null
3057
- const avatar = normalizeChannelAvatar(member?.avatar)
3058
- const baseMessage = avatar && message?.avatar !== avatar
3059
- ? { ...message, avatar }
3060
- : message
3135
+ let baseMessage = message
3136
+ if (member) {
3137
+ const displayName = normalizeChannelDisplayName(
3138
+ member.displayName,
3139
+ authorAddress
3140
+ )
3141
+ const avatar = normalizeChannelAvatar(member.avatar)
3142
+ if (displayName && baseMessage?.authorName !== displayName) {
3143
+ baseMessage = { ...baseMessage, authorName: displayName }
3144
+ }
3145
+ if (avatar && baseMessage?.avatar !== avatar) {
3146
+ baseMessage = { ...baseMessage, avatar }
3147
+ } else if (!avatar && baseMessage?.avatar) {
3148
+ baseMessage = { ...baseMessage }
3149
+ delete baseMessage.avatar
3150
+ }
3151
+ }
3061
3152
  const attachment = baseMessage?.attachment
3062
3153
  if (!attachment?.cid || !attachment.fileName) {
3063
3154
  return baseMessage
@@ -3344,8 +3435,10 @@ export class MostBoxEngine extends EventEmitter {
3344
3435
  .filter(channel => this.#channelHasMember(channel, ownerAddress))
3345
3436
  .map(channel => this.#formatChannelForSync(channel, ownerAddress))
3346
3437
  .filter(Boolean)
3438
+ const profile = this.#formatUserProfileForSync(ownerAddress)
3347
3439
  return this.#appendUserSyncOp(ownerAddress, 'snapshot', {
3348
3440
  reason,
3441
+ profile,
3349
3442
  files,
3350
3443
  trashFiles,
3351
3444
  channels,
@@ -3364,8 +3457,16 @@ export class MostBoxEngine extends EventEmitter {
3364
3457
  async #applyUserSyncOp(session, op) {
3365
3458
  let changedFiles = false
3366
3459
  let changedChannels = false
3460
+ let changedProfile = false
3367
3461
  if (op.kind === 'snapshot') {
3368
3462
  const payload = op.payload || {}
3463
+ const profileResult = this.#applyUserSyncProfileRecord(
3464
+ session.ownerAddress,
3465
+ payload.profile,
3466
+ getSyncTimestamp(payload.profile?.syncUpdatedAt, op.timestamp)
3467
+ )
3468
+ changedProfile = profileResult.changedProfile || changedProfile
3469
+ changedChannels = profileResult.changedChannels || changedChannels
3369
3470
  for (const file of Array.isArray(payload.files) ? payload.files : []) {
3370
3471
  changedFiles =
3371
3472
  this.#applyUserSyncFileRecord(
@@ -3424,8 +3525,22 @@ export class MostBoxEngine extends EventEmitter {
3424
3525
  op.payload?.channelKey,
3425
3526
  getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3426
3527
  )
3528
+ } else if (op.kind === 'profile:upsert') {
3529
+ const profileResult = this.#applyUserSyncProfileRecord(
3530
+ session.ownerAddress,
3531
+ op.payload?.profile,
3532
+ getSyncTimestamp(op.payload?.profile?.syncUpdatedAt, op.timestamp)
3533
+ )
3534
+ changedProfile = profileResult.changedProfile
3535
+ changedChannels = changedChannels || profileResult.changedChannels
3427
3536
  }
3428
3537
 
3538
+ if (changedProfile) {
3539
+ this.emit('user:metadata:updated', {
3540
+ ownerAddress: session.ownerAddress,
3541
+ scope: 'profile',
3542
+ })
3543
+ }
3429
3544
  if (changedFiles) {
3430
3545
  this.#savePublishedMetadata()
3431
3546
  this.#saveTrashMetadata()
@@ -3444,7 +3559,10 @@ export class MostBoxEngine extends EventEmitter {
3444
3559
  if (changedFiles || changedChannels) {
3445
3560
  this.#touchUserSyncSession(session)
3446
3561
  }
3447
- return changedFiles || changedChannels
3562
+ if (changedProfile) {
3563
+ this.#touchUserSyncSession(session)
3564
+ }
3565
+ return changedFiles || changedChannels || changedProfile
3448
3566
  }
3449
3567
 
3450
3568
  async #mergeUserSyncWriterCoreKeys(session, writerCoreKeys = []) {
@@ -3523,7 +3641,6 @@ export class MostBoxEngine extends EventEmitter {
3523
3641
  }
3524
3642
  return {
3525
3643
  channelId: channel.channelId,
3526
- fingerprint: channel.fingerprint,
3527
3644
  channelKey: channel.channelKey,
3528
3645
  type: channel.type,
3529
3646
  createdAt: channel.createdAt,
@@ -3540,6 +3657,70 @@ export class MostBoxEngine extends EventEmitter {
3540
3657
  }
3541
3658
  }
3542
3659
 
3660
+ #formatUserProfileForSync(ownerAddress) {
3661
+ const owner = normalizeOwnerAddress(ownerAddress)
3662
+ if (!owner) return null
3663
+ const profile = this.#userSyncMetadata.profiles?.[owner]
3664
+ return profile ? { ...profile } : null
3665
+ }
3666
+
3667
+ #normalizeUserProfileRecord(ownerAddress, record, timestamp = Date.now()) {
3668
+ const owner = normalizeOwnerAddress(ownerAddress)
3669
+ if (!owner || !record || typeof record !== 'object') return null
3670
+ const displayName = normalizeChannelDisplayName(record.displayName, owner)
3671
+ const avatar = normalizeChannelAvatar(record.avatar)
3672
+ const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3673
+ return {
3674
+ displayName,
3675
+ avatar,
3676
+ syncUpdatedAt,
3677
+ }
3678
+ }
3679
+
3680
+ #applyUserSyncProfileRecord(ownerAddress, record, timestamp) {
3681
+ const owner = normalizeOwnerAddress(ownerAddress)
3682
+ const profile = this.#normalizeUserProfileRecord(owner, record, timestamp)
3683
+ if (!profile) {
3684
+ return { changedProfile: false, changedChannels: false }
3685
+ }
3686
+ if (!this.#shouldApplyUserSyncEntity(owner, 'profile', profile.syncUpdatedAt)) {
3687
+ return { changedProfile: false, changedChannels: false }
3688
+ }
3689
+
3690
+ const existing = this.#userSyncMetadata.profiles?.[owner]
3691
+ this.#userSyncMetadata.profiles = this.#userSyncMetadata.profiles || {}
3692
+ this.#userSyncMetadata.profiles[owner] = profile
3693
+ this.#setUserSyncClock(owner, 'profile', profile.syncUpdatedAt)
3694
+ const changedChannels = this.#applyUserProfileToJoinedChannels(
3695
+ owner,
3696
+ profile
3697
+ )
3698
+ return {
3699
+ changedProfile:
3700
+ !existing ||
3701
+ existing.displayName !== profile.displayName ||
3702
+ existing.avatar !== profile.avatar ||
3703
+ Number(existing.syncUpdatedAt) !== profile.syncUpdatedAt,
3704
+ changedChannels,
3705
+ }
3706
+ }
3707
+
3708
+ #applyUserProfileToJoinedChannels(ownerAddress, profile) {
3709
+ const owner = normalizeOwnerAddress(ownerAddress)
3710
+ if (!owner || !profile) return false
3711
+ let changed = false
3712
+ for (const channel of this.#channels) {
3713
+ if (!this.#channelHasMember(channel, owner)) continue
3714
+ changed =
3715
+ this.#upsertChannelMember(channel, {
3716
+ ownerAddress: owner,
3717
+ displayName: profile.displayName,
3718
+ avatar: profile.avatar,
3719
+ }) || changed
3720
+ }
3721
+ return changed
3722
+ }
3723
+
3543
3724
  #appendUserSyncChannelUpsertSoon(channel, ownerAddress) {
3544
3725
  const owner = normalizeOwnerAddress(ownerAddress)
3545
3726
  const record = this.#formatChannelForSync(channel, owner)
@@ -3701,15 +3882,9 @@ export class MostBoxEngine extends EventEmitter {
3701
3882
  async #applyUserSyncChannelRecord(ownerAddress, record, timestamp) {
3702
3883
  if (!record || typeof record !== 'object') return false
3703
3884
  const channelId = normalizeChannelId(record.channelId)
3704
- const fingerprint = String(record.fingerprint || '').trim()
3705
- const expectedChannelKey =
3706
- channelId && fingerprint ? buildChannelKey(channelId, fingerprint) : ''
3707
- const recordChannelKey = normalizeChannelKey(record.channelKey)
3708
- if (recordChannelKey && recordChannelKey !== expectedChannelKey) {
3709
- return false
3710
- }
3711
- const channelKey = recordChannelKey || expectedChannelKey
3712
- if (!channelId || !fingerprint || !channelKey) return false
3885
+ const expectedChannelKey = buildChannelKey(channelId)
3886
+ const channelKey = expectedChannelKey
3887
+ if (!channelId || !channelKey) return false
3713
3888
  const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3714
3889
  const entityKey = `channel:${channelKey}`
3715
3890
  if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
@@ -3721,7 +3896,6 @@ export class MostBoxEngine extends EventEmitter {
3721
3896
  if (!channel) {
3722
3897
  channel = {
3723
3898
  channelId,
3724
- fingerprint,
3725
3899
  channelKey,
3726
3900
  name: channelId,
3727
3901
  createdAt:
@@ -3886,6 +4060,12 @@ export class MostBoxEngine extends EventEmitter {
3886
4060
  if (channelKey) {
3887
4061
  this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
3888
4062
  }
4063
+ } else if (op.kind === 'profile:upsert') {
4064
+ const timestamp = getSyncTimestamp(
4065
+ op.payload?.profile?.syncUpdatedAt,
4066
+ op.timestamp
4067
+ )
4068
+ this.#setUserSyncClock(ownerAddress, 'profile', timestamp)
3889
4069
  }
3890
4070
  }
3891
4071
 
@@ -4626,7 +4806,18 @@ export class MostBoxEngine extends EventEmitter {
4626
4806
  }
4627
4807
  }
4628
4808
 
4629
- return { sessions, clocks }
4809
+ const profiles = {}
4810
+ for (const [owner, profile] of Object.entries(parsed.profiles || {})) {
4811
+ const ownerAddress = normalizeOwnerAddress(owner)
4812
+ const normalized = this.#normalizeUserProfileRecord(
4813
+ ownerAddress,
4814
+ profile,
4815
+ profile?.syncUpdatedAt
4816
+ )
4817
+ if (normalized) profiles[ownerAddress] = normalized
4818
+ }
4819
+
4820
+ return { sessions, clocks, profiles }
4630
4821
  }
4631
4822
  } catch (err) {
4632
4823
  console.warn(
@@ -4634,7 +4825,7 @@ export class MostBoxEngine extends EventEmitter {
4634
4825
  err.message
4635
4826
  )
4636
4827
  }
4637
- return { sessions: {}, clocks: {} }
4828
+ return { sessions: {}, clocks: {}, profiles: {} }
4638
4829
  }
4639
4830
 
4640
4831
  #saveUserSyncMetadata() {
@@ -4668,16 +4859,11 @@ export class MostBoxEngine extends EventEmitter {
4668
4859
  .filter(channel => channel && typeof channel === 'object')
4669
4860
  .map(channel => {
4670
4861
  const channelId = normalizeChannelId(channel.channelId)
4671
- const fingerprint = String(channel.fingerprint || '').trim()
4672
- const expectedChannelKey =
4673
- channelId && fingerprint
4674
- ? buildChannelKey(channelId, fingerprint)
4675
- : ''
4676
4862
  const channelKey = normalizeChannelKey(channel.channelKey)
4863
+ const expectedChannelKey = buildChannelKey(channelId)
4677
4864
  return {
4678
4865
  ...channel,
4679
4866
  channelId,
4680
- fingerprint,
4681
4867
  channelKey,
4682
4868
  expectedChannelKey,
4683
4869
  name: channelId,
@@ -4687,7 +4873,6 @@ export class MostBoxEngine extends EventEmitter {
4687
4873
  .filter(
4688
4874
  channel =>
4689
4875
  CHANNEL_NAME_REGEX.test(channel.channelId) &&
4690
- channel.fingerprint &&
4691
4876
  channel.channelKey === channel.expectedChannelKey &&
4692
4877
  channel.writerId &&
4693
4878
  channel.localWriterCoreKey
@@ -4710,7 +4895,6 @@ export class MostBoxEngine extends EventEmitter {
4710
4895
  .filter(channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type))
4711
4896
  .map(channel => ({
4712
4897
  channelId: channel.channelId,
4713
- fingerprint: channel.fingerprint,
4714
4898
  channelKey: channel.channelKey,
4715
4899
  name: channel.channelId,
4716
4900
  type: channel.type,
@@ -4840,13 +5024,9 @@ export class MostBoxEngine extends EventEmitter {
4840
5024
  }
4841
5025
  }
4842
5026
 
4843
- async #handleChannelConnection(conn) {
4844
- const stream = conn
4845
- let connectedPeerId = null
4846
-
5027
+ #buildChannelHelloMessage() {
4847
5028
  const channels = this.#channels.map(channel => ({
4848
5029
  channelId: channel.channelId,
4849
- fingerprint: channel.fingerprint,
4850
5030
  channelKey: channel.channelKey,
4851
5031
  type: channel.type,
4852
5032
  createdAt: channel.createdAt,
@@ -4865,122 +5045,157 @@ export class MostBoxEngine extends EventEmitter {
4865
5045
  })
4866
5046
  )
4867
5047
 
4868
- const helloMessage = JSON.stringify({
5048
+ return {
4869
5049
  type: 'channel-hello',
4870
5050
  peerId: this.getNodeId(),
4871
5051
  authorName: this.getNodeId().slice(0, 4),
4872
5052
  channels,
4873
5053
  userSyncSessions,
4874
- })
5054
+ }
5055
+ }
4875
5056
 
5057
+ #sendChannelHello(stream) {
5058
+ if (!stream || stream.destroyed || stream.writableEnded) {
5059
+ this.#channelStreams.delete(stream)
5060
+ return false
5061
+ }
4876
5062
  try {
4877
- stream.write(helloMessage)
5063
+ stream.write(`${JSON.stringify(this.#buildChannelHelloMessage())}\n`)
5064
+ return true
4878
5065
  } catch {
4879
- return
5066
+ this.#channelStreams.delete(stream)
5067
+ return false
4880
5068
  }
5069
+ }
4881
5070
 
4882
- stream.on('data', async data => {
4883
- try {
4884
- const msg = JSON.parse(data.toString())
4885
- if (msg.type === 'channel-hello') {
4886
- connectedPeerId = msg.peerId
4887
-
4888
- const remoteChannels = Array.isArray(msg.channels)
4889
- ? msg.channels
4890
- .filter(channel => channel && typeof channel === 'object')
4891
- .map(channel => ({
4892
- channelId: normalizeChannelId(channel.channelId),
4893
- fingerprint: String(channel.fingerprint || '').trim(),
4894
- channelKey: normalizeChannelKey(channel.channelKey),
4895
- type: String(channel.type || 'public').trim() || 'public',
4896
- createdAt:
4897
- typeof channel.createdAt === 'string'
4898
- ? channel.createdAt
4899
- : '',
4900
- lastMessageAt:
4901
- typeof channel.lastMessageAt === 'string'
4902
- ? channel.lastMessageAt
4903
- : '',
4904
- writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4905
- }))
4906
- .filter(
4907
- channel =>
4908
- channel.channelId &&
4909
- channel.fingerprint &&
4910
- channel.channelKey
4911
- )
4912
- : []
4913
-
4914
- for (const remoteChannel of remoteChannels) {
4915
- this.#cacheChannelCandidate({
4916
- ...remoteChannel,
4917
- local: false,
4918
- peerId: msg.peerId,
4919
- onlineCount: 1,
4920
- })
5071
+ #broadcastChannelHello() {
5072
+ for (const stream of [...this.#channelStreams]) {
5073
+ this.#sendChannelHello(stream)
5074
+ }
5075
+ }
4921
5076
 
4922
- const localChannel = this.#channels.find(
4923
- channel => channel.channelKey === remoteChannel.channelKey
4924
- )
4925
- if (!localChannel) continue
4926
-
4927
- const peers = this.#channelPeers.get(localChannel.channelKey)
4928
- if (peers) {
4929
- peers.set(msg.peerId, {
4930
- peerId: msg.peerId,
4931
- authorName: msg.authorName,
4932
- lastSeen: Date.now(),
4933
- })
4934
- }
5077
+ async #processChannelHelloMessage(msg) {
5078
+ if (msg.type !== 'channel-hello') return null
4935
5079
 
4936
- for (const writerCoreKey of remoteChannel.writerCoreKeys) {
4937
- if (
4938
- writerCoreKey &&
4939
- writerCoreKey !== this.#channelLocalCoreKey.get(localChannel.channelKey)
4940
- ) {
4941
- await this.#openRemoteChannelCore(
4942
- localChannel.channelKey,
4943
- writerCoreKey
4944
- )
4945
- }
5080
+ const remoteChannels = Array.isArray(msg.channels)
5081
+ ? msg.channels
5082
+ .filter(channel => channel && typeof channel === 'object')
5083
+ .map(channel => {
5084
+ const channelId = normalizeChannelId(channel.channelId)
5085
+ return {
5086
+ channelId,
5087
+ channelKey: buildChannelKey(channelId),
5088
+ type: String(channel.type || 'public').trim() || 'public',
5089
+ createdAt:
5090
+ typeof channel.createdAt === 'string' ? channel.createdAt : '',
5091
+ lastMessageAt:
5092
+ typeof channel.lastMessageAt === 'string'
5093
+ ? channel.lastMessageAt
5094
+ : '',
5095
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4946
5096
  }
4947
- }
5097
+ })
5098
+ .filter(channel => channel.channelId && channel.channelKey)
5099
+ : []
4948
5100
 
4949
- const remoteUserSyncSessions = Array.isArray(msg.userSyncSessions)
4950
- ? msg.userSyncSessions
4951
- .filter(session => session && typeof session === 'object')
4952
- .map(session => ({
4953
- ownerAddress: normalizeOwnerAddress(session.ownerAddress),
4954
- syncId: String(session.syncId || '').trim(),
4955
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4956
- }))
4957
- .filter(session => session.ownerAddress && session.syncId)
4958
- : []
4959
-
4960
- for (const remoteSession of remoteUserSyncSessions) {
4961
- const localSession = this.#userSyncSessions.get(
4962
- remoteSession.ownerAddress
4963
- )
4964
- if (!localSession || localSession.syncId !== remoteSession.syncId) {
4965
- continue
4966
- }
4967
- await this.#mergeUserSyncWriterCoreKeys(
4968
- localSession,
4969
- remoteSession.writerCoreKeys
4970
- )
4971
- }
5101
+ for (const remoteChannel of remoteChannels) {
5102
+ this.#cacheChannelCandidate({
5103
+ ...remoteChannel,
5104
+ local: false,
5105
+ peerId: msg.peerId,
5106
+ })
4972
5107
 
4973
- this.emit('channel:peer:online', {
4974
- peerId: msg.peerId,
4975
- authorName: msg.authorName,
4976
- })
5108
+ const localChannel = this.#channels.find(
5109
+ channel => channel.channelKey === remoteChannel.channelKey
5110
+ )
5111
+ if (!localChannel) continue
5112
+
5113
+ const peers = this.#channelPeers.get(localChannel.channelKey)
5114
+ if (peers) {
5115
+ peers.set(msg.peerId, {
5116
+ peerId: msg.peerId,
5117
+ authorName: msg.authorName,
5118
+ lastSeen: Date.now(),
5119
+ })
5120
+ }
5121
+
5122
+ for (const writerCoreKey of remoteChannel.writerCoreKeys) {
5123
+ if (
5124
+ writerCoreKey &&
5125
+ writerCoreKey !== this.#channelLocalCoreKey.get(localChannel.channelKey)
5126
+ ) {
5127
+ await this.#openRemoteChannelCore(
5128
+ localChannel.channelKey,
5129
+ writerCoreKey
5130
+ )
4977
5131
  }
4978
- } catch (err) {
4979
- console.warn(`[MostBox] Failed to process channel data:`, err.message)
4980
5132
  }
5133
+ }
5134
+
5135
+ const remoteUserSyncSessions = Array.isArray(msg.userSyncSessions)
5136
+ ? msg.userSyncSessions
5137
+ .filter(session => session && typeof session === 'object')
5138
+ .map(session => ({
5139
+ ownerAddress: normalizeOwnerAddress(session.ownerAddress),
5140
+ syncId: String(session.syncId || '').trim(),
5141
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
5142
+ }))
5143
+ .filter(session => session.ownerAddress && session.syncId)
5144
+ : []
5145
+
5146
+ for (const remoteSession of remoteUserSyncSessions) {
5147
+ const localSession = this.#userSyncSessions.get(
5148
+ remoteSession.ownerAddress
5149
+ )
5150
+ if (!localSession || localSession.syncId !== remoteSession.syncId) {
5151
+ continue
5152
+ }
5153
+ await this.#mergeUserSyncWriterCoreKeys(
5154
+ localSession,
5155
+ remoteSession.writerCoreKeys
5156
+ )
5157
+ }
5158
+
5159
+ this.emit('channel:peer:online', {
5160
+ peerId: msg.peerId,
5161
+ authorName: msg.authorName,
4981
5162
  })
4982
5163
 
4983
- stream.on('close', () => {
5164
+ return msg.peerId
5165
+ }
5166
+
5167
+ async #handleChannelConnection(conn) {
5168
+ const stream = conn
5169
+ let connectedPeerId = null
5170
+ let readBuffer = ''
5171
+ let closed = false
5172
+
5173
+ this.#channelStreams.add(stream)
5174
+ if (!this.#sendChannelHello(stream)) return
5175
+
5176
+ stream.on('data', async data => {
5177
+ readBuffer += data.toString()
5178
+ let newlineIndex = readBuffer.indexOf('\n')
5179
+ while (newlineIndex !== -1) {
5180
+ const line = readBuffer.slice(0, newlineIndex).trim()
5181
+ readBuffer = readBuffer.slice(newlineIndex + 1)
5182
+ newlineIndex = readBuffer.indexOf('\n')
5183
+ if (!line) continue
5184
+ try {
5185
+ const peerId = await this.#processChannelHelloMessage(
5186
+ JSON.parse(line)
5187
+ )
5188
+ if (peerId) connectedPeerId = peerId
5189
+ } catch (err) {
5190
+ console.warn(`[MostBox] Failed to process channel data:`, err.message)
5191
+ }
5192
+ }
5193
+ })
5194
+
5195
+ const cleanup = () => {
5196
+ if (closed) return
5197
+ closed = true
5198
+ this.#channelStreams.delete(stream)
4984
5199
  if (connectedPeerId) {
4985
5200
  for (const [, peers] of this.#channelPeers) {
4986
5201
  if (peers.has(connectedPeerId)) {
@@ -4993,7 +5208,10 @@ export class MostBoxEngine extends EventEmitter {
4993
5208
  }
4994
5209
  }
4995
5210
  }
4996
- })
5211
+ }
5212
+
5213
+ stream.on('close', cleanup)
5214
+ stream.on('error', cleanup)
4997
5215
  }
4998
5216
 
4999
5217
  /**