@massalabs/gossip-sdk 0.0.2-dev.20260410072437 → 0.0.2-dev.20260410093719

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.
@@ -1,7 +1,5 @@
1
1
  /**
2
- * SDK Event Emitter
3
- *
4
- * Type-safe event emitter for SDK events.
2
+ * SDK Event Emitter — type-safe event bus backed by mitt.
5
3
  */
6
4
  import type { Message, Discussion, Contact } from '../db';
7
5
  import type { SessionStatus } from '../wasm/bindings';
@@ -19,42 +17,71 @@ export declare enum SdkEventType {
19
17
  DISCUSSION_UPDATED = "discussionUpdated",
20
18
  WRITE_FAILED = "writeFailed",
21
19
  MESSAGE_OPTIMISTIC = "messageOptimistic",
20
+ MESSAGE_DELETED_OPTIMISTIC = "messageDeletedOptimistic",
21
+ MESSAGE_EDITED_OPTIMISTIC = "messageEditedOptimistic",
22
+ MESSAGE_DELETE_FAILED = "messageDeleteFailed",
23
+ MESSAGE_EDIT_FAILED = "messageEditFailed",
22
24
  ERROR = "error"
23
25
  }
24
- export interface SdkEventHandlers {
25
- [SdkEventType.MESSAGE_RECEIVED]: (message: Omit<Message, 'id'> & {
26
+ export type SdkEvents = {
27
+ [SdkEventType.MESSAGE_RECEIVED]: Omit<Message, 'id'> & {
26
28
  id?: number;
27
- }) => void;
28
- [SdkEventType.MESSAGE_SENT]: (message: Message) => void;
29
- [SdkEventType.MESSAGE_READ]: (messageId: number) => void;
30
- [SdkEventType.MESSAGE_FAILED]: (message: Message, error: Error) => void;
31
- [SdkEventType.SESSION_REQUESTED]: (discussion: Discussion, contact: Contact) => void;
32
- [SdkEventType.SESSION_CREATED]: (discussion: Discussion) => void;
33
- [SdkEventType.SESSION_RENEWED]: (discussion: Discussion) => void;
34
- [SdkEventType.SESSION_ACCEPTED]: (contactUserId: string) => void;
35
- [SdkEventType.SEEKERS_UPDATED]: (seekers: Uint8Array[]) => void;
36
- [SdkEventType.SESSION_STATUS_CHANGED]: (contactUserId: string, status: SessionStatus) => void;
37
- [SdkEventType.DISCUSSION_UPDATED]: (contactUserId: string) => void;
38
- [SdkEventType.WRITE_FAILED]: (messageId: Uint8Array | undefined, entityType: 'message' | 'discussion' | 'contact', error: Error) => void;
39
- [SdkEventType.MESSAGE_OPTIMISTIC]: (message: Message) => void;
40
- [SdkEventType.ERROR]: (error: Error, context: string) => void;
41
- }
29
+ };
30
+ [SdkEventType.MESSAGE_SENT]: Message;
31
+ [SdkEventType.MESSAGE_READ]: number;
32
+ [SdkEventType.MESSAGE_FAILED]: {
33
+ message: Message;
34
+ error: Error;
35
+ };
36
+ [SdkEventType.SESSION_REQUESTED]: {
37
+ discussion: Discussion;
38
+ contact: Contact;
39
+ };
40
+ [SdkEventType.SESSION_CREATED]: Discussion;
41
+ [SdkEventType.SESSION_RENEWED]: Discussion;
42
+ [SdkEventType.SESSION_ACCEPTED]: string;
43
+ [SdkEventType.SEEKERS_UPDATED]: Uint8Array[];
44
+ [SdkEventType.SESSION_STATUS_CHANGED]: {
45
+ contactUserId: string;
46
+ status: SessionStatus;
47
+ };
48
+ [SdkEventType.DISCUSSION_UPDATED]: string;
49
+ [SdkEventType.WRITE_FAILED]: {
50
+ messageId: Uint8Array | undefined;
51
+ entityType: 'message' | 'discussion' | 'contact';
52
+ error: Error;
53
+ };
54
+ [SdkEventType.MESSAGE_OPTIMISTIC]: Message;
55
+ [SdkEventType.MESSAGE_DELETED_OPTIMISTIC]: {
56
+ contactUserId: string;
57
+ messageDbId: number;
58
+ originalMsgId: Uint8Array;
59
+ };
60
+ [SdkEventType.MESSAGE_EDITED_OPTIMISTIC]: {
61
+ contactUserId: string;
62
+ messageDbId: number;
63
+ newContent: string;
64
+ metadata: Record<string, unknown>;
65
+ };
66
+ [SdkEventType.MESSAGE_DELETE_FAILED]: {
67
+ contactUserId: string;
68
+ messageDbId: number;
69
+ original: Message;
70
+ };
71
+ [SdkEventType.MESSAGE_EDIT_FAILED]: {
72
+ contactUserId: string;
73
+ messageDbId: number;
74
+ original: Message;
75
+ };
76
+ [SdkEventType.ERROR]: {
77
+ error: Error;
78
+ context: string;
79
+ };
80
+ };
42
81
  export declare class SdkEventEmitter {
43
- private handlers;
44
- /**
45
- * Register an event handler
46
- */
47
- on<K extends keyof SdkEventHandlers>(event: K, handler: SdkEventHandlers[K]): void;
48
- /**
49
- * Remove an event handler
50
- */
51
- off<K extends keyof SdkEventHandlers>(event: K, handler: SdkEventHandlers[K]): void;
52
- /**
53
- * Emit an event to all registered handlers
54
- */
55
- emit<K extends keyof SdkEventHandlers>(event: K, ...args: Parameters<SdkEventHandlers[K]>): void;
56
- /**
57
- * Remove all handlers for all events
58
- */
82
+ private bus;
83
+ on<K extends keyof SdkEvents>(event: K, handler: (payload: SdkEvents[K]) => void): void;
84
+ off<K extends keyof SdkEvents>(event: K, handler: (payload: SdkEvents[K]) => void): void;
85
+ emit<K extends keyof SdkEvents>(event: K, payload: SdkEvents[K]): void;
59
86
  clear(): void;
60
87
  }
