From 32805576ac2fefb6b777f812870aa0dba1a78f8e Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Fri, 23 May 2025 20:35:39 +0400 Subject: [PATCH 1/2] Add link preview Signed-off-by: Kristina Fefelova --- packages/cockroach/src/adapter.ts | 40 +++--- packages/cockroach/src/db/mapping.ts | 34 +++-- packages/cockroach/src/db/message.ts | 133 +++++++++++++++--- packages/cockroach/src/db/schema.ts | 23 ++- packages/cockroach/src/init.ts | 30 +++- packages/query/src/messages/query.ts | 72 ++++++++-- .../query/src/notification-contexts/query.ts | 4 +- packages/query/src/notifications/query.ts | 2 +- packages/query/src/types.ts | 1 + packages/query/src/utils.ts | 19 +++ packages/rest-client/src/rest.ts | 19 +-- packages/rest-client/src/types.ts | 8 +- packages/sdk-types/src/db.ts | 29 ++-- .../sdk-types/src/requestEvents/message.ts | 33 ++++- .../sdk-types/src/responseEvents/message.ts | 30 +++- packages/server/src/middleware/broadcast.ts | 17 +-- packages/server/src/middleware/db.ts | 89 ++++++++---- packages/server/src/middleware/permissions.ts | 2 + packages/server/src/middleware/validate.ts | 55 +++++++- .../server/src/notification/notification.ts | 2 +- packages/server/src/triggers/message.ts | 20 +-- packages/shared/src/patch.ts | 6 +- packages/types/src/file.ts | 13 +- packages/types/src/message.ts | 36 ++++- packages/types/src/query.ts | 1 + packages/yaml/src/deserialize.ts | 17 +-- packages/yaml/src/parse.ts | 15 +- 27 files changed, 548 insertions(+), 202 deletions(-) diff --git a/packages/cockroach/src/adapter.ts b/packages/cockroach/src/adapter.ts index ca39db0..a2e8783 100644 --- a/packages/cockroach/src/adapter.ts +++ b/packages/cockroach/src/adapter.ts @@ -43,9 +43,11 @@ import { type CardType, type MessageData, type PatchData, - type BlobMetadata, NotificationType, - type NotificationContent + type NotificationContent, + type FileData, + type LinkPreviewData, + type LinkPreviewID } from '@hcengineering/communication-types' import type { DbAdapter, @@ -148,32 +150,32 @@ export class CockroachAdapter implements DbAdapter { card: CardID, message: MessageID, messageCreated: Date, - blobId: BlobID, - fileType: string, - filename: string, - size: number, - meta: BlobMetadata | undefined, + data: FileData, creator: SocialID, created: Date ): Promise { - await this.message.createFile( - card, - message, - messageCreated, - blobId, - fileType, - filename, - size, - meta, - creator, - created - ) + await this.message.createFile(card, message, messageCreated, data, creator, created) } async removeFiles(card: CardID, query: RemoveFileQuery): Promise { await this.message.removeFiles(card, query) } + async createLinkPreview( + card: CardID, + message: MessageID, + messageCreated: Date, + data: LinkPreviewData, + creator: SocialID, + created: Date + ): Promise { + return await this.message.createLinkPreview(card, message, messageCreated, data, creator, created) + } + + async removeLinkPreview(card: CardID, message: MessageID, id: LinkPreviewID): Promise { + await this.message.removeLinkPreview(card, message, id) + } + async createThread( card: CardID, message: MessageID, diff --git a/packages/cockroach/src/db/mapping.ts b/packages/cockroach/src/db/mapping.ts index a65f95f..27dc12b 100644 --- a/packages/cockroach/src/db/mapping.ts +++ b/packages/cockroach/src/db/mapping.ts @@ -36,7 +36,9 @@ import { type Label, type CardType, type BlobMetadata, - type AccountID + type AccountID, + type LinkPreview, + type LinkPreviewID } from '@hcengineering/communication-types' import { applyPatches } from '@hcengineering/communication-shared' @@ -50,7 +52,8 @@ import { type PatchDb, type ReactionDb, type ThreadDb, - type LabelDb + type LabelDb, + type LinkPreviewDb } from './schema' interface RawMessage extends MessageDb { @@ -61,6 +64,7 @@ interface RawMessage extends MessageDb { patches?: PatchDb[] files?: FileDb[] reactions?: ReactionDb[] + link_previews?: LinkPreviewDb[] } interface RawNotification extends NotificationDb { @@ -102,7 +106,6 @@ type RawContext = ContextDb & { id: ContextID } & { export function toMessage(raw: RawMessage): Message { const patches = (raw.patches ?? []).map((it) => toPatch(it)) - const rawMessage: Message = { id: String(raw.id) as MessageID, type: raw.type, @@ -126,7 +129,8 @@ export function toMessage(raw: RawMessage): Message { } : undefined, reactions: (raw.reactions ?? []).map(toReaction), - files: (raw.files ?? []).map(toFile) + files: (raw.files ?? []).map(toFile), + links: (raw.link_previews ?? []).map(toLinkPreview) } if (patches.length === 0) { @@ -138,7 +142,6 @@ export function toMessage(raw: RawMessage): Message { export function toReaction(raw: ReactionDb): Reaction { return { - message: String(raw.message_id) as MessageID, reaction: raw.reaction, creator: raw.creator, created: new Date(raw.created) @@ -147,9 +150,6 @@ export function toReaction(raw: ReactionDb): Reaction { export function toFile(raw: Omit): File { return { - card: raw.card_id, - message: String(raw.message_id) as MessageID, - messageCreated: new Date(raw.message_created), blobId: raw.blob_id, type: raw.type, filename: raw.filename, @@ -160,6 +160,21 @@ export function toFile(raw: Omit): File { } } +export function toLinkPreview(raw: LinkPreviewDb): LinkPreview { + return { + id: String(raw.id) as LinkPreviewID, + url: raw.url, + host: raw.host, + title: raw.title ?? undefined, + description: raw.description ?? undefined, + favicon: raw.favicon ?? undefined, + hostname: raw.hostname ?? undefined, + image: raw.image ?? undefined, + created: new Date(raw.created), + creator: raw.creator + } +} + export function toMessagesGroup(raw: MessagesGroupDb): MessagesGroup { return { card: raw.card_id, @@ -272,7 +287,8 @@ function toNotificationRaw(id: ContextID, card: CardID, raw: RawNotification): N edited: undefined, reactions: [], files: messageFiles ?? [], - thread + thread, + links: [] } if (patches.length > 0) { diff --git a/packages/cockroach/src/db/message.ts b/packages/cockroach/src/db/message.ts index bf73b80..e8a0b52 100644 --- a/packages/cockroach/src/db/message.ts +++ b/packages/cockroach/src/db/message.ts @@ -30,12 +30,15 @@ import { type SocialID, SortingOrder, type Thread, - type BlobMetadata + type FileData, + type LinkPreviewData, + type LinkPreviewID } from '@hcengineering/communication-types' import { BaseDb } from './base' import { type FileDb, + type LinkPreviewDb, type MessageDb, messageSchema, type MessagesGroupDb, @@ -146,11 +149,7 @@ export class MessagesDb extends BaseDb { card: CardID, message: MessageID, messageCreated: Date, - blobId: BlobID, - fileType: string, - filename: string, - size: number, - meta: BlobMetadata | undefined, + data: FileData, creator: SocialID, created: Date ): Promise { @@ -158,14 +157,14 @@ export class MessagesDb extends BaseDb { workspace_id: this.workspace, card_id: card, message_id: message, - blob_id: blobId, - type: fileType, - filename, - size, + blob_id: data.blobId, + type: data.type, + filename: data.filename, + size: data.size, creator, created, message_created: messageCreated, - meta + meta: data.meta } const sql = `INSERT INTO ${TableName.File} (workspace_id, card_id, message_id, blob_id, type, filename, creator, created, message_created, size, meta) @@ -213,6 +212,60 @@ export class MessagesDb extends BaseDb { await this.execute(sql, whereValues, 'remove files') } + async createLinkPreview( + card: CardID, + message: MessageID, + messageCreated: Date, + data: LinkPreviewData, + creator: SocialID, + created: Date + ): Promise { + const db: Omit = { + workspace_id: this.workspace, + card_id: card, + message_id: message, + message_created: messageCreated, + url: data.url, + host: data.host, + title: data.title ?? null, + description: data.description ?? null, + favicon: data.favicon ?? null, + hostname: data.hostname ?? null, + image: data.image ?? null, + creator, + created + } + const sql = `INSERT INTO ${TableName.LinkPreview} (workspace_id, card_id, message_id, url, host, title, description, favicon, hostname, image, creator, created, message_created) + VALUES ($1::uuid, $2::varchar, $3::int8, $4::varchar, $5::varchar, $6::varchar, $7::varchar, $8::varchar, $9::varchar, $10::jsonb, $11::varchar, $12::timestamptz, $13::timestamptz) + RETURNING id::text` + const result = await this.execute( + sql, + [ + db.workspace_id, + db.card_id, + db.message_id, + db.url, + db.host, + db.title, + db.description, + db.favicon, + db.hostname, + db.image, + db.creator, + db.created, + db.message_created + ], + 'insert link preview' + ) + + return result[0].id as LinkPreviewID + } + + async removeLinkPreview(card: CardID, message: MessageID, id: LinkPreviewID): Promise { + const sql = `DELETE FROM ${TableName.LinkPreview} WHERE workspace_id = $1::uuid AND card_id = $2::varchar AND message_id = $3::int8 AND id = $4::int8` + await this.execute(sql, [this.workspace, card, message, id], 'remove link preview') + } + // Reaction async createReaction( card: CardID, @@ -411,6 +464,7 @@ export class MessagesDb extends BaseDb { WITH ${this.buildCteLimitedMessages(where, orderBy, limit)} ${this.buildCteAggregatedFiles(params)} + ${this.buildCteAggregatedLinkPreviews(params)} ${this.buildCteAggregatedReactions(params)} ${this.buildCteAggregatedPatches()} ${this.buildMainSelect(params)} @@ -449,9 +503,6 @@ export class MessagesDb extends BaseDb { f.card_id, f.message_id, jsonb_agg(jsonb_build_object( - 'card_id', f.card_id, - 'message_id', f.message_id::text, - 'message_created', f.message_created, 'blob_id', f.blob_id, 'type', f.type, 'size', f.size, @@ -470,6 +521,36 @@ export class MessagesDb extends BaseDb { ` } + private buildCteAggregatedLinkPreviews(params: FindMessagesParams): string { + if (!params.links) return '' + return `, + agg_link_previews AS ( + SELECT + l.workspace_id, + l.card_id, + l.message_id, + jsonb_agg(jsonb_build_object( + 'id', l.id::text, + 'url', l.url, + 'host', l.host, + 'title', l.title, + 'description', l.description, + 'favicon', l.favicon, + 'hostname', l.hostname, + 'image', l.image, + 'creator', l.creator, + 'created', l.created + )) AS link_previews + FROM ${TableName.LinkPreview} l + INNER JOIN limited_messages m + ON m.workspace_id = l.workspace_id + AND m.card_id = l.card_id + AND m.id = l.message_id + GROUP BY l.workspace_id, l.card_id, l.message_id + ) + ` + } + private buildCteAggregatedReactions(params: FindMessagesParams): string { if (!params.reactions) return '' return `, @@ -479,7 +560,6 @@ export class MessagesDb extends BaseDb { r.card_id, r.message_id, jsonb_agg(jsonb_build_object( - 'message_id', r.message_id::text, 'reaction', r.reaction, 'creator', r.creator, 'created', r.created @@ -526,6 +606,9 @@ export class MessagesDb extends BaseDb { : '' const selectFiles = params.files ? `COALESCE(f.files, '[]'::jsonb) AS files,` : `'[]'::jsonb AS files,` + const selectLinks = params.links + ? `COALESCE(l.link_previews, '[]'::jsonb) AS link_previews,` + : `'[]'::jsonb AS link_previews,` const selectReactions = params.reactions ? `COALESCE(r.reactions, '[]'::jsonb) AS reactions,` @@ -539,6 +622,14 @@ export class MessagesDb extends BaseDb { AND f.message_id = m.id` : '' + const joinLinks = params.links + ? ` + LEFT JOIN agg_link_previews l + ON l.workspace_id = m.workspace_id + AND l.card_id = m.card_id + AND l.message_id = m.id` + : '' + const joinReactions = params.reactions ? ` LEFT JOIN agg_reactions r @@ -557,16 +648,18 @@ export class MessagesDb extends BaseDb { m.data, m.external_id, ${selectReplies} - ${selectFiles} - ${selectReactions} - COALESCE(p.patches, '[]'::jsonb) AS patches + ${selectFiles} + ${selectLinks} + ${selectReactions} + COALESCE(p.patches, '[]'::jsonb) AS patches FROM limited_messages m LEFT JOIN ${TableName.Thread} t ON t.workspace_id = m.workspace_id AND t.card_id = m.card_id AND t.message_id = m.id - ${joinFiles} - ${joinReactions} + ${joinFiles} + ${joinLinks} + ${joinReactions} LEFT JOIN agg_patches p ON p.workspace_id = m.workspace_id AND p.card_id = m.card_id diff --git a/packages/cockroach/src/db/schema.ts b/packages/cockroach/src/db/schema.ts index 1d25c98..f65cbf7 100644 --- a/packages/cockroach/src/db/schema.ts +++ b/packages/cockroach/src/db/schema.ts @@ -27,7 +27,9 @@ import { type NotificationID, type LabelID, type CardType, - type BlobMetadata + type BlobMetadata, + type LinkPreviewImage, + type LinkPreviewID } from '@hcengineering/communication-types' import type { NotificationContent, NotificationType } from '@hcengineering/communication-types' @@ -41,7 +43,8 @@ export enum TableName { Reaction = 'communication.reactions', Thread = 'communication.thread', Collaborators = 'communication.collaborators', - Label = 'communication.label' + Label = 'communication.label', + LinkPreview = 'communication.link_preview' } export interface MessageDb { @@ -112,6 +115,22 @@ export interface FileDb { message_created: Date } +export interface LinkPreviewDb { + workspace_id: WorkspaceID + id: LinkPreviewID + card_id: CardID + message_id: MessageID + message_created: Date + url: string + host: string + title: string | null + description: string | null + favicon: string | null + hostname: string | null + image: LinkPreviewImage | null + creator: SocialID + created: Date +} export interface ThreadDb { workspace_id: WorkspaceID card_id: CardID diff --git a/packages/cockroach/src/init.ts b/packages/cockroach/src/init.ts index 960996d..ce0a984 100644 --- a/packages/cockroach/src/init.ts +++ b/packages/cockroach/src/init.ts @@ -109,7 +109,8 @@ function getMigrations(): [string, string][] { migrationV2_4(), migrationV2_5(), migrationV2_6(), - migrationV2_7() + migrationV2_7(), + migrationV3_1() ] } @@ -333,3 +334,30 @@ function migrationV2_7(): [string, string] { return ['set_last_notify_to_last_update-v2_7', sql] } + +function migrationV3_1(): [string, string] { + const sql = ` + CREATE TABLE IF NOT EXISTS communication.link_preview + ( + id INT8 NOT NULL DEFAULT unique_rowid(), + workspace_id UUID NOT NULL, + card_id VARCHAR(255) NOT NULL, + message_id INT8 NOT NULL, + message_created TIMESTAMPTZ NOT NULL, + url TEXT NOT NULL, + host TEXT NOT NULL, + hostname TEXT, + title TEXT, + description TEXT, + favicon TEXT, + image JSONB, + creator VARCHAR(255) NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (id) + ); + + CREATE INDEX IF NOT EXISTS workspace_id_card_id_message_id_idx ON communication.link_preview (workspace_id, card_id, message_id); + ` + + return ['init_link_preview-v3_1', sql] +} diff --git a/packages/query/src/messages/query.ts b/packages/query/src/messages/query.ts index acff16d..657d8ec 100644 --- a/packages/query/src/messages/query.ts +++ b/packages/query/src/messages/query.ts @@ -35,6 +35,8 @@ import { type FileCreatedEvent, type FileRemovedEvent, type FindClient, + type LinkPreviewCreatedEvent, + type LinkPreviewRemovedEvent, type MessageCreatedEvent, MessageRequestEventType, MessageResponseEventType, @@ -60,7 +62,7 @@ import { type QueryId } from '../types' import { WindowImpl } from '../window' -import { addFile, addReaction, removeFile, removeReaction } from '../utils.ts' +import { addFile, addLinkPreview, addReaction, removeFile, removeLinkPreview, removeReaction } from '../utils.ts' const GROUPS_LIMIT = 4 @@ -169,6 +171,14 @@ export class MessagesQuery implements PagedQuery { await this.onFileRemovedEvent(event) break } + case MessageResponseEventType.LinkPreviewCreated: { + await this.onLinkPreviewCreatedEvent(event) + break + } + case MessageResponseEventType.LinkPreviewRemoved: { + await this.onLinkPreviewRemovedEvent(event) + break + } case MessageResponseEventType.ThreadCreated: await this.onThreadCreatedEvent(event) break @@ -231,7 +241,8 @@ export class MessagesQuery implements PagedQuery { edited: undefined, thread: undefined, reactions: [], - files: [] + files: [], + links: [] } if (!this.match(tmpMessage)) return @@ -869,17 +880,17 @@ export class MessagesQuery implements PagedQuery { created: event.reaction.created } - const message = this.result.get(reaction.message) + const message = this.result.get(event.message) if (message !== undefined) { this.result.update(addReaction(message, reaction)) void this.notify() } - const fromNextBuffer = this.next.buffer.find((it) => it.id === reaction.message) + const fromNextBuffer = this.next.buffer.find((it) => it.id === event.message) if (fromNextBuffer !== undefined) { addReaction(fromNextBuffer, reaction) } - const fromPrevBuffer = this.prev.buffer.find((it) => it.id === reaction.message) + const fromPrevBuffer = this.prev.buffer.find((it) => it.id === event.message) if (fromPrevBuffer !== undefined) { addReaction(fromPrevBuffer, reaction) } @@ -907,11 +918,11 @@ export class MessagesQuery implements PagedQuery { } private async onFileCreatedEvent(event: FileCreatedEvent): Promise { - if (this.params.files !== true || event.file.card !== this.params.card) return + if (this.params.files !== true || event.card !== this.params.card) return if (this.result instanceof Promise) this.result = await this.result const { file } = event - const message = this.result.get(file.message) + const message = this.result.get(event.message) if (message !== undefined) { if (!message.files.some((it) => it.blobId === file.blobId)) { message.files.push(file) @@ -920,16 +931,59 @@ export class MessagesQuery implements PagedQuery { } } - const fromNextBuffer = this.next.buffer.find((it) => it.id === file.message) + const fromNextBuffer = this.next.buffer.find((it) => it.id === event.message) if (fromNextBuffer !== undefined) { addFile(fromNextBuffer, file) } - const fromPrevBuffer = this.prev.buffer.find((it) => it.id === file.message) + const fromPrevBuffer = this.prev.buffer.find((it) => it.id === event.message) if (fromPrevBuffer !== undefined) { addFile(fromPrevBuffer, file) } } + private async onLinkPreviewCreatedEvent(event: LinkPreviewCreatedEvent): Promise { + if (this.params.links !== true || this.params.card !== event.card) return + if (this.result instanceof Promise) this.result = await this.result + const message = this.result.get(event.message) + const { linkPreview } = event + if (message !== undefined) { + if (!message.links.some((it) => it.id === linkPreview.id)) { + message.links.push(linkPreview) + this.result.update(message) + await this.notify() + } + } + + const fromNextBuffer = this.next.buffer.find((it) => it.id === event.message) + if (fromNextBuffer !== undefined) { + addLinkPreview(fromNextBuffer, linkPreview) + } + const fromPrevBuffer = this.prev.buffer.find((it) => it.id === event.message) + if (fromPrevBuffer !== undefined) { + addLinkPreview(fromPrevBuffer, linkPreview) + } + } + + private async onLinkPreviewRemovedEvent(event: LinkPreviewRemovedEvent): Promise { + if (this.params.links !== true || this.params.card !== event.card) return + if (this.result instanceof Promise) this.result = await this.result + const message = this.result.get(event.message) + if (message !== undefined) { + const links = message.links.filter((it) => it.id !== event.id) + if (links.length === message.links.length) return + + const updated = { + ...message, + links + } + this.result.update(updated) + await this.notify() + } + + this.next.buffer = this.next.buffer.map((it) => (it.id === event.message ? removeLinkPreview(it, event.id) : it)) + this.prev.buffer = this.prev.buffer.map((it) => (it.id === event.message ? removeLinkPreview(it, event.id) : it)) + } + private async onFileRemovedEvent(event: FileRemovedEvent): Promise { if (this.params.files !== true) return if (this.params.card !== event.card) return diff --git a/packages/query/src/notification-contexts/query.ts b/packages/query/src/notification-contexts/query.ts index ec84085..aa7f031 100644 --- a/packages/query/src/notification-contexts/query.ts +++ b/packages/query/src/notification-contexts/query.ts @@ -343,9 +343,7 @@ export class NotificationContextsQuery implements PagedQuery { - const isUpdated = await this.updateMessage(event.card, event.file.message, (message) => - addFile(message, event.file) - ) + const isUpdated = await this.updateMessage(event.card, event.message, (message) => addFile(message, event.file)) if (isUpdated) { void this.notify() } diff --git a/packages/query/src/notifications/query.ts b/packages/query/src/notifications/query.ts index 2ed31fd..a651bf7 100644 --- a/packages/query/src/notifications/query.ts +++ b/packages/query/src/notifications/query.ts @@ -360,7 +360,7 @@ export class NotificationQuery implements PagedQuery { if (this.params.message !== true) return const isUpdated = await this.updateMessage( - (it) => this.matchNotificationByMessage(it, event.card, event.file.message), + (it) => this.matchNotificationByMessage(it, event.card, event.message), (message) => addFile(message, event.file) ) if (isUpdated) { diff --git a/packages/query/src/types.ts b/packages/query/src/types.ts index 9e55412..8c367d5 100644 --- a/packages/query/src/types.ts +++ b/packages/query/src/types.ts @@ -88,6 +88,7 @@ interface BaseMessageQueryParams { files?: boolean reactions?: boolean replies?: boolean + links?: boolean } export interface ManyMessagesQueryParams extends BaseMessageQueryParams { diff --git a/packages/query/src/utils.ts b/packages/query/src/utils.ts index 037d9af..c3b0b19 100644 --- a/packages/query/src/utils.ts +++ b/packages/query/src/utils.ts @@ -20,6 +20,8 @@ import { type CardType, type File, type FindNotificationsParams, + type LinkPreview, + type LinkPreviewID, type Message, type MessageID, type MessagesGroup, @@ -128,6 +130,23 @@ export function removeFile(message: Message, blobId: BlobID): Message { } } +export function addLinkPreview(message: Message, linkPreview: LinkPreview): Message { + const current = message.links.find((it) => it.id === linkPreview.id) + if (current === undefined) { + message.links.push(linkPreview) + } + return message +} + +export function removeLinkPreview(message: Message, id: LinkPreviewID): Message { + const links = message.links.filter((it) => it.id !== id) + if (links.length === message.links.length) return message + return { + ...message, + links + } +} + export function addReaction(message: Message, reaction: Reaction): Message { const current = message.reactions.find((it) => it.reaction === reaction.reaction && it.creator === reaction.creator) if (current === undefined) { diff --git a/packages/rest-client/src/rest.ts b/packages/rest-client/src/rest.ts index e2201e2..c5d6f9c 100644 --- a/packages/rest-client/src/rest.ts +++ b/packages/rest-client/src/rest.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import { type BlobMetadata, concatLink } from '@hcengineering/core' +import { concatLink } from '@hcengineering/core' import { PlatformError, unknownError } from '@hcengineering/platform' import { MessageRequestEventType, @@ -38,7 +38,8 @@ import { type CardType, type MessageType, PatchType, - type BlobID + type BlobID, + type FileData } from '@hcengineering/communication-types' import { retry } from '@hcengineering/communication-shared' @@ -154,23 +155,15 @@ class RestClientImpl implements RestClient { card: CardID, message: MessageID, messageCreated: Date, - blobId: BlobID, - fileType: string, - filename: string, - size: number, - creator: SocialID, - meta?: BlobMetadata + data: FileData, + creator: SocialID ): Promise { await this.event({ type: MessageRequestEventType.CreateFile, card, message, messageCreated, - blobId, - fileType, - filename, - size, - meta, + data, creator }) } diff --git a/packages/rest-client/src/types.ts b/packages/rest-client/src/types.ts index 156c020..6a1c21e 100644 --- a/packages/rest-client/src/types.ts +++ b/packages/rest-client/src/types.ts @@ -30,7 +30,8 @@ import type { MessageData, BlobID, CardID, - CardType + CardType, + FileData } from '@hcengineering/communication-types' export interface RestClient { @@ -62,10 +63,7 @@ export interface RestClient { card: CardID, message: MessageID, messageCreated: Date, - blobId: BlobID, - fileType: string, - filename: string, - size: number, + data: FileData, creator: SocialID ) => Promise removeFile: ( diff --git a/packages/sdk-types/src/db.ts b/packages/sdk-types/src/db.ts index 3b0dd38..3d7b8dc 100644 --- a/packages/sdk-types/src/db.ts +++ b/packages/sdk-types/src/db.ts @@ -41,11 +41,12 @@ import type { LabelID, CardType, PatchData, - File, - BlobMetadata, NotificationContent, NotificationType, - ComparisonOperator + ComparisonOperator, + FileData, + LinkPreviewData, + LinkPreviewID } from '@hcengineering/communication-types' export interface DbAdapter { @@ -95,16 +96,23 @@ export interface DbAdapter { card: CardID, message: MessageID, messageCreated: Date, - blobId: BlobID, - fileType: string, - filename: string, - size: number, - meta: BlobMetadata | undefined, + data: FileData, creator: SocialID, created: Date ): Promise removeFiles(card: CardID, query: RemoveFileQuery): Promise + createLinkPreview( + card: CardID, + message: MessageID, + messageCreated: Date, + data: LinkPreviewData, + creator: SocialID, + created: Date + ): Promise + + removeLinkPreview(card: CardID, message: MessageID, id: LinkPreviewID): Promise + createThread( card: CardID, message: MessageID, @@ -162,7 +170,10 @@ export interface DbAdapter { close(): void } -export type RemoveFileQuery = Partial> +export type RemoveFileQuery = { + message: MessageID + blobId: BlobID +} export type RemoveThreadQuery = Partial> export type RemoveCollaboratorsQuery = { accounts?: AccountID[] diff --git a/packages/sdk-types/src/requestEvents/message.ts b/packages/sdk-types/src/requestEvents/message.ts index c3385b6..6e2d129 100644 --- a/packages/sdk-types/src/requestEvents/message.ts +++ b/packages/sdk-types/src/requestEvents/message.ts @@ -25,7 +25,9 @@ import type { MessageData, CardType, PatchData, - BlobMetadata + LinkPreviewID, + FileData, + LinkPreviewData } from '@hcengineering/communication-types' import type { BaseRequestEvent } from './common' @@ -40,6 +42,9 @@ export enum MessageRequestEventType { CreateFile = 'createFile', RemoveFile = 'removeFile', + CreateLinkPreview = 'createLinkPreview', + RemoveLinkPreview = 'removeLinkPreview', + CreateThread = 'createThread', UpdateThread = 'updateThread', @@ -58,6 +63,8 @@ export type MessageRequestEvent = | RemoveMessagesGroupEvent | RemoveReactionEvent | UpdateThreadEvent + | CreateLinkPreviewEvent + | RemoveLinkPreviewEvent export interface CreateMessageEvent extends BaseRequestEvent { type: MessageRequestEventType.CreateMessage @@ -106,12 +113,8 @@ export interface CreateFileEvent extends BaseRequestEvent { card: CardID message: MessageID messageCreated: Date - blobId: BlobID - size: number - fileType: string - filename: string + data: FileData creator: SocialID - meta?: BlobMetadata } export interface RemoveFileEvent extends BaseRequestEvent { @@ -123,6 +126,24 @@ export interface RemoveFileEvent extends BaseRequestEvent { creator: SocialID } +export interface CreateLinkPreviewEvent extends BaseRequestEvent { + type: MessageRequestEventType.CreateLinkPreview + card: CardID + message: MessageID + messageCreated: Date + data: LinkPreviewData + creator: SocialID +} + +export interface RemoveLinkPreviewEvent extends BaseRequestEvent { + type: MessageRequestEventType.RemoveLinkPreview + card: CardID + message: MessageID + messageCreated: Date + id: LinkPreviewID + creator: SocialID +} + export interface CreateThreadEvent extends BaseRequestEvent { type: MessageRequestEventType.CreateThread card: CardID diff --git a/packages/sdk-types/src/responseEvents/message.ts b/packages/sdk-types/src/responseEvents/message.ts index c206e8e..eeaf64e 100644 --- a/packages/sdk-types/src/responseEvents/message.ts +++ b/packages/sdk-types/src/responseEvents/message.ts @@ -24,7 +24,9 @@ import type { Thread, MessagesGroup, BlobID, - CardType + CardType, + LinkPreview, + LinkPreviewID } from '@hcengineering/communication-types' import type { BaseResponseEvent } from './common' @@ -39,6 +41,9 @@ export enum MessageResponseEventType { FileCreated = 'fileCreated', FileRemoved = 'fileRemoved', + LinkPreviewCreated = 'linkPreviewCreated', + LinkPreviewRemoved = 'linkPreviewRemoved', + ThreadCreated = 'threadCreated', ThreadUpdated = 'threadUpdated', @@ -57,6 +62,8 @@ export type MessageResponseEvent = | ReactionRemovedEvent | ThreadCreatedEvent | ThreadUpdatedEvent + | LinkPreviewCreatedEvent + | LinkPreviewRemovedEvent export interface MessageCreatedEvent extends BaseResponseEvent { type: MessageResponseEventType.MessageCreated @@ -73,8 +80,9 @@ export interface PatchCreatedEvent extends BaseResponseEvent { export interface ReactionCreatedEvent extends BaseResponseEvent { type: MessageResponseEventType.ReactionCreated card: CardID - reaction: Reaction + message: MessageID messageCreated: Date + reaction: Reaction } export interface ReactionRemovedEvent extends BaseResponseEvent { @@ -89,9 +97,27 @@ export interface ReactionRemovedEvent extends BaseResponseEvent { export interface FileCreatedEvent extends BaseResponseEvent { type: MessageResponseEventType.FileCreated card: CardID + message: MessageID + messageCreated: Date file: File } +export interface LinkPreviewCreatedEvent extends BaseResponseEvent { + type: MessageResponseEventType.LinkPreviewCreated + card: CardID + message: MessageID + messageCreated: Date + linkPreview: LinkPreview +} + +export interface LinkPreviewRemovedEvent extends BaseResponseEvent { + type: MessageResponseEventType.LinkPreviewRemoved + card: CardID + message: MessageID + messageCreated: Date + id: LinkPreviewID +} + export interface FileRemovedEvent extends BaseResponseEvent { type: MessageResponseEventType.FileRemoved card: CardID diff --git a/packages/server/src/middleware/broadcast.ts b/packages/server/src/middleware/broadcast.ts index bbaa866..0fa160b 100644 --- a/packages/server/src/middleware/broadcast.ts +++ b/packages/server/src/middleware/broadcast.ts @@ -191,23 +191,10 @@ export class BroadcastMiddleware extends BaseMiddleware implements Middleware { new Set(Array.from(info.contextQueries.values()).flatMap((it) => Array.from(it))) ) case MessageResponseEventType.ReactionCreated: - return this.matchMessagesQuery( - { card: event.card, ids: [event.reaction.message] }, - Array.from(info.messageQueries.values()), - new Set() - ) case MessageResponseEventType.ReactionRemoved: - return this.matchMessagesQuery( - { card: event.card, ids: [event.message] }, - Array.from(info.messageQueries.values()), - new Set() - ) + case MessageResponseEventType.LinkPreviewCreated: + case MessageResponseEventType.LinkPreviewRemoved: case MessageResponseEventType.FileCreated: - return this.matchMessagesQuery( - { card: event.card, ids: [event.file.message] }, - Array.from(info.messageQueries.values()), - new Set() - ) case MessageResponseEventType.FileRemoved: return this.matchMessagesQuery( { card: event.card, ids: [event.message] }, diff --git a/packages/server/src/middleware/db.ts b/packages/server/src/middleware/db.ts index 07f1c19..84c5a8e 100644 --- a/packages/server/src/middleware/db.ts +++ b/packages/server/src/middleware/db.ts @@ -35,6 +35,7 @@ import { CardResponseEventType, type CreateFileEvent, type CreateLabelEvent, + type CreateLinkPreviewEvent, type CreateMessageEvent, type CreateMessagesGroupEvent, type CreateNotificationContextEvent, @@ -48,6 +49,8 @@ import { type FileRemovedEvent, LabelRequestEventType, LabelResponseEventType, + type LinkPreviewCreatedEvent, + type LinkPreviewRemovedEvent, type MessageCreatedEvent, MessageRequestEventType, MessageResponseEventType, @@ -66,6 +69,7 @@ import { type RemoveCollaboratorsEvent, type RemoveFileEvent, type RemoveLabelEvent, + type RemoveLinkPreviewEvent, type RemoveMessagesGroupEvent, type RemoveNotificationContextEvent, type RemoveNotificationsEvent, @@ -82,8 +86,6 @@ import { import type { Middleware, MiddlewareContext } from '../types' import { BaseMiddleware } from './base' -import { systemAccountUuid } from '@hcengineering/core' -import { findMessage } from '../triggers/utils.ts' interface Result { responseEvent?: ResponseEvent @@ -181,6 +183,10 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { return await this.updateCardType(event) case CardRequestEventType.RemoveCard: return await this.removeCard(event) + case MessageRequestEventType.CreateLinkPreview: + return await this.createLinkPreview(event) + case MessageRequestEventType.RemoveLinkPreview: + return await this.removeLinkPreview(event) } } @@ -251,7 +257,8 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { data: event.data, externalId: event.externalId, reactions: [], - files: [] + files: [], + links: [] } const responseEvent: MessageCreatedEvent = { _id: event._id, @@ -308,7 +315,6 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { ) const reaction: Reaction = { - message: event.message, reaction: event.reaction, creator: event.creator, created @@ -317,8 +323,9 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { _id: event._id, type: MessageResponseEventType.ReactionCreated, card: event.card, - reaction, - messageCreated: event.messageCreated + message: event.message, + messageCreated: event.messageCreated, + reaction } return { responseEvent @@ -343,32 +350,16 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { private async createFile(event: CreateFileEvent): Promise { const created = new Date() - await this.db.createFile( - event.card, - event.message, - event.messageCreated, - event.blobId, - event.fileType, - event.filename, - event.size, - event.meta, - event.creator, - created - ) + await this.db.createFile(event.card, event.message, event.messageCreated, event.data, event.creator, created) const responseEvent: FileCreatedEvent = { _id: event._id, type: MessageResponseEventType.FileCreated, card: event.card, + message: event.message, + messageCreated: event.messageCreated, file: { - card: event.card, - message: event.message, - messageCreated: event.messageCreated, - blobId: event.blobId, - type: event.fileType, - filename: event.filename, - size: event.size, + ...event.data, creator: event.creator, - meta: event.meta, created } } @@ -393,6 +384,52 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { } } + private async createLinkPreview(event: CreateLinkPreviewEvent): Promise { + const created = new Date() + const id = await this.db.createLinkPreview( + event.card, + event.message, + event.messageCreated, + event.data, + event.creator, + created + ) + + const responseEvent: LinkPreviewCreatedEvent = { + _id: event._id, + type: MessageResponseEventType.LinkPreviewCreated, + card: event.card, + message: event.message, + messageCreated: event.messageCreated, + linkPreview: { + id, + ...event.data, + creator: event.creator, + created + } + } + + return { + responseEvent + } + } + + private async removeLinkPreview(event: RemoveLinkPreviewEvent): Promise { + await this.db.removeLinkPreview(event.card, event.message, event.id) + const responseEvent: LinkPreviewRemovedEvent = { + _id: event._id, + type: MessageResponseEventType.LinkPreviewRemoved, + card: event.card, + message: event.message, + messageCreated: event.messageCreated, + id: event.id + } + + return { + responseEvent + } + } + private async createNotification(event: CreateNotificationEvent): Promise { const id = await this.db.createNotification( event.context, diff --git a/packages/server/src/middleware/permissions.ts b/packages/server/src/middleware/permissions.ts index 37f3c8a..e6e2bb0 100644 --- a/packages/server/src/middleware/permissions.ts +++ b/packages/server/src/middleware/permissions.ts @@ -61,6 +61,8 @@ export class PermissionsMiddleware extends BaseMiddleware implements Middleware case MessageRequestEventType.CreateReaction: case MessageRequestEventType.RemoveReaction: case MessageRequestEventType.RemoveFile: + case MessageRequestEventType.CreateLinkPreview: + case MessageRequestEventType.RemoveLinkPreview: case MessageRequestEventType.CreateFile: { this.checkSocialId(session, event.creator) break diff --git a/packages/server/src/middleware/validate.ts b/packages/server/src/middleware/validate.ts index 7b57e35..0339184 100644 --- a/packages/server/src/middleware/validate.ts +++ b/packages/server/src/middleware/validate.ts @@ -161,6 +161,12 @@ export class ValidateMiddleware extends BaseMiddleware implements Middleware { case NotificationRequestEventType.UpdateNotificationContext: this.validate(event, UpdateNotificationContextEventSchema) break + case MessageRequestEventType.CreateLinkPreview: + this.validate(event, CreateLinkPreviewEventSchema) + break + case MessageRequestEventType.RemoveLinkPreview: + this.validate(event, RemoveLinkPreviewEventSchema) + break } return await this.provideEvent(session, deserializeEvent(event), derived) } @@ -201,6 +207,7 @@ const FindMessagesParamsSchema = FindParamsSchema.extend({ files: z.boolean().optional(), reactions: z.boolean().optional(), replies: z.boolean().optional(), + links: z.boolean().optional(), created: dateOrRecordSchema.optional() }).strict() @@ -321,12 +328,14 @@ const CreateFileEventSchema = BaseRequestEventSchema.extend({ card: CardID, message: MessageID, messageCreated: DateSchema, - blobId: BlobID, - size: z.number(), - fileType: z.string(), - filename: z.string(), - creator: SocialID, - meta: z.record(z.string(), z.any()).optional() + data: z.object({ + blobId: BlobID, + size: z.number(), + type: z.string(), + filename: z.string(), + meta: z.record(z.string(), z.any()).optional() + }), + creator: SocialID }).strict() const RemoveFileEventSchema = BaseRequestEventSchema.extend({ @@ -338,6 +347,38 @@ const RemoveFileEventSchema = BaseRequestEventSchema.extend({ blobId: BlobID }).strict() +const CreateLinkPreviewEventSchema = BaseRequestEventSchema.extend({ + type: z.literal(MessageRequestEventType.CreateLinkPreview), + card: CardID, + message: MessageID, + messageCreated: DateSchema, + data: z.object({ + url: z.string(), + host: z.string(), + title: z.string().optional(), + description: z.string().optional(), + favicon: z.string().optional(), + hostname: z.string().optional(), + image: z + .object({ + url: z.string(), + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }), + creator: SocialID +}).strict() + +const RemoveLinkPreviewEventSchema = BaseRequestEventSchema.extend({ + type: z.literal(MessageRequestEventType.RemoveLinkPreview), + card: CardID, + message: MessageID, + messageCreated: DateSchema, + id: z.string(), + creator: SocialID +}).strict() + const CreateThreadEventSchema = BaseRequestEventSchema.extend({ type: z.literal(MessageRequestEventType.CreateThread), card: CardID, @@ -456,6 +497,8 @@ function deserializeEvent(event: RequestEvent): RequestEvent { case MessageRequestEventType.RemoveReaction: case MessageRequestEventType.CreateReaction: case MessageRequestEventType.CreatePatch: + case MessageRequestEventType.CreateLinkPreview: + case MessageRequestEventType.RemoveLinkPreview: return { ...event, messageCreated: deserializeDate(event.messageCreated)! diff --git a/packages/server/src/notification/notification.ts b/packages/server/src/notification/notification.ts index 3354dcf..12d4d2b 100644 --- a/packages/server/src/notification/notification.ts +++ b/packages/server/src/notification/notification.ts @@ -49,7 +49,7 @@ export async function notify(ctx: TriggerCtx, event: ResponseEvent): Promise { ctx.registeredCards.delete(event.group.card) @@ -80,7 +80,7 @@ async function onMessagesRemoved(ctx: TriggerCtx, event: PatchCreatedEvent): Pro } async function onFileCreated(ctx: TriggerCtx, event: FileCreatedEvent): Promise { - const message = (await ctx.db.findMessages({ card: event.card, id: event.file.message, limit: 1 }))[0] + const message = (await ctx.db.findMessages({ card: event.card, id: event.message, limit: 1 }))[0] if (message !== undefined) return [] const { file } = event @@ -96,8 +96,8 @@ async function onFileCreated(ctx: TriggerCtx, event: FileCreatedEvent): Promise< type: MessageRequestEventType.CreatePatch, patchType: PatchType.addFile, card: event.card, - message: file.message, - messageCreated: file.messageCreated, + message: event.message, + messageCreated: event.messageCreated, data: patchData, creator: file.creator } @@ -285,11 +285,13 @@ async function onThreadCreated(ctx: TriggerCtx, event: ThreadCreatedEvent): Prom card: event.thread.thread, message: messageId, messageCreated: message.created, - blobId: file.blobId, - fileType: file.type, - filename: file.filename, - size: file.size, - meta: file.meta, + data: { + blobId: file.blobId, + type: file.type, + filename: file.filename, + size: file.size, + meta: file.meta + }, creator: file.creator }) } diff --git a/packages/shared/src/patch.ts b/packages/shared/src/patch.ts index be99522..d812d07 100644 --- a/packages/shared/src/patch.ts +++ b/packages/shared/src/patch.ts @@ -55,7 +55,6 @@ export function applyPatch(message: Message, patch: Patch, allowedPatchTypes: Pa } case PatchType.addReaction: return addReaction(message, { - message: message.id, reaction: patch.data.reaction, creator: patch.creator, created: patch.created @@ -124,11 +123,8 @@ function addFile(message: Message, data: AddFilePatchData, created: Date, creato if (isExists !== undefined) return message message.files.push({ ...data, - card: message.card, - message: message.id, created, - creator, - messageCreated: message.created + creator }) return message } diff --git a/packages/types/src/file.ts b/packages/types/src/file.ts index 7837ec2..fd8f0d9 100644 --- a/packages/types/src/file.ts +++ b/packages/types/src/file.ts @@ -14,7 +14,7 @@ // import type { BlobID, CardID, CardType, RichText, SocialID } from './core' -import type { Message, MessageID, MessageType, MessageData } from './message' +import type { Message, MessageID, MessageType, MessageData, LinkPreview, Reaction, File } from './message' export interface FileMetadata { card: CardID @@ -33,8 +33,9 @@ export interface FileMessage { externalId?: string created: Date edited?: Date - reactions: FileReaction[] - files: FileBlob[] + reactions: Reaction[] + files: File[] + links: LinkPreview[] thread?: FileThread } @@ -48,12 +49,6 @@ export interface FileBlob { meta: Record } -export interface FileReaction { - reaction: string - creator: SocialID - created: Date -} - export interface FileThread { thread: CardID threadType: CardType diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 8d0a738..ee82822 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -34,6 +34,7 @@ export interface Message { thread?: Thread reactions: Reaction[] files: File[] + links: LinkPreview[] } export enum MessageType { @@ -193,25 +194,48 @@ export enum PatchType { } export interface Reaction { - message: MessageID reaction: string creator: SocialID created: Date } -export interface File { - card: CardID - message: MessageID - messageCreated: Date +export interface FileData { blobId: BlobID type: string filename: string size: number - creator: SocialID meta?: BlobMetadata +} + +export interface File extends FileData { + creator: SocialID created: Date } +export type LinkPreviewID = string & { __linkPreviewId: true } + +export interface LinkPreviewData { + url: string + host: string + title?: string + description?: string + favicon?: string + hostname?: string + image?: LinkPreviewImage +} + +export interface LinkPreview extends LinkPreviewData { + id: LinkPreviewID + creator: SocialID + created: Date +} + +export interface LinkPreviewImage { + url: string + width?: number + height?: number +} + export interface Thread { card: CardID message: MessageID diff --git a/packages/types/src/query.ts b/packages/types/src/query.ts index c3cec71..cf43144 100644 --- a/packages/types/src/query.ts +++ b/packages/types/src/query.ts @@ -48,6 +48,7 @@ export interface FindMessagesParams extends FindParams { files?: boolean reactions?: boolean replies?: boolean + links?: boolean created?: Partial> | Date } diff --git a/packages/yaml/src/deserialize.ts b/packages/yaml/src/deserialize.ts index d341236..22df10a 100644 --- a/packages/yaml/src/deserialize.ts +++ b/packages/yaml/src/deserialize.ts @@ -35,19 +35,8 @@ export function deserializeMessage(message: Message): FileMessage { lastReply: message.thread.lastReply } : undefined, - files: message.files.map((file) => ({ - blobId: file.blobId, - type: file.type, - filename: file.filename, - size: file.size, - created: file.created, - creator: file.creator, - meta: file.meta ?? {} - })), - reactions: message.reactions.map((reaction) => ({ - reaction: reaction.reaction, - creator: reaction.creator, - created: reaction.created - })) + files: message.files, + reactions: message.reactions, + links: message.links } } diff --git a/packages/yaml/src/parse.ts b/packages/yaml/src/parse.ts index 8b43c12..762835c 100644 --- a/packages/yaml/src/parse.ts +++ b/packages/yaml/src/parse.ts @@ -83,18 +83,9 @@ export function parseYaml(data: string): ParsedFile { lastReply: message.thread.lastReply } : undefined, - files: message.files.map((file) => ({ - ...file, - message: message.id, - messageCreated: message.created, - card: metadata.card - })), - reactions: message.reactions.map((reaction) => ({ - message: message.id, - reaction: reaction.reaction, - creator: reaction.creator, - created: reaction.created - })) + files: message.files, + reactions: message.reactions, + links: message.links ?? [] })) } } From b5965dbe592c1e4fe34042860b5f086e62a816f6 Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Fri, 23 May 2025 20:54:45 +0400 Subject: [PATCH 2/2] Update version Signed-off-by: Kristina Fefelova --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 7e87f1d..75c3441 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.1.191 +0.1.192