@quilibrium/quorum-shared 2.1.0-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +2414 -0
- package/dist/index.d.ts +2414 -0
- package/dist/index.js +2788 -0
- package/dist/index.mjs +2678 -0
- package/package.json +49 -0
- package/src/api/client.ts +86 -0
- package/src/api/endpoints.ts +87 -0
- package/src/api/errors.ts +179 -0
- package/src/api/index.ts +35 -0
- package/src/crypto/encryption-state.ts +249 -0
- package/src/crypto/index.ts +55 -0
- package/src/crypto/types.ts +307 -0
- package/src/crypto/wasm-provider.ts +298 -0
- package/src/hooks/index.ts +31 -0
- package/src/hooks/keys.ts +62 -0
- package/src/hooks/mutations/index.ts +15 -0
- package/src/hooks/mutations/useDeleteMessage.ts +67 -0
- package/src/hooks/mutations/useEditMessage.ts +87 -0
- package/src/hooks/mutations/useReaction.ts +163 -0
- package/src/hooks/mutations/useSendMessage.ts +131 -0
- package/src/hooks/useChannels.ts +49 -0
- package/src/hooks/useMessages.ts +77 -0
- package/src/hooks/useSpaces.ts +60 -0
- package/src/index.ts +32 -0
- package/src/signing/index.ts +10 -0
- package/src/signing/types.ts +83 -0
- package/src/signing/wasm-provider.ts +75 -0
- package/src/storage/adapter.ts +118 -0
- package/src/storage/index.ts +9 -0
- package/src/sync/index.ts +83 -0
- package/src/sync/service.test.ts +822 -0
- package/src/sync/service.ts +947 -0
- package/src/sync/types.ts +267 -0
- package/src/sync/utils.ts +588 -0
- package/src/transport/browser-websocket.ts +299 -0
- package/src/transport/index.ts +34 -0
- package/src/transport/rn-websocket.ts +321 -0
- package/src/transport/types.ts +56 -0
- package/src/transport/websocket.ts +212 -0
- package/src/types/bookmark.ts +29 -0
- package/src/types/conversation.ts +25 -0
- package/src/types/index.ts +57 -0
- package/src/types/message.ts +178 -0
- package/src/types/space.ts +75 -0
- package/src/types/user.ts +72 -0
- package/src/utils/encoding.ts +106 -0
- package/src/utils/formatting.ts +139 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/logger.ts +141 -0
- package/src/utils/mentions.ts +135 -0
- package/src/utils/validation.ts +84 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Utility Functions
|
|
3
|
+
*
|
|
4
|
+
* Hash computation, digest creation, and delta calculation for sync protocol.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
8
|
+
import { bytesToHex } from '../utils/encoding';
|
|
9
|
+
import type { Message, SpaceMember, Reaction } from '../types';
|
|
10
|
+
import type {
|
|
11
|
+
MessageDigest,
|
|
12
|
+
ReactionDigest,
|
|
13
|
+
SyncManifest,
|
|
14
|
+
MessageDelta,
|
|
15
|
+
ReactionDelta,
|
|
16
|
+
MemberDigest,
|
|
17
|
+
MemberDelta,
|
|
18
|
+
DeletedMessageTombstone,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
// ============ Constants ============
|
|
22
|
+
|
|
23
|
+
/** Maximum chunk size for message transmission (5MB) */
|
|
24
|
+
export const MAX_CHUNK_SIZE = 5 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** Default sync request expiry (30 seconds) */
|
|
27
|
+
export const DEFAULT_SYNC_EXPIRY_MS = 30000;
|
|
28
|
+
|
|
29
|
+
/** Aggressive sync timeout after receiving first response (1 second) */
|
|
30
|
+
export const AGGRESSIVE_SYNC_TIMEOUT_MS = 1000;
|
|
31
|
+
|
|
32
|
+
// ============ Hash Functions ============
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute SHA-256 hash of a string
|
|
36
|
+
*/
|
|
37
|
+
export function computeHash(data: string): string {
|
|
38
|
+
const hash = sha256(new TextEncoder().encode(data));
|
|
39
|
+
return bytesToHex(hash);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compute hash of message content for comparison.
|
|
44
|
+
* Uses canonical representation: senderId + type + content-specific fields
|
|
45
|
+
*/
|
|
46
|
+
export function computeContentHash(message: Message): string {
|
|
47
|
+
const content = message.content;
|
|
48
|
+
let canonical = `${content.senderId}:${content.type}`;
|
|
49
|
+
|
|
50
|
+
switch (content.type) {
|
|
51
|
+
case 'post':
|
|
52
|
+
canonical += `:${Array.isArray(content.text) ? content.text.join('\n') : content.text}`;
|
|
53
|
+
if (content.repliesToMessageId) {
|
|
54
|
+
canonical += `:reply:${content.repliesToMessageId}`;
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
|
|
58
|
+
case 'embed':
|
|
59
|
+
canonical += `:${content.imageUrl || ''}:${content.videoUrl || ''}`;
|
|
60
|
+
if (content.repliesToMessageId) {
|
|
61
|
+
canonical += `:reply:${content.repliesToMessageId}`;
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'sticker':
|
|
66
|
+
canonical += `:${content.stickerId}`;
|
|
67
|
+
if (content.repliesToMessageId) {
|
|
68
|
+
canonical += `:reply:${content.repliesToMessageId}`;
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
|
|
72
|
+
case 'edit-message':
|
|
73
|
+
canonical += `:${content.originalMessageId}:${Array.isArray(content.editedText) ? content.editedText.join('\n') : content.editedText}:${content.editedAt}`;
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'remove-message':
|
|
77
|
+
canonical += `:${content.removeMessageId}`;
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case 'join':
|
|
81
|
+
case 'leave':
|
|
82
|
+
case 'kick':
|
|
83
|
+
// No additional content
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case 'event':
|
|
87
|
+
canonical += `:${content.text}`;
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 'update-profile':
|
|
91
|
+
canonical += `:${content.displayName}:${content.userIcon}`;
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case 'mute':
|
|
95
|
+
canonical += `:${content.targetUserId}:${content.action}:${content.muteId}`;
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
case 'pin':
|
|
99
|
+
canonical += `:${content.targetMessageId}:${content.action}`;
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case 'reaction':
|
|
103
|
+
case 'remove-reaction':
|
|
104
|
+
canonical += `:${content.messageId}:${content.reaction}`;
|
|
105
|
+
break;
|
|
106
|
+
|
|
107
|
+
case 'delete-conversation':
|
|
108
|
+
// No additional content
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return computeHash(canonical);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute hash of reaction state for a message
|
|
117
|
+
*/
|
|
118
|
+
export function computeReactionHash(reactions: Reaction[]): string {
|
|
119
|
+
if (!reactions || reactions.length === 0) {
|
|
120
|
+
return computeHash('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Sort reactions by emojiId for deterministic hash
|
|
124
|
+
const sorted = [...reactions].sort((a, b) => a.emojiId.localeCompare(b.emojiId));
|
|
125
|
+
|
|
126
|
+
// Create canonical representation
|
|
127
|
+
const canonical = sorted
|
|
128
|
+
.map((r) => {
|
|
129
|
+
const sortedMembers = [...r.memberIds].sort();
|
|
130
|
+
return `${r.emojiId}:${sortedMembers.join(',')}`;
|
|
131
|
+
})
|
|
132
|
+
.join('|');
|
|
133
|
+
|
|
134
|
+
return computeHash(canonical);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compute hash of member's mutable fields
|
|
139
|
+
*/
|
|
140
|
+
export function computeMemberHash(member: SpaceMember): {
|
|
141
|
+
displayNameHash: string;
|
|
142
|
+
iconHash: string;
|
|
143
|
+
} {
|
|
144
|
+
const displayNameHash = computeHash(member.display_name || '');
|
|
145
|
+
const iconHash = computeHash(member.profile_image || '');
|
|
146
|
+
return { displayNameHash, iconHash };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compute manifest hash for quick comparison.
|
|
151
|
+
* Hash of sorted message IDs.
|
|
152
|
+
*/
|
|
153
|
+
export function computeManifestHash(digests: MessageDigest[]): string {
|
|
154
|
+
if (digests.length === 0) {
|
|
155
|
+
return computeHash('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sort by messageId for deterministic hash
|
|
159
|
+
const sorted = [...digests].sort((a, b) => a.messageId.localeCompare(b.messageId));
|
|
160
|
+
const ids = sorted.map((d) => d.messageId).join(':');
|
|
161
|
+
return computeHash(ids);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============ Digest Creation ============
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create digest from message
|
|
168
|
+
*/
|
|
169
|
+
export function createMessageDigest(message: Message): MessageDigest {
|
|
170
|
+
return {
|
|
171
|
+
messageId: message.messageId,
|
|
172
|
+
createdDate: message.createdDate,
|
|
173
|
+
contentHash: computeContentHash(message),
|
|
174
|
+
modifiedDate: message.modifiedDate !== message.createdDate ? message.modifiedDate : undefined,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create reaction digest for a message
|
|
180
|
+
*/
|
|
181
|
+
export function createReactionDigest(
|
|
182
|
+
messageId: string,
|
|
183
|
+
reactions: Reaction[]
|
|
184
|
+
): ReactionDigest[] {
|
|
185
|
+
if (!reactions || reactions.length === 0) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return reactions.map((r) => ({
|
|
190
|
+
messageId,
|
|
191
|
+
emojiId: r.emojiId,
|
|
192
|
+
count: r.count,
|
|
193
|
+
membersHash: computeHash([...r.memberIds].sort().join(',')),
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create manifest from messages
|
|
199
|
+
*/
|
|
200
|
+
export function createManifest(
|
|
201
|
+
spaceId: string,
|
|
202
|
+
channelId: string,
|
|
203
|
+
messages: Message[]
|
|
204
|
+
): SyncManifest {
|
|
205
|
+
// Sort by createdDate ascending
|
|
206
|
+
const sorted = [...messages].sort((a, b) => a.createdDate - b.createdDate);
|
|
207
|
+
const digests = sorted.map(createMessageDigest);
|
|
208
|
+
|
|
209
|
+
// Collect all reaction digests
|
|
210
|
+
const reactionDigests: ReactionDigest[] = [];
|
|
211
|
+
for (const msg of sorted) {
|
|
212
|
+
if (msg.reactions && msg.reactions.length > 0) {
|
|
213
|
+
reactionDigests.push(...createReactionDigest(msg.messageId, msg.reactions));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
spaceId,
|
|
219
|
+
channelId,
|
|
220
|
+
messageCount: messages.length,
|
|
221
|
+
oldestTimestamp: sorted[0]?.createdDate || 0,
|
|
222
|
+
newestTimestamp: sorted[sorted.length - 1]?.createdDate || 0,
|
|
223
|
+
digests,
|
|
224
|
+
reactionDigests,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create member digest
|
|
230
|
+
*/
|
|
231
|
+
export function createMemberDigest(member: SpaceMember): MemberDigest {
|
|
232
|
+
const { displayNameHash, iconHash } = computeMemberHash(member);
|
|
233
|
+
return {
|
|
234
|
+
address: member.address,
|
|
235
|
+
inboxAddress: member.inbox_address || '',
|
|
236
|
+
displayNameHash,
|
|
237
|
+
iconHash,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============ Delta Calculation ============
|
|
242
|
+
|
|
243
|
+
export interface MessageDiffResult {
|
|
244
|
+
/** Message IDs we don't have */
|
|
245
|
+
missingIds: string[];
|
|
246
|
+
/** Message IDs we have but are outdated (content changed) */
|
|
247
|
+
outdatedIds: string[];
|
|
248
|
+
/** Message IDs we have but they don't (we should send to them) */
|
|
249
|
+
extraIds: string[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Compare manifests and determine what messages differ
|
|
254
|
+
*/
|
|
255
|
+
export function computeMessageDiff(
|
|
256
|
+
ourManifest: SyncManifest,
|
|
257
|
+
theirManifest: SyncManifest
|
|
258
|
+
): MessageDiffResult {
|
|
259
|
+
const ourDigests = new Map(ourManifest.digests.map((d) => [d.messageId, d]));
|
|
260
|
+
const theirDigests = new Map(theirManifest.digests.map((d) => [d.messageId, d]));
|
|
261
|
+
|
|
262
|
+
const missingIds: string[] = [];
|
|
263
|
+
const outdatedIds: string[] = [];
|
|
264
|
+
const extraIds: string[] = [];
|
|
265
|
+
|
|
266
|
+
// Find messages we're missing or that are outdated in our copy
|
|
267
|
+
for (const [id, theirDigest] of theirDigests) {
|
|
268
|
+
const ourDigest = ourDigests.get(id);
|
|
269
|
+
if (!ourDigest) {
|
|
270
|
+
missingIds.push(id);
|
|
271
|
+
} else if (ourDigest.contentHash !== theirDigest.contentHash) {
|
|
272
|
+
// Content differs - check which is newer
|
|
273
|
+
const theirModified = theirDigest.modifiedDate || theirDigest.createdDate;
|
|
274
|
+
const ourModified = ourDigest.modifiedDate || ourDigest.createdDate;
|
|
275
|
+
if (theirModified > ourModified) {
|
|
276
|
+
outdatedIds.push(id);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Find messages we have that they don't
|
|
282
|
+
for (const [id] of ourDigests) {
|
|
283
|
+
if (!theirDigests.has(id)) {
|
|
284
|
+
extraIds.push(id);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { missingIds, outdatedIds, extraIds };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface ReactionDiffResult {
|
|
292
|
+
/** Reactions to add: { messageId, emojiId, memberIds to add } */
|
|
293
|
+
toAdd: Array<{ messageId: string; emojiId: string; memberIds: string[] }>;
|
|
294
|
+
/** Reactions to remove: { messageId, emojiId, memberIds to remove } */
|
|
295
|
+
toRemove: Array<{ messageId: string; emojiId: string; memberIds: string[] }>;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compare reaction digests and determine differences.
|
|
300
|
+
* This requires the full reaction data to compute actual member diffs.
|
|
301
|
+
*/
|
|
302
|
+
export function computeReactionDiff(
|
|
303
|
+
ourReactions: Map<string, Reaction[]>, // messageId -> reactions
|
|
304
|
+
theirDigests: ReactionDigest[]
|
|
305
|
+
): ReactionDiffResult {
|
|
306
|
+
const toAdd: ReactionDiffResult['toAdd'] = [];
|
|
307
|
+
const toRemove: ReactionDiffResult['toRemove'] = [];
|
|
308
|
+
|
|
309
|
+
// Group their digests by messageId
|
|
310
|
+
const theirByMessage = new Map<string, ReactionDigest[]>();
|
|
311
|
+
for (const digest of theirDigests) {
|
|
312
|
+
const existing = theirByMessage.get(digest.messageId) || [];
|
|
313
|
+
existing.push(digest);
|
|
314
|
+
theirByMessage.set(digest.messageId, existing);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Compare each message's reactions
|
|
318
|
+
for (const [messageId, theirMsgDigests] of theirByMessage) {
|
|
319
|
+
const ourMsgReactions = ourReactions.get(messageId) || [];
|
|
320
|
+
const ourByEmoji = new Map(ourMsgReactions.map((r) => [r.emojiId, r]));
|
|
321
|
+
|
|
322
|
+
for (const theirDigest of theirMsgDigests) {
|
|
323
|
+
const ourReaction = ourByEmoji.get(theirDigest.emojiId);
|
|
324
|
+
if (!ourReaction) {
|
|
325
|
+
// We're missing this entire reaction - but we don't have the memberIds
|
|
326
|
+
// This will be resolved when we receive the full reaction data
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check if members hash differs
|
|
331
|
+
const ourMembersHash = computeHash([...ourReaction.memberIds].sort().join(','));
|
|
332
|
+
if (ourMembersHash !== theirDigest.membersHash) {
|
|
333
|
+
// Reactions differ - we'll need to reconcile when we get full data
|
|
334
|
+
// For now, mark as needing update
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { toAdd, toRemove };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface MemberDiffResult {
|
|
343
|
+
/** Member addresses we don't have */
|
|
344
|
+
missingAddresses: string[];
|
|
345
|
+
/** Member addresses where our data is outdated */
|
|
346
|
+
outdatedAddresses: string[];
|
|
347
|
+
/** Member addresses we have that they don't */
|
|
348
|
+
extraAddresses: string[];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Compare member digests and determine differences
|
|
353
|
+
*/
|
|
354
|
+
export function computeMemberDiff(
|
|
355
|
+
ourDigests: MemberDigest[],
|
|
356
|
+
theirDigests: MemberDigest[]
|
|
357
|
+
): MemberDiffResult {
|
|
358
|
+
const ourMap = new Map(ourDigests.map((d) => [d.address, d]));
|
|
359
|
+
const theirMap = new Map(theirDigests.map((d) => [d.address, d]));
|
|
360
|
+
|
|
361
|
+
const missingAddresses: string[] = [];
|
|
362
|
+
const outdatedAddresses: string[] = [];
|
|
363
|
+
const extraAddresses: string[] = [];
|
|
364
|
+
|
|
365
|
+
for (const [address, theirDigest] of theirMap) {
|
|
366
|
+
const ourDigest = ourMap.get(address);
|
|
367
|
+
if (!ourDigest) {
|
|
368
|
+
missingAddresses.push(address);
|
|
369
|
+
} else if (
|
|
370
|
+
ourDigest.displayNameHash !== theirDigest.displayNameHash ||
|
|
371
|
+
ourDigest.iconHash !== theirDigest.iconHash
|
|
372
|
+
) {
|
|
373
|
+
outdatedAddresses.push(address);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Find members we have that they don't
|
|
378
|
+
for (const [address] of ourMap) {
|
|
379
|
+
if (!theirMap.has(address)) {
|
|
380
|
+
extraAddresses.push(address);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { missingAddresses, outdatedAddresses, extraAddresses };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface PeerDiffResult {
|
|
388
|
+
/** Peer IDs they have that we don't */
|
|
389
|
+
missingPeerIds: number[];
|
|
390
|
+
/** Peer IDs we have that they don't */
|
|
391
|
+
extraPeerIds: number[];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Compare peer ID sets
|
|
396
|
+
*/
|
|
397
|
+
export function computePeerDiff(ourPeerIds: number[], theirPeerIds: number[]): PeerDiffResult {
|
|
398
|
+
const ourSet = new Set(ourPeerIds);
|
|
399
|
+
const theirSet = new Set(theirPeerIds);
|
|
400
|
+
|
|
401
|
+
const missingPeerIds = theirPeerIds.filter((id) => !ourSet.has(id));
|
|
402
|
+
const extraPeerIds = ourPeerIds.filter((id) => !theirSet.has(id));
|
|
403
|
+
|
|
404
|
+
return { missingPeerIds, extraPeerIds };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ============ Delta Building ============
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Build message delta from diff result and message lookup
|
|
411
|
+
*/
|
|
412
|
+
export function buildMessageDelta(
|
|
413
|
+
spaceId: string,
|
|
414
|
+
channelId: string,
|
|
415
|
+
diff: MessageDiffResult,
|
|
416
|
+
messageMap: Map<string, Message>,
|
|
417
|
+
tombstones: DeletedMessageTombstone[]
|
|
418
|
+
): MessageDelta {
|
|
419
|
+
const newMessages = diff.extraIds
|
|
420
|
+
.map((id) => messageMap.get(id))
|
|
421
|
+
.filter((m): m is Message => m !== undefined);
|
|
422
|
+
|
|
423
|
+
const updatedMessages = diff.outdatedIds
|
|
424
|
+
.map((id) => messageMap.get(id))
|
|
425
|
+
.filter((m): m is Message => m !== undefined);
|
|
426
|
+
|
|
427
|
+
// Filter tombstones for this channel
|
|
428
|
+
const deletedMessageIds = tombstones
|
|
429
|
+
.filter((t) => t.spaceId === spaceId && t.channelId === channelId)
|
|
430
|
+
.map((t) => t.messageId);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
spaceId,
|
|
434
|
+
channelId,
|
|
435
|
+
newMessages,
|
|
436
|
+
updatedMessages,
|
|
437
|
+
deletedMessageIds,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Build reaction delta for messages
|
|
443
|
+
*/
|
|
444
|
+
export function buildReactionDelta(
|
|
445
|
+
spaceId: string,
|
|
446
|
+
channelId: string,
|
|
447
|
+
messageMap: Map<string, Message>,
|
|
448
|
+
messageIds: string[]
|
|
449
|
+
): ReactionDelta {
|
|
450
|
+
const added: ReactionDelta['added'] = [];
|
|
451
|
+
|
|
452
|
+
for (const messageId of messageIds) {
|
|
453
|
+
const message = messageMap.get(messageId);
|
|
454
|
+
if (message?.reactions) {
|
|
455
|
+
for (const reaction of message.reactions) {
|
|
456
|
+
added.push({
|
|
457
|
+
messageId,
|
|
458
|
+
emojiId: reaction.emojiId,
|
|
459
|
+
memberIds: reaction.memberIds,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
spaceId,
|
|
467
|
+
channelId,
|
|
468
|
+
added,
|
|
469
|
+
removed: [], // Removed reactions are harder to track without explicit tombstones
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Build member delta from diff result and member lookup
|
|
475
|
+
*/
|
|
476
|
+
export function buildMemberDelta(
|
|
477
|
+
spaceId: string,
|
|
478
|
+
diff: MemberDiffResult,
|
|
479
|
+
memberMap: Map<string, SpaceMember>
|
|
480
|
+
): MemberDelta {
|
|
481
|
+
const addresses = [...diff.missingAddresses, ...diff.outdatedAddresses];
|
|
482
|
+
const members = addresses
|
|
483
|
+
.map((addr) => memberMap.get(addr))
|
|
484
|
+
.filter((m): m is SpaceMember => m !== undefined);
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
spaceId,
|
|
488
|
+
members,
|
|
489
|
+
removedAddresses: [], // Would need explicit tracking
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ============ Chunking ============
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Chunk messages for transmission to stay under size limit
|
|
497
|
+
*/
|
|
498
|
+
export function chunkMessages(messages: Message[]): Message[][] {
|
|
499
|
+
const chunks: Message[][] = [];
|
|
500
|
+
let currentChunk: Message[] = [];
|
|
501
|
+
let currentSize = 0;
|
|
502
|
+
|
|
503
|
+
for (const msg of messages) {
|
|
504
|
+
const msgSize = JSON.stringify(msg).length;
|
|
505
|
+
|
|
506
|
+
// If single message exceeds limit, send it alone
|
|
507
|
+
if (msgSize > MAX_CHUNK_SIZE) {
|
|
508
|
+
if (currentChunk.length > 0) {
|
|
509
|
+
chunks.push(currentChunk);
|
|
510
|
+
currentChunk = [];
|
|
511
|
+
currentSize = 0;
|
|
512
|
+
}
|
|
513
|
+
chunks.push([msg]);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Check if adding this message would exceed limit
|
|
518
|
+
if (currentSize + msgSize > MAX_CHUNK_SIZE && currentChunk.length > 0) {
|
|
519
|
+
chunks.push(currentChunk);
|
|
520
|
+
currentChunk = [];
|
|
521
|
+
currentSize = 0;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
currentChunk.push(msg);
|
|
525
|
+
currentSize += msgSize;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (currentChunk.length > 0) {
|
|
529
|
+
chunks.push(currentChunk);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return chunks;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Chunk members for transmission
|
|
537
|
+
*/
|
|
538
|
+
export function chunkMembers(members: SpaceMember[]): SpaceMember[][] {
|
|
539
|
+
const chunks: SpaceMember[][] = [];
|
|
540
|
+
let currentChunk: SpaceMember[] = [];
|
|
541
|
+
let currentSize = 0;
|
|
542
|
+
|
|
543
|
+
for (const member of members) {
|
|
544
|
+
const memberSize = JSON.stringify(member).length;
|
|
545
|
+
|
|
546
|
+
if (currentSize + memberSize > MAX_CHUNK_SIZE && currentChunk.length > 0) {
|
|
547
|
+
chunks.push(currentChunk);
|
|
548
|
+
currentChunk = [];
|
|
549
|
+
currentSize = 0;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
currentChunk.push(member);
|
|
553
|
+
currentSize += memberSize;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (currentChunk.length > 0) {
|
|
557
|
+
chunks.push(currentChunk);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return chunks;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ============ Summary Helpers ============
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Create sync summary from messages and members
|
|
567
|
+
*/
|
|
568
|
+
export function createSyncSummary(
|
|
569
|
+
messages: Message[],
|
|
570
|
+
memberCount: number
|
|
571
|
+
): {
|
|
572
|
+
memberCount: number;
|
|
573
|
+
messageCount: number;
|
|
574
|
+
newestMessageTimestamp: number;
|
|
575
|
+
oldestMessageTimestamp: number;
|
|
576
|
+
manifestHash: string;
|
|
577
|
+
} {
|
|
578
|
+
const digests = messages.map(createMessageDigest);
|
|
579
|
+
const sorted = [...messages].sort((a, b) => a.createdDate - b.createdDate);
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
memberCount,
|
|
583
|
+
messageCount: messages.length,
|
|
584
|
+
newestMessageTimestamp: sorted[sorted.length - 1]?.createdDate || 0,
|
|
585
|
+
oldestMessageTimestamp: sorted[0]?.createdDate || 0,
|
|
586
|
+
manifestHash: computeManifestHash(digests),
|
|
587
|
+
};
|
|
588
|
+
}
|