@@ -1,11 +1,7 @@
1
1
  /**
2
- * SDK Event Emitter
3
- *
4
- * Type-safe event emitter for SDK events.
2
+ * SDK Event Emitter — type-safe event bus backed by mitt.
5
3
  */
6
- // ─────────────────────────────────────────────────────────────────────────────
7
- // Event Types
8
- // ─────────────────────────────────────────────────────────────────────────────
4
+ import mitt from 'mitt';
9
5
  export var SdkEventType;
10
6
  (function (SdkEventType) {
11
7
  SdkEventType["MESSAGE_RECEIVED"] = "messageReceived";
@@ -21,65 +17,41 @@ export var SdkEventType;
21
17
  SdkEventType["DISCUSSION_UPDATED"] = "discussionUpdated";
22
18
  SdkEventType["WRITE_FAILED"] = "writeFailed";
23
19
  SdkEventType["MESSAGE_OPTIMISTIC"] = "messageOptimistic";
20
+ SdkEventType["MESSAGE_DELETED_OPTIMISTIC"] = "messageDeletedOptimistic";
21
+ SdkEventType["MESSAGE_EDITED_OPTIMISTIC"] = "messageEditedOptimistic";
22
+ SdkEventType["MESSAGE_DELETE_FAILED"] = "messageDeleteFailed";
23
+ SdkEventType["MESSAGE_EDIT_FAILED"] = "messageEditFailed";
24
24
  SdkEventType["ERROR"] = "error";
25
25
  })(SdkEventType || (SdkEventType = {}));
