From d9c1f872ef312c7c12c667b0d10c74026996cda7 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 09:36:41 +0200 Subject: [PATCH] Leave a line for apps from before MLS (GRYT-1508) An MLS message never reached the messages table, so an app from before stage 1 got nothing for it. Now each application message sent with mls:send also writes a system line into the DM: "@Kari sent an end-to-end encrypted message. Update Gryt to read it." The row carries mls_placeholder { seq, sender_server_id }, and apps that read MLS hide it. It's a system line because desktop 1.11.46 puts a red "Not encrypted" mark on any plain DM message, right next to text saying it was encrypted. System rows don't get the mark. A client passes placeholder: false for application messages that aren't a message, like reactions, edits and deletes. The sender's own sockets don't get the line live, a block hides it in history, and hasWrittenTo counts it. Co-Authored-By: Claude Opus 5.5 --- src/db/interfaces.ts | 10 +++++++++ src/db/sqlite/connection.ts | 6 +++++ src/db/sqlite/contactPrefs.ts | 10 +++++---- src/db/sqlite/mergeGuest.ts | 1 + src/db/sqlite/messages.ts | 7 +++++- src/socket/handlers/chat.ts | 2 +- src/socket/handlers/mls.test.ts | 34 ++++++++++++++++++++++++++++- src/socket/handlers/mls.ts | 35 +++++++++++++++++++++++++++++- src/socket/utils/systemMessages.ts | 8 ++++++- 9 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 46c34f4e..b6e1d4ac 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -147,6 +147,16 @@ export interface MessageRecord { text_fallback?: boolean; /** What a webhook posted under. Enrichment turns it into sender_nickname. */ sender_display_name?: string | null; + /** Set on the notice the server writes for apps from before MLS, sent as "system". + Apps that read MLS hide these rows (GRYT-1508). */ + mls_placeholder?: MlsPlaceholder; +} + +export interface MlsPlaceholder { + /** The entry in the conversation's MLS group log this row stands in for. */ + seq: number; + /** Who really sent it, so a block hides the notice too. */ + sender_server_id: string; } export interface StoredWebhookCard { diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 559a736e..c6db13ce 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -929,6 +929,12 @@ function runMigrations(d: DatabaseSync): void { if (!hasColumn(d, "messages", "sender_avatar_file_id")) { d.exec("ALTER TABLE messages ADD COLUMN sender_avatar_file_id TEXT"); } + // A placeholder the server writes for apps from before MLS, and whose MLS message + // it stands in for (GRYT-1508). NULL on every other row. + if (!hasColumn(d, "messages", "mls_seq")) { + d.exec("ALTER TABLE messages ADD COLUMN mls_seq INTEGER"); + d.exec("ALTER TABLE messages ADD COLUMN mls_sender_server_id TEXT"); + } // One stored file per picture a webhook sends, however often it sends it. d.exec(`CREATE TABLE IF NOT EXISTS webhook_media ( webhook_id TEXT NOT NULL, diff --git a/src/db/sqlite/contactPrefs.ts b/src/db/sqlite/contactPrefs.ts index af4d428a..002f80c8 100644 --- a/src/db/sqlite/contactPrefs.ts +++ b/src/db/sqlite/contactPrefs.ts @@ -47,11 +47,13 @@ export async function setContactPrefs(grytUserId: string, prefs: ContactPrefs): ).run(grytUserId, prefs.messages, prefs.calls, toIso(new Date())); } -/** Whether `author` has sent anything in their one-to-one with `other`. The - sender column is stored for sealed messages too, so this works on those. */ +/** Whether `author` has sent anything in their one-to-one with `other`. Sealed messages + keep their sender, and an MLS one counts through its placeholder. */ export async function hasWrittenTo(authorServerUserId: string, otherServerUserId: string): Promise { const row = getSqliteDb() - .prepare(`SELECT 1 FROM messages WHERE conversation_id = ? AND sender_server_id = ? LIMIT 1`) - .get(directConversationId(authorServerUserId, otherServerUserId), authorServerUserId); + .prepare( + `SELECT 1 FROM messages WHERE conversation_id = ? AND (sender_server_id = ? OR mls_sender_server_id = ?) LIMIT 1`, + ) + .get(directConversationId(authorServerUserId, otherServerUserId), authorServerUserId, authorServerUserId); return !!row; } diff --git a/src/db/sqlite/mergeGuest.ts b/src/db/sqlite/mergeGuest.ts index 7db2fc49..f9f0cda0 100644 --- a/src/db/sqlite/mergeGuest.ts +++ b/src/db/sqlite/mergeGuest.ts @@ -17,6 +17,7 @@ export interface GuestMerge { fails when a new column that can hold a server user id is not accounted for. */ export const AUTHOR_COLUMNS: ReadonlyArray = [ ["messages", "sender_server_id"], + ["messages", "mls_sender_server_id"], ["threads", "created_by"], ["conversations", "created_by_server_user_id"], ["files", "uploaded_by_server_user_id"], diff --git a/src/db/sqlite/messages.ts b/src/db/sqlite/messages.ts index a78f32a3..e01dba35 100644 --- a/src/db/sqlite/messages.ts +++ b/src/db/sqlite/messages.ts @@ -21,6 +21,9 @@ function rowToMessage(r: Record): MessageRecord { ...(r.text_fallback ? { text_fallback: true } : {}), ...(r.sender_display_name ? { sender_display_name: r.sender_display_name as string } : {}), ...(r.sender_avatar_file_id ? { sender_avatar_file_id: r.sender_avatar_file_id as string } : {}), + ...(r.mls_seq != null + ? { mls_placeholder: { seq: Number(r.mls_seq), sender_server_id: r.mls_sender_server_id as string } } + : {}), }; } @@ -32,7 +35,7 @@ export async function insertMessage(record: Omit