Include @mentioned names in search results
This commit is contained in:
parent
e3c6b4d9b1
commit
9c6fb29edb
14 changed files with 1052 additions and 126 deletions
|
@ -57,6 +57,7 @@ import { MINUTE } from '../util/durations';
|
|||
import { getMessageIdForLogging } from '../util/idForLogging';
|
||||
import type { MessageAttributesType } from '../model-types';
|
||||
import { incrementMessageCounter } from '../util/incrementMessageCounter';
|
||||
import { generateSnippetAroundMention } from '../util/search';
|
||||
|
||||
const ERASE_SQL_KEY = 'erase-sql-key';
|
||||
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
|
||||
|
@ -90,7 +91,6 @@ const exclusiveInterface: ClientExclusiveInterface = {
|
|||
removeConversation,
|
||||
|
||||
searchMessages,
|
||||
searchMessagesInConversation,
|
||||
|
||||
getOlderMessagesByConversation,
|
||||
getConversationRangeCenteredOnMessage,
|
||||
|
@ -415,36 +415,48 @@ async function removeConversation(id: string): Promise<void> {
|
|||
function handleSearchMessageJSON(
|
||||
messages: Array<ServerSearchResultMessageType>
|
||||
): Array<ClientSearchResultMessageType> {
|
||||
return messages.map(message => ({
|
||||
json: message.json,
|
||||
return messages.map<ClientSearchResultMessageType>(message => {
|
||||
const parsedMessage = JSON.parse(message.json);
|
||||
assertDev(
|
||||
message.ftsSnippet ?? typeof message.mentionStart === 'number',
|
||||
'Neither ftsSnippet nor matching mention returned from message search'
|
||||
);
|
||||
const snippet =
|
||||
message.ftsSnippet ??
|
||||
generateSnippetAroundMention({
|
||||
body: parsedMessage.body,
|
||||
mentionStart: message.mentionStart ?? 0,
|
||||
mentionLength: message.mentionLength ?? 1,
|
||||
});
|
||||
|
||||
// Empty array is a default value. `message.json` has the real field
|
||||
bodyRanges: [],
|
||||
return {
|
||||
json: message.json,
|
||||
|
||||
...JSON.parse(message.json),
|
||||
snippet: message.snippet,
|
||||
}));
|
||||
// Empty array is a default value. `message.json` has the real field
|
||||
bodyRanges: [],
|
||||
...parsedMessage,
|
||||
snippet,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function searchMessages(
|
||||
query: string,
|
||||
{ limit }: { limit?: number } = {}
|
||||
): Promise<Array<ClientSearchResultMessageType>> {
|
||||
const messages = await channels.searchMessages(query, { limit });
|
||||
|
||||
return handleSearchMessageJSON(messages);
|
||||
}
|
||||
|
||||
async function searchMessagesInConversation(
|
||||
query: string,
|
||||
conversationId: string,
|
||||
{ limit }: { limit?: number } = {}
|
||||
): Promise<Array<ClientSearchResultMessageType>> {
|
||||
const messages = await channels.searchMessagesInConversation(
|
||||
async function searchMessages({
|
||||
query,
|
||||
options,
|
||||
contactUuidsMatchingQuery,
|
||||
conversationId,
|
||||
}: {
|
||||
query: string;
|
||||
options?: { limit?: number };
|
||||
contactUuidsMatchingQuery?: Array<string>;
|
||||
conversationId?: string;
|
||||
}): Promise<Array<ClientSearchResultMessageType>> {
|
||||
const messages = await channels.searchMessages({
|
||||
query,
|
||||
conversationId,
|
||||
{ limit }
|
||||
);
|
||||
options,
|
||||
contactUuidsMatchingQuery,
|
||||
});
|
||||
|
||||
return handleSearchMessageJSON(messages);
|
||||
}
|
||||
|
|
|
@ -126,7 +126,14 @@ export type StoredPreKeyType = {
|
|||
export type PreKeyIdType = PreKeyType['id'];
|
||||
export type ServerSearchResultMessageType = {
|
||||
json: string;
|
||||
snippet: string;
|
||||
|
||||
// If the FTS matches text in message.body, snippet will be populated
|
||||
ftsSnippet: string | null;
|
||||
|
||||
// Otherwise, a matching mention will be returned
|
||||
mentionUuid: string | null;
|
||||
mentionStart: number | null;
|
||||
mentionLength: number | null;
|
||||
};
|
||||
export type ClientSearchResultMessageType = MessageType & {
|
||||
json: string;
|
||||
|
@ -488,9 +495,6 @@ export type DataInterface = {
|
|||
id: UUIDStringType
|
||||
) => Promise<Array<ConversationType>>;
|
||||
|
||||
// searchMessages is JSON on server, full message on Client
|
||||
// searchMessagesInConversation is JSON on server, full message on Client
|
||||
|
||||
getMessageCount: (conversationId?: string) => Promise<number>;
|
||||
getStoryCount: (conversationId: string) => Promise<number>;
|
||||
saveMessage: (
|
||||
|
@ -788,16 +792,17 @@ export type ServerInterface = DataInterface & {
|
|||
updateConversation: (data: ConversationType) => Promise<void>;
|
||||
removeConversation: (id: Array<string> | string) => Promise<void>;
|
||||
|
||||
searchMessages: (
|
||||
query: string,
|
||||
options?: { limit?: number }
|
||||
) => Promise<Array<ServerSearchResultMessageType>>;
|
||||
searchMessagesInConversation: (
|
||||
query: string,
|
||||
conversationId: string,
|
||||
options?: { limit?: number }
|
||||
) => Promise<Array<ServerSearchResultMessageType>>;
|
||||
|
||||
searchMessages: ({
|
||||
query,
|
||||
conversationId,
|
||||
options,
|
||||
contactUuidsMatchingQuery,
|
||||
}: {
|
||||
query: string;
|
||||
conversationId?: string;
|
||||
options?: { limit?: number };
|
||||
contactUuidsMatchingQuery?: Array<string>;
|
||||
}) => Promise<Array<ServerSearchResultMessageType>>;
|
||||
getOlderMessagesByConversation: (
|
||||
options: AdjacentMessagesByConversationOptionsType
|
||||
) => Promise<Array<MessageTypeUnhydrated>>;
|
||||
|
@ -868,16 +873,17 @@ export type ClientExclusiveInterface = {
|
|||
updateConversation: (data: ConversationType) => void;
|
||||
removeConversation: (id: string) => Promise<void>;
|
||||
|
||||
searchMessages: (
|
||||
query: string,
|
||||
options?: { limit?: number }
|
||||
) => Promise<Array<ClientSearchResultMessageType>>;
|
||||
searchMessagesInConversation: (
|
||||
query: string,
|
||||
conversationId: string,
|
||||
options?: { limit?: number }
|
||||
) => Promise<Array<ClientSearchResultMessageType>>;
|
||||
|
||||
searchMessages: ({
|
||||
query,
|
||||
conversationId,
|
||||
options,
|
||||
contactUuidsMatchingQuery,
|
||||
}: {
|
||||
query: string;
|
||||
conversationId?: string;
|
||||
options?: { limit?: number };
|
||||
contactUuidsMatchingQuery?: Array<string>;
|
||||
}) => Promise<Array<ClientSearchResultMessageType>>;
|
||||
getOlderMessagesByConversation: (
|
||||
options: AdjacentMessagesByConversationOptionsType
|
||||
) => Promise<Array<MessageAttributesType>>;
|
||||
|
|
114
ts/sql/Server.ts
114
ts/sql/Server.ts
|
@ -135,6 +135,11 @@ import type {
|
|||
GetNearbyMessageFromDeletedSetOptionsType,
|
||||
} from './Interface';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
import {
|
||||
SNIPPET_LEFT_PLACEHOLDER,
|
||||
SNIPPET_RIGHT_PLACEHOLDER,
|
||||
SNIPPET_TRUNCATION_PLACEHOLDER,
|
||||
} from '../util/search';
|
||||
|
||||
type ConversationRow = Readonly<{
|
||||
json: string;
|
||||
|
@ -234,7 +239,6 @@ const dataInterface: ServerInterface = {
|
|||
getAllGroupsInvolvingUuid,
|
||||
|
||||
searchMessages,
|
||||
searchMessagesInConversation,
|
||||
|
||||
getMessageCount,
|
||||
getStoryCount,
|
||||
|
@ -1587,11 +1591,18 @@ async function getAllGroupsInvolvingUuid(
|
|||
return rows.map(row => rowToConversation(row));
|
||||
}
|
||||
|
||||
async function searchMessages(
|
||||
query: string,
|
||||
params: { limit?: number; conversationId?: string } = {}
|
||||
): Promise<Array<ServerSearchResultMessageType>> {
|
||||
const { limit = 500, conversationId } = params;
|
||||
async function searchMessages({
|
||||
query,
|
||||
options,
|
||||
conversationId,
|
||||
contactUuidsMatchingQuery,
|
||||
}: {
|
||||
query: string;
|
||||
options?: { limit?: number };
|
||||
conversationId?: string;
|
||||
contactUuidsMatchingQuery?: Array<string>;
|
||||
}): Promise<Array<ServerSearchResultMessageType>> {
|
||||
const { limit = conversationId ? 100 : 500 } = options ?? {};
|
||||
|
||||
const db = getInstance();
|
||||
|
||||
|
@ -1662,24 +1673,70 @@ async function searchMessages(
|
|||
// give us the right results. We can't call `snippet()` in the query above
|
||||
// because it would bloat the temporary table with text data and we want
|
||||
// to keep its size minimal for `ORDER BY` + `LIMIT` to be fast.
|
||||
const result = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
SELECT
|
||||
messages.json,
|
||||
snippet(messages_fts, -1, '<<left>>', '<<right>>', '<<truncation>>', 10)
|
||||
AS snippet
|
||||
FROM tmp_filtered_results
|
||||
INNER JOIN messages_fts
|
||||
ON messages_fts.rowid = tmp_filtered_results.rowid
|
||||
INNER JOIN messages
|
||||
ON messages.rowid = tmp_filtered_results.rowid
|
||||
WHERE
|
||||
messages_fts.body MATCH $query
|
||||
ORDER BY messages.received_at DESC, messages.sent_at DESC;
|
||||
`
|
||||
)
|
||||
.all({ query });
|
||||
const ftsFragment = sqlFragment`
|
||||
SELECT
|
||||
messages.rowid,
|
||||
messages.json,
|
||||
messages.sent_at,
|
||||
messages.received_at,
|
||||
snippet(messages_fts, -1, ${SNIPPET_LEFT_PLACEHOLDER}, ${SNIPPET_RIGHT_PLACEHOLDER}, ${SNIPPET_TRUNCATION_PLACEHOLDER}, 10) AS ftsSnippet
|
||||
FROM tmp_filtered_results
|
||||
INNER JOIN messages_fts
|
||||
ON messages_fts.rowid = tmp_filtered_results.rowid
|
||||
INNER JOIN messages
|
||||
ON messages.rowid = tmp_filtered_results.rowid
|
||||
WHERE
|
||||
messages_fts.body MATCH ${query}
|
||||
ORDER BY messages.received_at DESC, messages.sent_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
let result: Array<ServerSearchResultMessageType>;
|
||||
|
||||
if (!contactUuidsMatchingQuery?.length) {
|
||||
const [sqlQuery, params] = sql`${ftsFragment};`;
|
||||
result = db.prepare(sqlQuery).all(params);
|
||||
} else {
|
||||
// If contactUuidsMatchingQuery is not empty, we due an OUTER JOIN between:
|
||||
// 1) the messages that mention at least one of contactUuidsMatchingQuery, and
|
||||
// 2) the messages that match all the search terms via FTS
|
||||
//
|
||||
// Note: this groups the results by rowid, so even if one message mentions multiple
|
||||
// matching UUIDs, we only return one to be highlighted
|
||||
const [sqlQuery, params] = sql`
|
||||
SELECT
|
||||
messages.rowid as rowid,
|
||||
COALESCE(messages.json, ftsResults.json) as json,
|
||||
COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at,
|
||||
COALESCE(messages.received_at, ftsResults.received_at) as received_at,
|
||||
ftsResults.ftsSnippet,
|
||||
mentionUuid,
|
||||
start as mentionStart,
|
||||
length as mentionLength
|
||||
FROM mentions
|
||||
INNER JOIN messages
|
||||
ON
|
||||
messages.id = mentions.messageId
|
||||
AND mentions.mentionUuid IN (
|
||||
${sqlJoin(contactUuidsMatchingQuery, ', ')}
|
||||
)
|
||||
AND ${
|
||||
conversationId
|
||||
? sqlFragment`messages.conversationId = ${conversationId}`
|
||||
: '1 IS 1'
|
||||
}
|
||||
AND messages.isViewOnce IS NOT 1
|
||||
AND messages.storyId IS NULL
|
||||
FULL OUTER JOIN (
|
||||
${ftsFragment}
|
||||
) as ftsResults
|
||||
USING (rowid)
|
||||
GROUP BY rowid
|
||||
ORDER BY received_at DESC, sent_at DESC
|
||||
LIMIT ${limit};
|
||||
`;
|
||||
result = db.prepare(sqlQuery).all(params);
|
||||
}
|
||||
|
||||
db.exec(
|
||||
`
|
||||
|
@ -1687,19 +1744,10 @@ async function searchMessages(
|
|||
DROP TABLE tmp_filtered_results;
|
||||
`
|
||||
);
|
||||
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
async function searchMessagesInConversation(
|
||||
query: string,
|
||||
conversationId: string,
|
||||
{ limit = 100 }: { limit?: number } = {}
|
||||
): Promise<Array<ServerSearchResultMessageType>> {
|
||||
return searchMessages(query, { conversationId, limit });
|
||||
}
|
||||
|
||||
function getMessageCountSync(
|
||||
conversationId?: string,
|
||||
db = getInstance()
|
||||
|
|
58
ts/sql/migrations/84-all-mentions.ts
Normal file
58
ts/sql/migrations/84-all-mentions.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Database } from '@signalapp/better-sqlite3';
|
||||
import type { LoggerType } from '../../types/Logging';
|
||||
|
||||
export default function updateToSchemaVersion84(
|
||||
currentVersion: number,
|
||||
db: Database,
|
||||
logger: LoggerType
|
||||
): void {
|
||||
if (currentVersion >= 84) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
const selectMentionsFromMessages = `
|
||||
SELECT messages.id, bodyRanges.value ->> 'mentionUuid' as mentionUuid, bodyRanges.value ->> 'start' as start, bodyRanges.value ->> 'length' as length
|
||||
FROM messages, json_each(messages.json ->> 'bodyRanges') as bodyRanges
|
||||
WHERE bodyRanges.value ->> 'mentionUuid' IS NOT NULL
|
||||
`;
|
||||
|
||||
db.exec(`
|
||||
DROP TABLE IF EXISTS mentions;
|
||||
|
||||
CREATE TABLE mentions (
|
||||
messageId REFERENCES messages(id) ON DELETE CASCADE,
|
||||
mentionUuid STRING,
|
||||
start INTEGER,
|
||||
length INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX mentions_uuid ON mentions (mentionUuid);
|
||||
|
||||
INSERT INTO mentions (messageId, mentionUuid, start, length)
|
||||
${selectMentionsFromMessages};
|
||||
|
||||
CREATE TRIGGER messages_on_insert_insert_mentions AFTER INSERT ON messages
|
||||
BEGIN
|
||||
INSERT INTO mentions (messageId, mentionUuid, start, length)
|
||||
${selectMentionsFromMessages}
|
||||
AND messages.id = new.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER messages_on_update_update_mentions AFTER UPDATE ON messages
|
||||
BEGIN
|
||||
DELETE FROM mentions WHERE messageId = new.id;
|
||||
INSERT INTO mentions (messageId, mentionUuid, start, length)
|
||||
${selectMentionsFromMessages}
|
||||
AND messages.id = new.id;
|
||||
END;
|
||||
`);
|
||||
|
||||
db.pragma('user_version = 84');
|
||||
})();
|
||||
|
||||
logger.info('updateToSchemaVersion84: success!');
|
||||
}
|
|
@ -59,6 +59,7 @@ import updateToSchemaVersion80 from './80-edited-messages';
|
|||
import updateToSchemaVersion81 from './81-contact-removed-notification';
|
||||
import updateToSchemaVersion82 from './82-edited-messages-read-index';
|
||||
import updateToSchemaVersion83 from './83-mentions';
|
||||
import updateToSchemaVersion84 from './84-all-mentions';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -1987,6 +1988,7 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion81,
|
||||
updateToSchemaVersion82,
|
||||
updateToSchemaVersion83,
|
||||
updateToSchemaVersion84,
|
||||
];
|
||||
|
||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue