@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 +7 -1
- package/dist/db/db.d.ts +8 -1
- package/dist/db/db.js +1 -0
- package/dist/db/generated-migrations.js +7 -1
- package/dist/db/queries/messages.d.ts +1 -0
- package/dist/db/queries/messages.js +19 -3
- package/dist/db/schema/messages.d.ts +38 -0
- package/dist/db/schema/messages.js +2 -0
- package/dist/proto/generated/message.d.ts +3 -1
- package/dist/proto/generated/message.js +2 -0
- package/dist/services/message.d.ts +17 -1
- package/dist/services/message.js +207 -2
- package/dist/utils/messageSerialization.d.ts +30 -0
- package/dist/utils/messageSerialization.js +77 -1
- package/package.json +1 -1
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),
|
|
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
|
}
|
package/dist/services/message.js
CHANGED
|
@@ -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
|
-
|
|
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