mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-13 03:40:48 +00:00
feat: add links preview (#7600)
Signed-off-by: Denis Tingaikin <denis.tingajkin@xored.com>
This commit is contained in:
parent
81dd4cdee7
commit
d020053d35
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
@ -69,3 +69,4 @@ export * from './preview'
|
||||
export * from './sound'
|
||||
export * from './stats'
|
||||
export * from './drawing'
|
||||
export * from './link-preview'
|
||||
|
60
packages/presentation/src/link-preview.ts
Normal file
60
packages/presentation/src/link-preview.ts
Normal 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 {}
|
||||
}
|
||||
}
|
@ -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>,
|
||||
|
@ -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} />
|
||||
|
@ -42,6 +42,7 @@
|
||||
function openAttachment (): void {
|
||||
showAttachmentPreviewPopup(value)
|
||||
}
|
||||
|
||||
$: src = getFileUrl(value.file, value.name)
|
||||
</script>
|
||||
|
||||
|
@ -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>
|
@ -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'
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
@ -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>
|
25
plugins/attachment-resources/src/components/icons/Web.svelte
Normal file
25
plugins/attachment-resources/src/components/icons/Web.svelte
Normal 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>
|
@ -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,
|
||||
|
@ -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'
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user