From 6869245b89d77b35c3bf67ca02f7a11831930658 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:48:33 -0500 Subject: [PATCH] Allow poll questions to be searchable --- ts/sql/Server.node.ts | 31 ++++++-------- ts/sql/migrations/1500-search-polls.std.ts | 50 ++++++++++++++++++++++ ts/sql/migrations/index.node.ts | 3 ++ ts/state/selectors/search.preload.ts | 25 +++++++++-- 4 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 ts/sql/migrations/1500-search-polls.std.ts diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 2daaeb677a..86312bf37e 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -267,6 +267,7 @@ import type { import { sqlLogger } from './sqlLogger.node.js'; import { permissiveMessageAttachmentSchema } from './server/messageAttachments.std.js'; import { getFilePathsOwnedByMessage } from '../util/messageFilePaths.std.js'; +import { createMessagesOnInsertTrigger } from './migrations/1500-search-polls.std.js'; const { forEach, @@ -8878,29 +8879,23 @@ function enableFSyncAndCheckpoint(db: WritableDB): void { } function enableMessageInsertTriggersAndBackfill(db: WritableDB): void { - const createTriggersQuery = ` - DROP TRIGGER IF EXISTS messages_on_insert; - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages - WHEN new.isViewOnce IS NOT 1 AND new.storyId IS NULL - BEGIN - INSERT INTO messages_fts - (rowid, body) - VALUES - (new.rowid, new.body); - END; + db.transaction(() => { + backfillMentionsTable(db); + backfillMessagesFtsTable(db); - DROP TRIGGER IF EXISTS messages_on_insert_insert_mentions; + db.exec('DROP TRIGGER IF EXISTS messages_on_insert'); + db.exec(createMessagesOnInsertTrigger); + + db.exec('DROP TRIGGER IF EXISTS messages_on_insert_insert_mentions'); + db.exec(` CREATE TRIGGER messages_on_insert_insert_mentions AFTER INSERT ON messages BEGIN INSERT INTO mentions (messageId, mentionAci, start, length) ${selectMentionsFromMessages} AND messages.id = new.id; END; - `; - db.transaction(() => { - backfillMentionsTable(db); - backfillMessagesFtsTable(db); - db.exec(createTriggersQuery); + `); + createOrUpdateItem(db, { id: 'messageInsertTriggersDisabled', value: false, @@ -8912,9 +8907,9 @@ function backfillMessagesFtsTable(db: WritableDB): void { db.exec(` DELETE FROM messages_fts; INSERT OR REPLACE INTO messages_fts (rowid, body) - SELECT rowid, body + SELECT rowid, searchableText FROM messages - WHERE isViewOnce IS NOT 1 AND storyId IS NULL; + WHERE isSearchable = 1; `); } diff --git a/ts/sql/migrations/1500-search-polls.std.ts b/ts/sql/migrations/1500-search-polls.std.ts new file mode 100644 index 0000000000..b30272c761 --- /dev/null +++ b/ts/sql/migrations/1500-search-polls.std.ts @@ -0,0 +1,50 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { WritableDB } from '../Interface.std.js'; +import { sql } from '../util.std.js'; + +export const createMessagesOnInsertTrigger = sql` + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.isSearchable IS 1 + BEGIN + INSERT INTO messages_fts + (rowid, body) + VALUES + (new.rowid, new.searchableText); + END; +`[0]; + +const createMessagesOnUpdateTrigger = sql` + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN + new.isSearchable IS 1 AND old.searchableText IS NOT new.searchableText + BEGIN + UPDATE messages_fts SET body = new.searchableText WHERE rowId = new.rowId; + END; +`[0]; + +export default function updateToSchemaVersion1500(db: WritableDB): void { + db.exec( + `ALTER TABLE messages ADD COLUMN isSearchable INT + GENERATED ALWAYS AS (isViewOnce IS NOT 1 AND storyId IS NULL) VIRTUAL;` + ); + + // Must be kept in sync with logic in getSearchableTextAndBodyRanges + db.exec(` + ALTER TABLE messages ADD COLUMN searchableText TEXT GENERATED ALWAYS AS ( + CASE + WHEN json->'poll' IS NOT NULL THEN json->'poll'->>'question' + ELSE body + END + ) VIRTUAL; + `); + + // If the messages_on_insert query is updated, enableMessageInsertTriggersAndBackfill + // and backfillMessagesFtsTable must be as well + db.exec('DROP TRIGGER IF EXISTS messages_on_insert;'); + db.exec(createMessagesOnInsertTrigger); + + db.exec('DROP TRIGGER IF EXISTS messages_on_update;'); + db.exec(createMessagesOnUpdateTrigger); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 14a7d67486..5968d8153b 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -125,6 +125,7 @@ import updateToSchemaVersion1460 from './1460-attachment-duration.std.js'; import updateToSchemaVersion1470 from './1470-kyber-triple.std.js'; import updateToSchemaVersion1480 from './1480-chat-folders-remove-duplicates.std.js'; import updateToSchemaVersion1490 from './1490-lowercase-notification-profiles.std.js'; +import updateToSchemaVersion1500 from './1500-search-polls.std.js'; import { DataWriter } from '../Server.node.js'; @@ -1607,6 +1608,8 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1470, update: updateToSchemaVersion1470 }, { version: 1480, update: updateToSchemaVersion1480 }, { version: 1490, update: updateToSchemaVersion1490 }, + + { version: 1500, update: updateToSchemaVersion1500 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/selectors/search.preload.ts b/ts/state/selectors/search.preload.ts index 85723d6ff2..afd20f9509 100644 --- a/ts/state/selectors/search.preload.ts +++ b/ts/state/selectors/search.preload.ts @@ -30,8 +30,10 @@ import { } from './conversations.dom.js'; import { hydrateRanges } from '../../util/BodyRange.node.js'; +import type { RawBodyRange } from '../../types/BodyRange.std.js'; import { createLogger } from '../../logging/log.std.js'; import { getOwn } from '../../util/getOwn.std.js'; +import type { MessageAttributesType } from '../../model-types.js'; const log = createLogger('search'); @@ -196,6 +198,23 @@ type CachedMessageSearchResultSelectorType = ( targetedMessageId?: string ) => MessageSearchResultPropsDataType; +/** Must be kept in sync with messages.searchableText virtual column */ +function getSearchableTextAndBodyRanges(message: MessageAttributesType): { + text: string | undefined; + bodyRanges: ReadonlyArray | undefined; +} { + if (message.poll) { + return { + text: message.poll.question, + bodyRanges: undefined, + }; + } + + return { + text: message.body, + bodyRanges: message.bodyRanges, + }; +} export const getCachedSelectorForMessageSearchResult = createSelector( getUserConversationId, getConversationSelector, @@ -213,6 +232,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector( searchConversationId?: string, targetedMessageId?: string ) => { + const { text, bodyRanges } = getSearchableTextAndBodyRanges(message); return { from, to, @@ -221,9 +241,8 @@ export const getCachedSelectorForMessageSearchResult = createSelector( conversationId: message.conversationId, sentAt: message.sent_at, snippet: message.snippet || '', - bodyRanges: - hydrateRanges(message.bodyRanges, conversationSelector) || [], - body: message.body || '', + bodyRanges: hydrateRanges(bodyRanges, conversationSelector) || [], + body: text ?? '', isSelected: Boolean( targetedMessageId && message.id === targetedMessageId