diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 869cd0dd7d..3ffc48f869 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -40,7 +40,7 @@ import { taskId } from '@hcengineering/task' import telegram, { telegramId } from '@hcengineering/telegram' import { templatesId } from '@hcengineering/templates' import tracker, { trackerId } from '@hcengineering/tracker' -import uiPlugin, { getCurrentLocation, locationStorageKeyId, locationToUrl, navigate, parseLocation, setLocationStorageKey } from '@hcengineering/ui' +import uiPlugin, { getCurrentLocation, locationStorageKeyId, navigate, setLocationStorageKey } from '@hcengineering/ui' import { uploaderId } from '@hcengineering/uploader' import { viewId } from '@hcengineering/view' import workbench, { workbenchId } from '@hcengineering/workbench' @@ -215,6 +215,7 @@ export async function configurePlatform (): Promise<void> { setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG)) setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL)) setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL) + setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL ?? '') setMetadata(presentation.metadata.StatsUrl, config.STATS_URL) setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR ?? '') @@ -238,7 +239,7 @@ export async function configurePlatform (): Promise<void> { setMetadata(notification.metadata.PushPublicKey, config.PUSH_PUBLIC_KEY) setMetadata(rekoni.metadata.RekoniUrl, config.REKONI_URL) - setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true' ?? false) + setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true') setMetadata(love.metadata.ServiceEnpdoint, config.LOVE_ENDPOINT) setMetadata(love.metadata.WebSocketURL, config.LIVEKIT_WS) setMetadata(print.metadata.PrintURL, config.PRINT_URL) diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 195ac20381..30f4daef1f 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -5,39 +5,39 @@ import { ScreenSource } from '@hcengineering/love' */ export interface Config { ACCOUNTS_URL: string + AI_URL?: string + ANALYTICS_COLLECTOR_URL?: string + BRANDING_URL?: string + CALENDAR_URL: string COLLABORATOR?: string COLLABORATOR_URL: string - FRONT_URL: string + CONFIG_URL: string + DESKTOP_UPDATES_CHANNEL?: string + DESKTOP_UPDATES_URL?: string + DISABLE_SIGNUP?: string FILES_URL: string - UPLOAD_URL: string - MODEL_VERSION?: string - VERSION?: string - TELEGRAM_URL: string - GMAIL_URL: string - CALENDAR_URL: string - REKONI_URL: string - INITIAL_URL: string + FRONT_URL: string GITHUB_APP: string GITHUB_CLIENTID: string GITHUB_URL: string - CONFIG_URL: string - LOVE_ENDPOINT?: string + GMAIL_URL: string + INITIAL_URL: string + LINK_PREVIEW_URL?: string LIVEKIT_WS?: string - SIGN_URL?: string + LOVE_ENDPOINT?: string + MODEL_VERSION?: string + PRESENCE_URL?: string + PREVIEW_CONFIG: string PRINT_URL?: string PUSH_PUBLIC_KEY: string - ANALYTICS_COLLECTOR_URL?: string - AI_URL?:string - DISABLE_SIGNUP?: string - BRANDING_URL?: string - PREVIEW_CONFIG: string - UPLOAD_CONFIG: string - DESKTOP_UPDATES_URL?: string - DESKTOP_UPDATES_CHANNEL?: string - TELEGRAM_BOT_URL?: string - PRESENCE_URL?: string - + REKONI_URL: string + SIGN_URL?: string STATS_URL?: string + TELEGRAM_BOT_URL?: string + TELEGRAM_URL: string + UPLOAD_CONFIG: string + UPLOAD_URL: string + VERSION?: string } export interface Branding { diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 23e83112ee..cfee6d5396 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -1,5 +1,5 @@ // -// Copyright © 2022, 2023 Hardcore Engineering Inc. +// Copyright © 2022, 2023, 2025 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may @@ -157,6 +157,7 @@ export interface Config { TELEGRAM_BOT_URL?: string AI_URL?:string DISABLE_SIGNUP?: string + LINK_PREVIEW_URL?: string // Could be defined for dev environment FRONT_URL?: string PREVIEW_CONFIG?: string @@ -314,7 +315,7 @@ export async function configurePlatform() { setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG)) setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL)) setMetadata(presentation.metadata.StatsUrl, config.STATS_URL) - + setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL) setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR) if (config.MODEL_VERSION != null) { @@ -340,7 +341,7 @@ export async function configurePlatform() { setMetadata(rekoni.metadata.RekoniUrl, config.REKONI_URL) setMetadata(uiPlugin.metadata.DefaultApplication, login.component.LoginApp) - setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true' ?? false) + setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true') setMetadata(love.metadata.ServiceEnpdoint, config.LOVE_ENDPOINT) setMetadata(love.metadata.WebSocketURL, config.LIVEKIT_WS) setMetadata(print.metadata.PrintURL, config.PRINT_URL) diff --git a/packages/presentation/src/drawing.ts b/packages/presentation/src/drawing.ts index 59e20f1232..f5c2a1dabe 100644 --- a/packages/presentation/src/drawing.ts +++ b/packages/presentation/src/drawing.ts @@ -12,11 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. // - export interface DrawingData { content?: string } - export interface DrawingProps { readonly: boolean autoSize?: boolean diff --git a/packages/presentation/src/file.ts b/packages/presentation/src/file.ts index b526378f2b..c5beacb19a 100644 --- a/packages/presentation/src/file.ts +++ b/packages/presentation/src/file.ts @@ -284,3 +284,13 @@ async function uploadFileWithSignedUrl (file: File, uuid: string, uploadUrl: str }) } } + +export async function getJsonOrEmpty (file: string, name: string): Promise<any> { + try { + const fileUrl = getFileUrl(file, name) + const resp = await fetch(fileUrl) + return await resp.json() + } catch { + return {} + } +} diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 62a913052d..0a6f29a548 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -69,3 +69,4 @@ export * from './preview' export * from './sound' export * from './stats' export * from './drawing' +export * from './link-preview' diff --git a/packages/presentation/src/link-preview.ts b/packages/presentation/src/link-preview.ts new file mode 100644 index 0000000000..580f18ae91 --- /dev/null +++ b/packages/presentation/src/link-preview.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { getMetadata } from '@hcengineering/platform' +import plugin from './plugin' + +export function isLinkPreviewEnabled (): boolean { + return getMetadata(plugin.metadata.LinkPreviewUrl) !== undefined +} +export interface LinkPreviewDetails { + title?: string + description?: string + url?: string + icon?: string + image?: string + charset?: string + hostname?: string + host?: string +} + +export function canDisplayLinkPreview (val: LinkPreviewDetails): boolean { + if (val.hostname === undefined) { + return false + } + if (val.image === undefined && val.description === undefined) { + return false + } + if (val.title === undefined && val.description === undefined) { + return false + } + return true +} + +export async function fetchLinkPreviewDetails (url: string, timeoutMs = 15000): Promise<LinkPreviewDetails> { + try { + const linkPreviewUrl = getMetadata(plugin.metadata.LinkPreviewUrl) + let token: string = '' + if (getMetadata(plugin.metadata.Token) !== undefined) { + token = getMetadata(plugin.metadata.Token) as string + } + const response = await fetch(`${linkPreviewUrl}/details?q=${url}`, { + headers: { Authorization: 'Bearer ' + token }, + signal: AbortSignal.timeout(timeoutMs) + }) + return response.json() as LinkPreviewDetails + } catch { + return {} + } +} diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 84c592159e..7737e4a664 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -144,6 +144,7 @@ export default plugin(presentationId, { Workspace: '' as Metadata<string>, WorkspaceId: '' as Metadata<string>, FrontUrl: '' as Asset, + LinkPreviewUrl: '' as Metadata<string>, UploadConfig: '' as Metadata<UploadConfig>, PreviewConfig: '' as Metadata<PreviewConfig | undefined>, ClientHook: '' as Metadata<ClientHook>, diff --git a/plugins/attachment-resources/src/components/AttachmentDocList.svelte b/plugins/attachment-resources/src/components/AttachmentDocList.svelte index 335893196f..474372c8bc 100644 --- a/plugins/attachment-resources/src/components/AttachmentDocList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentDocList.svelte @@ -14,12 +14,12 @@ --> <script lang="ts"> import { Attachment } from '@hcengineering/attachment' - import core, { type Doc, type Ref, type WithLookup } from '@hcengineering/core' + import { type Doc, type Ref, type WithLookup } from '@hcengineering/core' import { createQuery } from '@hcengineering/presentation' import attachment from '../plugin' import { AttachmentImageSize } from '../types' - import AttachmentList from './AttachmentList.svelte' + import AttachmentGroup from './AttachmentGroup.svelte' export let value: Doc & { attachments?: number } export let attachments: Attachment[] | undefined = undefined @@ -39,7 +39,6 @@ resAttachments = attachments return } - if (value && value.attachments && value.attachments > 0) { query.query( attachment.class.Attachment, @@ -60,4 +59,4 @@ }) </script> -<AttachmentList attachments={resAttachments} {savedAttachmentsIds} {imageSize} {videoPreload} /> +<AttachmentGroup attachments={resAttachments} {savedAttachmentsIds} {imageSize} {videoPreload} /> diff --git a/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte b/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte index c12836f692..666e4d3767 100644 --- a/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte +++ b/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte @@ -42,6 +42,7 @@ function openAttachment (): void { showAttachmentPreviewPopup(value) } + $: src = getFileUrl(value.file, value.name) </script> diff --git a/plugins/attachment-resources/src/components/AttachmentGroup.svelte b/plugins/attachment-resources/src/components/AttachmentGroup.svelte new file mode 100644 index 0000000000..96d4295239 --- /dev/null +++ b/plugins/attachment-resources/src/components/AttachmentGroup.svelte @@ -0,0 +1,51 @@ +<!-- // +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// --> + +<script lang="ts"> + import { type Attachment } from '@hcengineering/attachment' + import { type WithLookup, Ref } from '@hcengineering/core' + import { AttachmentImageSize } from '../types' + import AttachmentList from './AttachmentList.svelte' + import LinkPreviewList from './LinkPreviewList.svelte' + + export let attachments: WithLookup<Attachment>[] + export let savedAttachmentsIds: Ref<Attachment>[] = [] + export let imageSize: AttachmentImageSize = 'auto' + export let videoPreload = true + + let otherAttachments: WithLookup<Attachment>[] + let linkPreviewAttachments: WithLookup<Attachment>[] + + $: filter(attachments) + function filter (value: WithLookup<Attachment>[]): void { + linkPreviewAttachments = [] + otherAttachments = [] + for (const attachment of value) { + if (attachment === undefined) { + continue + } + if (attachment.type === 'application/link-preview') { + linkPreviewAttachments.push(attachment) + } else { + otherAttachments.push(attachment) + } + } + } +</script> + +<div class="gapV-2"> + <AttachmentList attachments={otherAttachments} {savedAttachmentsIds} {imageSize} {videoPreload} /> + <LinkPreviewList attachments={linkPreviewAttachments} /> +</div> diff --git a/plugins/attachment-resources/src/components/AttachmentList.svelte b/plugins/attachment-resources/src/components/AttachmentList.svelte index c71622a2a4..84ac7baaf2 100644 --- a/plugins/attachment-resources/src/components/AttachmentList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentList.svelte @@ -16,7 +16,6 @@ import { Attachment } from '@hcengineering/attachment' import { Ref, type WithLookup } from '@hcengineering/core' import { Scroller } from '@hcengineering/ui' - import { AttachmentImageSize } from '../types' import AttachmentPreview from './AttachmentPreview.svelte' diff --git a/plugins/attachment-resources/src/components/AttachmentPopup.svelte b/plugins/attachment-resources/src/components/AttachmentPopup.svelte index a37f5ebd20..41b11006cf 100644 --- a/plugins/attachment-resources/src/components/AttachmentPopup.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPopup.svelte @@ -19,7 +19,7 @@ import { ActionIcon, IconAdd, Label, Loading } from '@hcengineering/ui' import type { Doc, WithLookup } from '@hcengineering/core' - import core from '@hcengineering/core' + import { setPlatformStatus, unknownError } from '@hcengineering/platform' import { AttachmentPresenter } from '..' import attachment from '../plugin' diff --git a/plugins/attachment-resources/src/components/AttachmentPresenter.svelte b/plugins/attachment-resources/src/components/AttachmentPresenter.svelte index b199fddf5c..0b02673066 100644 --- a/plugins/attachment-resources/src/components/AttachmentPresenter.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPresenter.svelte @@ -21,14 +21,15 @@ getBlobRef, getFileUrl, previewTypes, + getJsonOrEmpty, sizeToWidth } from '@hcengineering/presentation' - import { Label } from '@hcengineering/ui' + import { Label, Spinner } from '@hcengineering/ui' import { permissionsStore } from '@hcengineering/view-resources' + import WebIcon from './icons/Web.svelte' import filesize from 'filesize' import { createEventDispatcher } from 'svelte' import { getType, openAttachmentInSidebar, showAttachmentPreviewPopup } from '../utils' - import AttachmentName from './AttachmentName.svelte' export let value: WithLookup<Attachment> | undefined @@ -38,9 +39,10 @@ const dispatch = createEventDispatcher() - const maxLenght: number = 30 + const maxLength: number = 30 + const trimFilename = (fname: string): string => - fname.length > maxLenght ? fname.substr(0, (maxLenght - 1) / 2) + '...' + fname.substr(-(maxLenght - 1) / 2) : fname + fname.length > maxLength ? fname.substr(0, (maxLength - 1) / 2) + '...' + fname.substr(-(maxLength - 1) / 2) : fname $: canRemove = removable && @@ -58,7 +60,10 @@ return getType(contentType) === 'image' } - let canPreview: boolean = false + let canPreview = false + let useDefaultIcon = false + const canLinkPreview = value?.type.includes('link-preview') ?? false + $: if (value !== undefined) { void canPreviewFile(value.type, $previewTypes).then((res) => { canPreview = res @@ -106,49 +111,36 @@ {:else} <div class="flex-row-center attachment-container"> {#if value} - {#await getBlobRef(value.file, value.name, sizeToWidth('large')) then valueRef} - <a - class="no-line" - style:flex-shrink={0} - href={valueRef.src} - download={value.name} - on:click={clickHandler} - on:mousedown={middleClickHandler} - on:dragstart={dragStart} - > - {#if showPreview && isImage(value.type)} - <img - src={valueRef.src} - data-id={value.file} - srcset={valueRef.srcset} - class="flex-center icon" - class:svg={value.type === 'image/svg+xml'} - class:image={isImage(value.type)} - alt={value.name} - /> - {:else} - <div class="flex-center icon"> - {iconLabel(value.name)} - </div> - {/if} - </a> - <div class="flex-col info-container"> - <div class="name"> - <a href={valueRef.src} download={value.name} on:click={clickHandler} on:mousedown={middleClickHandler}> - {trimFilename(value.name)} - </a> + {#if canLinkPreview} + {#await getJsonOrEmpty(value.file, value.name)} + <Spinner size="small" /> + {:then linkPreviewDetails} + <div class="flex-center icon"> + {#if linkPreviewDetails.icon !== undefined && !useDefaultIcon} + <img + src={linkPreviewDetails.icon} + class="link-preview-icon" + alt="link-preview" + on:error={() => { + useDefaultIcon = true + }} + /> + {:else} + <WebIcon size="medium" /> + {/if} </div> - <div class="info-content flex-row-center"> - {filesize(value.size, { spacer: '' })} - <span class="actions inline-flex clear-mins ml-1 gap-1"> - <span>•</span> - <a class="no-line colorInherit" href={valueRef.src} download={value.name} bind:this={download}> - <Label label={presentation.string.Download} /> - </a> - {#if canRemove} - <span>•</span> - <!-- svelte-ignore a11y-click-events-have-key-events --> - <!-- svelte-ignore a11y-no-static-element-interactions --> + <div class="flex-col info-container"> + <div class="name"> + <a target="_blank" class="no-line" style:flex-shrink={0} href={linkPreviewDetails.url} + >{trimFilename(linkPreviewDetails?.title ?? value.name)}</a + > + </div> + <div class="info-content flex-row-center"> + <span class="actions inline-flex clear-mins gap-1"> + {#if linkPreviewDetails.description} + {trimFilename(linkPreviewDetails.description)} + <span>•</span> + {/if} <span class="remove-link" on:click={(ev) => { @@ -159,16 +151,79 @@ > <Label label={presentation.string.Delete} /> </span> - {/if} - </span> + </span> + </div> </div> - </div> - {/await} + {/await} + {:else} + {#await getBlobRef(value.file, value.name, sizeToWidth('large')) then valueRef} + <a + class="no-line" + style:flex-shrink={0} + href={valueRef.src} + download={value.name} + on:click={clickHandler} + on:mousedown={middleClickHandler} + on:dragstart={dragStart} + > + {#if showPreview && isImage(value.type)} + <img + src={valueRef.src} + data-id={value.file} + srcset={valueRef.srcset} + class="flex-center icon" + class:svg={value.type === 'image/svg+xml'} + class:image={isImage(value.type)} + alt={value.name} + /> + {:else} + <div class="flex-center icon"> + {iconLabel(value.name)} + </div> + {/if} + </a> + <div class="flex-col info-container"> + <div class="name"> + <a href={valueRef.src} download={value.name} on:click={clickHandler} on:mousedown={middleClickHandler}> + {trimFilename(value.name)} + </a> + </div> + <div class="info-content flex-row-center"> + {filesize(value.size, { spacer: '' })} + <span class="actions inline-flex clear-mins ml-1 gap-1"> + <span>•</span> + <a class="no-line colorInherit" href={valueRef.src} download={value.name} bind:this={download}> + <Label label={presentation.string.Download} /> + </a> + {#if canRemove} + <span>•</span> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <span + class="remove-link" + on:click={(ev) => { + ev.stopPropagation() + ev.preventDefault() + dispatch('remove', value) + }} + > + <Label label={presentation.string.Delete} /> + </span> + {/if} + </span> + </div> + </div> + {/await} + {/if} {/if} </div> {/if} <style lang="scss"> + .link-preview-icon { + max-width: 32px; + max-height: 32px; + } .attachment-container { flex-shrink: 0; width: auto; diff --git a/plugins/attachment-resources/src/components/AttachmentPreview.svelte b/plugins/attachment-resources/src/components/AttachmentPreview.svelte index 6a5984563e..b9c8ebaeb6 100644 --- a/plugins/attachment-resources/src/components/AttachmentPreview.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPreview.svelte @@ -18,7 +18,6 @@ import { ListSelectionProvider } from '@hcengineering/view-resources' import { createEventDispatcher } from 'svelte' import { WithLookup } from '@hcengineering/core' - import { AttachmentImageSize } from '../types' import { getType, showAttachmentPreviewPopup } from '../utils' import AttachmentActions from './AttachmentActions.svelte' @@ -107,8 +106,8 @@ } .content { - max-width: 20rem; - max-height: 20rem; + max-width: 25rem; + max-height: 25rem; scroll-snap-align: start; } </style> diff --git a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte index 74fa2b1234..06d19dacfb 100644 --- a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte +++ b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte @@ -1,5 +1,5 @@ <!-- -// Copyright © 2022 Hardcore Engineering Inc. +// Copyright © 2022, 2025 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may @@ -23,7 +23,10 @@ draftsStore, getClient, getFileMetadata, - uploadFile + uploadFile, + fetchLinkPreviewDetails, + canDisplayLinkPreview, + isLinkPreviewEnabled } from '@hcengineering/presentation' import { EmptyMarkup } from '@hcengineering/text' import textEditor, { type RefAction } from '@hcengineering/text-editor' @@ -32,6 +35,7 @@ import { createEventDispatcher, onDestroy, tick } from 'svelte' import attachment from '../plugin' import AttachmentPresenter from './AttachmentPresenter.svelte' + import { rmSync } from 'fs' export let objectId: Ref<Doc> export let space: Ref<Space> @@ -70,6 +74,8 @@ let originalAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>() const newAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>() const removedAttachments: Set<Attachment> = new Set<Attachment>() + const maxLinkPreviewCount = 3 + const urlSet = new Set<string>() let progress = false @@ -88,7 +94,12 @@ _id: { $in: Array.from(attachments.keys()) } }, (res) => { - existingAttachments = res.map((p) => p._id) + existingAttachments = res.map((p) => { + if (p.type === 'application/link-preview') { + urlSet.add(getUrlKey(p.name)) + } + return p._id + }) } ) } else { @@ -96,6 +107,33 @@ existingAttachmentsQuery.unsubscribe() } + function isValidUrl (s: string): boolean { + let url: URL + try { + url = new URL(s) + } catch { + return false + } + return url.protocol.startsWith('http') + } + + function longestSegment (s: string): string { + const segments = s.split('.') + let maxLen = segments[0].length + let result = segments[0] + for (const segment of segments) { + if (segment.length > maxLen) { + result = segment + maxLen = segment.length + } + } + return result + } + function getUrlKey (s: string): string { + const url = new URL(s) + return longestSegment(url.host) + url.pathname + } + $: objectId && updateAttachments(objectId) async function updateAttachments (objectId: Ref<Doc>): Promise<void> { @@ -111,6 +149,7 @@ }) originalAttachments.clear() removedAttachments.clear() + urlSet.clear() query.unsubscribe() } else if (!skipAttachmentsPreload) { query.query( @@ -128,6 +167,7 @@ newAttachments.clear() originalAttachments.clear() removedAttachments.clear() + urlSet.clear() query.unsubscribe() } } @@ -220,6 +260,9 @@ } async function deleteAttachment (attachment: Attachment): Promise<void> { + if (attachment.type === 'application/link-preview') { + urlSet.delete(getUrlKey(attachment.name)) + } if (originalAttachments.has(attachment._id)) { await client.removeCollection( attachment._class, @@ -274,6 +317,7 @@ }) await limiter.waitProcessing() newAttachments.clear() + urlSet.clear() removedAttachments.clear() saveDraft() } @@ -285,10 +329,49 @@ dispatch('message', { message: event.detail, attachments: attachments.size }) } + function updateLinkPreview (): void { + const hrefs = refContainer.getElementsByTagName('a') + const newUrls: string[] = [] + for (let i = 0; i < hrefs.length; i++) { + if (hrefs[i].target !== '_blank' || !isValidUrl(hrefs[i].href)) { + continue + } + const key = getUrlKey(hrefs[i].href) + if (urlSet.has(key)) { + continue + } + urlSet.add(key) + newUrls.push(hrefs[i].href) + } + if (newUrls.length > 0) { + void loadLinks(newUrls) + } + } + function onUpdate (event: CustomEvent): void { + if (isLinkPreviewEnabled() && !loading && urlSet.size < maxLinkPreviewCount) { + updateLinkPreview() + } dispatch('update', { message: event.detail, attachments: attachments.size }) } + async function loadLinks (urls: string[]): Promise<void> { + progress = true + for (const url of urls) { + try { + const meta = await fetchLinkPreviewDetails(url) + if (canDisplayLinkPreview(meta) && meta.url !== undefined) { + const blob = new Blob([JSON.stringify(meta)]) + const file = new File([blob], meta.url, { type: 'application/link-preview' }) + void createAttachment(file) + } + } catch (err: any) { + void setPlatformStatus(unknownError(err)) + } + } + progress = false + } + async function loadFiles (evt: ClipboardEvent): Promise<void> { progress = true const files = (evt.clipboardData?.files ?? []) as File[] @@ -313,15 +396,14 @@ if (!allowed) { return false } - const hasFiles = Array.from(evt.clipboardData?.items ?? []).some((i) => i.kind === 'file') - if (!hasFiles) { - return false + if (hasFiles) { + void loadFiles(evt) + return true } - void loadFiles(evt) - return true + return false } </script> diff --git a/plugins/attachment-resources/src/components/LinkPreviewList.svelte b/plugins/attachment-resources/src/components/LinkPreviewList.svelte new file mode 100644 index 0000000000..e6602de5db --- /dev/null +++ b/plugins/attachment-resources/src/components/LinkPreviewList.svelte @@ -0,0 +1,28 @@ +<!-- // +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// --> + +<script lang="ts"> + import LinkPreviewPresenter from './LinkPreviewPresenter.svelte' + import { type WithLookup } from '@hcengineering/core' + import { type Attachment } from '@hcengineering/attachment' + + export let attachments: WithLookup<Attachment>[] = [] +</script> + +<div class="gapV-2"> + {#each attachments as attachment} + <LinkPreviewPresenter {attachment} /> + {/each} +</div> diff --git a/plugins/attachment-resources/src/components/LinkPreviewPresenter.svelte b/plugins/attachment-resources/src/components/LinkPreviewPresenter.svelte new file mode 100644 index 0000000000..6a24c3bee9 --- /dev/null +++ b/plugins/attachment-resources/src/components/LinkPreviewPresenter.svelte @@ -0,0 +1,95 @@ +<!-- // +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// --> +<script lang="ts"> + import { getJsonOrEmpty, type LinkPreviewDetails, canDisplayLinkPreview } from '@hcengineering/presentation' + import { type Attachment } from '@hcengineering/attachment' + import { type WithLookup } from '@hcengineering/core' + import { Spinner } from '@hcengineering/ui' + import WebIcon from './icons/Web.svelte' + + export let attachment: WithLookup<Attachment> + let useDefaultIcon = false + let viewModel: LinkPreviewDetails + let canDisplay = false + + void getJsonOrEmpty(attachment.file, attachment.name).then((res) => { + viewModel = res as LinkPreviewDetails + canDisplay = canDisplayLinkPreview(viewModel) + }) +</script> + +<div class="quote content"> + {#if canDisplay} + <div class="gapV-2"> + <div class="flex gap-1"> + {#if viewModel.icon !== undefined && !useDefaultIcon} + <img + src={viewModel.icon} + class="preview-icon" + alt="link-preview-icon" + on:error={() => { + useDefaultIcon = true + }} + /> + {:else} + <WebIcon size="medium" /> + {/if} + <b><a target="_blank" href={viewModel.host}>{viewModel.hostname}</a></b> + </div> + <div> + <div> + {#if viewModel.title?.toLowerCase() !== viewModel.hostname?.toLowerCase()} + <b><a target="_blank" href={viewModel.url}>{viewModel.title}</a></b> + {/if} + </div> + <div> + {#if viewModel.description} + {viewModel.description} + {/if} + {#if viewModel.image} + <a target="_blank" href={viewModel.url}> + <img src={viewModel.image} class="round-image" alt="link-preview" /> + </a> + {/if} + </div> + </div> + </div> + {:else} + <div class="centered"> + <Spinner size="medium" /> + </div> + {/if} +</div> + +<style lang="scss"> + .round-image { + border: 0.5px solid; + border-radius: 7px; + max-width: 25rem; + max-height: 25rem; + } + .preview-icon { + max-width: 22px; + max-height: 22px; + } + .quote { + border-left: 0.25rem solid; + padding-left: 15px; + } + .content { + max-width: 35rem; + max-height: 35rem; + } +</style> diff --git a/plugins/attachment-resources/src/components/icons/Web.svelte b/plugins/attachment-resources/src/components/icons/Web.svelte new file mode 100644 index 0000000000..56d73a2be4 --- /dev/null +++ b/plugins/attachment-resources/src/components/icons/Web.svelte @@ -0,0 +1,25 @@ +<!-- +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +--> + +<script lang="ts"> + export let size: 'tiny' | 'x-small' | 'small' | 'medium' | 'large' | 'full' + export let fill: string = 'currentColor' +</script> + +<svg class="svg-{size}" {fill} viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg"> + <path + d="M14 0.000244141C11.2311 0.000244141 8.52431 0.82133 6.22202 2.35967C3.91973 3.89801 2.12532 6.08451 1.06569 8.64268C0.00606596 11.2008 -0.271181 14.0158 0.269012 16.7315C0.809205 19.4472 2.14258 21.9418 4.10051 23.8997C6.05845 25.8577 8.55301 27.191 11.2687 27.7312C13.9845 28.2714 16.7994 27.9942 19.3576 26.9346C21.9157 25.8749 24.1022 24.0805 25.6406 21.7782C27.1789 19.4759 28 16.7692 28 14.0002C28 10.2872 26.525 6.72626 23.8995 4.10075C21.274 1.47524 17.713 0.000244141 14 0.000244141ZM26 13.0002H20C19.8833 9.31733 18.9291 5.70939 17.21 2.45024C19.5786 3.09814 21.6914 4.45709 23.2632 6.34367C24.8351 8.23024 25.7903 10.5536 26 13.0002ZM14 26.0002C13.7769 26.0152 13.5531 26.0152 13.33 26.0002C11.2583 22.6964 10.1085 18.8984 10 15.0002H18C17.9005 18.8956 16.7612 22.6934 14.7 26.0002C14.467 26.0166 14.2331 26.0166 14 26.0002ZM10 13.0002C10.0995 9.10492 11.2388 5.30707 13.3 2.00024C13.7453 1.95021 14.1947 1.95021 14.64 2.00024C16.7223 5.30104 17.8825 9.09931 18 13.0002H10ZM10.76 2.45024C9.0513 5.71189 8.10746 9.31969 8.00001 13.0002H2.00001C2.20971 10.5536 3.16495 8.23024 4.7368 6.34367C6.30865 4.45709 8.42144 3.09814 10.79 2.45024H10.76ZM2.05001 15.0002H8.05001C8.15437 18.68 9.09478 22.2878 10.8 25.5502C8.43887 24.8954 6.33478 23.5334 4.77056 21.6474C3.20634 19.7614 2.25695 17.4418 2.05001 15.0002ZM17.21 25.5502C18.9291 22.2911 19.8833 18.6832 20 15.0002H26C25.7903 17.4469 24.8351 19.7702 23.2632 21.6568C21.6914 23.5434 19.5786 24.9023 17.21 25.5502Z" + /> +</svg> diff --git a/plugins/attachment-resources/src/index.ts b/plugins/attachment-resources/src/index.ts index 972ceaaf18..eecca09990 100644 --- a/plugins/attachment-resources/src/index.ts +++ b/plugins/attachment-resources/src/index.ts @@ -25,6 +25,7 @@ import AttachmentDocList from './components/AttachmentDocList.svelte' import AttachmentDroppable from './components/AttachmentDroppable.svelte' import AttachmentGalleryPresenter from './components/AttachmentGalleryPresenter.svelte' import AttachmentList from './components/AttachmentList.svelte' +import AttachmentGroup from './components/AttachmentGroup.svelte' import AttachmentPresenter from './components/AttachmentPresenter.svelte' import AttachmentPreview from './components/AttachmentPreview.svelte' import AttachmentRefInput from './components/AttachmentRefInput.svelte' @@ -53,6 +54,7 @@ export { AttachmentDroppable, AttachmentGalleryPresenter, AttachmentList, + AttachmentGroup, AttachmentPresenter, AttachmentPreview, AttachmentRefInput, diff --git a/plugins/attachment-resources/src/utils.ts b/plugins/attachment-resources/src/utils.ts index d48476e0ac..147373c103 100644 --- a/plugins/attachment-resources/src/utils.ts +++ b/plugins/attachment-resources/src/utils.ts @@ -89,7 +89,9 @@ export async function createAttachment ( } } -export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'audio' | 'pdf' | 'other' { +export function getType ( + type: string +): 'image' | 'text' | 'json' | 'video' | 'audio' | 'pdf' | 'link-preview' | 'other' { if (type.startsWith('image/')) { return 'image' } @@ -102,13 +104,15 @@ export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'a if (type.includes('application/pdf')) { return 'pdf' } - if (type === 'application/json') { + if (type.includes('application/json')) { return 'json' } if (type.startsWith('text/')) { return 'text' } - + if (type.includes('application/link-preview')) { + return 'link-preview' + } return 'other' } diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte index 4c82e87122..e6dbacd49f 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte @@ -15,8 +15,6 @@ <script lang="ts"> import { Label } from '@hcengineering/ui' import { IntlString } from '@hcengineering/platform' - import { Timestamp } from '@hcengineering/core' - export let label: IntlString | undefined </script> diff --git a/plugins/telegram-resources/src/components/Message.svelte b/plugins/telegram-resources/src/components/Message.svelte index 5e76c1e529..146cfd207e 100644 --- a/plugins/telegram-resources/src/components/Message.svelte +++ b/plugins/telegram-resources/src/components/Message.svelte @@ -15,7 +15,7 @@ --> <script lang="ts"> import { Attachment } from '@hcengineering/attachment' - import { AttachmentList } from '@hcengineering/attachment-resources' + import { AttachmentGroup } from '@hcengineering/attachment-resources' import { formatName } from '@hcengineering/contact' import { WithLookup } from '@hcengineering/core' import { HTMLViewer } from '@hcengineering/presentation' @@ -54,7 +54,7 @@ </div> {/if} {#if attachments} - <AttachmentList {attachments} /> + <AttachmentGroup {attachments} /> {/if} <div class="flex"> <div class="caption-color mr-4"><HTMLViewer value={message.content} /></div>