mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-05 16:30:33 +08:00
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:
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
+26
-18
@@ -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
|
||||
|
||||
+245
@@ -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')}`;
|
||||
}
|
||||
+199
@@ -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);
|
||||
}
|
||||
+901
@@ -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(``);
|
||||
}
|
||||
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(``);
|
||||
}
|
||||
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),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user