26
- // ─────────────────────────────────────────────────────────────────────────────
27
- // Event Emitter Class
28
- // ─────────────────────────────────────────────────────────────────────────────
29
26
  export class SdkEventEmitter {
30
27
  constructor() {
31
- Object.defineProperty(this, "handlers", {
28
+ Object.defineProperty(this, "bus", {
32
29
  enumerable: true,
33
30
  configurable: true,
34
31
  writable: true,
35
- value: {
36
- [SdkEventType.MESSAGE_RECEIVED]: new Set(),
37
- [SdkEventType.MESSAGE_SENT]: new Set(),
38
- [SdkEventType.MESSAGE_READ]: new Set(),
39
- [SdkEventType.MESSAGE_FAILED]: new Set(),
40
- [SdkEventType.SESSION_REQUESTED]: new Set(),
41
- [SdkEventType.SESSION_CREATED]: new Set(),
42
- [SdkEventType.SESSION_RENEWED]: new Set(),
43
- [SdkEventType.SESSION_ACCEPTED]: new Set(),
44
- [SdkEventType.SEEKERS_UPDATED]: new Set(),
45
- [SdkEventType.SESSION_STATUS_CHANGED]: new Set(),
46
- [SdkEventType.DISCUSSION_UPDATED]: new Set(),
47
- [SdkEventType.WRITE_FAILED]: new Set(),
48
- [SdkEventType.MESSAGE_OPTIMISTIC]: new Set(),
49
- [SdkEventType.ERROR]: new Set(),
50
- }
32
+ value: mitt()
51
33
  });
52
34
  }
53
- /**
54
- * Register an event handler
55
- */
56
35
  on(event, handler) {
57
- this.handlers[event].add(handler);
36
+ this.bus.on(event, handler);
58
37
  }
59
- /**
60
- * Remove an event handler
61
- */
62
38
  off(event, handler) {
63
- this.handlers[event].delete(handler);
39
+ this.bus.off(event, handler);
64
40
  }
65
- /**
66
- * Emit an event to all registered handlers
67
- */
68
- emit(event, ...args) {
69
- const handlers = this.handlers[event];
70
- handlers.forEach(handler => {
71
- try {
72
- handler(...args);
73
- }
74
- catch (error) {
75
- console.error(`[SdkEventEmitter] Error in ${event} handler:`, error);
41
+ emit(event, payload) {
42
+ const handlers = this.bus.all.get(event);
43
+ if (handlers) {
44
+ for (const handler of handlers) {
45
+ try {
46
+ handler(payload);
47
+ }
48
+ catch (error) {
49
+ console.error(`[SdkEventEmitter] Error in ${String(event)} handler:`, error);
50
+ }
76
51
  }
77
- });
52
+ }
78
53
  }
79
- /**
80
- * Remove all handlers for all events
81
- */
82
54
  clear() {
83
- Object.values(this.handlers).forEach(set => set.clear());
55
+ this.bus.all.clear();
84
56
  }
85
57
  }
@@ -54,7 +54,10 @@ export class SdkPolling {
54
54
  }
55
55
  catch (error) {
56
56
  const err = error instanceof Error ? error : new Error(String(error));
57
- this.eventEmitter?.emit(SdkEventType.ERROR, err, 'message_polling');
57
+ this.eventEmitter?.emit(SdkEventType.ERROR, {
58
+ error: err,
59
+ context: 'message_polling',
60
+ });
58
61
  }
59
62
  }, config.polling.messagesIntervalMs);
60
63
  // Start announcement polling
@@ -64,7 +67,10 @@ export class SdkPolling {
64
67
  }
65
68
  catch (error) {
66
69
  const err = error instanceof Error ? error : new Error(String(error));
67
- this.eventEmitter?.emit(SdkEventType.ERROR, err, 'announcement_polling');
70
+ this.eventEmitter?.emit(SdkEventType.ERROR, {
71
+ error: err,
72
+ context: 'announcement_polling',
73
+ });
68
74
  }
69
75
  }, config.polling.announcementsIntervalMs);
70
76
  // Start session refresh polling
@@ -74,7 +80,10 @@ export class SdkPolling {
74
80
  }
75
81
  catch (error) {
76
82
  const err = error instanceof Error ? error : new Error(String(error));
77
- this.eventEmitter?.emit(SdkEventType.ERROR, err, 'session_update');
83
+ this.eventEmitter?.emit(SdkEventType.ERROR, {
84
+ error: err,
85
+ context: 'session_update',
86
+ });
78
87
  }
79
88
  }, config.polling.sessionRefreshIntervalMs);
80
89
  // Start session status change polling (fixed 3s interval)
@@ -84,7 +93,10 @@ export class SdkPolling {
84
93
  }
85
94
  catch (error) {
86
95
  const err = error instanceof Error ? error : new Error(String(error));
87
- this.eventEmitter?.emit(SdkEventType.ERROR, err, 'session_status_polling');
96
+ this.eventEmitter?.emit(SdkEventType.ERROR, {
97
+ error: err,
98
+ context: 'session_status_polling',
99
+ });
88
100
  }
89
101
  }, SESSION_STATUS_POLL_INTERVAL_MS);
90
102
  }
@@ -2,6 +2,6 @@
2
2
  * Core SDK components
