@massalabs/gossip-sdk 0.0.2-dev.20260313034537 → 0.0.2-dev.20260313145228

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/README.md CHANGED
@@ -147,9 +147,15 @@ await sdk.messages.sendText(contactId, 'Hello!', {
147
147
  // Fetch new messages from server
148
148
  const fetchResult = await sdk.messages.fetch();
149
149
 
150
- // Get all messages for a given contact
150
+ // Get all messages for a given contact (raw, protocol-level view)
151
151
  const messages = await sdk.messages.getMessages(contactId);
152
152
 
153
+ // Get only user-visible messages for a contact
154
+ // - Excludes KEEP_ALIVE protocol pings
155
+ // - Excludes outgoing delete control messages (empty content)
156
+ // - Ordered by ascending database id
157
+ const visibleMessages = await sdk.messages.getVisibleMessages(contactId);
158
+
153
159
  // Mark a specific message (by DB ID) as read
154
160
  await sdk.messages.markAsRead(messageId);
155
161
  ```
package/dist/db/db.d.ts CHANGED
@@ -39,6 +39,12 @@ export interface Message {
39
39
  originalContent?: string;
40
40
  originalContactId?: Uint8Array;
41
41
  };
42
+ deleteOf?: {
43
+ originalMsgId: Uint8Array;
44
+ };
45
+ editOf?: {
46
+ originalMsgId: Uint8Array;
47
+ };
42
48
  }
43
49
  export interface UserProfile {
44
50
  userId: string;
@@ -89,7 +95,8 @@ export declare enum MessageType {
89
95
  IMAGE = "image",
90
96
  FILE = "file",
91
97
  AUDIO = "audio",
92
- VIDEO = "video"
98
+ VIDEO = "video",
99
+ DELETED = "deleted"
93
100
  }
94
101
  export interface ReadyAnnouncement {
95
102
  announcement_bytes: Uint8Array;
package/dist/db/db.js CHANGED
@@ -36,6 +36,7 @@ export var MessageType;
36
36
  MessageType["FILE"] = "file";
37
37
  MessageType["AUDIO"] = "audio";
38
38
  MessageType["VIDEO"] = "video";
39
+ MessageType["DELETED"] = "deleted";
39
40
  })(MessageType || (MessageType = {}));
40
41
  /** Serialize a SendAnnouncement to a JSON string for SQLite text column */
41
42
  export function serializeSendAnnouncement(announcement) {
@@ -14,7 +14,7 @@ export const MIGRATIONS = [
14
14
  'CREATE INDEX `contacts_owner_name_idx` ON `contacts` (`ownerUserId`,`name`);',
15
15
  'CREATE TABLE `discussions` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`contactUserId` text NOT NULL,\n\t`weAccepted` integer DEFAULT false NOT NULL,\n\t`sendAnnouncement` text,\n\t`direction` text NOT NULL,\n\t`nextSeeker` blob,\n\t`initiationAnnouncement` blob,\n\t`announcementMessage` text,\n\t`lastSyncTimestamp` integer,\n\t`customName` text,\n\t`lastMessageId` integer,\n\t`lastMessageContent` text,\n\t`lastMessageTimestamp` integer,\n\t`unreadCount` integer DEFAULT 0 NOT NULL,\n\t`killedNextRetryAt` integer,\n\t`saturatedRetryAt` integer,\n\t`saturatedRetryDone` integer DEFAULT 0 NOT NULL,\n\t`createdAt` integer NOT NULL,\n\t`updatedAt` integer NOT NULL\n);',
16
16
  'CREATE UNIQUE INDEX `discussions_owner_contact_idx` ON `discussions` (`ownerUserId`,`contactUserId`);',
17
- 'CREATE TABLE `messages` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`contactUserId` text NOT NULL,\n\t`messageId` blob,\n\t`content` text NOT NULL,\n\t`serializedContent` blob,\n\t`type` text NOT NULL,\n\t`direction` text NOT NULL,\n\t`status` text NOT NULL,\n\t`timestamp` integer NOT NULL,\n\t`metadata` text,\n\t`seeker` blob,\n\t`replyTo` text,\n\t`forwardOf` text,\n\t`encryptedMessage` blob,\n\t`whenToSend` integer\n);',
17
+ 'CREATE TABLE `messages` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`contactUserId` text NOT NULL,\n\t`messageId` blob,\n\t`content` text NOT NULL,\n\t`serializedContent` blob,\n\t`type` text NOT NULL,\n\t`direction` text NOT NULL,\n\t`status` text NOT NULL,\n\t`timestamp` integer NOT NULL,\n\t`metadata` text,\n\t`seeker` blob,\n\t`replyTo` text,\n\t`forwardOf` text,\n\t`deleteOf` text,\n\t`encryptedMessage` blob,\n\t`whenToSend` integer\n);',
18
18
  'CREATE INDEX `messages_owner_contact_idx` ON `messages` (`ownerUserId`,`contactUserId`);',
19
19
  'CREATE INDEX `messages_owner_status_idx` ON `messages` (`ownerUserId`,`status`);',
20
20
  'CREATE INDEX `messages_owner_contact_status_idx` ON `messages` (`ownerUserId`,`contactUserId`,`status`);',
@@ -34,4 +34,10 @@ export const MIGRATIONS = [
34
34
  'CREATE INDEX `userProfile_status_idx` ON `userProfile` (`status`);',
35
35
  ],
36
36
  },
37
+ {
38
+ idx: 1,
39
+ tag: '0001_messages_edit_of',
40
+ when: 1740000000000,
41
+ statements: ['ALTER TABLE `messages` ADD COLUMN `editOf` text;'],
42
+ },
37
43
  ];
@@ -8,6 +8,7 @@ export declare class MessageQueries {
8
8
  constructor(conn: DatabaseConnection);
9
9
  getById(id: number): Promise<MessageRow | undefined>;
10
10
  getByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
11
+ getVisibleByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
11
12
  getByOwnerAndSeeker(ownerUserId: string, seeker: Uint8Array): Promise<MessageRow | undefined>;
12
13
  findByMessageId(ownerUserId: string, contactUserId: string, messageId: Uint8Array): Promise<MessageRow | undefined>;
13
14
  insert(values: MessageInsert): Promise<number>;
@@ -1,4 +1,4 @@
1
- import { eq, and, sql, inArray, asc } from 'drizzle-orm';
1
+ import { eq, and, or, sql, inArray, asc, ne } from 'drizzle-orm';
2
2
  import * as schema from '../schema/index.js';
3
3
  import { MessageDirection, MessageStatus, MessageType } from '../../db/db.js';
4
4
  export class MessageQueries {
@@ -25,6 +25,20 @@ export class MessageQueries {
25
25
  .orderBy(asc(schema.messages.timestamp), asc(schema.messages.id))
26
26
  .all();
27
27
  }
28
+ async getVisibleByOwnerAndContact(ownerUserId, contactUserId) {
29
+ return this.conn.db
30
+ .select()
31
+ .from(schema.messages)
32
+ .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId),
33
+ // Hide keep-alive messages from UI
34
+ ne(schema.messages.type, MessageType.KEEP_ALIVE),
35
+ // Hide delete control messages (outgoing DELETED with empty content)
36
+ or(ne(schema.messages.type, MessageType.DELETED), ne(schema.messages.direction, MessageDirection.OUTGOING), ne(schema.messages.content, '')),
37
+ // Hide edit control messages tagged via metadata.control === 'edit'
38
+ sql `(metadata IS NULL OR metadata NOT LIKE '%"control":"edit"%')`))
39
+ .orderBy(asc(schema.messages.id))
40
+ .all();
41
+ }
28
42
  async getByOwnerAndSeeker(ownerUserId, seeker) {
29
43
  return this.conn.db
30
44
  .select()
@@ -98,7 +112,6 @@ export class MessageQueries {
98
112
  .all();
99
113
  }
100
114
  async resetSendQueue(ownerUserId, contactUserId) {
101
- const statuses = [MessageStatus.READY, MessageStatus.SENT];
102
115
  await this.conn.db
103
116
  .update(schema.messages)
104
117
  .set({
@@ -106,7 +119,10 @@ export class MessageQueries {
106
119
  encryptedMessage: null,
107
120
  seeker: null,
108
121
  })
109
- .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.direction, MessageDirection.OUTGOING), statuses ? inArray(schema.messages.status, statuses) : undefined));
122
+ .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.direction, MessageDirection.OUTGOING), inArray(schema.messages.status, [
123
+ MessageStatus.READY,
124
+ MessageStatus.SENT,
125
+ ])));
110
126
  const waitingSessionMessages = await this.conn.db
111
127
  .select()
112
128
  .from(schema.messages)
@@ -268,6 +268,44 @@ export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWith
268
268
  }, {}, {
269
269
  length: number | undefined;
270
270
  }>;
271
+ deleteOf: import("drizzle-orm/sqlite-core").SQLiteColumn<{
272
+ name: "deleteOf";
273
+ tableName: "messages";
274
+ dataType: "string";
275
+ columnType: "SQLiteText";
276
+ data: string;
277
+ driverParam: string;
278
+ notNull: false;
279
+ hasDefault: false;
280
+ isPrimaryKey: false;
281
+ isAutoincrement: false;
282
+ hasRuntimeDefault: false;
283
+ enumValues: [string, ...string[]];
284
+ baseColumn: never;
285
+ identity: undefined;
286
+ generated: undefined;
287
+ }, {}, {
288
+ length: number | undefined;
289
+ }>;
290
+ editOf: import("drizzle-orm/sqlite-core").SQLiteColumn<{
291
+ name: "editOf";
292
+ tableName: "messages";
293
+ dataType: "string";
294
+ columnType: "SQLiteText";
295
+ data: string;
296
+ driverParam: string;
297
+ notNull: false;
298
+ hasDefault: false;
299
+ isPrimaryKey: false;
300
+ isAutoincrement: false;
301
+ hasRuntimeDefault: false;
302
+ enumValues: [string, ...string[]];
303
+ baseColumn: never;
304
+ identity: undefined;
305
+ generated: undefined;
306
+ }, {}, {
307
+ length: number | undefined;
308
+ }>;
271
309
  encryptedMessage: import("drizzle-orm/sqlite-core").SQLiteColumn<{
272
310
  name: "encryptedMessage";
273
311
  tableName: "messages";
@@ -15,6 +15,8 @@ export const messages = sqliteTable('messages', {
15
15
  seeker: bytes('seeker'),
16
16
  replyTo: text('replyTo'),
17
17
  forwardOf: text('forwardOf'),
18
+ deleteOf: text('deleteOf'),
19
+ editOf: text('editOf'),
18
20
  encryptedMessage: bytes('encryptedMessage'),
19
21
  whenToSend: integer('whenToSend', { mode: 'timestamp_ms' }),
20
22
  }, table => [
@@ -2,7 +2,9 @@ export declare enum MessageType {
2
2
  MESSAGE_TYPE_REGULAR = 0,
3
3
  MESSAGE_TYPE_REPLY = 1,
4
4
  MESSAGE_TYPE_FORWARD = 2,
5
- MESSAGE_TYPE_KEEP_ALIVE = 3
5
+ MESSAGE_TYPE_KEEP_ALIVE = 3,
6
+ MESSAGE_TYPE_DELETE = 4,
7
+ MESSAGE_TYPE_EDIT = 5
6
8
  }
7
9
  export interface Message {
8
10
  messageType?: MessageType;
@@ -6,6 +6,8 @@ export var MessageType;
6
6
  MessageType[MessageType["MESSAGE_TYPE_REPLY"] = 1] = "MESSAGE_TYPE_REPLY";
7
7
  MessageType[MessageType["MESSAGE_TYPE_FORWARD"] = 2] = "MESSAGE_TYPE_FORWARD";
8
8
  MessageType[MessageType["MESSAGE_TYPE_KEEP_ALIVE"] = 3] = "MESSAGE_TYPE_KEEP_ALIVE";
9
+ MessageType[MessageType["MESSAGE_TYPE_DELETE"] = 4] = "MESSAGE_TYPE_DELETE";
10
+ MessageType[MessageType["MESSAGE_TYPE_EDIT"] = 5] = "MESSAGE_TYPE_EDIT";
9
11
  })(MessageType || (MessageType = {}));
10
12
  const textEncoder = new TextEncoder();
11
13
  const textDecoder = new TextDecoder();
@@ -81,8 +81,12 @@ export declare class MessageService {
81
81
  getWaitingMessageCount(contactUserId: string): Promise<number>;
82
82
  /** Get a message by its database ID */
83
83
  get(id: number): Promise<Message | undefined>;
84
- /** Get all messages for a contact (using session owner) */
84
+ /** Get all messages for a contact (using session owner).
85
+ * NOTE: This returns raw rows without UI-level filtering.
86
+ */
85
87
  getMessages(contactUserId: string): Promise<Message[]>;
88
+ /** Get only user-visible messages for a contact (filtered + ordered). */
89
+ getVisibleMessages(contactUserId: string): Promise<Message[]>;
86
90
  /** Send a message, queued via QueueManager if available */
87
91
  send(message: Omit<Message, 'id'>): Promise<SendMessageResult>;
88
92
  /**
@@ -92,5 +96,17 @@ export declare class MessageService {
92
96
  sendText(contactUserId: string, text: string, options?: SendTextOptions): Promise<SendMessageResult>;
93
97
  /** Fetch and decrypt messages from the protocol (alias) */
94
98
  fetch(): Promise<MessageResult>;
99
+ /**
100
+ * Delete an outgoing message by its database ID.
101
+ * Marks the local message as deleted and enqueues a delete control message
102
+ * so the peer can mark their copy as deleted as well.
103
+ */
104
+ deleteMessage(id: number): Promise<boolean>;
105
+ /**
106
+ * Edit an outgoing message by its database ID.
107
+ * Updates the local content (preserving timestamp) and enqueues an edit
108
+ * control message so the peer can update their copy as well.
109
+ */
110
+ editMessage(id: number, newContent: string): Promise<boolean>;
95
111
  markAsRead(id: number): Promise<boolean>;
96
112
  }
@@ -7,7 +7,7 @@
7
7
  import { MessageDirection, MessageStatus, MessageType, MESSAGE_ID_SIZE, } from '../db/index.js';
8
8
  import { decodeUserId, encodeUserId } from '../utils/userId.js';
9
9
  import { SessionStatus } from '../wasm/bindings.js';
10
- import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, deserializeMessage, } from '../utils/messageSerialization.js';
10
+ import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, serializeDeleteMessage, serializeEditMessage, deserializeMessage, } from '../utils/messageSerialization.js';
11
11
  import { encodeToBase64, decodeFromBase64 } from '../utils/base64.js';
12
12
  import { sessionStatusToString } from '../wasm/session.js';
13
13
  import { Logger } from '../utils/logs.js';
@@ -57,6 +57,38 @@ function deserializeForwardOf(json) {
57
57
  : undefined,
58
58
  };
59
59
  }
60
+ /** Serialize deleteOf to JSON string for SQLite storage. */
61
+ function serializeDeleteOf(deleteOf) {
62
+ if (!deleteOf)
63
+ return null;
64
+ return JSON.stringify({
65
+ originalMsgId: encodeToBase64(deleteOf.originalMsgId),
66
+ });
67
+ }
68
+ /** Deserialize deleteOf from JSON string. */
69
+ function deserializeDeleteOf(json) {
70
+ if (!json)
71
+ return undefined;
72
+ const parsed = JSON.parse(json);
73
+ return {
74
+ originalMsgId: decodeFromBase64(parsed.originalMsgId),
75
+ };
76
+ }
77
+ function serializeEditOf(editOf) {
78
+ if (!editOf)
79
+ return null;
80
+ return JSON.stringify({
81
+ originalMsgId: encodeToBase64(editOf.originalMsgId),
82
+ });
83
+ }
84
+ function deserializeEditOf(json) {
85
+ if (!json)
86
+ return undefined;
87
+ const parsed = JSON.parse(json);
88
+ return {
89
+ originalMsgId: decodeFromBase64(parsed.originalMsgId),
90
+ };
91
+ }
60
92
  /** Serialize metadata to JSON string. */
61
93
  function serializeMetadata(metadata) {
62
94
  if (!metadata)
@@ -86,6 +118,8 @@ export function rowToMessage(row) {
86
118
  seeker: row.seeker ?? undefined,
87
119
  replyTo: deserializeReplyTo(row.replyTo),
88
120
  forwardOf: deserializeForwardOf(row.forwardOf),
121
+ deleteOf: deserializeDeleteOf(row.deleteOf ?? null),
122
+ editOf: deserializeEditOf(row.editOf ?? null),
89
123
  encryptedMessage: row.encryptedMessage ?? undefined,
90
124
  whenToSend: row.whenToSend ?? undefined,
91
125
  };
@@ -257,6 +291,8 @@ export class MessageService {
257
291
  seeker: message.seeker,
258
292
  replyTo: serializeReplyTo(message.replyTo),
259
293
  forwardOf: serializeForwardOf(message.forwardOf),
294
+ deleteOf: serializeDeleteOf(message.deleteOf),
295
+ editOf: serializeEditOf(message.editOf),
260
296
  encryptedMessage: message.encryptedMessage,
261
297
  whenToSend: message.whenToSend,
262
298
  });
@@ -290,6 +326,7 @@ export class MessageService {
290
326
  if (deserialized.type === MessageType.KEEP_ALIVE) {
291
327
  continue;
292
328
  }
329
+ // Delete control messages are handled at storage time; keep them in decrypted array
293
330
  if (!deserialized.messageId ||
294
331
  deserialized.messageId.length !== MESSAGE_ID_SIZE) {
295
332
  log.warn('missing or invalid messageId, skipping message', {
@@ -306,6 +343,8 @@ export class MessageService {
306
343
  type: deserialized.type,
307
344
  replyTo: deserialized.replyTo,
308
345
  forwardOf: deserialized.forwardOf,
346
+ deleteOf: deserialized.deleteOf,
347
+ editOf: deserialized.editOf,
309
348
  });
310
349
  }
311
350
  catch (deserializationError) {
@@ -330,6 +369,43 @@ export class MessageService {
330
369
  const log = logger.forMethod('storeDecryptedMessages');
331
370
  const storedIds = [];
332
371
  for (const message of decrypted) {
372
+ // Handle delete control messages by updating the referenced message in-place
373
+ if (message.type === MessageType.DELETED &&
374
+ message.deleteOf?.originalMsgId) {
375
+ const target = await this.findMessageByMsgId(message.deleteOf.originalMsgId, ownerUserId, message.senderId);
376
+ if (!target || !target.id) {
377
+ log.warn('delete target not found', {
378
+ originalMsgId: encodeToBase64(message.deleteOf.originalMsgId),
379
+ });
380
+ continue;
381
+ }
382
+ await this.queries.messages.updateById(target.id, {
383
+ content: '[Message deleted]',
384
+ type: MessageType.DELETED,
385
+ });
386
+ // Do not insert a new message row for delete control messages
387
+ continue;
388
+ }
389
+ // Handle edit control messages by updating the referenced message in-place
390
+ if (message.editOf?.originalMsgId) {
391
+ const target = await this.findMessageByMsgId(message.editOf.originalMsgId, ownerUserId, message.senderId);
392
+ if (!target || !target.id) {
393
+ log.warn('edit target not found', {
394
+ originalMsgId: encodeToBase64(message.editOf.originalMsgId),
395
+ });
396
+ continue;
397
+ }
398
+ const mergedMetadata = {
399
+ ...(target.metadata ?? {}),
400
+ edited: true,
401
+ };
402
+ await this.queries.messages.updateById(target.id, {
403
+ content: message.content,
404
+ metadata: serializeMetadata(mergedMetadata),
405
+ });
406
+ // Do not insert a new message row for edit control messages
407
+ continue;
408
+ }
333
409
  const discussion = await this.queries.discussions.getByOwnerAndContact(ownerUserId, message.senderId);
334
410
  if (!discussion) {
335
411
  log.error('no discussion for incoming message', {
@@ -513,6 +589,33 @@ export class MessageService {
513
589
  data: serializeKeepAliveMessage(),
514
590
  };
515
591
  }
592
+ else if (message.type === MessageType.DELETED && message.deleteOf) {
593
+ // Serialize a delete control message targeting an existing messageId
594
+ const originalMsgId = message.deleteOf.originalMsgId;
595
+ if (!originalMsgId || originalMsgId.length !== MESSAGE_ID_SIZE) {
596
+ return {
597
+ success: false,
598
+ error: 'Original messageId is required for delete messages',
599
+ };
600
+ }
601
+ return {
602
+ success: true,
603
+ data: serializeDeleteMessage(originalMsgId, message.messageId),
604
+ };
605
+ }
606
+ else if (message.editOf) {
607
+ const originalMsgId = message.editOf.originalMsgId;
608
+ if (!originalMsgId || originalMsgId.length !== MESSAGE_ID_SIZE) {
609
+ return {
610
+ success: false,
611
+ error: 'Original messageId is required for edit messages',
612
+ };
613
+ }
614
+ return {
615
+ success: true,
616
+ data: serializeEditMessage(message.content, originalMsgId, message.messageId),
617
+ };
618
+ }
516
619
  else if (message.forwardOf) {
517
620
  try {
518
621
  return {
@@ -781,11 +884,18 @@ export class MessageService {
781
884
  const row = await this.queries.messages.getById(id);
782
885
  return row ? rowToMessage(row) : undefined;
783
886
  }
784
- /** Get all messages for a contact (using session owner) */
887
+ /** Get all messages for a contact (using session owner).
888
+ * NOTE: This returns raw rows without UI-level filtering.
889
+ */
785
890
  async getMessages(contactUserId) {
786
891
  const rows = await this.queries.messages.getByOwnerAndContact(this.session.userIdEncoded, contactUserId);
787
892
  return rows.map(rowToMessage);
788
893
  }
894
+ /** Get only user-visible messages for a contact (filtered + ordered). */
895
+ async getVisibleMessages(contactUserId) {
896
+ const rows = await this.queries.messages.getVisibleByOwnerAndContact(this.session.userIdEncoded, contactUserId);
897
+ return rows.map(rowToMessage);
898
+ }
789
899
  /** Send a message, queued via QueueManager if available */
790
900
  async send(message) {
791
901
  if (this.queueManager) {
@@ -817,6 +927,101 @@ export class MessageService {
817
927
  async fetch() {
818
928
  return this.fetchMessages();
819
929
  }
930
+ /**
931
+ * Delete an outgoing message by its database ID.
932
+ * Marks the local message as deleted and enqueues a delete control message
933
+ * so the peer can mark their copy as deleted as well.
934
+ */
935
+ async deleteMessage(id) {
936
+ const row = await this.queries.messages.getById(id);
937
+ if (!row) {
938
+ return false;
939
+ }
940
+ // Only allow deleting our own outgoing messages
941
+ if (row.direction !== MessageDirection.OUTGOING) {
942
+ return false;
943
+ }
944
+ if (!row.messageId) {
945
+ throw new Error('Cannot delete a message that has no messageId');
946
+ }
947
+ const ownerUserId = this.session.userIdEncoded;
948
+ // Mark the original message as deleted locally
949
+ await this.queries.messages.updateById(id, {
950
+ content: '[Message deleted]',
951
+ type: MessageType.DELETED,
952
+ });
953
+ // Enqueue a delete control message to notify the peer
954
+ const controlMessage = {
955
+ ownerUserId,
956
+ contactUserId: row.contactUserId,
957
+ content: '',
958
+ type: MessageType.DELETED,
959
+ direction: MessageDirection.OUTGOING,
960
+ status: MessageStatus.WAITING_SESSION,
961
+ timestamp: new Date(),
962
+ deleteOf: {
963
+ originalMsgId: row.messageId,
964
+ },
965
+ };
966
+ const result = await this.send(controlMessage);
967
+ if (!result.success) {
968
+ throw new Error(result.error ?? 'Failed to enqueue delete message');
969
+ }
970
+ await this.refreshService?.stateUpdate();
971
+ return true;
972
+ }
973
+ /**
974
+ * Edit an outgoing message by its database ID.
975
+ * Updates the local content (preserving timestamp) and enqueues an edit
976
+ * control message so the peer can update their copy as well.
977
+ */
978
+ async editMessage(id, newContent) {
979
+ const row = await this.queries.messages.getById(id);
980
+ if (!row) {
981
+ return false;
982
+ }
983
+ // Only allow editing our own outgoing messages
984
+ if (row.direction !== MessageDirection.OUTGOING) {
985
+ return false;
986
+ }
987
+ if (!row.messageId || row.messageId.length !== MESSAGE_ID_SIZE) {
988
+ throw new Error('Cannot edit a message that has no valid messageId');
989
+ }
990
+ const ownerUserId = this.session.userIdEncoded;
991
+ // Merge existing metadata with edited flag
992
+ const existingMetadata = deserializeMetadata(row.metadata) ?? {};
993
+ const mergedMetadata = {
994
+ ...existingMetadata,
995
+ edited: true,
996
+ };
997
+ // Update the original message content locally, preserving timestamp
998
+ await this.queries.messages.updateById(id, {
999
+ content: newContent,
1000
+ metadata: serializeMetadata(mergedMetadata),
1001
+ });
1002
+ // Enqueue an edit control message to notify the peer
1003
+ const controlMessage = {
1004
+ ownerUserId,
1005
+ contactUserId: row.contactUserId,
1006
+ content: newContent,
1007
+ type: MessageType.TEXT,
1008
+ direction: MessageDirection.OUTGOING,
1009
+ status: MessageStatus.WAITING_SESSION,
1010
+ timestamp: new Date(),
1011
+ editOf: {
1012
+ originalMsgId: row.messageId,
1013
+ },
1014
+ metadata: {
1015
+ control: 'edit',
1016
+ },
1017
+ };
1018
+ const result = await this.send(controlMessage);
1019
+ if (!result.success) {
1020
+ throw new Error(result.error ?? 'Failed to enqueue edit message');
1021
+ }
1022
+ await this.refreshService?.stateUpdate();
1023
+ return true;
1024
+ }
820
1025
  // Mark a message as read. Returns true if the message has been marked as read, false if it was already marked as read or doesn't exist.
821
1026
  async markAsRead(id) {
822
1027
  // Check current message status from DB to avoid race conditions
@@ -7,6 +7,8 @@
7
7
  import { MessageType } from '../db/index.js';
8
8
  import { MessageType as ProtoMessageType } from '../proto/generated/message.js';
9
9
  export declare const MESSAGE_TYPE_KEEP_ALIVE = ProtoMessageType.MESSAGE_TYPE_KEEP_ALIVE;
10
+ export declare const MESSAGE_TYPE_DELETE = ProtoMessageType.MESSAGE_TYPE_DELETE;
11
+ export declare const MESSAGE_TYPE_EDIT = ProtoMessageType.MESSAGE_TYPE_EDIT;
10
12
  export interface DeserializedMessage {
11
13
  content: string;
12
14
  messageId?: Uint8Array;
@@ -17,6 +19,12 @@ export interface DeserializedMessage {
17
19
  originalContent: string;
18
20
  originalContactId?: Uint8Array;
19
21
  };
22
+ deleteOf?: {
23
+ originalMsgId: Uint8Array;
24
+ };
25
+ editOf?: {
26
+ originalMsgId: Uint8Array;
27
+ };
20
28
  type: MessageType;
21
29
  }
22
30
  /**
@@ -57,6 +65,28 @@ export declare function serializeReplyMessage(newContent: string, originalMsgId:
57
65
  * @returns Serialized forward message bytes
58
66
  */
59
67
  export declare function serializeForwardMessage(forwardedContent: string, newContent: string, messageId: Uint8Array, originalContactId?: Uint8Array): Uint8Array;
68
+ /**
69
+ * Serialize a delete control message
70
+ *
71
+ * Format: protobuf Message with citedMsgId set and delete type
72
+ *
73
+ * @param originalMsgId - The messageId of the message being deleted
74
+ * @param messageId - 12-byte random message ID for the control message
75
+ * @returns Serialized delete message bytes
76
+ */
77
+ export declare function serializeDeleteMessage(originalMsgId: Uint8Array, messageId: Uint8Array): Uint8Array;
78
+ /**
79
+ * Serialize an edit control message
80
+ *
81
+ * Format: protobuf Message with citedMsgId set to the original messageId and
82
+ * content carrying the new text.
83
+ *
84
+ * @param newContent - The updated message content
85
+ * @param originalMsgId - The messageId of the message being edited
86
+ * @param messageId - 12-byte random message ID for the control message
87
+ * @returns Serialized edit message bytes
88
+ */
89
+ export declare function serializeEditMessage(newContent: string, originalMsgId: Uint8Array, messageId: Uint8Array): Uint8Array;
60
90
  /**
61
91
  * Deserialize a message from bytes
62
92
  *
@@ -7,6 +7,8 @@
7
7
  import { MessageType, MESSAGE_ID_SIZE } from '../db/index.js';
8
8
  import { Message as ProtoMessage, MessageType as ProtoMessageType, } from '../proto/generated/message.js';
9
9
  export const MESSAGE_TYPE_KEEP_ALIVE = ProtoMessageType.MESSAGE_TYPE_KEEP_ALIVE;
10
+ export const MESSAGE_TYPE_DELETE = ProtoMessageType.MESSAGE_TYPE_DELETE;
11
+ export const MESSAGE_TYPE_EDIT = ProtoMessageType.MESSAGE_TYPE_EDIT;
10
12
  /**
11
13
  * Serialize a keep-alive message
12
14
  * Keep-alive messages are used to maintain session activity
@@ -86,6 +88,54 @@ export function serializeForwardMessage(forwardedContent, newContent, messageId,
86
88
  forwardedContent,
87
89
  });
88
90
  }
91
+ /**
92
+ * Serialize a delete control message
93
+ *
94
+ * Format: protobuf Message with citedMsgId set and delete type
95
+ *
96
+ * @param originalMsgId - The messageId of the message being deleted
97
+ * @param messageId - 12-byte random message ID for the control message
98
+ * @returns Serialized delete message bytes
99
+ */
100
+ export function serializeDeleteMessage(originalMsgId, messageId) {
101
+ if (messageId.length !== MESSAGE_ID_SIZE) {
102
+ throw new Error(`messageId must be ${MESSAGE_ID_SIZE} bytes`);
103
+ }
104
+ if (originalMsgId.length !== MESSAGE_ID_SIZE) {
105
+ throw new Error(`originalMsgId must be ${MESSAGE_ID_SIZE} bytes`);
106
+ }
107
+ return ProtoMessage.encode({
108
+ messageType: ProtoMessageType.MESSAGE_TYPE_DELETE,
109
+ messageId,
110
+ content: '',
111
+ citedMsgId: originalMsgId,
112
+ });
113
+ }
114
+ /**
115
+ * Serialize an edit control message
116
+ *
117
+ * Format: protobuf Message with citedMsgId set to the original messageId and
118
+ * content carrying the new text.
119
+ *
120
+ * @param newContent - The updated message content
121
+ * @param originalMsgId - The messageId of the message being edited
122
+ * @param messageId - 12-byte random message ID for the control message
123
+ * @returns Serialized edit message bytes
124
+ */
125
+ export function serializeEditMessage(newContent, originalMsgId, messageId) {
126
+ if (messageId.length !== MESSAGE_ID_SIZE) {
127
+ throw new Error(`messageId must be ${MESSAGE_ID_SIZE} bytes`);
128
+ }
129
+ if (originalMsgId.length !== MESSAGE_ID_SIZE) {
130
+ throw new Error(`originalMsgId must be ${MESSAGE_ID_SIZE} bytes`);
131
+ }
132
+ return ProtoMessage.encode({
133
+ messageType: ProtoMessageType.MESSAGE_TYPE_EDIT,
134
+ messageId,
135
+ content: newContent,
136
+ citedMsgId: originalMsgId,
137
+ });
138
+ }
89
139
  /**
90
140
  * Deserialize a message from bytes
91
141
  *
@@ -109,6 +159,8 @@ export function deserializeMessage(buffer) {
109
159
  const messageId = decoded.messageId;
110
160
  const citedMsgId = decoded.citedMsgId;
111
161
  let replyTo = undefined;
162
+ let deleteOf = undefined;
163
+ let editOf = undefined;
112
164
  if (protoType === ProtoMessageType.MESSAGE_TYPE_REPLY) {
113
165
  if (citedMsgId && citedMsgId.length === MESSAGE_ID_SIZE) {
114
166
  replyTo = {
@@ -119,6 +171,26 @@ export function deserializeMessage(buffer) {
119
171
  throw new Error(`invalid message format: message of type reply but citedMsgId empty or not correct size (${MESSAGE_ID_SIZE})`);
120
172
  }
121
173
  }
174
+ if (protoType === ProtoMessageType.MESSAGE_TYPE_DELETE) {
175
+ if (citedMsgId && citedMsgId.length === MESSAGE_ID_SIZE) {
176
+ deleteOf = {
177
+ originalMsgId: citedMsgId,
178
+ };
179
+ }
180
+ else {
181
+ throw new Error(`invalid message format: message of type delete but citedMsgId empty or not correct size (${MESSAGE_ID_SIZE})`);
182
+ }
183
+ }
184
+ if (protoType === ProtoMessageType.MESSAGE_TYPE_EDIT) {
185
+ if (citedMsgId && citedMsgId.length === MESSAGE_ID_SIZE) {
186
+ editOf = {
187
+ originalMsgId: citedMsgId,
188
+ };
189
+ }
190
+ else {
191
+ throw new Error(`invalid message format: message of type edit but citedMsgId empty or not correct size (${MESSAGE_ID_SIZE})`);
192
+ }
193
+ }
122
194
  let forwardOf = undefined;
123
195
  if (protoType === ProtoMessageType.MESSAGE_TYPE_FORWARD) {
124
196
  if (decoded.forwardedContent &&
@@ -138,6 +210,10 @@ export function deserializeMessage(buffer) {
138
210
  messageId,
139
211
  replyTo,
140
212
  forwardOf,
141
- type: MessageType.TEXT,
213
+ deleteOf,
214
+ editOf,
215
+ type: protoType === ProtoMessageType.MESSAGE_TYPE_DELETE
216
+ ? MessageType.DELETED
217
+ : MessageType.TEXT,
142
218
  };
143
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/gossip-sdk",
3
- "version": "0.0.2-dev.20260313034537",
3
+ "version": "0.0.2-dev.20260313145228",
4
4
  "description": "Gossip SDK for automation, chatbot, and integration use cases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",