feat(ios): improve share preview (#15538)

#### PR Dependency Tree

* **PR #15538** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added rich link previews to mobile and iOS sharing, including images,
metadata, transcripts, and selected text.
* Share imports can now create structured content blocks, embeds,
bookmarks, and transcript callouts.
* Added workspace-aware preview handling for cloud, self-hosted, and
signed-out modes.
* **Accessibility**
* Improved collapse/expand controls with semantic buttons and ARIA
relationships.
* **Bug Fixes**
  * Enhanced URL and error sanitization in server logs.
* Improved link-preview CORS support, validation, and request handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-28 03:32:26 +08:00
committed by GitHub
parent 329839e467
commit b6de0ad51b
36 changed files with 3152 additions and 372 deletions
@@ -36,6 +36,8 @@ import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view';
import { FoundationViewExtension } from '@blocksuite/affine/foundation/view';
import { InlineCommentViewExtension } from '@blocksuite/affine/inlines/comment';
import { AffineCanvasTextFonts } from '@blocksuite/affine/shared/services';
import { BlockStdScope } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store';
import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view';
import type { FrameworkProvider } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
@@ -364,3 +366,10 @@ class ViewProvider {
export function getViewManager() {
return ViewProvider.getInstance();
}
export function createBlockStdScope(store: Store) {
return new BlockStdScope({
store,
extensions: getViewManager().config.init().value.get('page'),
});
}
@@ -1,4 +1,3 @@
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine/shared/consts';
import {
LinkPreviewCacheIdentifier,
type LinkPreviewCacheProvider,
@@ -11,13 +10,32 @@ import type { FrameworkProvider } from '@toeverything/infra';
import { ServerService } from '../../../modules/cloud/services/server';
const LINK_PREVIEW_PATH = '/api/worker/link-preview';
export function resolveLinkPreviewEndpoint(value: string, baseUrl: string) {
if (!value.trim() || !URL.canParse(value, baseUrl)) return null;
const endpoint = new URL(value, baseUrl);
return endpoint.pathname === LINK_PREVIEW_PATH ? endpoint.toString() : null;
}
class AffineLinkPreviewService extends LinkPreviewService {
constructor(endpoint: string, cache: LinkPreviewCacheProvider) {
super(cache);
constructor(endpoint: string | null, cache: LinkPreviewCacheProvider) {
super(cache, createAffineLinkPreviewFetch(BUILD_CONFIG.appVersion));
this.setEndpoint(endpoint);
}
}
export function createAffineLinkPreviewFetch(
version: string,
fetcher: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return (input, init) => {
const headers = new Headers(init?.headers);
if (version) headers.set('x-affine-version', version);
return fetcher(input, { ...init, headers });
};
}
/**
* Patch the link preview service, set the endpoint and cache
* @param framework
@@ -26,21 +44,11 @@ class AffineLinkPreviewService extends LinkPreviewService {
export function patchLinkPreviewService(
framework: FrameworkProvider
): ExtensionType {
// get link preview service endpoint from server and BUILD_CONFIG
let linkPreviewUrl: string;
try {
const server = framework.get(ServerService).server;
linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl || '/',
server.baseUrl
).toString();
} catch (err) {
console.error(
'Invalid BUILD_CONFIG.linkPreviewUrl, falling back to default',
err
);
linkPreviewUrl = DEFAULT_LINK_PREVIEW_ENDPOINT;
}
const server = framework.get(ServerService).server;
const linkPreviewUrl = resolveLinkPreviewEndpoint(
BUILD_CONFIG.linkPreviewUrl,
server.baseUrl
);
return {
setup: (di: Container) => {
@@ -1,4 +1,5 @@
import { Button, Modal, notify, SafeArea, Scrollable } from '@affine/component';
import { type Server, ServersService } from '@affine/core/modules/cloud';
import {
ImportClipperService,
type ShareDestinationOptions,
@@ -12,18 +13,31 @@ import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { PageHeader } from '../page-header';
import { LinkPreview, resolveShareTitle } from './link-preview';
import {
resolveShareWorkspaceMode,
SharePreviewRouteOwner,
} from './preview-route-owner';
import { SelectionPage, type SelectionPageOption } from './selection-page';
import * as styles from './style.css';
import type {
PendingShareItem,
ShareImportTarget,
ShareInboxProvider,
ShareLinkPreview,
} from './types';
export type { ShareInboxProvider } from './types';
type Page = 'main' | 'workspace' | 'tags' | 'collection' | 'offline';
interface ShareDestinationSelection {
itemId: string;
workspaceKey: string;
tagIds: string[];
collectionId: string;
}
const errorMessage = (error?: string) => {
switch (error) {
case 'workspace-not-found':
@@ -44,6 +58,22 @@ const errorMessage = (error?: string) => {
const workspaceKey = (workspace: WorkspaceMetadata) =>
`${workspace.flavour}:${workspace.id}`;
const selectionFromItem = (
item: PendingShareItem
): ShareDestinationSelection => ({
itemId: item.id,
workspaceKey: item.target
? `${item.target.workspaceFlavour}:${item.target.workspaceId}`
: '',
tagIds: item.target?.tagIds ?? [],
collectionId: item.target?.collectionId ?? '',
});
const reconcileShareDestinationSelection = (
current: ShareDestinationSelection | undefined,
item: PendingShareItem
) => (current?.itemId === item.id ? current : selectionFromItem(item));
const sourceDetails = (item: PendingShareItem) => {
if (item.content.kind === 'url') {
return {
@@ -63,6 +93,35 @@ const sourceDetails = (item: PendingShareItem) => {
};
};
async function previewForImport(
item: PendingShareItem,
workspace: WorkspaceMetadata,
current: ShareLinkPreview | undefined,
currentOwner: SharePreviewRouteOwner | undefined,
servers: Server[]
) {
if (item.content.kind !== 'url' || current) return current;
const owner = currentOwner ?? new SharePreviewRouteOwner(item);
owner.selectWorkspace(workspace, servers);
const controller = new AbortController();
const request = owner.load(controller.signal);
if (!request) return undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
request.catch(() => undefined),
new Promise<undefined>(resolve => {
timeout = setTimeout(() => {
controller.abort();
resolve(undefined);
}, 1200);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
const SourceIcon = ({
kind,
}: {
@@ -84,39 +143,76 @@ export const ShareImportController = ({
provider: ShareInboxProvider;
}) => {
const workspacesService = useService(WorkspacesService);
const serversService = useService(ServersService);
const importer = useService(ImportClipperService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const serverAccounts = useLiveData(serversService.serversWithAccount$);
const servers = useLiveData(serversService.servers$);
const [item, setItem] = useState<PendingShareItem>();
const [page, setPage] = useState<Page>('main');
const [selectedWorkspaceKey, setSelectedWorkspaceKey] = useState('');
const [tagIds, setTagIds] = useState<string[]>([]);
const [collectionId, setCollectionId] = useState('');
const [selection, setSelection] = useState<ShareDestinationSelection>();
const [destinations, setDestinations] = useState<ShareDestinationOptions>();
const [isLoadingDestinations, setIsLoadingDestinations] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [attachmentPreview, setAttachmentPreview] = useState<string>();
const [linkPreview, setLinkPreview] = useState<ShareLinkPreview>();
const refreshing = useRef(false);
const itemId = item?.id;
const activeItemIdRef = useRef(itemId);
activeItemIdRef.current = itemId;
const previewOwnerRef = useRef<
| {
itemId: string;
owner: SharePreviewRouteOwner;
}
| undefined
>(undefined);
if (item && previewOwnerRef.current?.itemId !== item.id) {
previewOwnerRef.current = {
itemId: item.id,
owner: new SharePreviewRouteOwner(item),
};
}
const previewOwnerEntry = previewOwnerRef.current;
const previewOwner =
previewOwnerEntry && previewOwnerEntry.itemId === item?.id
? previewOwnerEntry.owner
: undefined;
useEffect(() => {
const signedIn = serverAccounts.filter(({ account }) => !!account);
const mode = resolveShareWorkspaceMode(servers, signedIn.length > 0);
void provider.updateWorkspaceMode(mode).catch(console.error);
}, [provider, serverAccounts, servers]);
const activeSelection = selection?.itemId === itemId ? selection : undefined;
const selectedWorkspaceKey = activeSelection?.workspaceKey ?? '';
const selectedWorkspace = workspaces.find(
workspace => workspaceKey(workspace) === selectedWorkspaceKey
);
const selectedWorkspaceAvailable = !!selectedWorkspace;
const selectedWorkspaceName = selectedWorkspace
? workspacesService.getProfile(selectedWorkspace).name$.value ||
selectedWorkspace.id
: undefined;
const setManualItem = useCallback((next: PendingShareItem) => {
const isCurrentItem = activeItemIdRef.current === next.id;
activeItemIdRef.current = next.id;
setItem(next);
setPage('main');
setSelectedWorkspaceKey(
next.target
? `${next.target.workspaceFlavour}:${next.target.workspaceId}`
: ''
);
setTagIds(next.target?.tagIds ?? []);
setCollectionId(next.target?.collectionId ?? '');
if (!isCurrentItem) setPage('main');
setSelection(current => reconcileShareDestinationSelection(current, next));
}, []);
const updateSelection = useCallback(
(
update: (current: ShareDestinationSelection) => ShareDestinationSelection
) => {
setSelection(current => {
if (!current || current.itemId !== itemId) return current;
return update(current);
});
},
[itemId]
);
const importItem = useCallback(
async (
@@ -142,13 +238,25 @@ export const ShareImportController = ({
await provider.setError(pending.id, 'attachment-missing');
return false;
}
const preview = await previewForImport(
pending,
workspace,
pending.id === item?.id ? linkPreview : undefined,
pending.id === item?.id ? previewOwner : undefined,
servers
);
const result = await importer.importShareToWorkspace(
workspace,
{
documentId: pending.documentId,
title: pending.title,
title: resolveShareTitle(
pending.title,
preview?.title,
pending.title
),
content: pending.content,
preview,
attachmentUrl,
tagIds: target.tagIds,
collectionId: target.collectionId,
@@ -162,7 +270,15 @@ export const ShareImportController = ({
await provider.complete(pending.id, result.docId);
return true;
},
[importer, provider, workspacesService]
[
importer,
item?.id,
linkPreview,
previewOwner,
provider,
servers,
workspacesService,
]
);
const refresh = useCallback(async () => {
@@ -201,19 +317,26 @@ export const ShareImportController = ({
}
}, [importItem, provider, setManualItem]);
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
useEffect(() => {
void refresh().catch(console.error);
const requestRefresh = () => {
void refreshRef.current().catch(console.error);
};
requestRefresh();
const handleRefresh = () => {
void refresh().catch(console.error);
requestRefresh();
};
window.addEventListener('affine:share-inbox', handleRefresh);
return () =>
window.removeEventListener('affine:share-inbox', handleRefresh);
}, [refresh]);
}, [provider]);
useEffect(() => {
let active = true;
setAttachmentPreview(undefined);
setLinkPreview(undefined);
if (item?.content.kind === 'image') {
void provider
.resolveAttachment(item.id)
@@ -228,15 +351,24 @@ export const ShareImportController = ({
}, [item?.content.kind, item?.id, provider]);
useEffect(() => {
if (!selectedWorkspace) {
if (!selectedWorkspaceKey) {
setDestinations(undefined);
setIsLoadingDestinations(false);
return;
}
const workspace = workspacesService.list.workspaces$.value.find(
workspace => workspaceKey(workspace) === selectedWorkspaceKey
);
if (!workspace) {
setDestinations(undefined);
setIsLoadingDestinations(false);
return;
}
let active = true;
setDestinations(undefined);
setIsLoadingDestinations(true);
void importer
.getShareDestinationOptions(selectedWorkspace)
.getShareDestinationOptions(workspace)
.then(async options => {
if (!active) return;
if (!options) {
@@ -253,12 +385,17 @@ export const ShareImportController = ({
}
setDestinations(options);
const validTags = new Set(options.tags.map(tag => tag.id));
setTagIds(ids => ids.filter(id => validTags.has(id)));
setCollectionId(id =>
id && options.collections.some(collection => collection.id === id)
? id
: ''
);
updateSelection(current => ({
...current,
tagIds: current.tagIds.filter(id => validTags.has(id)),
collectionId:
current.collectionId &&
options.collections.some(
collection => collection.id === current.collectionId
)
? current.collectionId
: '',
}));
})
.catch(console.error)
.finally(() => {
@@ -267,7 +404,15 @@ export const ShareImportController = ({
return () => {
active = false;
};
}, [importer, itemId, provider, selectedWorkspace]);
}, [
importer,
itemId,
provider,
selectedWorkspaceKey,
selectedWorkspaceAvailable,
updateSelection,
workspacesService,
]);
const save = async (allowOffline: boolean) => {
if (!item || !selectedWorkspace || isSaving) return;
@@ -278,8 +423,8 @@ export const ShareImportController = ({
{
workspaceId: selectedWorkspace.id,
workspaceFlavour: selectedWorkspace.flavour,
tagIds,
collectionId: collectionId || undefined,
tagIds: activeSelection?.tagIds ?? [],
collectionId: activeSelection?.collectionId || undefined,
},
allowOffline
);
@@ -294,6 +439,9 @@ export const ShareImportController = ({
if (!item) return null;
const tagIds = activeSelection?.tagIds ?? [];
const collectionId = activeSelection?.collectionId ?? '';
const workspaceOptions: SelectionPageOption[] = workspaces.map(workspace => ({
id: workspaceKey(workspace),
label: workspacesService.getProfile(workspace).name$.value || workspace.id,
@@ -334,9 +482,16 @@ export const ShareImportController = ({
selectedIds={selectedWorkspaceKey ? [selectedWorkspaceKey] : []}
onBack={() => setPage('main')}
onSelect={id => {
setSelectedWorkspaceKey(id);
setTagIds([]);
setCollectionId('');
updateSelection(current =>
current.workspaceKey === id
? current
: {
...current,
workspaceKey: id,
tagIds: [],
collectionId: '',
}
);
setItem(current =>
current ? { ...current, lastError: undefined } : current
);
@@ -354,11 +509,12 @@ export const ShareImportController = ({
selectedIds={tagIds}
onBack={() => setPage('main')}
onSelect={id =>
setTagIds(ids =>
ids.includes(id)
? ids.filter(current => current !== id)
: [...ids, id]
)
updateSelection(current => ({
...current,
tagIds: current.tagIds.includes(id)
? current.tagIds.filter(currentId => currentId !== id)
: [...current.tagIds, id],
}))
}
onConfirm={() => setPage('main')}
/>
@@ -372,7 +528,7 @@ export const ShareImportController = ({
selectedIds={[collectionId]}
onBack={() => setPage('main')}
onSelect={id => {
setCollectionId(id);
updateSelection(current => ({ ...current, collectionId: id }));
setPage('main');
}}
/>
@@ -426,25 +582,35 @@ export const ShareImportController = ({
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<main className={styles.main}>
<section className={styles.source}>
<div className={styles.sourceIcon}>
{attachmentPreview ? (
<img
className={styles.sourceImage}
src={attachmentPreview}
alt=""
/>
) : (
<SourceIcon kind={item.content.kind} />
)}
</div>
<div className={styles.sourceContent}>
<div className={styles.sourceTitle}>{source.title}</div>
{source.detail ? (
<div className={styles.sourceDetail}>{source.detail}</div>
) : null}
</div>
</section>
{item.content.kind === 'url' && previewOwner ? (
<LinkPreview
item={item}
owner={previewOwner}
workspace={selectedWorkspace}
servers={servers}
onPreview={setLinkPreview}
/>
) : (
<section className={styles.source}>
<div className={styles.sourceIcon}>
{attachmentPreview ? (
<img
className={styles.sourceImage}
src={attachmentPreview}
alt=""
/>
) : (
<SourceIcon kind={item.content.kind} />
)}
</div>
<div className={styles.sourceContent}>
<div className={styles.sourceTitle}>{source.title}</div>
{source.detail ? (
<div className={styles.sourceDetail}>{source.detail}</div>
) : null}
</div>
</section>
)}
<section className={styles.destinationGroup}>
<button
@@ -0,0 +1,245 @@
import type { Server } from '@affine/core/modules/cloud';
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
import { LinkIcon, WaveRectangleIcon } from '@blocksuite/icons/rc';
import { useEffect, useRef, useState } from 'react';
import type { SharePreviewRouteOwner } from './preview-route-owner';
import * as styles from './style.css';
import type { PendingShareItem, ShareLinkPreview as Preview } from './types';
type PreviewState =
| { status: 'idle' | 'loading' | 'failed' }
| { status: 'loaded'; preview: Preview };
export function resolveShareTitle(
originalTitle: string,
previewTitle: string | undefined,
fallback: string
) {
return originalTitle === 'Shared'
? previewTitle || fallback
: originalTitle || fallback;
}
const graphemeSegmenter = new Intl.Segmenter(undefined, {
granularity: 'grapheme',
});
export function transcriptPreviewText(
transcript: Preview['transcript']
): string | undefined {
const text = transcript?.segments
.map(segment => segment.text.trim().replace(/\s+/g, ' '))
.filter(Boolean)
.join(' ');
if (!text) return undefined;
const graphemes = Array.from(
graphemeSegmenter.segment(text),
segment => segment.segment
);
return graphemes.length > 240 ? `${graphemes.slice(0, 240).join('')}` : text;
}
export const LinkPreview = ({
item,
owner,
workspace,
servers,
onPreview,
}: {
item: PendingShareItem;
owner: SharePreviewRouteOwner;
workspace: WorkspaceMetadata | undefined;
servers: Server[];
onPreview(preview: Preview | undefined): void;
}) => {
const [state, setState] = useState<PreviewState>({ status: 'idle' });
const activeRequest = useRef<Promise<Preview> | undefined>(undefined);
useEffect(() => {
let active = true;
owner.selectWorkspace(workspace, servers);
const controller = new AbortController();
const request = owner.load(controller.signal);
if (!request) {
activeRequest.current = undefined;
setState({ status: 'idle' });
onPreview(undefined);
return () => {
active = false;
controller.abort();
};
}
activeRequest.current = request;
setState({ status: 'loading' });
const isCurrent = () => active && activeRequest.current === request;
void request.then(
preview => {
if (!isCurrent()) return;
setState({ status: 'loaded', preview });
onPreview(preview);
},
error => {
if (!isCurrent()) return;
if (error instanceof DOMException && error.name === 'AbortError') {
setState({ status: 'idle' });
onPreview(undefined);
return;
}
setState({ status: 'failed' });
onPreview(undefined);
}
);
return () => {
active = false;
if (activeRequest.current === request) activeRequest.current = undefined;
controller.abort();
};
}, [item.id, onPreview, owner, servers, workspace]);
let hostname = 'Link';
if (item.content.url) {
try {
hostname = new URL(item.content.url).hostname || hostname;
} catch {}
}
if (state.status === 'loading') {
return (
<section
className={styles.linkPreview}
aria-label="Link preview"
aria-busy="true"
>
<div className={styles.previewMediaSkeleton} />
<div className={styles.previewSkeletonContent}>
<div className={styles.previewSkeletonSite} />
<div className={styles.previewSkeletonTitle} />
<div className={styles.previewSkeletonDescription} />
</div>
<span className={styles.srOnly} aria-live="polite">
Loading link preview
</span>
{item.content.text ? (
<blockquote className={styles.selectedText}>
<span className={styles.selectedTextLabel}>Selected text</span>
{item.content.text}
</blockquote>
) : null}
</section>
);
}
if (state.status !== 'loaded') {
return (
<section className={styles.linkPreview} aria-label="Link preview">
<div className={styles.previewContent}>
<div className={styles.previewFallbackRow}>
<div className={styles.previewFallbackIcon}>
<LinkIcon />
</div>
<div className={styles.previewBody}>
<div className={styles.previewTitle}>
{item.title || hostname}
</div>
<div className={styles.previewSite}>{hostname}</div>
{state.status === 'failed' ? (
<div className={styles.previewSite} aria-live="polite">
Preview unavailable
</div>
) : null}
</div>
</div>
</div>
{item.content.text ? (
<blockquote className={styles.selectedText}>
<span className={styles.selectedTextLabel}>Selected text</span>
{item.content.text}
</blockquote>
) : null}
</section>
);
}
const { preview } = state;
const title = resolveShareTitle(item.title, preview.title, hostname);
const description =
preview.description && preview.description !== title
? preview.description
: undefined;
const metadata = [
preview.author?.name,
formatDuration(preview.durationSeconds),
]
.filter(Boolean)
.slice(0, 2)
.join(' · ');
const transcript = transcriptPreviewText(preview.transcript);
return (
<section className={styles.linkPreview} aria-label="Link preview">
{preview.images?.[0] ? (
<img className={styles.previewMedia} src={preview.images[0]} alt="" />
) : (
<div className={styles.previewMediaPlaceholder} aria-hidden="true">
<LinkIcon />
</div>
)}
<div className={styles.previewContent}>
<div className={styles.previewBody}>
<div className={styles.previewSite}>
{preview.favicons?.[0] ? (
<img
className={styles.previewFavicon}
src={preview.favicons[0]}
alt=""
/>
) : null}
{preview.siteName || hostname}
</div>
<div className={styles.previewTitle}>{title || hostname}</div>
{description ? (
<div className={styles.previewDescription}>{description}</div>
) : null}
{metadata ? (
<div className={styles.previewMeta}>{metadata}</div>
) : null}
</div>
{transcript ? (
<div
className={styles.transcriptPreview}
role="group"
aria-label={`Transcript preview: ${transcript}`}
>
<div className={styles.transcriptLabel} aria-hidden="true">
<WaveRectangleIcon className={styles.transcriptIcon} />
Transcript
</div>
<div
className={
item.content.text
? styles.transcriptExcerptWithSelectedText
: styles.transcriptExcerpt
}
aria-hidden="true"
>
{transcript}
</div>
</div>
) : null}
</div>
{item.content.text ? (
<blockquote className={styles.selectedText}>
<span className={styles.selectedTextLabel}>Selected text</span>
{item.content.text}
</blockquote>
) : null}
</section>
);
};
function formatDuration(duration?: number) {
if (duration === undefined) return undefined;
const minutes = Math.floor(duration / 60);
return `${minutes}:${Math.floor(duration % 60)
.toString()
.padStart(2, '0')}`;
}
@@ -0,0 +1,199 @@
import type { Server } from '@affine/core/modules/cloud';
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
import { ServerDeploymentType } from '@affine/graphql';
import type { PendingShareItem, ShareLinkPreview } from './types';
const LINK_PREVIEW_PATH = '/api/worker/link-preview';
const OFFICIAL_LINK_PREVIEW_ENDPOINT = `https://app.affine.pro${LINK_PREVIEW_PATH}`;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseShareLinkPreview(value: unknown): ShareLinkPreview {
if (!isRecord(value) || typeof value.url !== 'string') {
throw new Error('Invalid link preview response');
}
const preview: ShareLinkPreview = { url: value.url };
for (const key of [
'title',
'siteName',
'description',
'mediaType',
'publishedAt',
] as const) {
if (typeof value[key] === 'string') preview[key] = value[key];
}
if (value.provider === 'youtube' || value.provider === 'x') {
preview.provider = value.provider;
}
for (const key of ['images', 'favicons'] as const) {
if (Array.isArray(value[key])) {
preview[key] = value[key].filter(item => typeof item === 'string');
}
}
if (typeof value.durationSeconds === 'number') {
preview.durationSeconds = value.durationSeconds;
}
if (isRecord(value.author) && typeof value.author.name === 'string') {
preview.author = {
name: value.author.name,
...(typeof value.author.handle === 'string'
? { handle: value.author.handle }
: {}),
...(typeof value.author.avatar === 'string'
? { avatar: value.author.avatar }
: {}),
};
}
if (isRecord(value.transcript) && Array.isArray(value.transcript.segments)) {
preview.transcript = {
...(typeof value.transcript.language === 'string'
? { language: value.transcript.language }
: {}),
segments: value.transcript.segments
.filter(isRecord)
.filter(segment => typeof segment.text === 'string')
.map(segment => ({
text: segment.text as string,
...(typeof segment.startSeconds === 'number'
? { startSeconds: segment.startSeconds }
: {}),
...(typeof segment.durationSeconds === 'number'
? { durationSeconds: segment.durationSeconds }
: {}),
...(typeof segment.speaker === 'string'
? { speaker: segment.speaker }
: {}),
})),
...(Array.isArray(value.transcript.chapters)
? {
chapters: value.transcript.chapters
.filter(isRecord)
.filter(
chapter =>
typeof chapter.title === 'string' &&
typeof chapter.startSeconds === 'number'
)
.map(chapter => ({
title: chapter.title as string,
startSeconds: chapter.startSeconds as number,
})),
}
: {}),
...(typeof value.transcript.truncated === 'boolean'
? { truncated: value.transcript.truncated }
: {}),
};
}
return preview;
}
export class SharePreviewRouteOwner {
private endpoint: string | undefined;
private inFlight:
| {
endpoint: string;
controller: AbortController;
request: Promise<ShareLinkPreview>;
}
| undefined;
private selectedWorkspaceKey: string | undefined;
constructor(private readonly item: PendingShareItem) {
this.selectedWorkspaceKey = undefined;
}
get routeEndpoint() {
return this.endpoint;
}
selectWorkspace(workspace: WorkspaceMetadata | undefined, servers: Server[]) {
if (this.item.previewRoute === 'official') {
this.endpoint ??= OFFICIAL_LINK_PREVIEW_ENDPOINT;
return;
}
if (!workspace || workspace.flavour === 'local') {
this.setEndpoint(
undefined,
workspace ? `${workspace.flavour}:${workspace.id}` : undefined
);
return;
}
const workspaceKey = `${workspace.flavour}:${workspace.id}`;
if (this.selectedWorkspaceKey === workspaceKey && this.endpoint) return;
const server = servers.find(server => server.id === workspace.flavour);
const type = server?.config$.value?.type;
const endpoint =
server && type === ServerDeploymentType.Selfhosted
? new URL(LINK_PREVIEW_PATH, server.baseUrl).toString()
: type === ServerDeploymentType.Affine
? OFFICIAL_LINK_PREVIEW_ENDPOINT
: undefined;
this.setEndpoint(endpoint, workspaceKey);
}
load(signal?: AbortSignal): Promise<ShareLinkPreview> | undefined {
const url = this.item.content.url;
if (!url || !this.endpoint) return undefined;
if (
this.inFlight?.endpoint === this.endpoint &&
!this.inFlight.controller.signal.aborted
) {
return this.inFlight.request;
}
const endpoint = this.endpoint;
const controller = new AbortController();
const abort = () => controller.abort();
if (signal?.aborted) {
abort();
} else {
signal?.addEventListener('abort', abort, { once: true });
}
const request = fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ url, include: ['transcript'] }),
signal: controller.signal,
}).then(async response => {
if (!response.ok) throw new Error('Link preview unavailable');
return parseShareLinkPreview(await response.json());
});
this.inFlight = { endpoint, controller, request };
void request.then(
() => {
signal?.removeEventListener('abort', abort);
if (this.inFlight?.request === request) this.inFlight = undefined;
},
() => {
signal?.removeEventListener('abort', abort);
if (this.inFlight?.request === request) this.inFlight = undefined;
}
);
return request;
}
private setEndpoint(endpoint: string | undefined, workspaceKey?: string) {
if (this.endpoint !== endpoint) {
this.inFlight?.controller.abort();
this.inFlight = undefined;
}
this.endpoint = endpoint;
this.selectedWorkspaceKey = workspaceKey;
}
}
export function resolveShareWorkspaceMode(
servers: Server[],
hasSignedInAccount: boolean
) {
const types = servers.map(server => server.config$.value?.type);
if (types.includes(ServerDeploymentType.Selfhosted))
return 'selfHostedPresent' as const;
if (types.some(type => type === undefined)) return 'unknown' as const;
return hasSignedInAccount ? ('cloudOnly' as const) : ('signedOut' as const);
}
@@ -0,0 +1,901 @@
/** @vitest-environment happy-dom */
import { type Server, ServersService } from '@affine/core/modules/cloud';
import {
ImportClipperService,
type ShareImportInput,
} from '@affine/core/modules/import-clipper';
import {
type WorkspaceMetadata,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { ServerDeploymentType } from '@affine/graphql';
import { ToggleButton } from '@blocksuite/affine/components/toggle-button';
import {
type LinkPreviewCacheProvider,
LinkPreviewService,
} from '@blocksuite/affine/shared/services';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import type * as Infra from '@toeverything/infra';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
createAffineLinkPreviewFetch,
resolveLinkPreviewEndpoint,
} from '../../../blocksuite/view-extensions/link-preview-service/link-preview-service';
import { createShareMarkdown } from '../../../modules/import-clipper/services/import';
import { createShareBlockPlan } from '../../../modules/import-clipper/services/share-block-plan';
import { ShareImportController } from './index';
import {
LinkPreview,
resolveShareTitle,
transcriptPreviewText,
} from './link-preview';
import {
resolveShareWorkspaceMode,
SharePreviewRouteOwner,
} from './preview-route-owner';
import type { PendingShareItem, ShareLinkPreview } from './types';
const controllerServiceMocks = vi.hoisted(() => ({
services: new Map<string, unknown>(),
}));
vi.mock('@toeverything/infra', async importOriginal => {
const original = await importOriginal<typeof Infra>();
return {
...original,
useLiveData: (source: { value: unknown }) => source.value,
useService: (token: { name: string }) =>
controllerServiceMocks.services.get(token.name),
};
});
const cache: LinkPreviewCacheProvider = {
get: () => undefined,
set: () => {},
getPendingRequest: () => undefined,
setPendingRequest: () => {},
deletePendingRequest: () => {},
clear: () => {},
};
const item = (previewRoute?: PendingShareItem['previewRoute']) =>
({
id: 'item',
documentId: 'doc',
title: 'Shared',
content: { kind: 'url', url: 'https://youtube.com/watch?v=123' },
previewRoute,
}) satisfies PendingShareItem;
const workspace = (flavour: string) =>
({ id: 'workspace', flavour }) as WorkspaceMetadata;
const server = (id: string, baseUrl: string, type?: ServerDeploymentType) =>
({ id, baseUrl, config$: { value: { type } } }) as unknown as Server;
afterEach(() => {
cleanup();
controllerServiceMocks.services.clear();
vi.unstubAllGlobals();
});
describe('link preview transport and route ownership', () => {
test('adds the app version only in the AFFiNE transport', async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ title: 'Preview' }), { status: 200 })
);
const service = new LinkPreviewService(
cache,
createAffineLinkPreviewFetch('0.27.0', fetch)
);
service.setEndpoint('https://self.example/api/worker/link-preview');
await service.query('https://example.com/versioned');
const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers);
expect(headers.get('Content-Type')).toBe('application/json');
expect(headers.get('x-affine-version')).toBe('0.27.0');
});
test.each([
['', null],
[' ', null],
['/', null],
[
'/api/worker/link-preview',
'https://self.example/api/worker/link-preview',
],
[
'https://preview.example/api/worker/link-preview',
'https://preview.example/api/worker/link-preview',
],
])('validates configured endpoint %j', (value, endpoint) => {
expect(resolveLinkPreviewEndpoint(value, 'https://self.example/')).toBe(
endpoint
);
});
test.each([
['missing endpoint', null, undefined],
[
'timeout',
'https://self.example/api/worker/link-preview',
new DOMException('Timed out', 'AbortError'),
],
[
'server error',
'https://self.example/api/worker/link-preview',
new Response(null, { status: 500 }),
],
])(
'returns no preview on %s without a fallback',
async (_name, endpoint, result) => {
const fetch = vi.fn(async () => {
if (result instanceof Error) throw result;
return result;
});
vi.stubGlobal('fetch', fetch);
const service = new LinkPreviewService(cache);
service.setEndpoint(endpoint);
await expect(service.query(item().content.url!)).resolves.toEqual({});
expect(fetch).toHaveBeenCalledTimes(endpoint ? 1 : 0);
}
);
test.each([
[
'official route',
'official' as const,
workspace('local'),
[] as Server[],
'https://app.affine.pro/api/worker/link-preview',
],
[
'self-hosted route',
'deferred' as const,
workspace('self'),
[
server(
'self',
'https://self.example/',
ServerDeploymentType.Selfhosted
),
],
'https://self.example/api/worker/link-preview',
],
[
'cloud route',
'deferred' as const,
workspace('cloud'),
[server('cloud', 'https://cloud.example/', ServerDeploymentType.Affine)],
'https://app.affine.pro/api/worker/link-preview',
],
[
'local deferred route',
'deferred' as const,
workspace('local'),
[],
undefined,
],
[
'missing server',
'deferred' as const,
workspace('missing'),
[],
undefined,
],
[
'server with unknown config',
'deferred' as const,
workspace('unknown'),
[server('unknown', 'https://unknown.example/')],
undefined,
],
])('selects the %s', (_name, route, target, servers, endpoint) => {
const owner = new SharePreviewRouteOwner(item(route));
owner.selectWorkspace(target, servers);
expect(owner.routeEndpoint).toBe(endpoint);
});
test('freezes a selected workspace route and deduplicates only active requests', async () => {
let resolve!: (response: Response) => void;
const fetch = vi.fn<typeof globalThis.fetch>(
() =>
new Promise<Response>(done => {
resolve = done;
})
);
vi.stubGlobal('fetch', fetch);
const owner = new SharePreviewRouteOwner(item('deferred'));
const selected = workspace('self');
owner.selectWorkspace(selected, [
server('self', 'https://first.example/', ServerDeploymentType.Selfhosted),
]);
owner.selectWorkspace(selected, [
server(
'self',
'https://changed.example/',
ServerDeploymentType.Selfhosted
),
]);
const first = owner.load()!;
expect(owner.load()).toBe(first);
expect(fetch.mock.calls[0]?.[1]?.headers).toEqual({
'Content-Type': 'application/json',
'x-affine-version': BUILD_CONFIG.appVersion,
});
expect(owner.routeEndpoint).toBe(
'https://first.example/api/worker/link-preview'
);
resolve(
new Response(
JSON.stringify({
url: item().content.url,
title: 42,
images: ['https://example.com/image.jpg', 42],
transcript: { segments: 'invalid' },
}),
{ status: 200 }
)
);
await expect(first).resolves.toEqual({
url: item().content.url,
images: ['https://example.com/image.jpg'],
});
fetch.mockResolvedValueOnce(
new Response(JSON.stringify({ url: item().content.url }), { status: 200 })
);
await owner.load();
expect(fetch).toHaveBeenCalledTimes(2);
});
test('invalidates an active request when the selected endpoint changes', () => {
const fetch = vi.fn<typeof globalThis.fetch>(
() => new Promise<Response>(() => {})
);
vi.stubGlobal('fetch', fetch);
const owner = new SharePreviewRouteOwner(item('deferred'));
owner.selectWorkspace(workspace('self'), [
server('self', 'https://self.example/', ServerDeploymentType.Selfhosted),
]);
const first = owner.load();
owner.selectWorkspace(workspace('cloud'), [
server('cloud', 'https://cloud.example/', ServerDeploymentType.Affine),
]);
const second = owner.load();
expect(second).not.toBe(first);
expect(fetch.mock.calls.map(([url]) => url)).toEqual([
'https://self.example/api/worker/link-preview',
'https://app.affine.pro/api/worker/link-preview',
]);
});
test('does not reuse an aborted request', async () => {
const responses: ((response: Response) => void)[] = [];
const fetch = vi.fn<typeof globalThis.fetch>(
(_input, init) =>
new Promise<Response>((resolve, reject) => {
responses.push(resolve);
init?.signal?.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true }
);
})
);
vi.stubGlobal('fetch', fetch);
const owner = new SharePreviewRouteOwner(item('official'));
owner.selectWorkspace(undefined, []);
const controller = new AbortController();
const first = owner.load(controller.signal)!;
controller.abort();
const second = owner.load()!;
expect(second).not.toBe(first);
expect(fetch).toHaveBeenCalledTimes(2);
await expect(first).rejects.toMatchObject({ name: 'AbortError' });
responses[1]?.(
new Response(JSON.stringify({ url: item().content.url }), { status: 200 })
);
await expect(second).resolves.toMatchObject({ url: item().content.url });
});
test('treats a legacy missing route as deferred until workspace selection', async () => {
const fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ url: item().content.url }), {
status: 200,
})
);
vi.stubGlobal('fetch', fetch);
const owner = new SharePreviewRouteOwner(item(undefined));
owner.selectWorkspace(undefined, []);
expect(owner.routeEndpoint).toBeUndefined();
expect(owner.load()).toBeUndefined();
expect(fetch).not.toHaveBeenCalled();
owner.selectWorkspace(workspace('cloud'), [
server('cloud', 'https://cloud.example/', ServerDeploymentType.Affine),
]);
expect(owner.routeEndpoint).toBe(
'https://app.affine.pro/api/worker/link-preview'
);
await owner.load();
expect(fetch).toHaveBeenCalledTimes(1);
});
test.each([
[
'self-hosted configuration without an account',
[
server(
'self',
'https://self.example/',
ServerDeploymentType.Selfhosted
),
],
true,
'selfHostedPresent',
],
[
'configuration still loading',
[server('unknown', 'https://unknown.example/')],
true,
'unknown',
],
[
'signed-in cloud configuration',
[server('cloud', 'https://cloud.example/', ServerDeploymentType.Affine)],
true,
'cloudOnly',
],
['signed-out cloud configuration', [], false, 'signedOut'],
])('resolves %s safely', (_name, servers, signedIn, mode) => {
expect(resolveShareWorkspaceMode(servers, signedIn)).toBe(mode);
});
});
describe('share destination selection lifecycle', () => {
test('keeps one workspace selection across preview completion and refreshes', async () => {
const selectedWorkspace = {
id: 'selected-workspace',
flavour: 'local',
} as WorkspaceMetadata;
const workspaces$ = { value: [selectedWorkspace] };
const servers$ = { value: [] as Server[] };
const pending = {
...item('official'),
content: {
kind: 'url' as const,
url: 'https://youtube.com/watch?v=selection',
},
} satisfies PendingShareItem;
let resolvePreview!: (response: Response) => void;
const previewFetch = vi.fn(
() =>
new Promise<Response>(resolve => {
resolvePreview = resolve;
})
);
vi.stubGlobal('fetch', previewFetch);
const importer = {
getShareDestinationOptions: vi.fn().mockResolvedValue({
verification: 'confirmed',
tags: [{ id: 'tag-one', name: 'Tag One', color: '#123456' }],
collections: [{ id: 'collection-one', name: 'Collection One' }],
}),
importShareToWorkspace: vi
.fn()
.mockResolvedValue({ status: 'imported', docId: 'saved-doc' }),
};
controllerServiceMocks.services.set(WorkspacesService.name, {
list: { workspaces$ },
getProfile: () => ({ name$: { value: 'Workspace One' } }),
});
controllerServiceMocks.services.set(ServersService.name, {
serversWithAccount$: { value: [] },
servers$,
});
controllerServiceMocks.services.set(ImportClipperService.name, importer);
const provider = {
updateWorkspaceMode: vi.fn().mockResolvedValue(undefined),
listPending: vi.fn().mockResolvedValue([pending]),
updateTarget: vi.fn().mockResolvedValue(undefined),
resolveAttachment: vi.fn().mockResolvedValue(undefined),
complete: vi.fn().mockResolvedValue(undefined),
setError: vi.fn().mockResolvedValue(undefined),
};
const view = render(<ShareImportController provider={provider} />);
await screen.findByText('Choose where to save');
fireEvent.click(screen.getByRole('button', { name: /Workspace Choose/ }));
fireEvent.click(screen.getByRole('button', { name: /Workspace One/ }));
const save = await screen.findByRole('button', { name: 'Save' });
await waitFor(() =>
expect((save as HTMLButtonElement).disabled).toBe(false)
);
expect(
screen.getByRole('button', { name: /Workspace Workspace One/ })
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Tags Optional/ }));
await screen.findByRole('button', { name: /Tag One/ });
resolvePreview(
new Response(JSON.stringify({ url: pending.content.url }), {
status: 200,
})
);
previewFetch.mockResolvedValue(
new Response(JSON.stringify({ url: pending.content.url }), {
status: 200,
})
);
await waitFor(() => expect(provider.listPending).toHaveBeenCalledTimes(1));
expect(screen.getByText('Tags')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Tag One/ }));
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
fireEvent.click(
screen.getByRole('button', { name: /Collection Optional/ })
);
fireEvent.click(
await screen.findByRole('button', { name: 'Collection One' })
);
workspaces$.value = [{ ...selectedWorkspace }];
servers$.value = [];
view.rerender(<ShareImportController provider={provider} />);
expect(
screen.getByRole('button', { name: /Workspace Workspace One/ })
).toBeTruthy();
expect(
(screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement)
.disabled
).toBe(false);
expect(provider.listPending).toHaveBeenCalledTimes(1);
workspaces$.value = [];
view.rerender(<ShareImportController provider={provider} />);
expect(
(screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement)
.disabled
).toBe(true);
workspaces$.value = [{ ...selectedWorkspace }];
view.rerender(<ShareImportController provider={provider} />);
await waitFor(() =>
expect(
(screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement)
.disabled
).toBe(false)
);
window.dispatchEvent(new Event('affine:share-inbox'));
await waitFor(() => expect(provider.listPending).toHaveBeenCalledTimes(2));
expect(
screen.getByRole('button', { name: /Workspace Workspace One/ })
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(provider.updateTarget).toHaveBeenCalledWith('item', {
workspaceId: 'selected-workspace',
workspaceFlavour: 'local',
tagIds: ['tag-one'],
collectionId: 'collection-one',
})
);
expect(importer.getShareDestinationOptions).toHaveBeenCalledWith(
expect.objectContaining({ id: 'selected-workspace', flavour: 'local' })
);
expect(importer.importShareToWorkspace).toHaveBeenCalledWith(
expect.objectContaining({ id: 'selected-workspace', flavour: 'local' }),
expect.objectContaining({
tagIds: ['tag-one'],
collectionId: 'collection-one',
}),
{ allowOffline: false }
);
});
});
describe('share preview presentation', () => {
test.each([
[
'loading',
() => new Promise<never>(() => {}),
'Loading link preview',
undefined,
],
[
'failed',
() => Promise.reject(new Error('unavailable')),
'Preview unavailable',
undefined,
],
[
'partial',
() => Promise.resolve({ url: item().content.url! }),
'youtube.com',
undefined,
],
[
'aborted',
() => Promise.reject(new DOMException('Aborted', 'AbortError')),
'youtube.com',
undefined,
],
[
'invalid persisted URL',
() => Promise.reject(new Error('unavailable')),
'Link',
'/relative',
],
])('renders the %s state', async (_name, load, expected, url) => {
const owner = {
routeEndpoint: 'https://app.affine.pro/api/worker/link-preview',
selectWorkspace: vi.fn(),
load,
} as unknown as SharePreviewRouteOwner;
render(
<LinkPreview
item={{
...item('official'),
content: {
...item('official').content,
url: url ?? item().content.url,
},
}}
owner={owner}
workspace={undefined}
servers={[]}
onPreview={() => {}}
/>
);
await waitFor(() =>
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
);
});
test('ignores stale preview results after the item changes', async () => {
let resolveFirst!: (preview: ShareLinkPreview) => void;
let resolveSecond!: (preview: ShareLinkPreview) => void;
const load = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<ShareLinkPreview>(resolve => {
resolveFirst = resolve;
})
)
.mockImplementationOnce(
() =>
new Promise<ShareLinkPreview>(resolve => {
resolveSecond = resolve;
})
);
const owner = {
selectWorkspace: vi.fn(),
load,
} as unknown as SharePreviewRouteOwner;
const onPreview = vi.fn();
const firstItem = { ...item('official'), id: 'first' };
const secondItem = { ...item('official'), id: 'second' };
const view = render(
<LinkPreview
item={firstItem}
owner={owner}
workspace={undefined}
servers={[]}
onPreview={onPreview}
/>
);
view.rerender(
<LinkPreview
item={secondItem}
owner={owner}
workspace={undefined}
servers={[]}
onPreview={onPreview}
/>
);
resolveFirst({ url: firstItem.content.url!, title: 'Stale preview' });
await Promise.resolve();
expect(screen.queryByText('Stale preview')).toBeNull();
expect(onPreview).not.toHaveBeenCalled();
resolveSecond({ url: secondItem.content.url!, title: 'Current preview' });
await screen.findByText('Current preview');
expect(onPreview).toHaveBeenCalledTimes(1);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Current preview' })
);
});
test('uses one media-first card for rich preview content', async () => {
const shared = {
...item('official'),
content: {
...item('official').content,
text: 'Selected passage',
},
} satisfies PendingShareItem;
const owner = {
routeEndpoint: 'https://app.affine.pro/api/worker/link-preview',
selectWorkspace: vi.fn(),
load: () =>
Promise.resolve({
url: shared.content.url!,
title: 'Provider title',
images: ['https://youtube.com/thumbnail.jpg'],
transcript: {
segments: [{ text: ' Hello\n\tworld ' }, { text: ' again ' }],
},
}),
} as unknown as SharePreviewRouteOwner;
const { container } = render(
<LinkPreview
item={shared}
owner={owner}
workspace={undefined}
servers={[]}
onPreview={() => {}}
/>
);
await screen.findByText('Transcript');
expect(screen.getByText('Hello world again')).toBeTruthy();
expect(
screen.getByRole('group', {
name: 'Transcript preview: Hello world again',
})
).toBeTruthy();
expect(screen.getByText('Selected passage')).toBeTruthy();
expect(container.querySelector('section > img')?.getAttribute('src')).toBe(
'https://youtube.com/thumbnail.jpg'
);
const family = String.fromCodePoint(
0x1f468,
0x200d,
0x1f469,
0x200d,
0x1f467
);
expect(
transcriptPreviewText({
segments: [{ text: ' ' }, { text: family.repeat(241) }],
})
).toBe(`${family.repeat(240)}`);
expect(
transcriptPreviewText({ segments: [{ text: '\n\t' }] })
).toBeUndefined();
});
test('keeps failure compact without an empty media region', async () => {
const owner = {
routeEndpoint: 'https://app.affine.pro/api/worker/link-preview',
selectWorkspace: vi.fn(),
load: () => Promise.reject(new Error('unavailable')),
} as unknown as SharePreviewRouteOwner;
const { container } = render(
<LinkPreview
item={item('official')}
owner={owner}
workspace={undefined}
servers={[]}
onPreview={() => {}}
/>
);
await screen.findByText('Preview unavailable');
expect(container.querySelector('section > img')).toBeNull();
});
test.each([
['Shared', 'Provider title', 'host', 'Provider title'],
['Saved title', 'Provider title', 'host', 'Saved title'],
['Shared', undefined, 'host', 'host'],
])(
'preserves the title priority',
(original, preview, fallback, expected) => {
expect(resolveShareTitle(original, preview, fallback)).toBe(expected);
}
);
});
describe('share document block projection', () => {
test.each<
[
string,
ShareImportInput,
Parameters<typeof createShareBlockPlan>[1],
unknown,
string,
]
>([
[
'generic metadata',
{
documentId: 'doc',
title: 'Page',
content: { kind: 'url', url: 'https://example.com' },
preview: {
url: 'https://redirect.example',
title: 'Example',
description: 'Description',
favicons: ['https://example.com/icon.png'],
images: ['https://example.com/image.png'],
},
tagIds: [],
},
null,
[
{
flavour: 'affine:bookmark',
props: {
url: 'https://example.com',
title: 'Example',
description: 'Description',
icon: 'https://example.com/icon.png',
image: 'https://example.com/image.png',
style: 'horizontal',
},
},
],
'',
],
[
'YouTube selection, chapters, and structured transcript',
{
documentId: 'doc',
title: 'Video',
content: {
kind: 'url',
url: 'https://youtube.com/watch?v=123',
text: 'Selected passage',
},
preview: {
url: 'https://youtube.com/watch?v=123',
provider: 'youtube',
transcript: {
chapters: [{ title: 'Opening', startSeconds: 0 }],
segments: [
{ text: 'Welcome', startSeconds: 1, speaker: 'Host' },
{ text: 'Plain paragraph' },
],
},
},
tagIds: [],
},
{ flavour: 'affine:embed-youtube', styles: ['video'] },
[
{
flavour: 'affine:embed-youtube',
props: {
url: 'https://youtube.com/watch?v=123',
style: 'video',
},
},
{
flavour: 'affine:paragraph',
props: { type: 'quote', text: 'Selected passage' },
},
{
flavour: 'affine:callout',
props: {
icon: { type: 'emoji', unicode: '💬' },
backgroundColorName: 'grey',
},
children: [
{
flavour: 'affine:paragraph',
props: { type: 'h6', text: 'Transcript', collapsed: true },
},
{
flavour: 'affine:paragraph',
props: { type: 'h6', text: 'Opening' },
},
{
flavour: 'affine:paragraph',
props: { type: 'text', text: '[0:01] Host: Welcome' },
},
{
flavour: 'affine:paragraph',
props: { type: 'text', text: 'Plain paragraph' },
},
],
},
],
'',
],
[
'X duplicate transcript',
{
documentId: 'doc',
title: 'Post',
content: { kind: 'url', url: 'https://x.com/affine/status/123' },
preview: {
url: 'https://x.com/affine/status/123',
provider: 'x',
description: 'A complete post',
transcript: {
segments: [{ text: 'A complete' }, { text: 'post' }],
},
},
tagIds: [],
},
null,
[
{
flavour: 'affine:bookmark',
props: {
url: 'https://x.com/affine/status/123',
title: undefined,
description: 'A complete post',
icon: undefined,
image: undefined,
style: 'horizontal',
},
},
],
'',
],
[
'plain text',
{
documentId: 'doc',
title: 'Note',
content: { kind: 'text', text: 'Plain *shared* text' },
tagIds: [],
},
null,
[],
'Plain \\*shared\\* text',
],
])(
'creates the same stable projection for %s',
(_name, input, embed, expected, markdown) => {
expect(createShareBlockPlan(input, embed)).toEqual(expected);
expect(createShareBlockPlan(input, embed)).toEqual(expected);
expect(createShareMarkdown(input)).toBe(markdown);
}
);
});
describe('collapsed content accessibility', () => {
test('uses native button semantics and identifies the controlled content', async () => {
if (!customElements.get('blocksuite-toggle-button')) {
customElements.define('blocksuite-toggle-button', ToggleButton);
}
const toggle = document.createElement('blocksuite-toggle-button');
toggle.collapsed = true;
toggle.controls = 'heading-children-id';
toggle.updateCollapsed = vi.fn();
document.body.append(toggle);
await toggle.updateComplete;
const button = toggle.querySelector('button')!;
expect(button.getAttribute('aria-label')).toBe('Expand content');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('aria-controls')).toBe('heading-children-id');
button.click();
expect(toggle.updateCollapsed).toHaveBeenCalledWith(false);
});
});
@@ -87,6 +87,233 @@ export const sourceDetail = style([
},
]);
export const linkPreview = style({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: 12,
color: cssVarV2('text/primary'),
background: cssVarV2('layer/background/primary'),
});
export const previewContent = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: 14,
});
export const previewBody = style({
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const previewSite = style([
footnoteRegular,
{
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
overflow: 'hidden',
color: cssVarV2('text/secondary'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
]);
export const previewFavicon = style({
width: 16,
height: 16,
flex: '0 0 auto',
objectFit: 'contain',
});
export const previewTitle = style([
bodyEmphasized,
{
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
},
]);
export const previewDescription = style([
{
fontSize: 14,
fontWeight: 400,
lineHeight: '20px',
display: '-webkit-box',
overflow: 'hidden',
color: cssVarV2('text/secondary'),
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
},
]);
export const previewMeta = style([
footnoteRegular,
{
overflow: 'hidden',
color: cssVarV2('text/secondary'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
]);
export const transcriptPreview = style({
minWidth: 0,
marginTop: 8,
paddingTop: 10,
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const transcriptLabel = style({
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 13,
fontWeight: 600,
lineHeight: '18px',
color: cssVarV2('text/secondary'),
});
export const transcriptIcon = style({
width: 16,
height: 16,
flex: '0 0 auto',
});
const transcriptExcerptBase = {
minWidth: 0,
marginTop: 4,
display: '-webkit-box',
overflow: 'hidden',
fontSize: 14,
fontWeight: 400,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
WebkitBoxOrient: 'vertical' as const,
};
export const transcriptExcerpt = style({
...transcriptExcerptBase,
WebkitLineClamp: 3,
});
export const transcriptExcerptWithSelectedText = style({
...transcriptExcerptBase,
WebkitLineClamp: 2,
});
export const previewMedia = style({
width: '100%',
maxHeight: 180,
aspectRatio: '16 / 9',
objectFit: 'cover',
});
export const previewMediaPlaceholder = style({
width: '100%',
maxHeight: 180,
aspectRatio: '16 / 9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: cssVarV2('icon/tertiary'),
background: cssVarV2('layer/background/secondary'),
});
export const previewFallbackIcon = style({
width: 40,
height: 40,
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
color: cssVarV2('icon/primary'),
background: cssVarV2('layer/background/secondary'),
});
export const previewFallbackRow = style({
display: 'flex',
alignItems: 'flex-start',
gap: 12,
});
export const previewMediaSkeleton = style({
width: '100%',
maxHeight: 180,
aspectRatio: '16 / 9',
background: cssVarV2('layer/background/secondary'),
});
export const previewSkeletonContent = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 14,
});
const skeletonLine = {
height: 12,
borderRadius: 6,
background: cssVarV2('layer/background/secondary'),
};
export const previewSkeletonSite = style({ ...skeletonLine, width: '60%' });
export const previewSkeletonTitle = style({
...skeletonLine,
width: '90%',
height: 16,
});
export const previewSkeletonDescription = style({
...skeletonLine,
width: '55%',
});
export const selectedText = style([
footnoteRegular,
{
width: '100%',
margin: 0,
padding: '12px 14px 14px',
display: '-webkit-box',
overflow: 'hidden',
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
color: cssVarV2('text/secondary'),
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3,
},
]);
export const selectedTextLabel = style({
display: 'block',
color: cssVarV2('text/primary'),
fontWeight: 600,
});
export const srOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
export const destinationGroup = style({
overflow: 'hidden',
borderRadius: 12,
@@ -1,3 +1,7 @@
import type { ShareLinkPreview } from '../../../modules/import-clipper';
export type { ShareLinkPreview };
export interface PendingShareItem {
id: string;
documentId: string;
@@ -7,8 +11,8 @@ export interface PendingShareItem {
url?: string;
text?: string;
};
previewRoute?: 'official' | 'deferred';
target?: ShareImportTarget;
previewText?: string;
attachments?: { fileName: string; mimeType: string }[];
lastError?: string;
}
@@ -21,6 +25,9 @@ export interface ShareImportTarget {
}
export interface ShareInboxProvider {
updateWorkspaceMode(
mode: 'selfHostedPresent' | 'cloudOnly' | 'signedOut' | 'unknown'
): Promise<void>;
listPending(): Promise<PendingShareItem[]>;
updateTarget(itemId: string, target: ShareImportTarget): Promise<void>;
resolveAttachment(itemId: string): Promise<string | undefined>;
@@ -1,20 +1,7 @@
import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { DebugLogger } from '@affine/debug';
import { BlockStdScope } from '@blocksuite/affine/std';
import { createBlockStdScope } from '@affine/core/blocksuite/manager/view';
import type { Store } from '@blocksuite/affine/store';
import { useMemo } from 'react';
const logger = new DebugLogger('doc-info');
// todo(pengx17): use rc pool?
export function createBlockStdScope(doc: Store) {
logger.debug('createBlockStdScope', doc.id);
const std = new BlockStdScope({
store: doc,
extensions: getViewManager().config.init().value.get('page'),
});
return std;
}
export function useBlockStdScope(doc: Store) {
return useMemo(() => createBlockStdScope(doc), [doc]);
}
@@ -9,6 +9,7 @@ export {
type ShareDestinationOptions,
type ShareImportInput,
type ShareImportResult,
type ShareLinkPreview,
} from './services/import';
export function configureImportClipperModule(framework: Framework) {
@@ -1,4 +1,7 @@
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { createBlockStdScope } from '@affine/core/blocksuite/manager/view';
import { EmbedOptionProvider } from '@blocksuite/affine/shared/services';
import { Text } from '@blocksuite/affine/store';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import { Service } from '@toeverything/infra';
@@ -11,6 +14,35 @@ import {
type WorkspaceMetadata,
type WorkspacesService,
} from '../../workspace';
import {
createShareBlockPlan,
type ShareBlockPlanNode,
} from './share-block-plan';
export interface ShareLinkPreview {
url: string;
title?: string;
siteName?: string;
description?: string;
images?: string[];
favicons?: string[];
mediaType?: string;
provider?: 'youtube' | 'x';
author?: { name: string; handle?: string; avatar?: string };
publishedAt?: string;
durationSeconds?: number;
transcript?: {
language?: string;
segments: {
text: string;
startSeconds?: number;
durationSeconds?: number;
speaker?: string;
}[];
chapters?: { title: string; startSeconds: number }[];
truncated?: boolean;
};
}
export interface ClipperInput {
title: string;
@@ -28,6 +60,7 @@ export interface ShareImportInput {
url?: string;
text?: string;
};
preview?: ShareLinkPreview;
attachmentUrl?: string;
tagIds: string[];
collectionId?: string;
@@ -52,6 +85,28 @@ export interface ShareDestinationOptions {
type WorkspaceVerification = 'confirmed' | 'missing' | 'unavailable';
export function createShareMarkdown(input: ShareImportInput) {
const parts: string[] = [];
if (input.content.kind === 'image') {
if (input.attachmentUrl) {
parts.push(`![Shared image](<${input.attachmentUrl}>)`);
}
if (input.content.text) {
parts.push(escapeMarkdown(input.content.text));
}
if (input.content.url) {
parts.push(`[Source](<${input.content.url}>)`);
}
} else if (input.content.kind === 'text' && input.content.text) {
parts.push(escapeMarkdown(input.content.text));
}
return parts.join('\n\n');
}
function escapeMarkdown(value: string) {
return value.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, '\\$&');
}
export class ImportClipperService extends Service {
constructor(private readonly workspacesService: WorkspacesService) {
super();
@@ -134,13 +189,16 @@ export class ImportClipperService extends Service {
});
const noteId = doc.blockSuiteDoc.addBlock('affine:note', {}, page.id);
if (input.content.kind === 'url' && input.content.url) {
doc.blockSuiteDoc.addBlock(
'affine:bookmark',
{ url: input.content.url, style: 'horizontal' },
noteId
const embedOptions = createBlockStdScope(doc.blockSuiteDoc)
.get(EmbedOptionProvider)
.getEmbedBlockOptions(input.content.url);
this.addShareBlocks(
doc.blockSuiteDoc,
noteId,
createShareBlockPlan(input, embedOptions)
);
}
const markdown = this.shareMarkdown(input);
const markdown = createShareMarkdown(input);
if (markdown) {
await MarkdownTransformer.importMarkdownToBlock({
doc: doc.blockSuiteDoc,
@@ -239,32 +297,25 @@ export class ImportClipperService extends Service {
}
}
private shareMarkdown(input: ShareImportInput) {
const parts: string[] = [];
if (input.content.kind === 'url') {
if (input.content.text) {
parts.push(
`> ${this.escapeMarkdown(input.content.text).replaceAll('\n', '\n> ')}`
);
private addShareBlocks(
store: Parameters<typeof createBlockStdScope>[0],
parentId: string,
nodes: ShareBlockPlanNode[]
) {
for (const node of nodes) {
const props = Object.fromEntries(
Object.entries(node.props)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [
key,
key === 'text' ? new Text(value as string) : value,
])
);
const blockId = store.addBlock(node.flavour, props, parentId);
if (node.children) {
this.addShareBlocks(store, blockId, node.children);
}
} else if (input.content.kind === 'image') {
if (input.attachmentUrl) {
parts.push(`![Shared image](<${input.attachmentUrl}>)`);
}
if (input.content.text) {
parts.push(this.escapeMarkdown(input.content.text));
}
if (input.content.url) {
parts.push(`[Source](<${input.content.url}>)`);
}
} else if (input.content.text) {
parts.push(this.escapeMarkdown(input.content.text));
}
return parts.join('\n\n');
}
private escapeMarkdown(value: string) {
return value.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, '\\$&');
}
private async revalidateWorkspace(
@@ -0,0 +1,140 @@
import type { EmbedCardStyle } from '@blocksuite/affine/model';
import type { ShareImportInput } from './import';
export interface ShareBlockPlanNode {
flavour: string;
props: Record<string, unknown>;
children?: ShareBlockPlanNode[];
}
export interface ShareEmbedOptions {
flavour: string;
styles: EmbedCardStyle[];
}
function normalized(value: string | undefined) {
return value?.replaceAll(/\s+/g, ' ').trim().toLowerCase() ?? '';
}
function timestamp(seconds: number) {
const value = Math.max(0, Math.floor(seconds));
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const remainder = value % 60;
return hours > 0
? `${hours}:${minutes.toString().padStart(2, '0')}:${remainder
.toString()
.padStart(2, '0')}`
: `${minutes}:${remainder.toString().padStart(2, '0')}`;
}
function transcriptNodes(input: ShareImportInput) {
const transcript = input.preview?.transcript;
if (!transcript) return [];
const duplicates = new Set([
normalized(input.preview?.description),
normalized(input.content.text),
]);
duplicates.delete('');
const segments = transcript.segments.filter(segment => {
const text = normalized(segment.text);
return text && !duplicates.has(text);
});
if (
segments.length === 0 ||
duplicates.has(normalized(segments.map(segment => segment.text).join(' ')))
) {
return [];
}
const chapters = [...(transcript.chapters ?? [])]
.filter(chapter => chapter.title.trim())
.sort((left, right) => left.startSeconds - right.startSeconds);
const children: ShareBlockPlanNode[] = [
{
flavour: 'affine:paragraph',
props: { type: 'h6', text: 'Transcript', collapsed: true },
},
];
let chapterIndex = 0;
for (const segment of segments) {
const segmentStart = segment.startSeconds ?? 0;
while (
chapterIndex < chapters.length &&
chapters[chapterIndex].startSeconds <= segmentStart
) {
children.push({
flavour: 'affine:paragraph',
props: { type: 'h6', text: chapters[chapterIndex].title },
});
chapterIndex += 1;
}
const prefix = [
segment.startSeconds === undefined
? undefined
: `[${timestamp(segment.startSeconds)}]`,
segment.speaker?.trim() ? `${segment.speaker.trim()}:` : undefined,
]
.filter(Boolean)
.join(' ');
children.push({
flavour: 'affine:paragraph',
props: {
type: 'text',
text: prefix ? `${prefix} ${segment.text.trim()}` : segment.text.trim(),
},
});
}
return [
{
flavour: 'affine:callout',
props: {
icon: { type: 'emoji', unicode: '💬' },
backgroundColorName: 'grey',
},
children,
},
];
}
export function createShareBlockPlan(
input: ShareImportInput,
embedOptions: ShareEmbedOptions | null
) {
if (input.content.kind !== 'url' || !input.content.url) return [];
const preview = input.preview;
const primary: ShareBlockPlanNode = embedOptions
? {
flavour: embedOptions.flavour,
props: { url: input.content.url, style: embedOptions.styles[0] },
}
: {
flavour: 'affine:bookmark',
props: {
url: input.content.url,
title: preview?.title,
description: preview?.description,
icon: preview?.favicons?.[0],
image: preview?.images?.[0],
style: 'horizontal',
},
};
const selectedText = input.content.text?.trim();
return [
primary,
...(selectedText
? [
{
flavour: 'affine:paragraph',
props: { type: 'quote', text: selectedText },
},
]
: []),
...transcriptNodes(input),
];
}