@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.
Files changed (51) hide show
  1. package/dist/index.d.mts +2414 -0
  2. package/dist/index.d.ts +2414 -0
  3. package/dist/index.js +2788 -0
  4. package/dist/index.mjs +2678 -0
  5. package/package.json +49 -0
  6. package/src/api/client.ts +86 -0
  7. package/src/api/endpoints.ts +87 -0
  8. package/src/api/errors.ts +179 -0
  9. package/src/api/index.ts +35 -0
  10. package/src/crypto/encryption-state.ts +249 -0
  11. package/src/crypto/index.ts +55 -0
  12. package/src/crypto/types.ts +307 -0
  13. package/src/crypto/wasm-provider.ts +298 -0
  14. package/src/hooks/index.ts +31 -0
  15. package/src/hooks/keys.ts +62 -0
  16. package/src/hooks/mutations/index.ts +15 -0
  17. package/src/hooks/mutations/useDeleteMessage.ts +67 -0
  18. package/src/hooks/mutations/useEditMessage.ts +87 -0
  19. package/src/hooks/mutations/useReaction.ts +163 -0
  20. package/src/hooks/mutations/useSendMessage.ts +131 -0
  21. package/src/hooks/useChannels.ts +49 -0
  22. package/src/hooks/useMessages.ts +77 -0
  23. package/src/hooks/useSpaces.ts +60 -0
  24. package/src/index.ts +32 -0
  25. package/src/signing/index.ts +10 -0
  26. package/src/signing/types.ts +83 -0
  27. package/src/signing/wasm-provider.ts +75 -0
  28. package/src/storage/adapter.ts +118 -0
  29. package/src/storage/index.ts +9 -0
  30. package/src/sync/index.ts +83 -0
  31. package/src/sync/service.test.ts +822 -0
  32. package/src/sync/service.ts +947 -0
  33. package/src/sync/types.ts +267 -0
  34. package/src/sync/utils.ts +588 -0
  35. package/src/transport/browser-websocket.ts +299 -0
  36. package/src/transport/index.ts +34 -0
  37. package/src/transport/rn-websocket.ts +321 -0
  38. package/src/transport/types.ts +56 -0
  39. package/src/transport/websocket.ts +212 -0
  40. package/src/types/bookmark.ts +29 -0
  41. package/src/types/conversation.ts +25 -0
  42. package/src/types/index.ts +57 -0
  43. package/src/types/message.ts +178 -0
  44. package/src/types/space.ts +75 -0
  45. package/src/types/user.ts +72 -0
  46. package/src/utils/encoding.ts +106 -0
  47. package/src/utils/formatting.ts +139 -0
  48. package/src/utils/index.ts +9 -0
  49. package/src/utils/logger.ts +141 -0
  50. package/src/utils/mentions.ts +135 -0
  51. 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
+ }