Link previews in all media

This commit is contained in:
Fedor Indutny
2025-11-14 10:35:51 -08:00
committed by GitHub
parent 11aa120c87
commit 252d38e002
29 changed files with 1122 additions and 422 deletions

View File

@@ -58,6 +58,7 @@ import type { SyncTaskType } from '../util/syncTasks.preload.js';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup.std.js';
import type { AttachmentType } from '../types/Attachment.std.js';
import type { MediaItemMessageType } from '../types/MediaItem.std.js';
import type { LinkPreviewType } from '../types/message/LinkPreviews.std.js';
import type { GifType } from '../components/fun/panels/FunPanelGifs.dom.js';
import type { NotificationProfileType } from '../types/NotificationProfile.std.js';
import type { DonationReceipt } from '../types/Donations.std.js';
@@ -590,7 +591,15 @@ export type GetOlderMediaOptionsType = Readonly<{
messageId?: string;
receivedAt?: number;
sentAt?: number;
type: 'media' | 'files';
type: 'media' | 'documents';
}>;
export type GetOlderLinkPreviewsOptionsType = Readonly<{
conversationId: string;
limit: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
}>;
export type MediaItemDBType = Readonly<{
@@ -599,6 +608,11 @@ export type MediaItemDBType = Readonly<{
message: MediaItemMessageType;
}>;
export type LinkPreviewMediaItemDBType = Readonly<{
preview: LinkPreviewType;
message: MediaItemMessageType;
}>;
export type KyberPreKeyTripleType = Readonly<{
id: PreKeyIdType;
signedPreKeyId: number;
@@ -829,6 +843,9 @@ type ReadableInterface = {
// getOlderMessagesByConversation is JSON on server, full message on Client
hasMedia: (conversationId: string) => boolean;
getOlderMedia: (options: GetOlderMediaOptionsType) => Array<MediaItemDBType>;
getOlderLinkPreviews: (
options: GetOlderLinkPreviewsOptionsType
) => Array<LinkPreviewMediaItemDBType>;
getAllStories: (options: {
conversationId?: string;
sourceServiceId?: ServiceIdString;

View File

@@ -136,11 +136,13 @@ import type {
GetKnownMessageAttachmentsResultType,
GetNearbyMessageFromDeletedSetOptionsType,
GetOlderMediaOptionsType,
GetOlderLinkPreviewsOptionsType,
GetRecentStoryRepliesOptionsType,
GetUnreadByConversationAndMarkReadResultType,
IdentityKeyIdType,
ItemKeyType,
KyberPreKeyTripleType,
LinkPreviewMediaItemDBType,
MediaItemDBType,
MessageAttachmentsCursorType,
MessageCursorType,
@@ -454,6 +456,7 @@ export const DataReader: ServerReadableInterface = {
hasMedia,
getOlderMedia,
getOlderLinkPreviews,
getAllNotificationProfiles,
getNotificationProfileById,
@@ -5192,25 +5195,49 @@ function hasGroupCallHistoryMessage(
}
function hasMedia(db: ReadableDB, conversationId: string): boolean {
const [query, params] = sql`
SELECT EXISTS(
SELECT 1 FROM message_attachments
INDEXED BY message_attachments_getOlderMedia
WHERE
conversationId IS ${conversationId} AND
editHistoryIndex IS -1 AND
attachmentType IS 'attachment' AND
messageType IN ('incoming', 'outgoing') AND
isViewOnce IS NOT 1 AND
contentType IS NOT NULL AND
contentType IS NOT '' AND
contentType IS NOT 'text/x-signal-plain' AND
contentType NOT LIKE 'audio/%'
);
`;
const exists = db.prepare(query, { pluck: true }).get<number>(params);
return db.transaction(() => {
let hasAttachments: boolean;
let hasPreviews: boolean;
return exists === 1;
{
const [query, params] = sql`
SELECT EXISTS(
SELECT 1 FROM message_attachments
INDEXED BY message_attachments_getOlderMedia
WHERE
conversationId IS ${conversationId} AND
editHistoryIndex IS -1 AND
attachmentType IS 'attachment' AND
messageType IN ('incoming', 'outgoing') AND
isViewOnce IS NOT 1 AND
contentType IS NOT NULL AND
contentType IS NOT '' AND
contentType IS NOT 'text/x-signal-plain' AND
contentType NOT LIKE 'audio/%'
);
`;
hasAttachments =
db.prepare(query, { pluck: true }).get<number>(params) === 1;
}
{
const [query, params] = sql`
SELECT EXISTS(
SELECT 1 FROM messages
INDEXED BY messages_hasPreviews
WHERE
conversationId IS ${conversationId} AND
type IN ('incoming', 'outgoing') AND
isViewOnce IS NOT 1 AND
hasPreviews IS 1
);
`;
hasPreviews =
db.prepare(query, { pluck: true }).get<number>(params) === 1;
}
return hasAttachments || hasPreviews;
})();
}
function getOlderMedia(
@@ -5225,26 +5252,30 @@ function getOlderMedia(
}: GetOlderMediaOptionsType
): Array<MediaItemDBType> {
const timeFilters = {
first: sqlFragment`receivedAt = ${maxReceivedAt} AND sentAt < ${maxSentAt}`,
second: sqlFragment`receivedAt < ${maxReceivedAt}`,
first: sqlFragment`
message_attachments.receivedAt = ${maxReceivedAt}
AND
message_attachments.sentAt < ${maxSentAt}
`,
second: sqlFragment`message_attachments.receivedAt < ${maxReceivedAt}`,
};
let contentFilter: QueryFragment;
if (type === 'media') {
// see 'isVisualMedia' in ts/types/Attachment.ts
contentFilter = sqlFragment`
contentType LIKE 'image/%' OR
contentType LIKE 'video/%'
message_attachments.contentType LIKE 'image/%' OR
message_attachments.contentType LIKE 'video/%'
`;
} else if (type === 'files') {
} else if (type === 'documents') {
// see 'isFile' in ts/types/Attachment.ts
contentFilter = sqlFragment`
contentType IS NOT NULL AND
contentType IS NOT '' AND
contentType IS NOT 'text/x-signal-plain' AND
contentType NOT LIKE 'audio/%' AND
contentType NOT LIKE 'image/%' AND
contentType NOT LIKE 'video/%'
message_attachments.contentType IS NOT NULL AND
message_attachments.contentType IS NOT '' AND
message_attachments.contentType IS NOT 'text/x-signal-plain' AND
message_attachments.contentType NOT LIKE 'audio/%' AND
message_attachments.contentType NOT LIKE 'image/%' AND
message_attachments.contentType NOT LIKE 'video/%'
`;
} else {
throw missingCaseError(type);
@@ -5252,21 +5283,25 @@ function getOlderMedia(
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
SELECT
*
message_attachments.*,
messages.source AS messageSource,
messages.sourceServiceId AS messageSourceServiceId
FROM message_attachments
INDEXED BY message_attachments_getOlderMedia
INNER JOIN messages ON
messages.id = message_attachments.messageId
WHERE
conversationId IS ${conversationId} AND
editHistoryIndex IS -1 AND
attachmentType IS 'attachment' AND
message_attachments.conversationId IS ${conversationId} AND
message_attachments.editHistoryIndex IS -1 AND
message_attachments.attachmentType IS 'attachment' AND
(
${timeFilter}
) AND
(${contentFilter}) AND
isViewOnce IS NOT 1 AND
messageType IN ('incoming', 'outgoing') AND
(${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null})
ORDER BY receivedAt DESC, sentAt DESC
message_attachments.isViewOnce IS NOT 1 AND
message_attachments.messageType IN ('incoming', 'outgoing') AND
(${messageId ?? null} IS NULL OR message_attachments.messageId IS NOT ${messageId ?? null})
ORDER BY message_attachments.receivedAt DESC, message_attachments.sentAt DESC
LIMIT ${limit}
`;
@@ -5276,16 +5311,30 @@ function getOlderMedia(
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
`;
const results: Array<MessageAttachmentDBType> = db.prepare(query).all(params);
const results: Array<
MessageAttachmentDBType & {
messageSource: string | null;
messageSourceServiceId: ServiceIdString | null;
}
> = db.prepare(query).all(params);
return results.map(attachment => {
const { orderInMessage, messageType, sentAt, receivedAt, receivedAtMs } =
attachment;
const {
orderInMessage,
messageType,
messageSource,
messageSourceServiceId,
sentAt,
receivedAt,
receivedAtMs,
} = attachment;
return {
message: {
id: attachment.messageId,
type: messageType as 'incoming' | 'outgoing',
source: messageSource ?? undefined,
sourceServiceId: messageSourceServiceId ?? undefined,
conversationId,
receivedAt,
receivedAtMs: receivedAtMs ?? undefined,
@@ -5297,6 +5346,67 @@ function getOlderMedia(
});
}
function getOlderLinkPreviews(
db: ReadableDB,
{
conversationId,
limit,
messageId,
receivedAt: maxReceivedAt = Number.MAX_VALUE,
sentAt: maxSentAt = Number.MAX_VALUE,
}: GetOlderLinkPreviewsOptionsType
): Array<LinkPreviewMediaItemDBType> {
const timeFilters = {
first: sqlFragment`received_at = ${maxReceivedAt} AND sent_at < ${maxSentAt}`,
second: sqlFragment`received_at < ${maxReceivedAt}`,
};
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
SELECT
${sqlJoin(MESSAGE_COLUMNS_FRAGMENTS)}
FROM messages
INDEXED BY messages_hasPreviews
WHERE
conversationId IS ${conversationId} AND
hasPreviews IS 1 AND
isViewOnce IS NOT 1 AND
type IN ('incoming', 'outgoing') AND
(${messageId ?? null} IS NULL OR id IS NOT ${messageId ?? null})
AND (${timeFilter})
ORDER BY received_at DESC, sent_at DESC
LIMIT ${limit}
`;
const [query, params] = sql`
SELECT first.* FROM (${createQuery(timeFilters.first)}) as first
UNION ALL
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
`;
const rows = db.prepare(query).all<MessageTypeUnhydrated>(params);
return hydrateMessages(db, rows).map(message => {
strictAssert(
message.preview != null && message.preview.length >= 1,
`getOlderLinkPreviews: got message without previe ${message.id}`
);
return {
message: {
id: message.id,
type: message.type as 'incoming' | 'outgoing',
conversationId,
source: message.source,
sourceServiceId: message.sourceServiceId,
receivedAt: message.received_at,
receivedAtMs: message.received_at_ms ?? undefined,
sentAt: message.sent_at,
},
preview: message.preview[0],
};
});
}
function _markCallHistoryMissed(
db: WritableDB,
callIds: ReadonlyArray<string>

View File

@@ -0,0 +1,21 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { WritableDB } from '../Interface.std.js';
export default function updateToSchemaVersion1520(db: WritableDB): void {
db.exec(`
ALTER TABLE messages
ADD COLUMN hasPreviews INTEGER NOT NULL
GENERATED ALWAYS AS (
IFNULL(json_array_length(json, '$.preview'), 0) > 0
);
CREATE INDEX messages_hasPreviews
ON messages (conversationId, received_at DESC, sent_at DESC)
WHERE
hasPreviews IS 1 AND
isViewOnce IS NOT 1 AND
type IN ('incoming', 'outgoing');
`);
}

View File

@@ -130,6 +130,7 @@ import updateToSchemaVersion1510 from './1510-chat-folders-normalize-all-chats.s
import updateToSchemaVersion1520 from './1520-poll-votes-unread.std.js';
import updateToSchemaVersion1530 from './1530-update-expiring-index.std.js';
import updateToSchemaVersion1540 from './1540-partial-expiring-index.std.js';
import updateToSchemaVersion1550 from './1550-has-link-preview.std.js';
import { DataWriter } from '../Server.node.js';
@@ -1618,6 +1619,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
{ version: 1520, update: updateToSchemaVersion1520 },
{ version: 1530, update: updateToSchemaVersion1530 },
{ version: 1540, update: updateToSchemaVersion1540 },
{ version: 1550, update: updateToSchemaVersion1550 },
];
export class DBVersionFromFutureError extends Error {