3
3
  */
4
4
  export { SdkEventEmitter } from './SdkEventEmitter.js';
5
- export { SdkEventType, type SdkEventHandlers } from './SdkEventEmitter.js';
5
+ export { SdkEventType, type SdkEvents } from './SdkEventEmitter.js';
6
6
  export { SdkPolling } from './SdkPolling.js';
7
7
  export type { PollingCallbacks } from './SdkPolling.js';
@@ -51,16 +51,13 @@ async function handleMessage(e) {
51
51
  try {
52
52
  switch (type) {
53
53
  case 'init': {
54
- const { dbPath, wasmUrl, initSql, useOPFS } = e.data;
54
+ const { dbPath, wasmBinary, initSql, useOPFS } = e.data;
55
55
  const moduleArg = {};
56
- // Pre-fetch WASM as ArrayBuffer and override instantiateWasm
57
- // so Emscripten never calls instantiateStreaming (Safari chokes
58
- // on chunked Transfer-Encoding).
59
- if (wasmUrl) {
60
- const resp = await fetch(wasmUrl);
61
- const bytes = await resp.arrayBuffer();
56
+ // WASM bytes are pre-fetched in the main thread to avoid
57
+ // Safari's chunked Transfer-Encoding bug in Worker fetch().
58
+ if (wasmBinary) {
62
59
  moduleArg.instantiateWasm = (imports, successCallback) => {
63
- WebAssembly.instantiate(bytes, imports).then(result => {
60
+ WebAssembly.instantiate(wasmBinary, imports).then(result => {
64
61
  successCallback(result.instance, result.module);
65
62
  });
66
63
  return {};
package/dist/db/sqlite.js CHANGED
@@ -92,12 +92,14 @@ export class DatabaseConnection {
92
92
  return this.state.drizzleDb !== null;
93
93
  }
94
94
  // ─── Raw SQL execution ─────────────────────────────────────────
95
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
- postToWorker(msg) {
95
+ postToWorker(msg, transfer = []) {
97
96
  return new Promise((resolve, reject) => {
98
97
  const id = ++this.state.msgId;
99
- this.state.pending.set(id, { resolve, reject });
100
- this.state.worker.postMessage({ ...msg, id });
98
+ this.state.pending.set(id, {
99
+ resolve: resolve,
100
+ reject,
101
+ });
102
+ this.state.worker.postMessage({ ...msg, id }, transfer);
101
103
  });
102
104
  }
103
105
  createDrizzleInstance() {
@@ -152,13 +154,14 @@ export class DatabaseConnection {
152
154
  this.state.worker.onmessage = this.handleWorkerMessage;
153
155
  this.state.useWorker = true;
154
156
  try {
155
- await this.postToWorker({
156
- type: 'init',
157
- dbPath,
158
- useOPFS,
159
- wasmUrl: storage.wasmUrl,
160
- initSql: PRAGMAS,
161
- });
157
+ // Pre-fetch WASM in the main thread to avoid Safari's
158
+ // chunked Transfer-Encoding bug in Worker fetch().
159
+ let wasmBinary;
160
+ if (storage.wasmUrl) {
161
+ const resp = await fetch(storage.wasmUrl);
162
+ wasmBinary = await resp.arrayBuffer();
163
+ }
164
+ await this.postToWorker({ type: 'init', dbPath, useOPFS, wasmBinary, initSql: PRAGMAS }, wasmBinary ? [wasmBinary] : []);
162
165
  }
163
166
  catch (err) {
164
167
  if (this.state.worker) {
package/dist/gossip.d.ts CHANGED
@@ -11,9 +11,7 @@ import { type ValidationResult } from './utils/validation.js';
11
11
  import { type StorageConfig } from './db/index.js';
12
12
  import { Queries } from './db/queries/index.js';
13
13
  import { type UserPublicKeys, type SessionConfig } from './wasm/bindings.js';
14
- import { SdkEventType, type SdkEventHandlers } from './core/SdkEventEmitter.js';
15
- export type { SdkEventHandlers };
16
- export { SdkEventType };
14
+ import { SdkEventType, type SdkEvents } from './core/SdkEventEmitter.js';
17
15
  export declare enum SdkStatus {
18
16
  UNINITIALIZED = "uninitialized",
19
17
  INITIALIZED = "initialized",
@@ -132,11 +130,11 @@ declare class GossipSdk {
132
130
  /**
133
131
  * Register an event handler
134
132
  */
135
- on<K extends SdkEventType>(event: K, handler: SdkEventHandlers[K]): void;
133
+ on<K extends SdkEventType>(event: K, handler: (payload: SdkEvents[K]) => void): void;
136
134
  /**
137
135
  * Remove an event handler
138
136
  */
139
- off<K extends SdkEventType>(event: K, handler: SdkEventHandlers[K]): void;
137
+ off<K extends SdkEventType>(event: K, handler: (payload: SdkEvents[K]) => void): void;
140
138
  private requireSession;
141
139
  private handleSessionPersist;
142
140
  /**
@@ -174,4 +172,4 @@ interface PollingAPI {
174
172
  }
175
173
  /** A convenience singleton for apps that only need one SDK instance. */
176
174
  export declare const gossipSdk: GossipSdk;
177
- export { GossipSdk };
175
+ export { GossipSdk, SdkEventType };
package/dist/gossip.js CHANGED
@@ -57,7 +57,9 @@ import { Queries } from './db/queries/index.js';
57
57
  import { SessionManagerWrapper, } from './wasm/bindings.js';
58
58
  import { SdkEventEmitter, SdkEventType, } from './core/SdkEventEmitter.js';
59
59
  import { SdkPolling } from './core/SdkPolling.js';
60
- export { SdkEventType };
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // Types
62
+ // ─────────────────────────────────────────────────────────────────────────────
61
63
  export var SdkStatus;
62
64
  (function (SdkStatus) {
63
65
  SdkStatus["UNINITIALIZED"] = "uninitialized";
@@ -247,7 +249,10 @@ class GossipSdk {
247
249
  // Publish gossip ID (public key) on messageProtocol so the user is discoverable.
248
250
  // Non-blocking: login must succeed even when the API is unreachable.
249
251
  this._auth.publishPublicKey(session.ourPk, session.userIdEncoded, queries).catch(err => {
250
- this.eventEmitter.emit(SdkEventType.ERROR, err instanceof Error ? err : new Error(String(err)), 'publishPublicKey');
252
+ this.eventEmitter.emit(SdkEventType.ERROR, {
253
+ error: err instanceof Error ? err : new Error(String(err)),
254
+ context: 'publishPublicKey',
255
+ });
251
256
  });
252
257
  // Now set refreshService on services (circular dependency resolved via setter)
253
258
  this._discussion.setRefreshService(this._refresh);
@@ -540,7 +545,10 @@ class GossipSdk {
540
545
  await onPersist(blob, encryptionKey);
541
546
  }
542
547
  catch (error) {
543
- this.eventEmitter.emit(SdkEventType.ERROR, error instanceof Error ? error : new Error(String(error)), 'session_persist');
548
+ this.eventEmitter.emit(SdkEventType.ERROR, {
549
+ error: error instanceof Error ? error : new Error(String(error)),
550
+ context: 'session_persist',
551
+ });
544
552
  }
545
553
  }
546
554
  /**
@@ -581,4 +589,4 @@ class GossipSdk {
581
589
  // ─────────────────────────────────────────────────────────────────────────────
582
590
  /** A convenience singleton for apps that only need one SDK instance. */
583
591
  export const gossipSdk = new GossipSdk();
584
- export { GossipSdk };
592
+ export { GossipSdk, SdkEventType };
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  *
20
20
  * @packageDocumentation
21
21
  */
22
+ export * from './core/SdkEventEmitter.js';
22
23
  export * from './api/index.js';
23
24
  export * from './crypto/index.js';
24
25
  export * from './gossip.js';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@
19
19
  *
20
20
  * @packageDocumentation
21
21
  */
22
+ export * from './core/SdkEventEmitter.js';
22
23
  export * from './api/index.js';
23
24
  export * from './crypto/index.js';
24
25
  export * from './gossip.js';
@@ -426,7 +426,10 @@ export class AnnouncementService {
426
426
  contactRow &&
427
427
  this.session.peerSessionStatus(decodeUserId(contactUserId)) ===
428
428
  SessionStatus.PeerRequested) {
429
- this.eventEmitter.emit(SdkEventType.SESSION_REQUESTED, toDiscussion(newDiscussion), contactRow);
429
+ this.eventEmitter.emit(SdkEventType.SESSION_REQUESTED, {
430
+ discussion: toDiscussion(newDiscussion),
431
+ contact: contactRow,
432
+ });
430
433
  }
431
434
  return { discussionId: existing.id };
432
435
  }
@@ -443,7 +446,10 @@ export class AnnouncementService {
443
446
  const discussion = await this.queries.discussions.getById(discussionId);
444
447
  const contactRow = await this.queries.contacts.getByOwnerAndUser(ownerUserId, contactUserId);
445
448
  if (discussion && contactRow) {
446
- this.eventEmitter.emit(SdkEventType.SESSION_REQUESTED, toDiscussion(discussion), contactRow);
449
+ this.eventEmitter.emit(SdkEventType.SESSION_REQUESTED, {
450
+ discussion: toDiscussion(discussion),
451
+ contact: contactRow,
452
+ });
447
453
  }
448
454
  return { discussionId };
449
455
  }
@@ -961,17 +961,23 @@ export class MessageService {
961
961
  type: msg.type,
962
962
  direction: msg.direction,
963
963
  });
964
- try {
965
- this.eventEmitter.emit(SdkEventType.MESSAGE_SENT, {
966
- ...msg,
967
- status: MessageStatus.SENT,
968
- });
969
- }
970
- catch (error) {
971
- log.error('failed to emit message sent event', {
972
- messageId: msg.id,
973
- error,
974
- });
964
+ // Skip emitting MESSAGE_SENT for control messages (delete/edit).
965
+ // These are internal transport details; the semantic optimistic
966
+ // events already handle UI state.
967
+ const isControlMessage = !!(msg.deleteOf || msg.editOf);
968
+ if (!isControlMessage) {
969
+ try {
970
+ this.eventEmitter.emit(SdkEventType.MESSAGE_SENT, {
971
+ ...msg,
972
+ status: MessageStatus.SENT,
973
+ });
974
+ }
975
+ catch (error) {
976
+ log.error('failed to emit message sent event', {
977
+ messageId: msg.id,
978
+ error,
979
+ });
980
+ }
975
981
  }
976
982
  }
977
983
  }
@@ -1066,11 +1072,19 @@ export class MessageService {
1066
1072
  // Persist in background (non-optimistic path)
1067
1073
  this.send({ ...message, messageId }).then(result => {
1068
1074
  if (!result.success) {
1069
- this.eventEmitter.emit(SdkEventType.WRITE_FAILED, messageId, 'message', new Error(result.error ?? 'Unknown error'));
1075
+ this.eventEmitter.emit(SdkEventType.WRITE_FAILED, {
1076
+ messageId,
1077
+ entityType: 'message',
1078
+ error: new Error(result.error ?? 'Unknown error'),
1079
+ });
1070
1080
  }
1071
1081
  }, error => {
1072
1082
  log.error('optimistic send failed', { error });
1073
- this.eventEmitter.emit(SdkEventType.WRITE_FAILED, messageId, 'message', error instanceof Error ? error : new Error(String(error)));
1083
+ this.eventEmitter.emit(SdkEventType.WRITE_FAILED, {
1084
+ messageId,
1085
+ entityType: 'message',
1086
+ error: error instanceof Error ? error : new Error(String(error)),
1087
+ });
1074
1088
  });
1075
1089
  return { success: true, message: optimisticMessage };
1076
1090
  }
@@ -1105,41 +1119,59 @@ export class MessageService {
1105
1119
  */
1106
1120
  async deleteMessage(id) {
1107
1121
  const row = await this.queries.messages.getById(id);
1108
- if (!row) {
1122
+ if (!row)
1109
1123
  return false;
1110
- }
1111
- // Only allow deleting our own outgoing messages
1112
- if (row.direction !== MessageDirection.OUTGOING) {
1124
+ if (row.direction !== MessageDirection.OUTGOING)
1113
1125
  return false;
1114
- }
1115
- if (!row.messageId) {
1126
+ if (!row.messageId)
1116
1127
  throw new Error('Cannot delete a message that has no messageId');
1117
- }
1128
+ const original = rowToMessage(row);
1118
1129
  const ownerUserId = this.session.userIdEncoded;
1119
- // Mark the original message as deleted locally
1120
- await this.queries.messages.updateById(id, {
1121
- content: '[Message deleted]',
1122
- type: MessageType.DELETED,
1123
- });
1124
- // Enqueue a delete control message to notify the peer
1125
- const controlMessage = {
1126
- ownerUserId,
1127
- contactUserId: row.contactUserId,
1128
- content: '',
1129
- type: MessageType.DELETED,
1130
- direction: MessageDirection.OUTGOING,
1131
- status: MessageStatus.WAITING_SESSION,
1132
- timestamp: new Date(),
1133
- deleteOf: {
1130
+ // Emit optimistic event so UI updates immediately (skip for reactions —
1131
+ // the store handles reaction removal separately).
1132
+ if (row.type !== MessageType.REACTION) {
1133
+ this.eventEmitter.emit(SdkEventType.MESSAGE_DELETED_OPTIMISTIC, {
1134
+ contactUserId: row.contactUserId,
1135
+ messageDbId: id,
1134
1136
  originalMsgId: row.messageId,
1135
- },
1136
- };
1137
- const result = await this.send(controlMessage);
1138
- if (!result.success) {
1139
- throw new Error(result.error ?? 'Failed to enqueue delete message');
1137
+ });
1138
+ }
1139
+ try {
1140
+ await this.queries.messages.updateById(id, {
1141
+ content: '[Message deleted]',
1142
+ type: MessageType.DELETED,
1143
+ });
1144
+ const controlMessage = {
1145
+ ownerUserId,
1146
+ contactUserId: row.contactUserId,
1147
+ content: '',
1148
+ type: MessageType.DELETED,
1149
+ direction: MessageDirection.OUTGOING,
1150
+ status: MessageStatus.WAITING_SESSION,
1151
+ timestamp: new Date(),
1152
+ deleteOf: { originalMsgId: row.messageId },
1153
+ };
1154
+ const result = await this.send(controlMessage);
1155
+ if (!result.success)
1156
+ throw new Error(result.error ?? 'Failed to enqueue delete message');
1157
+ await this.refreshService?.stateUpdate();
1158
+ return true;
1159
+ }
1160
+ catch (error) {
1161
+ // Rollback: emit failure so store can restore original
1162
+ if (row.type !== MessageType.REACTION) {
1163
+ this.eventEmitter.emit(SdkEventType.MESSAGE_DELETE_FAILED, {
1164
+ contactUserId: row.contactUserId,
1165
+ messageDbId: id,
1166
+ original,
1167
+ });
1168
+ }
1169
+ // Best-effort DB rollback
1170
+ await this.queries.messages
1171
+ .updateById(id, { content: original.content, type: original.type })
1172
+ .catch(() => { });
1173
+ throw error;
1140
1174
  }
1141
- await this.refreshService?.stateUpdate();
1142
- return true;
1143
1175
  }
1144
1176
  async sendReaction(contactUserId, emoji, originalMsgId) {
1145
1177
  const message = {
@@ -1163,50 +1195,59 @@ export class MessageService {
1163
1195
  */
1164
1196
  async editMessage(id, newContent) {
1165
1197
  const row = await this.queries.messages.getById(id);
1166
- if (!row) {
1198
+ if (!row)
1167
1199
  return false;
1168
- }
1169
- // Only allow editing our own outgoing messages
1170
- if (row.direction !== MessageDirection.OUTGOING) {
1200
+ if (row.direction !== MessageDirection.OUTGOING)
1171
1201
  return false;
1172
- }
1173
- if (!row.messageId || row.messageId.length !== MESSAGE_ID_SIZE) {
1202
+ if (!row.messageId || row.messageId.length !== MESSAGE_ID_SIZE)
1174
1203
  throw new Error('Cannot edit a message that has no valid messageId');
1175
- }
1204
+ const original = rowToMessage(row);
1176
1205
  const ownerUserId = this.session.userIdEncoded;
1177
- // Merge existing metadata with edited flag
1178
1206
  const existingMetadata = deserializeMetadata(row.metadata) ?? {};
1179
- const mergedMetadata = {
1180
- ...existingMetadata,
1181
- edited: true,
1182
- };
1183
- // Update the original message content locally, preserving timestamp
1184
- await this.queries.messages.updateById(id, {
1185
- content: newContent,
1186
- metadata: serializeMetadata(mergedMetadata),
1187
- });
1188
- // Enqueue an edit control message to notify the peer
1189
- const controlMessage = {
1190
- ownerUserId,
1207
+ const mergedMetadata = { ...existingMetadata, edited: true };
1208
+ // Emit optimistic event so UI updates immediately
1209
+ this.eventEmitter.emit(SdkEventType.MESSAGE_EDITED_OPTIMISTIC, {
1191
1210
  contactUserId: row.contactUserId,
1192
- content: newContent,
1193
- type: MessageType.TEXT,
1194
- direction: MessageDirection.OUTGOING,
1195
- status: MessageStatus.WAITING_SESSION,
1196
- timestamp: new Date(),
1197
- editOf: {
1198
- originalMsgId: row.messageId,
1199
- },
1200
- metadata: {
1201
- control: 'edit',
1202
- },
1203
- };
1204
- const result = await this.send(controlMessage);
1205
- if (!result.success) {
1206
- throw new Error(result.error ?? 'Failed to enqueue edit message');
1211
+ messageDbId: id,
1212
+ newContent,
1213
+ metadata: mergedMetadata,
1214
+ });
1215
+ try {
1216
+ await this.queries.messages.updateById(id, {
1217
+ content: newContent,
1218
+ metadata: serializeMetadata(mergedMetadata),
1219
+ });
1220
+ const controlMessage = {
1221
+ ownerUserId,
1222
+ contactUserId: row.contactUserId,
1223
+ content: newContent,
1224
+ type: MessageType.TEXT,
1225
+ direction: MessageDirection.OUTGOING,
1226
+ status: MessageStatus.WAITING_SESSION,
1227
+ timestamp: new Date(),
1228
+ editOf: { originalMsgId: row.messageId },
1229
+ metadata: { control: 'edit' },
1230
+ };
1231
+ const result = await this.send(controlMessage);
1232
+ if (!result.success)
1233
+ throw new Error(result.error ?? 'Failed to enqueue edit message');
1234
+ await this.refreshService?.stateUpdate();
1235
+ return true;
1236
+ }
1237
+ catch (error) {
1238
+ this.eventEmitter.emit(SdkEventType.MESSAGE_EDIT_FAILED, {
1239
+ contactUserId: row.contactUserId,
1240
+ messageDbId: id,
1241
+ original,
1242
+ });
1243
+ await this.queries.messages
1244
+ .updateById(id, {
1245
+ content: original.content,
1246
+ metadata: row.metadata ?? undefined,
1247
+ })
1248
+ .catch(() => { });
1249
+ throw error;
1207
1250
  }
1208
- await this.refreshService?.stateUpdate();
1209
- return true;
1210
1251
  }
1211
1252
  /**
1212
1253
  * Hard-delete messages that have exceeded their discussion retention duration.
@@ -113,7 +113,10 @@ export class RefreshService {
113
113
  const previous = this.sessionStatusMap.get(discussion.contactUserId);
114
114
  if (previous !== status) {
115
115
  this.sessionStatusMap.set(discussion.contactUserId, status);
116
- this.eventEmitter.emit(SdkEventType.SESSION_STATUS_CHANGED, discussion.contactUserId, status);
116
+ this.eventEmitter.emit(SdkEventType.SESSION_STATUS_CHANGED, {
117
+ contactUserId: discussion.contactUserId,
118
+ status,
119
+ });
117
120
  }
118
121
  }
119
122
  }
@@ -218,7 +221,10 @@ export class RefreshService {
218
221
  timestamp: new Date(),
219
222
  });
220
223
  if (!result.success) {
221
- this.eventEmitter.emit(SdkEventType.ERROR, new Error(result.error || 'Unknown error'), 'keep_alive_message');
224
+ this.eventEmitter.emit(SdkEventType.ERROR, {
225
+ error: new Error(result.error || 'Unknown error'),
226
+ context: 'keep_alive_message',
227
+ });
222
228
  }
223
229
  }
224
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/gossip-sdk",
3
- "version": "0.0.2-dev.20260410072437",
3
+ "version": "0.0.2-dev.20260410093719",
4
4
  "description": "Gossip SDK for automation, chatbot, and integration use cases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,6 +50,7 @@
50
50
  "@scure/base": "^2.0.0",
51
51
  "@scure/bip39": "^2.0.1",
52
52
  "drizzle-orm": "^0.45.2",
53
+ "mitt": "^3.0.1",
53
54
  "wa-sqlite": "^1.0.0"
54
55
  }
55
56
  }