feat: add links preview (#7600)

Signed-off-by: Denis Tingaikin <denis.tingajkin@xored.com>
This commit is contained in:
Denis Tingaikin 2025-01-15 08:07:01 +03:00 committed by GitHub
parent 81dd4cdee7
commit d020053d35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 515 additions and 105 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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 {}
}
}

View File

@ -69,3 +69,4 @@ export * from './preview'
export * from './sound'
export * from './stats'
export * from './drawing'
export * from './link-preview'

View File

@ -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 {}
}
}

View File

@ -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>,

View File

@ -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} />

View File

@ -42,6 +42,7 @@
function openAttachment (): void {
showAttachmentPreviewPopup(value)
}
$: src = getFileUrl(value.file, value.name)
</script>

View File

@ -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>

View File

@ -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'

View File

@ -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'

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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'
}

View File

@ -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>

View File

@ -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>