@massalabs/gossip-sdk 0.0.2-dev.20260417075224 → 0.0.2-dev.20260420074041
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/SdkEventEmitter.d.ts +9 -1
- package/dist/core/SdkEventEmitter.js +2 -0
- package/dist/db/queries/activeSeekers.js +3 -3
- package/dist/db/queries/discussions.d.ts +7 -5
- package/dist/db/queries/discussions.js +18 -8
- package/dist/db/queries/messages.d.ts +3 -3
- package/dist/db/queries/messages.js +4 -4
- package/dist/db/sqlite.d.ts +2 -0
- package/dist/services/discussion.js +1 -1
- package/dist/services/message.d.ts +4 -23
- package/dist/services/message.js +273 -323
- package/dist/utils/message.d.ts +2 -0
- package/dist/utils/message.js +9 -0
- package/package.json +1 -1
|
@@ -8,6 +8,8 @@ export declare enum SdkEventType {
|
|
|
8
8
|
MESSAGE_SENT = "messageSent",
|
|
9
9
|
MESSAGE_READ = "messageRead",
|
|
10
10
|
MESSAGE_FAILED = "messageFailed",
|
|
11
|
+
MESSAGE_DELETED = "messageDeleted",
|
|
12
|
+
MESSAGE_UPDATED = "messageUpdated",
|
|
11
13
|
SESSION_REQUESTED = "sessionRequested",
|
|
12
14
|
SESSION_CREATED = "sessionCreated",
|
|
13
15
|
SESSION_RENEWED = "sessionRenewed",
|
|
@@ -28,6 +30,12 @@ export type SdkEvents = {
|
|
|
28
30
|
message: Message;
|
|
29
31
|
error: Error;
|
|
30
32
|
};
|
|
33
|
+
[SdkEventType.MESSAGE_DELETED]: {
|
|
34
|
+
messages: Message[];
|
|
35
|
+
};
|
|
36
|
+
[SdkEventType.MESSAGE_UPDATED]: {
|
|
37
|
+
messages: Message[];
|
|
38
|
+
};
|
|
31
39
|
[SdkEventType.SESSION_REQUESTED]: {
|
|
32
40
|
discussion: Discussion;
|
|
33
41
|
contact: Contact;
|
|
@@ -40,7 +48,7 @@ export type SdkEvents = {
|
|
|
40
48
|
contactUserId: string;
|
|
41
49
|
status: SessionStatus;
|
|
42
50
|
};
|
|
43
|
-
[SdkEventType.DISCUSSION_UPDATED]:
|
|
51
|
+
[SdkEventType.DISCUSSION_UPDATED]: number;
|
|
44
52
|
[SdkEventType.MESSAGE_ACKNOWLEDGED]: {
|
|
45
53
|
contactUserId: string;
|
|
46
54
|
messageDbId: number;
|
|
@@ -8,6 +8,8 @@ export var SdkEventType;
|
|
|
8
8
|
SdkEventType["MESSAGE_SENT"] = "messageSent";
|
|
9
9
|
SdkEventType["MESSAGE_READ"] = "messageRead";
|
|
10
10
|
SdkEventType["MESSAGE_FAILED"] = "messageFailed";
|
|
11
|
+
SdkEventType["MESSAGE_DELETED"] = "messageDeleted";
|
|
12
|
+
SdkEventType["MESSAGE_UPDATED"] = "messageUpdated";
|
|
11
13
|
SdkEventType["SESSION_REQUESTED"] = "sessionRequested";
|
|
12
14
|
SdkEventType["SESSION_CREATED"] = "sessionCreated";
|
|
13
15
|
SdkEventType["SESSION_RENEWED"] = "sessionRenewed";
|
|
@@ -9,10 +9,10 @@ export class ActiveSeekerQueries {
|
|
|
9
9
|
});
|
|
10
10
|
}
|
|
11
11
|
async replaceAll(seekers) {
|
|
12
|
-
await this.conn.
|
|
13
|
-
await
|
|
12
|
+
await this.conn.db.transaction(async (tx) => {
|
|
13
|
+
await tx.delete(schema.activeSeekers);
|
|
14
14
|
if (seekers.length > 0) {
|
|
15
|
-
await
|
|
15
|
+
await tx
|
|
16
16
|
.insert(schema.activeSeekers)
|
|
17
17
|
.values(seekers.map(seeker => ({ seeker })));
|
|
18
18
|
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import * as schema from '../schema/index.js';
|
|
2
|
-
import type { DatabaseConnection } from '../sqlite.js';
|
|
2
|
+
import type { DatabaseConnection, GossipSqliteTx } from '../sqlite.js';
|
|
3
|
+
import type { MessageRow } from './messages.js';
|
|
3
4
|
export type DiscussionRow = typeof schema.discussions.$inferSelect;
|
|
4
5
|
export type DiscussionInsert = typeof schema.discussions.$inferInsert;
|
|
5
6
|
export declare class DiscussionQueries {
|
|
6
7
|
private conn;
|
|
7
8
|
constructor(conn: DatabaseConnection);
|
|
8
|
-
getByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<DiscussionRow | undefined>;
|
|
9
|
+
getByOwnerAndContact(ownerUserId: string, contactUserId: string, tx?: GossipSqliteTx): Promise<DiscussionRow | undefined>;
|
|
9
10
|
getByOwner(ownerUserId: string): Promise<DiscussionRow[]>;
|
|
10
11
|
getById(id: number): Promise<DiscussionRow | undefined>;
|
|
12
|
+
getLastTextMessage(contactUserId: string, tx?: GossipSqliteTx): Promise<MessageRow | undefined>;
|
|
11
13
|
insert(values: DiscussionInsert): Promise<number>;
|
|
12
|
-
updateById(id: number, data: Partial<DiscussionInsert
|
|
14
|
+
updateById(id: number, data: Partial<DiscussionInsert>, tx?: GossipSqliteTx): Promise<void>;
|
|
13
15
|
updateByOwnerAndContact(ownerUserId: string, contactUserId: string, data: Partial<DiscussionInsert>): Promise<void>;
|
|
14
16
|
deleteById(id: number): Promise<void>;
|
|
15
17
|
deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
|
|
16
|
-
incrementUnreadCount(discussionId: number): Promise<void>;
|
|
17
|
-
decrementUnreadCount(discussionId: number): Promise<void>;
|
|
18
|
+
incrementUnreadCount(discussionId: number, tx?: GossipSqliteTx): Promise<void>;
|
|
19
|
+
decrementUnreadCount(discussionId: number, tx?: GossipSqliteTx): Promise<void>;
|
|
18
20
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { eq, and, gt, sql } from 'drizzle-orm';
|
|
2
2
|
import * as schema from '../schema/index.js';
|
|
3
|
+
import { MessageType } from '../db.js';
|
|
3
4
|
export class DiscussionQueries {
|
|
4
5
|
constructor(conn) {
|
|
5
6
|
Object.defineProperty(this, "conn", {
|
|
@@ -9,8 +10,8 @@ export class DiscussionQueries {
|
|
|
9
10
|
value: conn
|
|
10
11
|
});
|
|
11
12
|
}
|
|
12
|
-
async getByOwnerAndContact(ownerUserId, contactUserId) {
|
|
13
|
-
return this.conn.db
|
|
13
|
+
async getByOwnerAndContact(ownerUserId, contactUserId, tx) {
|
|
14
|
+
return (tx ?? this.conn.db)
|
|
14
15
|
.select()
|
|
15
16
|
.from(schema.discussions)
|
|
16
17
|
.where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.contactUserId, contactUserId)))
|
|
@@ -30,12 +31,21 @@ export class DiscussionQueries {
|
|
|
30
31
|
.where(eq(schema.discussions.id, id))
|
|
31
32
|
.get();
|
|
32
33
|
}
|
|
34
|
+
async getLastTextMessage(contactUserId, tx) {
|
|
35
|
+
return (tx ?? this.conn.db)
|
|
36
|
+
.select()
|
|
37
|
+
.from(schema.messages)
|
|
38
|
+
.where(and(eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.type, MessageType.TEXT)))
|
|
39
|
+
.orderBy(sql `${schema.messages.timestamp} DESC`)
|
|
40
|
+
.limit(1)
|
|
41
|
+
.get();
|
|
42
|
+
}
|
|
33
43
|
async insert(values) {
|
|
34
44
|
await this.conn.db.insert(schema.discussions).values(values);
|
|
35
45
|
return this.conn.getLastInsertRowId();
|
|
36
46
|
}
|
|
37
|
-
async updateById(id, data) {
|
|
38
|
-
await this.conn.db
|
|
47
|
+
async updateById(id, data, tx) {
|
|
48
|
+
await (tx ?? this.conn.db)
|
|
39
49
|
.update(schema.discussions)
|
|
40
50
|
.set(data)
|
|
41
51
|
.where(eq(schema.discussions.id, id));
|
|
@@ -56,16 +66,16 @@ export class DiscussionQueries {
|
|
|
56
66
|
.delete(schema.discussions)
|
|
57
67
|
.where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.contactUserId, contactUserId)));
|
|
58
68
|
}
|
|
59
|
-
async incrementUnreadCount(discussionId) {
|
|
60
|
-
await this.conn.db
|
|
69
|
+
async incrementUnreadCount(discussionId, tx) {
|
|
70
|
+
await (tx ?? this.conn.db)
|
|
61
71
|
.update(schema.discussions)
|
|
62
72
|
.set({
|
|
63
73
|
unreadCount: sql `${schema.discussions.unreadCount} + 1`,
|
|
64
74
|
})
|
|
65
75
|
.where(eq(schema.discussions.id, discussionId));
|
|
66
76
|
}
|
|
67
|
-
async decrementUnreadCount(discussionId) {
|
|
68
|
-
await this.conn.db
|
|
77
|
+
async decrementUnreadCount(discussionId, tx) {
|
|
78
|
+
await (tx ?? this.conn.db)
|
|
69
79
|
.update(schema.discussions)
|
|
70
80
|
.set({
|
|
71
81
|
unreadCount: sql `MAX(${schema.discussions.unreadCount} - 1, 0)`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DiscussionRow } from './discussions.js';
|
|
2
2
|
import * as schema from '../schema/index.js';
|
|
3
|
-
import type { DatabaseConnection } from '../sqlite.js';
|
|
3
|
+
import type { DatabaseConnection, GossipSqliteTx } from '../sqlite.js';
|
|
4
4
|
import { MessageStatus } from '../../db/db.js';
|
|
5
5
|
export type MessageRow = typeof schema.messages.$inferSelect;
|
|
6
6
|
export type MessageInsert = typeof schema.messages.$inferInsert;
|
|
@@ -14,9 +14,9 @@ export declare class MessageQueries {
|
|
|
14
14
|
getReactionsByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
|
|
15
15
|
getByOwnerAndSeeker(ownerUserId: string, seeker: Uint8Array): Promise<MessageRow | undefined>;
|
|
16
16
|
findByMessageId(ownerUserId: string, contactUserId: string, messageId: Uint8Array): Promise<MessageRow | undefined>;
|
|
17
|
-
insert(values: MessageInsert): Promise<number>;
|
|
17
|
+
insert(values: MessageInsert, tx?: GossipSqliteTx): Promise<number>;
|
|
18
18
|
batchInsert(values: MessageInsert[]): Promise<void>;
|
|
19
|
-
updateById(id: number, data: Partial<MessageInsert
|
|
19
|
+
updateById(id: number, data: Partial<MessageInsert>, tx?: GossipSqliteTx): Promise<void>;
|
|
20
20
|
deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
|
|
21
21
|
deleteById(id: number): Promise<void>;
|
|
22
22
|
deleteReactionsForMessage(ownerUserId: string, contactUserId: string, messageIdBase64: string): Promise<void>;
|
|
@@ -74,8 +74,8 @@ export class MessageQueries {
|
|
|
74
74
|
.where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.messageId, messageId)))
|
|
75
75
|
.get();
|
|
76
76
|
}
|
|
77
|
-
async insert(values) {
|
|
78
|
-
await this.conn.db.insert(schema.messages).values(values);
|
|
77
|
+
async insert(values, tx) {
|
|
78
|
+
await (tx ?? this.conn.db).insert(schema.messages).values(values);
|
|
79
79
|
return this.conn.getLastInsertRowId();
|
|
80
80
|
}
|
|
81
81
|
async batchInsert(values) {
|
|
@@ -83,8 +83,8 @@ export class MessageQueries {
|
|
|
83
83
|
return;
|
|
84
84
|
await this.conn.db.insert(schema.messages).values(values);
|
|
85
85
|
}
|
|
86
|
-
async updateById(id, data) {
|
|
87
|
-
await this.conn.db
|
|
86
|
+
async updateById(id, data, tx) {
|
|
87
|
+
await (tx ?? this.conn.db)
|
|
88
88
|
.update(schema.messages)
|
|
89
89
|
.set(data)
|
|
90
90
|
.where(eq(schema.messages.id, id));
|
package/dist/db/sqlite.d.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { type SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
|
|
15
15
|
import * as schema from './schema/index.js';
|
|
16
16
|
export type GossipDatabase = SqliteRemoteDatabase<typeof schema>;
|
|
17
|
+
/** Callback `tx` from `GossipDatabase.transaction()` — pass through query helpers for the same API as `db`. */
|
|
18
|
+
export type GossipSqliteTx = Parameters<Parameters<GossipDatabase['transaction']>[0]>[0];
|
|
17
19
|
/** Selects the SQLite storage backend. */
|
|
18
20
|
export type StorageConfig = {
|
|
19
21
|
type: 'opfs';
|
|
@@ -275,7 +275,7 @@ export class DiscussionService {
|
|
|
275
275
|
const result = await this.initialize(contact, payload);
|
|
276
276
|
if (result.success) {
|
|
277
277
|
await this.refreshService?.stateUpdate();
|
|
278
|
-
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED,
|
|
278
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, result.data.discussionId);
|
|
279
279
|
}
|
|
280
280
|
return result;
|
|
281
281
|
}
|
|
@@ -14,6 +14,7 @@ import { SdkEventEmitter } from '../core/SdkEventEmitter.js';
|
|
|
14
14
|
import type { RefreshService } from './refresh.js';
|
|
15
15
|
import { Queries } from '../db/queries/index.js';
|
|
16
16
|
import { QueueManager } from '../utils/queue.js';
|
|
17
|
+
import { GossipSqliteTx } from '../db/sqlite.js';
|
|
17
18
|
/** Options for the simplified sendText method */
|
|
18
19
|
export interface SendTextOptions {
|
|
19
20
|
/** Reply to an existing message */
|
|
@@ -71,27 +72,9 @@ export declare class MessageService {
|
|
|
71
72
|
*/
|
|
72
73
|
private handleDuplicateMessageId;
|
|
73
74
|
findMessageByMsgId(messageId: Uint8Array, ownerUserId: string, contactUserId?: string): Promise<Message | undefined>;
|
|
74
|
-
findMessageBySeeker(seeker: Uint8Array, ownerUserId: string): Promise<Message | undefined>;
|
|
75
75
|
private acknowledgeMessages;
|
|
76
|
-
sendMessage(message: Message): Promise<SendMessageResult>;
|
|
77
|
-
/**
|
|
78
|
-
* Happy-path send: peer session is Active, so we can encrypt locally,
|
|
79
|
-
* fire the network POST, and INSERT the row in parallel. The three
|
|
80
|
-
* pieces are independent up until the final `UPDATE → SENT`, which
|
|
81
|
-
* needs both the row id (from INSERT) and a successful network ack.
|
|
82
|
-
*
|
|
83
|
-
* ┌── INSERT (~440 ms) ───────────────┐
|
|
84
|
-
* │ ├── UPDATE → SENT (background)
|
|
85
|
-
* └── encrypt (~150 ms) → POST (~620 ms)
|
|
86
|
-
* │
|
|
87
|
-
* └── emit MESSAGE_SENT
|
|
88
|
-
*
|
|
89
|
-
* Returns `null` if the fast path can't run (encrypt declined, network
|
|
90
|
-
* threw, etc.) so the caller can fall back to the slow path.
|
|
91
|
-
*/
|
|
92
|
-
private sendMessageFastPath;
|
|
76
|
+
sendMessage(message: Message, parentTx?: GossipSqliteTx): Promise<SendMessageResult>;
|
|
93
77
|
private serializeMessage;
|
|
94
|
-
resendMessages(messages: Map<string, Message[]>): Promise<void>;
|
|
95
78
|
/**
|
|
96
79
|
* Process the send queue for a single contact.
|
|
97
80
|
* Handles WAITING_SESSION -> READY encryption and READY -> SENT delivery.
|
|
@@ -101,10 +84,6 @@ export declare class MessageService {
|
|
|
101
84
|
* Count pending outgoing messages for a contact (WAITING_SESSION/READY).
|
|
102
85
|
*/
|
|
103
86
|
getPendingSendCount(contactUserId: string): Promise<number>;
|
|
104
|
-
/**
|
|
105
|
-
* Get count of messages waiting for session with a specific contact.
|
|
106
|
-
*/
|
|
107
|
-
getWaitingMessageCount(contactUserId: string): Promise<number>;
|
|
108
87
|
/** Get a message by its database ID */
|
|
109
88
|
get(id: number): Promise<Message | undefined>;
|
|
110
89
|
/** Get all messages for a contact (using session owner).
|
|
@@ -124,6 +103,7 @@ export declare class MessageService {
|
|
|
124
103
|
sendText(contactUserId: string, text: string, options?: SendTextOptions): Promise<SendMessageResult>;
|
|
125
104
|
/** Fetch and decrypt messages from the protocol (alias) */
|
|
126
105
|
fetch(): Promise<MessageResult>;
|
|
106
|
+
private PerformDeleteMessage;
|
|
127
107
|
/**
|
|
128
108
|
* Delete a message by its database ID (outgoing or incoming in 1-to-1).
|
|
129
109
|
* Marks the local message as deleted and enqueues a delete control message
|
|
@@ -133,6 +113,7 @@ export declare class MessageService {
|
|
|
133
113
|
*/
|
|
134
114
|
deleteMessage(id: number): Promise<boolean>;
|
|
135
115
|
sendReaction(contactUserId: string, emoji: string, originalMsgId: Uint8Array): Promise<SendMessageResult>;
|
|
116
|
+
private performEditMessage;
|
|
136
117
|
/**
|
|
137
118
|
* Edit an outgoing message by its database ID.
|
|
138
119
|
* Updates the local content (preserving timestamp) and enqueues an edit
|
package/dist/services/message.js
CHANGED
|
@@ -8,11 +8,14 @@ import { MessageDirection, MessageStatus, MessageType, MESSAGE_ID_SIZE, } from '
|
|
|
8
8
|
import { decodeUserId, encodeUserId } from '../utils/userId.js';
|
|
9
9
|
import { SessionStatus } from '../wasm/bindings.js';
|
|
10
10
|
import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, serializeDeleteMessage, serializeEditMessage, serializeReactionMessage, serializeRetentionPolicyMessage, deserializeMessage, } from '../utils/messageSerialization.js';
|
|
11
|
+
import * as schema from '../db/schema/index.js';
|
|
11
12
|
import { encodeToBase64, decodeFromBase64 } from '../utils/base64.js';
|
|
12
13
|
import { sessionStatusToString } from '../wasm/session.js';
|
|
13
14
|
import { Logger } from '../utils/logs.js';
|
|
14
15
|
import { defaultSdkConfig } from '../config/sdk.js';
|
|
15
16
|
import { SdkEventType } from '../core/SdkEventEmitter.js';
|
|
17
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
18
|
+
import { POST_MESSAGE_TYPES } from '../utils/message.js';
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
17
20
|
// JSON serialization helpers for message fields stored as text in SQLite
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
@@ -309,8 +312,9 @@ export class MessageService {
|
|
|
309
312
|
/**
|
|
310
313
|
* Add a message to SQLite and update the corresponding discussion.
|
|
311
314
|
*/
|
|
312
|
-
async addMessageAndUpdateDiscussion(message) {
|
|
313
|
-
|
|
315
|
+
async addMessageAndUpdateDiscussion(message, parentTx) {
|
|
316
|
+
const db = parentTx ?? this.queries.conn.db;
|
|
317
|
+
const result = await db.transaction(async (tx) => {
|
|
314
318
|
const messageId = await this.queries.messages.insert({
|
|
315
319
|
messageId: message.messageId,
|
|
316
320
|
ownerUserId: message.ownerUserId,
|
|
@@ -330,24 +334,26 @@ export class MessageService {
|
|
|
330
334
|
reactionOf: serializeReactionOf(message.reactionOf),
|
|
331
335
|
encryptedMessage: message.encryptedMessage,
|
|
332
336
|
whenToSend: message.whenToSend,
|
|
333
|
-
});
|
|
334
|
-
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
|
|
335
|
-
if (discussion &&
|
|
336
|
-
message.type !== MessageType.KEEP_ALIVE &&
|
|
337
|
-
message.type !== MessageType.REACTION &&
|
|
338
|
-
message.type !== MessageType.RETENTION_POLICY) {
|
|
337
|
+
}, tx);
|
|
338
|
+
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, tx);
|
|
339
|
+
if (discussion && POST_MESSAGE_TYPES.includes(message.type)) {
|
|
339
340
|
await this.queries.discussions.updateById(discussion.id, {
|
|
340
341
|
lastMessageId: messageId,
|
|
341
342
|
lastMessageContent: message.content,
|
|
342
343
|
lastMessageTimestamp: message.timestamp,
|
|
343
344
|
updatedAt: new Date(),
|
|
344
|
-
});
|
|
345
|
+
}, tx);
|
|
345
346
|
if (message.direction === MessageDirection.INCOMING) {
|
|
346
|
-
await this.queries.discussions.incrementUnreadCount(discussion.id);
|
|
347
|
+
await this.queries.discussions.incrementUnreadCount(discussion.id, tx);
|
|
347
348
|
}
|
|
349
|
+
return { messageId, updatedDiscussionId: discussion?.id };
|
|
348
350
|
}
|
|
349
|
-
return messageId;
|
|
351
|
+
return { messageId, updatedDiscussionId: null };
|
|
350
352
|
});
|
|
353
|
+
if (result.updatedDiscussionId) {
|
|
354
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, result.updatedDiscussionId);
|
|
355
|
+
}
|
|
356
|
+
return result.messageId;
|
|
351
357
|
}
|
|
352
358
|
async decryptMessages(encrypted) {
|
|
353
359
|
const log = logger.forMethod('decryptMessages');
|
|
@@ -419,34 +425,13 @@ export class MessageService {
|
|
|
419
425
|
});
|
|
420
426
|
continue;
|
|
421
427
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
this.emitMessageReceived({
|
|
426
|
-
...target,
|
|
427
|
-
type: MessageType.DELETED,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
else {
|
|
431
|
-
// Regular message delete: mark as deleted
|
|
432
|
-
this.emitMessageReceived({
|
|
433
|
-
...target,
|
|
434
|
-
content: '[Message deleted]',
|
|
435
|
-
type: MessageType.DELETED,
|
|
436
|
-
});
|
|
437
|
-
const targetId = target.id;
|
|
438
|
-
const deleteOriginalMsgId = message.deleteOf.originalMsgId;
|
|
439
|
-
await this.queries.conn.withTransaction(async () => {
|
|
440
|
-
await this.queries.messages.updateById(targetId, {
|
|
441
|
-
content: '[Message deleted]',
|
|
442
|
-
type: MessageType.DELETED,
|
|
443
|
-
});
|
|
444
|
-
await this.queries.messages.deleteReactionsForMessage(ownerUserId, target.contactUserId, encodeToBase64(deleteOriginalMsgId));
|
|
445
|
-
});
|
|
428
|
+
const resDb = await this.PerformDeleteMessage(target);
|
|
429
|
+
if (!resDb.success) {
|
|
430
|
+
throw new Error(resDb.error?.message ?? 'Failed to delete message from db');
|
|
446
431
|
}
|
|
447
432
|
continue;
|
|
448
433
|
}
|
|
449
|
-
// Handle
|
|
434
|
+
// Handle EDIT control messages by updating the referenced message in-place
|
|
450
435
|
if (message.editOf?.originalMsgId) {
|
|
451
436
|
const target = await this.findMessageByMsgId(message.editOf.originalMsgId, ownerUserId, message.senderId);
|
|
452
437
|
if (!target || !target.id) {
|
|
@@ -465,10 +450,10 @@ export class MessageService {
|
|
|
465
450
|
content: message.content,
|
|
466
451
|
metadata: mergedMetadata,
|
|
467
452
|
});
|
|
468
|
-
await this.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
453
|
+
const res = await this.performEditMessage(message.content, target, mergedMetadata);
|
|
454
|
+
if (!res.success) {
|
|
455
|
+
throw new Error(res.error?.message ?? 'Failed to edit message in db');
|
|
456
|
+
}
|
|
472
457
|
// Do not insert a new message row for edit control messages
|
|
473
458
|
continue;
|
|
474
459
|
}
|
|
@@ -482,7 +467,15 @@ export class MessageService {
|
|
|
482
467
|
messageRetentionDuration: duration,
|
|
483
468
|
retentionPolicySetAt: duration ? Date.now() : null,
|
|
484
469
|
});
|
|
485
|
-
this.
|
|
470
|
+
const discussion = await this.queries.discussions.getByOwnerAndContact(ownerUserId, message.senderId);
|
|
471
|
+
if (!discussion) {
|
|
472
|
+
this.eventEmitter.emit(SdkEventType.ERROR, {
|
|
473
|
+
error: new Error('could no retrieve discussion after updating retention policy'),
|
|
474
|
+
context: 'storeDecryptedMessages',
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussion.id);
|
|
486
479
|
// Do not insert a new message row for retention policy control messages
|
|
487
480
|
continue;
|
|
488
481
|
}
|
|
@@ -497,8 +490,7 @@ export class MessageService {
|
|
|
497
490
|
});
|
|
498
491
|
continue;
|
|
499
492
|
}
|
|
500
|
-
|
|
501
|
-
this.emitMessageReceived({
|
|
493
|
+
const id = await this.queries.messages.insert({
|
|
502
494
|
messageId: message.messageId,
|
|
503
495
|
ownerUserId,
|
|
504
496
|
contactUserId: discussion.contactUserId,
|
|
@@ -507,9 +499,11 @@ export class MessageService {
|
|
|
507
499
|
direction: MessageDirection.INCOMING,
|
|
508
500
|
status: MessageStatus.DELIVERED,
|
|
509
501
|
timestamp: message.sentAt,
|
|
510
|
-
|
|
502
|
+
metadata: serializeMetadata({}),
|
|
503
|
+
reactionOf: serializeReactionOf(message.reactionOf),
|
|
511
504
|
});
|
|
512
|
-
|
|
505
|
+
this.emitMessageReceived({
|
|
506
|
+
id,
|
|
513
507
|
messageId: message.messageId,
|
|
514
508
|
ownerUserId,
|
|
515
509
|
contactUserId: discussion.contactUserId,
|
|
@@ -518,8 +512,7 @@ export class MessageService {
|
|
|
518
512
|
direction: MessageDirection.INCOMING,
|
|
519
513
|
status: MessageStatus.DELIVERED,
|
|
520
514
|
timestamp: message.sentAt,
|
|
521
|
-
|
|
522
|
-
reactionOf: serializeReactionOf(message.reactionOf),
|
|
515
|
+
reactionOf: message.reactionOf,
|
|
523
516
|
});
|
|
524
517
|
storedIds.push(id);
|
|
525
518
|
// Do not update discussion lastMessageContent for reactions
|
|
@@ -536,7 +529,7 @@ export class MessageService {
|
|
|
536
529
|
// If received msg has same messageId as a previously received msg
|
|
537
530
|
const isDuplicate = await this.handleDuplicateMessageId(message, ownerUserId);
|
|
538
531
|
if (isDuplicate) {
|
|
539
|
-
log.info('Duplicate message received,
|
|
532
|
+
log.info('Duplicate message received, skip ping', {
|
|
540
533
|
senderId: message.senderId,
|
|
541
534
|
preview: message.content.slice(0, 30),
|
|
542
535
|
});
|
|
@@ -562,8 +555,6 @@ export class MessageService {
|
|
|
562
555
|
replyTo: message.replyTo,
|
|
563
556
|
forwardOf: message.forwardOf,
|
|
564
557
|
};
|
|
565
|
-
// Emit before DB write — UI shows message instantly
|
|
566
|
-
this.emitMessageReceived(incomingMsg);
|
|
567
558
|
const id = await this.queries.messages.insert({
|
|
568
559
|
messageId: message.messageId,
|
|
569
560
|
ownerUserId,
|
|
@@ -590,6 +581,7 @@ export class MessageService {
|
|
|
590
581
|
storedIds.push(id);
|
|
591
582
|
// Re-emit with DB id so the store patches the optimistic message
|
|
592
583
|
this.emitMessageReceived({ ...incomingMsg, id });
|
|
584
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussion.id);
|
|
593
585
|
}
|
|
594
586
|
return storedIds;
|
|
595
587
|
}
|
|
@@ -613,10 +605,6 @@ export class MessageService {
|
|
|
613
605
|
const row = await this.queries.messages.findByMessageId(ownerUserId, contactUserId, messageId);
|
|
614
606
|
return row ? rowToMessage(row) : undefined;
|
|
615
607
|
}
|
|
616
|
-
async findMessageBySeeker(seeker, ownerUserId) {
|
|
617
|
-
const row = await this.queries.messages.getByOwnerAndSeeker(ownerUserId, seeker);
|
|
618
|
-
return row ? rowToMessage(row) : undefined;
|
|
619
|
-
}
|
|
620
608
|
async acknowledgeMessages(seekers, userId) {
|
|
621
609
|
if (seekers.size === 0)
|
|
622
610
|
return;
|
|
@@ -645,7 +633,7 @@ export class MessageService {
|
|
|
645
633
|
.info(`acknowledged ${toUpdate.length} messages`);
|
|
646
634
|
}
|
|
647
635
|
}
|
|
648
|
-
async sendMessage(message) {
|
|
636
|
+
async sendMessage(message, parentTx) {
|
|
649
637
|
const log = logger.forMethod('sendMessage');
|
|
650
638
|
log.info('queueing message', {
|
|
651
639
|
messageType: message.type,
|
|
@@ -658,7 +646,7 @@ export class MessageService {
|
|
|
658
646
|
};
|
|
659
647
|
}
|
|
660
648
|
// Look up discussion
|
|
661
|
-
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
|
|
649
|
+
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, parentTx);
|
|
662
650
|
if (!discussion) {
|
|
663
651
|
return { success: false, error: 'Discussion not found' };
|
|
664
652
|
}
|
|
@@ -671,29 +659,12 @@ export class MessageService {
|
|
|
671
659
|
: undefined;
|
|
672
660
|
message.messageId = randomMessageId;
|
|
673
661
|
}
|
|
674
|
-
|
|
675
|
-
// and ship the message in parallel with the local INSERT instead of
|
|
676
|
-
// waiting for `addMessageAndUpdateDiscussion` to commit before
|
|
677
|
-
// `processSendQueueForContact` even starts. The user-perceived
|
|
678
|
-
// latency drops from `INSERT + encrypt + POST` (sequential) to
|
|
679
|
-
// `max(INSERT, encrypt + POST)` — ~440 ms saved on prod where the
|
|
680
|
-
// network round-trip dominates.
|
|
681
|
-
const sessionStatus = this.session.peerSessionStatus(peerId);
|
|
682
|
-
if (sessionStatus === SessionStatus.Active) {
|
|
683
|
-
const fastPathResult = await this.sendMessageFastPath(message, peerId);
|
|
684
|
-
if (fastPathResult) {
|
|
685
|
-
return fastPathResult;
|
|
686
|
-
}
|
|
687
|
-
// Fast path bailed (encrypt returned null, etc.) — fall back to
|
|
688
|
-
// the slow path below.
|
|
689
|
-
}
|
|
690
|
-
// Slow path: INSERT first, let stateUpdate handle the encrypt + send.
|
|
691
|
-
let messageId;
|
|
662
|
+
let messageIdDb;
|
|
692
663
|
try {
|
|
693
|
-
|
|
664
|
+
messageIdDb = await this.addMessageAndUpdateDiscussion({
|
|
694
665
|
...message,
|
|
695
666
|
status: MessageStatus.WAITING_SESSION,
|
|
696
|
-
});
|
|
667
|
+
}, parentTx);
|
|
697
668
|
}
|
|
698
669
|
catch (error) {
|
|
699
670
|
return {
|
|
@@ -701,186 +672,21 @@ export class MessageService {
|
|
|
701
672
|
error: 'Failed to add message to database, got error: ' + error,
|
|
702
673
|
};
|
|
703
674
|
}
|
|
704
|
-
const queuedMessage = {
|
|
705
|
-
...message,
|
|
706
|
-
id: messageId,
|
|
707
|
-
status: MessageStatus.WAITING_SESSION,
|
|
708
|
-
};
|
|
709
675
|
/*
|
|
710
|
-
Trigger a state update to send the new message.
|
|
711
|
-
If the
|
|
676
|
+
Trigger a sending queue state update for contact in order to send the new message.
|
|
677
|
+
If the processSendQueueForContact function is already running, it will be skipped.
|
|
712
678
|
*/
|
|
713
|
-
await this.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
message: queuedMessage,
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
/**
|
|
720
|
-
* Happy-path send: peer session is Active, so we can encrypt locally,
|
|
721
|
-
* fire the network POST, and INSERT the row in parallel. The three
|
|
722
|
-
* pieces are independent up until the final `UPDATE → SENT`, which
|
|
723
|
-
* needs both the row id (from INSERT) and a successful network ack.
|
|
724
|
-
*
|
|
725
|
-
* ┌── INSERT (~440 ms) ───────────────┐
|
|
726
|
-
* │ ├── UPDATE → SENT (background)
|
|
727
|
-
* └── encrypt (~150 ms) → POST (~620 ms)
|
|
728
|
-
* │
|
|
729
|
-
* └── emit MESSAGE_SENT
|
|
730
|
-
*
|
|
731
|
-
* Returns `null` if the fast path can't run (encrypt declined, network
|
|
732
|
-
* threw, etc.) so the caller can fall back to the slow path.
|
|
733
|
-
*/
|
|
734
|
-
async sendMessageFastPath(message, peerId) {
|
|
735
|
-
const log = logger.forMethod('sendMessageFastPath');
|
|
736
|
-
// 1. Serialize synchronously — needed before encrypt.
|
|
737
|
-
const serializeResult = await this.serializeMessage(message);
|
|
738
|
-
if (!serializeResult.success) {
|
|
739
|
-
log.error('failed to serialize message for fast path', {
|
|
740
|
-
error: serializeResult.error,
|
|
741
|
-
});
|
|
742
|
-
return null;
|
|
743
|
-
}
|
|
744
|
-
const serializedContent = serializeResult.data;
|
|
745
|
-
// 2. Kick off the local INSERT and the encrypt in parallel.
|
|
746
|
-
// encrypt is fast (~150 ms with rayon) and doesn't depend on
|
|
747
|
-
// the row id; INSERT is slow (~440 ms) and only the row id is
|
|
748
|
-
// later needed for the SENT update.
|
|
749
|
-
const insertPromise = this.addMessageAndUpdateDiscussion({
|
|
750
|
-
...message,
|
|
751
|
-
status: MessageStatus.WAITING_SESSION,
|
|
752
|
-
})
|
|
753
|
-
.then(id => {
|
|
754
|
-
this.inFlightFastPath.add(id);
|
|
755
|
-
return id;
|
|
756
|
-
})
|
|
757
|
-
.catch(error => {
|
|
758
|
-
log.error('addMessageAndUpdateDiscussion failed in fast path', {
|
|
759
|
-
error,
|
|
760
|
-
});
|
|
761
|
-
return null;
|
|
762
|
-
});
|
|
763
|
-
let sendOutput;
|
|
764
|
-
try {
|
|
765
|
-
sendOutput = await this.session.sendMessage(peerId, serializedContent);
|
|
766
|
-
}
|
|
767
|
-
catch (error) {
|
|
768
|
-
log.error('session.sendMessage threw in fast path, falling back', {
|
|
769
|
-
error,
|
|
770
|
-
});
|
|
771
|
-
sendOutput = undefined;
|
|
772
|
-
}
|
|
773
|
-
if (!sendOutput) {
|
|
774
|
-
// Session became inactive between status check and encrypt.
|
|
775
|
-
// Wait for the INSERT to land then bail out so the slow path
|
|
776
|
-
// can take over.
|
|
777
|
-
const messageId = await insertPromise;
|
|
778
|
-
if (messageId === null) {
|
|
779
|
-
return {
|
|
780
|
-
success: false,
|
|
781
|
-
error: 'Failed to add message to database',
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
log.info('encrypt returned null in fast path, falling back to slow path', { messageId });
|
|
785
|
-
this.inFlightFastPath.delete(messageId);
|
|
786
|
-
void this.refreshService?.stateUpdate();
|
|
787
|
-
return {
|
|
788
|
-
success: true,
|
|
789
|
-
message: {
|
|
790
|
-
...message,
|
|
791
|
-
id: messageId,
|
|
792
|
-
status: MessageStatus.WAITING_SESSION,
|
|
793
|
-
},
|
|
794
|
-
};
|
|
795
|
-
}
|
|
796
|
-
// 3. Encrypt succeeded. Network POST runs in parallel with the
|
|
797
|
-
// still-in-flight INSERT.
|
|
798
|
-
const networkPromise = this.messageProtocol
|
|
799
|
-
.sendMessage({
|
|
800
|
-
seeker: sendOutput.seeker,
|
|
801
|
-
ciphertext: sendOutput.data,
|
|
802
|
-
})
|
|
803
|
-
.then(() => true)
|
|
804
|
-
.catch(error => {
|
|
805
|
-
log.error('network send failed in fast path', { error });
|
|
806
|
-
return false;
|
|
807
|
-
});
|
|
808
|
-
const [messageId, networkOk] = await Promise.all([
|
|
809
|
-
insertPromise,
|
|
810
|
-
networkPromise,
|
|
811
|
-
]);
|
|
812
|
-
if (messageId === null) {
|
|
679
|
+
await this.processSendQueueForContact(message.contactUserId);
|
|
680
|
+
const messageDb = await this.queries.messages.getById(messageIdDb);
|
|
681
|
+
if (!messageDb) {
|
|
813
682
|
return {
|
|
814
683
|
success: false,
|
|
815
|
-
error: '
|
|
684
|
+
error: 'Could not retrieve message after adding it to the database',
|
|
816
685
|
};
|
|
817
686
|
}
|
|
818
|
-
if (!networkOk) {
|
|
819
|
-
// Network failed: persist READY so the next retry doesn't
|
|
820
|
-
// re-encrypt, and let stateUpdate pick it up.
|
|
821
|
-
this.inFlightFastPath.delete(messageId);
|
|
822
|
-
await this.queries.messages.updateById(messageId, {
|
|
823
|
-
status: MessageStatus.READY,
|
|
824
|
-
encryptedMessage: sendOutput.data,
|
|
825
|
-
seeker: sendOutput.seeker,
|
|
826
|
-
whenToSend: new Date(Date.now() + this.config.messages.retryDelayMs),
|
|
827
|
-
serializedContent,
|
|
828
|
-
});
|
|
829
|
-
void this.refreshService?.stateUpdate();
|
|
830
|
-
return {
|
|
831
|
-
success: true,
|
|
832
|
-
message: {
|
|
833
|
-
...message,
|
|
834
|
-
id: messageId,
|
|
835
|
-
status: MessageStatus.READY,
|
|
836
|
-
},
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
// 4. Both succeeded. Race-check then UPDATE → SENT.
|
|
840
|
-
const latestRow = await this.queries.messages.getById(messageId);
|
|
841
|
-
if (!latestRow || latestRow.status !== MessageStatus.WAITING_SESSION) {
|
|
842
|
-
log.debug('message gone or status changed during fast-path send, skipping SENT update', { messageId, currentStatus: latestRow?.status });
|
|
843
|
-
this.inFlightFastPath.delete(messageId);
|
|
844
|
-
return {
|
|
845
|
-
success: true,
|
|
846
|
-
message: {
|
|
847
|
-
...message,
|
|
848
|
-
id: messageId,
|
|
849
|
-
status: MessageStatus.SENT,
|
|
850
|
-
},
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
await this.queries.messages.updateById(messageId, {
|
|
854
|
-
status: MessageStatus.SENT,
|
|
855
|
-
seeker: sendOutput.seeker,
|
|
856
|
-
encryptedMessage: null,
|
|
857
|
-
serializedContent: null,
|
|
858
|
-
whenToSend: null,
|
|
859
|
-
});
|
|
860
|
-
this.inFlightFastPath.delete(messageId);
|
|
861
|
-
const isControlMessage = !!(message.deleteOf || message.editOf);
|
|
862
|
-
if (!isControlMessage) {
|
|
863
|
-
try {
|
|
864
|
-
this.eventEmitter.emit(SdkEventType.MESSAGE_SENT, {
|
|
865
|
-
...message,
|
|
866
|
-
id: messageId,
|
|
867
|
-
status: MessageStatus.SENT,
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
catch (error) {
|
|
871
|
-
log.error('failed to emit message sent event from fast path', {
|
|
872
|
-
messageId,
|
|
873
|
-
error,
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
687
|
return {
|
|
878
688
|
success: true,
|
|
879
|
-
message:
|
|
880
|
-
...message,
|
|
881
|
-
id: messageId,
|
|
882
|
-
status: MessageStatus.SENT,
|
|
883
|
-
},
|
|
689
|
+
message: rowToMessage(messageDb),
|
|
884
690
|
};
|
|
885
691
|
}
|
|
886
692
|
async serializeMessage(message) {
|
|
@@ -982,28 +788,6 @@ export class MessageService {
|
|
|
982
788
|
};
|
|
983
789
|
}
|
|
984
790
|
}
|
|
985
|
-
async resendMessages(messages) {
|
|
986
|
-
const log = logger.forMethod('resendMessages');
|
|
987
|
-
let totalProcessed = 0;
|
|
988
|
-
for (const [contactId, retryMessages] of messages.entries()) {
|
|
989
|
-
totalProcessed += retryMessages.length;
|
|
990
|
-
for (const msg of retryMessages) {
|
|
991
|
-
if (!msg.id)
|
|
992
|
-
continue;
|
|
993
|
-
await this.queries.messages.updateById(msg.id, {
|
|
994
|
-
status: MessageStatus.WAITING_SESSION,
|
|
995
|
-
encryptedMessage: null,
|
|
996
|
-
seeker: null,
|
|
997
|
-
whenToSend: null,
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
await this.processSendQueueForContact(contactId);
|
|
1001
|
-
}
|
|
1002
|
-
log.info('resend completed', {
|
|
1003
|
-
contacts: messages.size,
|
|
1004
|
-
messagesProcessed: totalProcessed,
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
791
|
/**
|
|
1008
792
|
* Process the send queue for a single contact.
|
|
1009
793
|
* Handles WAITING_SESSION -> READY encryption and READY -> SENT delivery.
|
|
@@ -1275,13 +1059,6 @@ export class MessageService {
|
|
|
1275
1059
|
const rows = await this.queries.messages.getSendQueue(ownerUserId, contactUserId);
|
|
1276
1060
|
return rows.length;
|
|
1277
1061
|
}
|
|
1278
|
-
/**
|
|
1279
|
-
* Get count of messages waiting for session with a specific contact.
|
|
1280
|
-
*/
|
|
1281
|
-
async getWaitingMessageCount(contactUserId) {
|
|
1282
|
-
const ownerUserId = this.session.userIdEncoded;
|
|
1283
|
-
return this.queries.messages.getWaitingCount(ownerUserId, contactUserId);
|
|
1284
|
-
}
|
|
1285
1062
|
// ─────────────────────────────────────────────────────────────────
|
|
1286
1063
|
// Consumer-facing convenience methods
|
|
1287
1064
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -1338,6 +1115,114 @@ export class MessageService {
|
|
|
1338
1115
|
async fetch() {
|
|
1339
1116
|
return this.fetchMessages();
|
|
1340
1117
|
}
|
|
1118
|
+
async PerformDeleteMessage(message, tx) {
|
|
1119
|
+
if (!message.id) {
|
|
1120
|
+
return { success: false, error: new Error('Message ID is required') };
|
|
1121
|
+
}
|
|
1122
|
+
const db = tx ?? this.queries.conn.db;
|
|
1123
|
+
if (message.type === MessageType.REACTION) {
|
|
1124
|
+
// Reaction delete: hard-delete the row, not "[Message deleted]"
|
|
1125
|
+
try {
|
|
1126
|
+
await this.queries.messages.deleteById(message.id);
|
|
1127
|
+
this.eventEmitter.emit(SdkEventType.MESSAGE_DELETED, {
|
|
1128
|
+
messages: [message],
|
|
1129
|
+
});
|
|
1130
|
+
return { success: true, data: null };
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
return {
|
|
1134
|
+
success: false,
|
|
1135
|
+
error: error instanceof Error
|
|
1136
|
+
? error
|
|
1137
|
+
: new Error('Unknown error occurred while deleting reaction message'),
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
let deletedMessages = [];
|
|
1142
|
+
let updatedMessages = [];
|
|
1143
|
+
let discussionId;
|
|
1144
|
+
try {
|
|
1145
|
+
// Run all operations inside an explicit transaction for atomicity
|
|
1146
|
+
await db.transaction(async (trx) => {
|
|
1147
|
+
const discussion = await trx
|
|
1148
|
+
.select()
|
|
1149
|
+
.from(schema.discussions)
|
|
1150
|
+
.where(and(eq(schema.discussions.ownerUserId, message.ownerUserId), eq(schema.discussions.contactUserId, message.contactUserId)))
|
|
1151
|
+
.get();
|
|
1152
|
+
if (!discussion) {
|
|
1153
|
+
throw new Error('Discussion not found');
|
|
1154
|
+
}
|
|
1155
|
+
// delete the message : MessageType.DELETED '[Message deleted]' in db
|
|
1156
|
+
await this.queries.messages.updateById(message.id, // message.id is guaranteed to be not null because we checked it above
|
|
1157
|
+
{
|
|
1158
|
+
content: '[Message deleted]',
|
|
1159
|
+
type: MessageType.DELETED,
|
|
1160
|
+
}, trx);
|
|
1161
|
+
if (POST_MESSAGE_TYPES.includes(message.type)) {
|
|
1162
|
+
// If the message to delete is the last text message in the discussion, update the discussion to the previous last text message
|
|
1163
|
+
if (discussion.lastMessageId === message.id) {
|
|
1164
|
+
const lastMessage = await this.queries.discussions.getLastTextMessage(message.contactUserId, trx);
|
|
1165
|
+
await this.queries.discussions.updateById(discussion.id, {
|
|
1166
|
+
lastMessageId: lastMessage?.id ?? null,
|
|
1167
|
+
lastMessageContent: lastMessage?.content ?? null,
|
|
1168
|
+
lastMessageTimestamp: lastMessage?.timestamp ?? null,
|
|
1169
|
+
updatedAt: new Date(),
|
|
1170
|
+
}, trx);
|
|
1171
|
+
discussionId = discussion.id;
|
|
1172
|
+
}
|
|
1173
|
+
// If deleted message is not read yet, decrement the discussion unread count
|
|
1174
|
+
if (message.status !== MessageStatus.READ &&
|
|
1175
|
+
message.direction === MessageDirection.INCOMING) {
|
|
1176
|
+
await this.queries.discussions.decrementUnreadCount(discussion.id, trx);
|
|
1177
|
+
discussionId = discussion.id;
|
|
1178
|
+
}
|
|
1179
|
+
// Delete all REACTION messages for this contact referencing this message
|
|
1180
|
+
const deletedReactionMessages = await trx
|
|
1181
|
+
.delete(schema.messages)
|
|
1182
|
+
.where(and(eq(schema.messages.contactUserId, message.contactUserId), eq(schema.messages.type, MessageType.REACTION), sql `json_extract(${schema.messages.reactionOf}, '$.originalMsgId') = ${encodeToBase64(message.messageId)}`))
|
|
1183
|
+
.returning();
|
|
1184
|
+
deletedMessages = deletedReactionMessages.map(rowToMessage);
|
|
1185
|
+
// Also update all messages REPLYING to this message by setting their replyTo to null
|
|
1186
|
+
const updatedMessagesDb = await trx
|
|
1187
|
+
.update(schema.messages)
|
|
1188
|
+
.set({ replyTo: null })
|
|
1189
|
+
.where(and(eq(schema.messages.contactUserId, message.contactUserId), sql `json_extract(${schema.messages.replyTo}, '$.originalMsgId') = ${encodeToBase64(message.messageId)}`))
|
|
1190
|
+
.returning();
|
|
1191
|
+
updatedMessages = updatedMessagesDb.map(row => rowToMessage(row));
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
return {
|
|
1197
|
+
success: false,
|
|
1198
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
// function to be called after the db transaction is committed.
|
|
1202
|
+
// Send events only when we are sure corresponding operation are saved in db
|
|
1203
|
+
const postDbCommit = () => {
|
|
1204
|
+
this.eventEmitter.emit(SdkEventType.MESSAGE_DELETED, {
|
|
1205
|
+
messages: [message, ...deletedMessages],
|
|
1206
|
+
});
|
|
1207
|
+
if (updatedMessages.length > 0) {
|
|
1208
|
+
this.eventEmitter.emit(SdkEventType.MESSAGE_UPDATED, {
|
|
1209
|
+
messages: updatedMessages,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
if (discussionId) {
|
|
1213
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionId);
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
if (!tx) {
|
|
1217
|
+
// if we are not in a db transaction, we can just emit the event and return
|
|
1218
|
+
postDbCommit();
|
|
1219
|
+
return { success: true, data: null };
|
|
1220
|
+
}
|
|
1221
|
+
else {
|
|
1222
|
+
// if we are in a db transaction, we need to return a function that will be called after the transaction is committed
|
|
1223
|
+
return { success: true, data: postDbCommit };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1341
1226
|
/**
|
|
1342
1227
|
* Delete a message by its database ID (outgoing or incoming in 1-to-1).
|
|
1343
1228
|
* Marks the local message as deleted and enqueues a delete control message
|
|
@@ -1352,28 +1237,33 @@ export class MessageService {
|
|
|
1352
1237
|
if (!row.messageId)
|
|
1353
1238
|
throw new Error('Cannot delete a message that has no messageId');
|
|
1354
1239
|
const ownerUserId = this.session.userIdEncoded;
|
|
1355
|
-
const
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1240
|
+
const callbackAfterDbCommit = await this.queries.conn.db.transaction(async (tx) => {
|
|
1241
|
+
const res = await this.PerformDeleteMessage(rowToMessage(row), tx);
|
|
1242
|
+
if (!res.success) {
|
|
1243
|
+
tx.rollback(); // if deleting the message from the db fails, rollback the transaction
|
|
1244
|
+
throw new Error(res.error?.message ?? 'Failed to delete message from db');
|
|
1245
|
+
}
|
|
1246
|
+
// Send the delete control message to the peer
|
|
1247
|
+
const controlMessage = {
|
|
1248
|
+
ownerUserId,
|
|
1249
|
+
contactUserId: row.contactUserId,
|
|
1250
|
+
content: '',
|
|
1359
1251
|
type: MessageType.DELETED,
|
|
1360
|
-
|
|
1361
|
-
|
|
1252
|
+
direction: MessageDirection.OUTGOING,
|
|
1253
|
+
status: MessageStatus.WAITING_SESSION,
|
|
1254
|
+
timestamp: new Date(),
|
|
1255
|
+
deleteOf: { originalMsgId: row.messageId }, // row.messageId was previously verified to be not null
|
|
1256
|
+
};
|
|
1257
|
+
const result = await this.sendMessage(controlMessage, tx);
|
|
1258
|
+
if (!result.success) {
|
|
1259
|
+
tx.rollback(); // if sending the delete control message fails, rollback the transaction
|
|
1260
|
+
throw new Error(result.error ?? 'Failed to enqueue delete message');
|
|
1261
|
+
}
|
|
1262
|
+
return res.data;
|
|
1362
1263
|
});
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
content: '',
|
|
1367
|
-
type: MessageType.DELETED,
|
|
1368
|
-
direction: MessageDirection.OUTGOING,
|
|
1369
|
-
status: MessageStatus.WAITING_SESSION,
|
|
1370
|
-
timestamp: new Date(),
|
|
1371
|
-
deleteOf: { originalMsgId: row.messageId },
|
|
1372
|
-
};
|
|
1373
|
-
const result = await this.send(controlMessage);
|
|
1374
|
-
if (!result.success)
|
|
1375
|
-
throw new Error(result.error ?? 'Failed to enqueue delete message');
|
|
1376
|
-
await this.refreshService?.stateUpdate();
|
|
1264
|
+
if (callbackAfterDbCommit) {
|
|
1265
|
+
callbackAfterDbCommit();
|
|
1266
|
+
}
|
|
1377
1267
|
return true;
|
|
1378
1268
|
}
|
|
1379
1269
|
async sendReaction(contactUserId, emoji, originalMsgId) {
|
|
@@ -1388,9 +1278,53 @@ export class MessageService {
|
|
|
1388
1278
|
reactionOf: { originalMsgId },
|
|
1389
1279
|
};
|
|
1390
1280
|
const result = await this.send(message);
|
|
1391
|
-
await this.refreshService?.stateUpdate();
|
|
1392
1281
|
return result;
|
|
1393
1282
|
}
|
|
1283
|
+
async performEditMessage(newContent, originalMsg, metadata, tx) {
|
|
1284
|
+
if (!originalMsg.id) {
|
|
1285
|
+
return { success: false, error: new Error('Message ID is required') };
|
|
1286
|
+
}
|
|
1287
|
+
const db = tx ?? this.queries.conn.db;
|
|
1288
|
+
let discussionUpdatedId;
|
|
1289
|
+
try {
|
|
1290
|
+
await db
|
|
1291
|
+
.update(schema.messages)
|
|
1292
|
+
.set({ content: newContent, metadata: serializeMetadata(metadata) })
|
|
1293
|
+
.where(eq(schema.messages.id, originalMsg.id))
|
|
1294
|
+
.returning();
|
|
1295
|
+
const discussion = await this.queries.discussions.getByOwnerAndContact(originalMsg.ownerUserId, originalMsg.contactUserId, tx);
|
|
1296
|
+
if (!discussion) {
|
|
1297
|
+
return { success: false, error: new Error('Discussion not found') };
|
|
1298
|
+
}
|
|
1299
|
+
if (discussion.lastMessageId === originalMsg.id) {
|
|
1300
|
+
await this.queries.discussions.updateById(discussion.id, {
|
|
1301
|
+
lastMessageContent: newContent,
|
|
1302
|
+
updatedAt: new Date(),
|
|
1303
|
+
}, tx);
|
|
1304
|
+
discussionUpdatedId = discussion.id;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
catch (error) {
|
|
1308
|
+
return {
|
|
1309
|
+
success: false,
|
|
1310
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
const postDbCommit = () => {
|
|
1314
|
+
if (discussionUpdatedId) {
|
|
1315
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionUpdatedId);
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
if (!tx) {
|
|
1319
|
+
// if we are not in a db transaction, we can just emit the event and return
|
|
1320
|
+
postDbCommit();
|
|
1321
|
+
return { success: true, data: null };
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
// if we are in a db transaction, we need to return a function that will be called after the transaction is committed
|
|
1325
|
+
return { success: true, data: postDbCommit };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1394
1328
|
/**
|
|
1395
1329
|
* Edit an outgoing message by its database ID.
|
|
1396
1330
|
* Updates the local content (preserving timestamp) and enqueues an edit
|
|
@@ -1407,25 +1341,33 @@ export class MessageService {
|
|
|
1407
1341
|
const ownerUserId = this.session.userIdEncoded;
|
|
1408
1342
|
const existingMetadata = deserializeMetadata(row.metadata) ?? {};
|
|
1409
1343
|
const mergedMetadata = { ...existingMetadata, edited: true };
|
|
1410
|
-
await this.queries.
|
|
1411
|
-
|
|
1412
|
-
|
|
1344
|
+
const callbackAfterDbCommit = await this.queries.conn.db.transaction(async (tx) => {
|
|
1345
|
+
const res = await this.performEditMessage(newContent, rowToMessage(row), mergedMetadata, tx);
|
|
1346
|
+
if (!res.success) {
|
|
1347
|
+
tx.rollback();
|
|
1348
|
+
throw new Error(res.error?.message ?? 'Failed to edit message in db');
|
|
1349
|
+
}
|
|
1350
|
+
const controlMessage = {
|
|
1351
|
+
ownerUserId,
|
|
1352
|
+
contactUserId: row.contactUserId,
|
|
1353
|
+
content: newContent,
|
|
1354
|
+
type: MessageType.TEXT,
|
|
1355
|
+
direction: MessageDirection.OUTGOING,
|
|
1356
|
+
status: MessageStatus.WAITING_SESSION,
|
|
1357
|
+
timestamp: new Date(),
|
|
1358
|
+
editOf: { originalMsgId: row.messageId }, // row.messageId was previously verified to be not null
|
|
1359
|
+
metadata: { control: 'edit' },
|
|
1360
|
+
};
|
|
1361
|
+
const result = await this.sendMessage(controlMessage, tx);
|
|
1362
|
+
if (!result.success) {
|
|
1363
|
+
tx.rollback();
|
|
1364
|
+
throw new Error(result.error ?? 'Failed to enqueue edit message');
|
|
1365
|
+
}
|
|
1366
|
+
return res.data;
|
|
1413
1367
|
});
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
content: newContent,
|
|
1418
|
-
type: MessageType.TEXT,
|
|
1419
|
-
direction: MessageDirection.OUTGOING,
|
|
1420
|
-
status: MessageStatus.WAITING_SESSION,
|
|
1421
|
-
timestamp: new Date(),
|
|
1422
|
-
editOf: { originalMsgId: row.messageId },
|
|
1423
|
-
metadata: { control: 'edit' },
|
|
1424
|
-
};
|
|
1425
|
-
const result = await this.send(controlMessage);
|
|
1426
|
-
if (!result.success)
|
|
1427
|
-
throw new Error(result.error ?? 'Failed to enqueue edit message');
|
|
1428
|
-
await this.refreshService?.stateUpdate();
|
|
1368
|
+
if (callbackAfterDbCommit) {
|
|
1369
|
+
callbackAfterDbCommit();
|
|
1370
|
+
}
|
|
1429
1371
|
return true;
|
|
1430
1372
|
}
|
|
1431
1373
|
/**
|
|
@@ -1449,14 +1391,22 @@ export class MessageService {
|
|
|
1449
1391
|
return false;
|
|
1450
1392
|
}
|
|
1451
1393
|
const message = rowToMessage(row);
|
|
1452
|
-
//
|
|
1453
|
-
await this.queries.
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
await this.queries.discussions.
|
|
1458
|
-
|
|
1394
|
+
// Perform message status update and unread count decrement atomically in a transaction
|
|
1395
|
+
const discussionId = await this.queries.conn.db.transaction(async (tx) => {
|
|
1396
|
+
// Update message status
|
|
1397
|
+
await this.queries.messages.updateById(id, { status: MessageStatus.READ }, tx);
|
|
1398
|
+
// Atomically decrement discussion unread count
|
|
1399
|
+
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, tx);
|
|
1400
|
+
if (!discussion || !discussion.id) {
|
|
1401
|
+
throw new Error('Discussion not found');
|
|
1402
|
+
}
|
|
1403
|
+
if (discussion) {
|
|
1404
|
+
await this.queries.discussions.decrementUnreadCount(discussion.id, tx);
|
|
1405
|
+
}
|
|
1406
|
+
return discussion.id;
|
|
1407
|
+
});
|
|
1459
1408
|
this.eventEmitter.emit(SdkEventType.MESSAGE_READ, id);
|
|
1409
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionId);
|
|
1460
1410
|
return true;
|
|
1461
1411
|
}
|
|
1462
1412
|
}
|
package/package.json
CHANGED