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
@@ -154,7 +154,9 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
textAlign: this.model.props.textAlign$?.value,
});
const childrenId = `list-children-${this.model.id}`;
const children = html`<div
id=${childrenId}
class="affine-block-children-container"
style=${styleMap({
paddingLeft: `${BLOCK_CHILDREN_CONTAINER_PADDING_LEFT}px`,
@@ -179,6 +181,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
? html`
<blocksuite-toggle-button
.collapsed=${collapsed}
.controls=${childrenId}
.updateCollapsed=${(value: boolean) => {
if (this.store.readonly) {
this._readonlyCollapsed = value;
@@ -270,7 +270,9 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
textAlign: this.model.props.textAlign$?.value,
});
const childrenId = `heading-children-${this.model.id}`;
const children = html`<div
id=${childrenId}
class="affine-block-children-container"
style=${styleMap({
paddingLeft: `${BLOCK_CHILDREN_CONTAINER_PADDING_LEFT}px`,
@@ -319,6 +321,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
? html`
<blocksuite-toggle-button
.collapsed=${collapsed}
.controls=${childrenId}
.updateCollapsed=${(value: boolean) => {
if (this.store.readonly) {
this._readonlyCollapsed = value;
@@ -15,6 +15,11 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
align-items: start;
justify-content: start;
position: absolute;
padding: 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
width: 16px;
height: 16px;
top: calc((1em - 16px) / 2 + 5px);
@@ -30,6 +35,11 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
background: var(--affine-hover-color);
}
.toggle-icon:focus-visible {
opacity: 1;
outline: 1px solid var(--affine-primary-color);
}
.toggle-icon[data-collapsed='true'] {
opacity: 1;
}
@@ -53,34 +63,23 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
`;
override render() {
const toggleDownTemplate = html`
<div
contenteditable="false"
class="toggle-icon"
@click=${() => this.updateCollapsed(!this.collapsed)}
>
${ToggleDownIcon({
width: '16px',
height: '16px',
})}
</div>
`;
const toggleRightTemplate = html`
<div
return html`
<button
type="button"
contenteditable="false"
class="toggle-icon"
data-collapsed=${this.collapsed}
aria-label=${this.collapsed ? 'Expand content' : 'Collapse content'}
aria-expanded=${!this.collapsed}
aria-controls=${this.controls}
@click=${() => this.updateCollapsed(!this.collapsed)}
>
${ToggleRightIcon({
${(this.collapsed ? ToggleRightIcon : ToggleDownIcon)({
width: '16px',
height: '16px',
})}
</div>
</button>
`;
return this.collapsed ? toggleRightTemplate : toggleDownTemplate;
}
@property({ attribute: false })
@@ -88,6 +87,9 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor updateCollapsed!: (collapsed: boolean) => void;
@property({ attribute: false })
accessor controls!: string;
}
declare global {
@@ -75,10 +75,6 @@ export const EMBED_BLOCK_MODEL_LIST = [
export const DEFAULT_IMAGE_PROXY_ENDPOINT =
'https://affine-worker.toeverything.workers.dev/api/worker/image-proxy';
// https://github.com/toeverything/affine-workers/tree/main/packages/link-preview
export const DEFAULT_LINK_PREVIEW_ENDPOINT =
'https://affine-worker.toeverything.workers.dev/api/worker/link-preview';
// This constant is used to ignore tags when exporting using html2canvas
export const CANVAS_EXPORT_IGNORE_TAGS = [
'EDGELESS-TOOLBAR-WIDGET',
@@ -3,7 +3,6 @@ import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Extension } from '@blocksuite/store';
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../../consts';
import { isAbortError } from '../../utils/is-abort-error';
import {
LinkPreviewCacheIdentifier,
@@ -34,12 +33,12 @@ export interface LinkPreviewProvider {
/**
* Set the endpoint for link preview
*/
setEndpoint: (endpoint: string) => void;
setEndpoint: (endpoint: string | null) => void;
/**
* Get the endpoint for link preview
*/
endpoint: string;
endpoint: string | null;
}
export const LinkPreviewServiceIdentifier =
@@ -55,9 +54,12 @@ export class LinkPreviewService
]);
}
private _endpoint: string = DEFAULT_LINK_PREVIEW_ENDPOINT;
private _endpoint: string | null = null;
constructor(private readonly _cache: LinkPreviewCacheProvider) {
constructor(
private readonly _cache: LinkPreviewCacheProvider,
private readonly _fetch: typeof globalThis.fetch = globalThis.fetch
) {
super();
}
@@ -65,52 +67,16 @@ export class LinkPreviewService
return this._endpoint;
}
setEndpoint = (endpoint: string) => {
setEndpoint = (endpoint: string | null) => {
this._endpoint = endpoint;
};
private readonly _fetchTwitterPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
try {
const match = /\/status\/(\d+)/.exec(url);
if (!match) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid tweet URL: ${url}`
);
}
const apiUrl = `https://api.fxtwitter.com/status/${match[1]}`;
const response = await fetch(apiUrl, { signal }).then(res => res.json());
const tweet = response?.tweet;
if (!tweet) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid tweet response: ${url}`
);
}
return {
title: tweet.author?.name ?? null,
icon: tweet.author?.avatar_url ?? null,
description: tweet.text ?? null,
image:
tweet.media?.photos?.[0]?.url || tweet.author?.banner_url || null,
};
} catch (e) {
console.error(`Failed to fetch tweet: ${url}`);
console.error(e);
return {};
}
};
private readonly _fetchStandardPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
const response = await fetch(this.endpoint, {
if (!this.endpoint) return {};
const response = await this._fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -145,29 +111,6 @@ export class LinkPreviewService
};
};
private readonly _isTwitterUrl = (url: string): boolean => {
const twitterDomains = [
'https://x.com/',
'https://www.x.com/',
'https://www.twitter.com/',
'https://twitter.com/',
];
return (
twitterDomains.some(domain => url.startsWith(domain)) &&
url.includes('/status/')
);
};
private readonly _fetchPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
if (this._isTwitterUrl(url)) {
return this._fetchTwitterPreview(url, signal);
}
return this._fetchStandardPreview(url, signal);
};
/**
* Fetch link preview data for a given URL
*/
@@ -191,7 +134,7 @@ export class LinkPreviewService
const promise = (async () => {
try {
// Fetch new data
const data = await this._fetchPreview(url, signal);
const data = await this._fetchStandardPreview(url, signal);
// If the data is not empty, set the data to the cache
if (data && Object.keys(data).length > 0) {
this._cache.set(url, data);
@@ -36,7 +36,7 @@ pub(in crate::runtime::backend_runtime::search) async fn sweep_generation_orphan
"query":{"match_all":{}},
"fields":["workspace_id"],
"size":GENERATION_GC_BATCH,
"sort":if remote.is_some() { json!([{"doc_id":"asc"},{"_id":"asc"}]) } else { json!(["doc_id","id"]) }
"sort":provider_page_sort(table, remote.is_some())
});
if let Some(cursor) = cursor.as_deref() {
dsl["cursor"] = json!(cursor);
@@ -334,7 +334,7 @@ async fn provider_document_page(
"query":{"term":{"workspace_id":{"value":workspace_id}}},
"fields":["doc_id"],
"size":RECONCILE_BATCH,
"sort":if remote.is_some() { json!([{"doc_id":"asc"},{"_id":"asc"}]) } else { json!(["doc_id","id"]) }
"sort":provider_page_sort(table, remote.is_some())
});
if let Some(cursor) = cursor {
dsl["cursor"] = json!(cursor);
@@ -351,6 +351,17 @@ async fn provider_document_page(
Ok((doc_ids, next_cursor))
}
fn provider_page_sort(table: SearchTable, remote: bool) -> Value {
if !remote {
return json!(["doc_id", "id"]);
}
let mut fields = vec!["workspace_id", "doc_id", "source_version", "permission_version"];
if table == SearchTable::Block {
fields.push("block_id");
}
json!(fields.into_iter().map(|field| json!({field:"asc"})).collect::<Vec<_>>())
}
fn provider_field_string(node: &Value, field: &str) -> Option<String> {
node
.pointer(&format!("/fields/{field}/0"))
@@ -582,7 +593,7 @@ mod tests {
use super::*;
#[test]
fn block_projection_check_rejects_missing_or_stale_rows() {
fn anti_entropy_provider_contracts_are_stable() {
let expected = HashSet::from(["one".to_string(), "two".to_string()]);
let complete = vec![
json!({"_source":{"block_id":"one","source_version":7,"permission_version":3}}),
@@ -599,5 +610,15 @@ mod tests {
7,
3,
));
assert_eq!(
provider_page_sort(SearchTable::Doc, true),
json!([
{"workspace_id":"asc"},
{"doc_id":"asc"},
{"source_version":"asc"},
{"permission_version":"asc"}
])
);
assert_eq!(provider_page_sort(SearchTable::Block, false), json!(["doc_id", "id"]));
}
}
@@ -1,4 +1,5 @@
import serverNativeModule from '@affine/server-native';
import { Logger } from '@nestjs/common';
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
@@ -91,6 +92,7 @@ const assertAndSnapshotRaw = async (
referer?: string | null;
method?: 'GET' | 'OPTIONS' | 'POST';
body?: any;
headers?: Record<string, string>;
checker?: (res: Response) => any;
}
) => {
@@ -109,6 +111,9 @@ const assertAndSnapshotRaw = async (
if (referer) {
req.set('Referer', referer);
}
if (options?.headers) {
req.set(options.headers);
}
const res = req.send(options?.body).expect(status).expect(checker);
await t.notThrowsAsync(res, message);
@@ -225,9 +230,19 @@ test('should preview link', async t => {
{
status: 204,
method: 'OPTIONS',
headers: {
'Access-Control-Request-Headers': 'content-type, x-affine-version',
},
checker: (res: Response) => {
if (!res.headers['access-control-allow-methods']) {
throw new Error('Missing CORS headers');
if (
!res.headers['access-control-allow-methods'] ||
!res.headers['access-control-allow-headers']
?.toLowerCase()
.includes('x-affine-version')
) {
throw new Error(
`Missing CORS headers: ${JSON.stringify(res.headers)}`
);
}
},
}
@@ -289,7 +304,7 @@ test('should preview link', async t => {
{
status: 200,
method: 'POST',
body: { url: pageUrl },
body: { url: pageUrl, include: ['transcript'] },
}
);
} finally {
@@ -297,6 +312,38 @@ test('should preview link', async t => {
}
}
{
const secret = `secret-${Date.now()}`;
const pageUrl = `http://external.com/private/page?token=${secret}&user=name`;
const logSpies = [
Sinon.spy(Logger.prototype, 'debug'),
Sinon.spy(Logger.prototype, 'warn'),
Sinon.spy(Logger.prototype, 'error'),
];
const fetchSpy = stubSafeFetch(request => ({
body: '<title>Safe log test</title>',
finalUrl: request.url,
headers: { 'content-type': 'text/html;charset=UTF-8' },
}));
try {
await t.context.app
.POST('/api/worker/link-preview')
.set('Origin', 'http://localhost:3010')
.send({ url: pageUrl })
.expect(200);
const logged = logSpies
.flatMap(spy => spy.getCalls())
.map(call => JSON.stringify(call.args))
.join('\n');
t.true(logged.includes('http://external.com/private/page'));
t.false(logged.includes(secret));
t.false(logged.includes('?token='));
} finally {
fetchSpy.restore();
logSpies.forEach(spy => spy.restore());
}
}
{
const encoded = [
{
@@ -48,6 +48,20 @@ const FETCH_TIMEOUT_MS = 10_000;
const IMAGE_PROXY_MAX_BYTES = 10 * 1024 * 1024;
const LINK_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
function safeLogUrl(value: string | URL | undefined) {
if (!value) return undefined;
try {
const url = value instanceof URL ? value : new URL(value);
return `${url.origin}${url.pathname}`;
} catch {
return 'invalid-url';
}
}
function safeLogError(error: unknown) {
return error instanceof Error ? error.name : 'UnknownError';
}
function toBadRequestReason(reason: SSRFBlockReason) {
switch (reason) {
case 'disallowed_protocol':
@@ -109,7 +123,10 @@ export class WorkerController {
? isRefererAllowed(referer, this.allowedOrigin)
: false;
if (!originAllowed && !refererAllowed) {
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
this.logger.error('Invalid Origin', {
origin: safeLogUrl(origin),
referer: safeLogUrl(referer),
});
throw new BadRequest('Invalid header');
}
const url = new URL(req.url, this.url.requestBaseUrl);
@@ -120,10 +137,12 @@ export class WorkerController {
const targetURL = fixUrl(imageURL);
if (!targetURL) {
this.logger.error(`Invalid URL: ${url}`);
this.logger.error('Invalid URL', { url: safeLogUrl(imageURL) });
throw new BadRequest(`Invalid URL`);
}
const logUrl = safeLogUrl(targetURL);
const cachedUrl = `image-proxy:${targetURL.toString()}`;
const cachedResponse = await this.cache.get<string>(cachedUrl);
if (cachedResponse) {
@@ -162,23 +181,23 @@ export class WorkerController {
if (error instanceof SsrfBlockedError) {
const reason = error.data?.reason as SSRFBlockReason | undefined;
this.logger.warn('Blocked image proxy target', {
url: imageURL,
url: logUrl,
reason,
});
throw new BadRequest(toBadRequestReason(reason ?? 'invalid_url'));
}
if (error instanceof ResponseTooLargeError) {
this.logger.warn('Image proxy response too large', {
url: imageURL,
url: logUrl,
limitBytes: error.data?.limitBytes,
receivedBytes: error.data?.receivedBytes,
});
throw new BadRequest('Response too large');
}
this.logger.error('Failed to fetch image', {
origin,
url: imageURL,
error,
origin: safeLogUrl(origin),
url: logUrl,
error: safeLogError(error),
});
throw new BadRequest('Failed to fetch image');
}
@@ -212,8 +231,8 @@ export class WorkerController {
});
}
this.logger.error('Failed to fetch image', {
origin,
url: imageURL,
origin: safeLogUrl(origin),
url: logUrl,
status: response.status,
});
throw new BadRequest('Failed to fetch image');
@@ -225,8 +244,8 @@ export class WorkerController {
return inspectImageForProxy(buffer);
} catch (error) {
this.logger.warn('Image proxy rejected invalid image', {
url,
error,
url: safeLogUrl(url),
error: safeLogError(error),
});
throw new BadRequest('Invalid image');
}
@@ -243,7 +262,7 @@ export class WorkerController {
.header({
...getCorsHeaders(origin),
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Headers': 'Content-Type, x-affine-version',
})
.send();
}
@@ -262,21 +281,32 @@ export class WorkerController {
? isRefererAllowed(referer, this.allowedOrigin)
: false;
if (!originAllowed && !refererAllowed) {
this.logger.error('Invalid Origin', { origin, referer });
this.logger.error('Invalid Origin', {
origin: safeLogUrl(origin),
referer: safeLogUrl(referer),
});
throw new BadRequest('Invalid header');
}
this.logger.debug('Received request', { origin, method: request.method });
const logOrigin = safeLogUrl(origin);
this.logger.debug('Received request', {
origin: logOrigin,
method: request.method,
});
const requestBody = parseJson<LinkPreviewRequest>(request.body);
const targetURL = fixUrl(requestBody?.url);
// not allow same site preview
if (!targetURL || isOriginAllowed(targetURL.origin, this.allowedOrigin)) {
this.logger.error('Invalid URL', { origin, url: requestBody?.url });
this.logger.error('Invalid URL', {
origin: logOrigin,
url: safeLogUrl(requestBody?.url),
});
throw new BadRequest('Invalid URL');
}
this.logger.debug('Processing request', { origin, url: targetURL });
const logUrl = safeLogUrl(targetURL);
this.logger.debug('Processing request', { origin: logOrigin, url: logUrl });
try {
const cachedUrl = `link-preview:${targetURL.toString()}`;
@@ -303,8 +333,8 @@ export class WorkerController {
}
);
this.logger.debug('Fetched URL', {
origin,
url: targetURL,
origin: logOrigin,
url: logUrl,
status: response.status,
});
@@ -396,8 +426,8 @@ export class WorkerController {
res.images = await reduceUrls(res.images);
this.logger.debug('Processed response with HTMLRewriter', {
origin,
url: response.url,
origin: logOrigin,
url: safeLogUrl(response.url),
});
}
@@ -423,8 +453,8 @@ export class WorkerController {
const json = JSON.stringify(res);
this.logger.debug('Sending response', {
origin,
url: res.url,
origin: logOrigin,
url: safeLogUrl(res.url),
responseSize: json.length,
});
@@ -440,25 +470,25 @@ export class WorkerController {
if (error instanceof SsrfBlockedError) {
const reason = error.data?.reason as SSRFBlockReason | undefined;
this.logger.warn('Blocked link preview target', {
origin,
url: requestBody?.url,
origin: logOrigin,
url: safeLogUrl(requestBody?.url),
reason,
});
throw new BadRequest(toBadRequestReason(reason ?? 'invalid_url'));
}
if (error instanceof ResponseTooLargeError) {
this.logger.warn('Link preview response too large', {
origin,
url: requestBody?.url,
origin: logOrigin,
url: safeLogUrl(requestBody?.url),
limitBytes: error.data?.limitBytes,
receivedBytes: error.data?.receivedBytes,
});
throw new BadRequest('Response too large');
}
this.logger.error('Error fetching URL', {
origin,
url: targetURL,
error,
origin: logOrigin,
url: logUrl,
error: safeLogError(error),
});
throw new BadRequest('Error fetching URL');
}
@@ -1,6 +1,7 @@
export type LinkPreviewRequest = {
url: string;
head?: boolean;
include?: Array<'transcript'>;
};
export type LinkPreviewResponse = {
@@ -1,12 +1,10 @@
import { app, clipboard, nativeImage, nativeTheme } from 'electron';
import { getLinkPreview } from 'link-preview-js';
import { map, shareReplay } from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { persistentConfig } from '../config-storage/persist';
import { logger } from '../logger';
import { openExternalSafely } from '../security/open-external';
import { resolveAndValidateUrlForPreview } from '../security/url-safety';
import type { WorkbenchViewMeta } from '../shared-state-schema';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
@@ -39,13 +37,6 @@ import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-wi
import { getChallengeResponse } from './challenge';
import { uiSubjects } from './subject';
const EMPTY_OBJECT = Object.freeze({
title: undefined,
description: undefined,
icon: undefined,
image: undefined,
});
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
@@ -134,83 +125,6 @@ export const uiHandlers = {
logger.error('handleOpenMainApp', err);
}
},
getBookmarkDataByLink: async (_, link: string) => {
try {
// Basic validation up-front to prevent SSRF (including redirects).
await resolveAndValidateUrlForPreview(link);
} catch {
return EMPTY_OBJECT;
}
if (
(link.startsWith('https://x.com/') ||
link.startsWith('https://www.x.com/') ||
link.startsWith('https://www.twitter.com/') ||
link.startsWith('https://twitter.com/')) &&
link.includes('/status/')
) {
// use api.fxtwitter.com
const statusId = /\/status\/(\d+)/.exec(link)?.[1];
if (!statusId) return EMPTY_OBJECT;
link = `https://api.fxtwitter.com/status/${statusId}`;
try {
const { tweet } = (await fetch(link).then(res => res.json())) as any;
return {
title: tweet.author.name,
icon: tweet.author.avatar_url,
description: tweet.text,
image: tweet.media?.photos[0].url || tweet.author.banner_url,
};
} catch (err) {
logger.error('getBookmarkDataByLink', err);
return {
title: undefined,
description: undefined,
icon: undefined,
image: undefined,
};
}
} else {
const previewData = (await getLinkPreview(link, {
timeout: 6000,
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0 Safari/537.36 Edg/120.0.0',
},
followRedirects: 'manual',
handleRedirects: (_baseUrl: string, forwardedUrl: string) => {
try {
// Only allow http(s) redirects and re-validate before following.
const u = new URL(forwardedUrl);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
},
resolveDNSHost: async (url: string) => {
const { address } = await resolveAndValidateUrlForPreview(url);
return address;
},
}).catch(() => {
return {
title: '',
siteName: '',
description: '',
images: [],
videos: [],
contentType: `text/html`,
favicons: [],
};
})) as any;
return {
title: previewData.title,
description: previewData.description,
icon: previewData.favicons[0],
image: previewData.images[0],
};
}
},
openExternal(_, url: string) {
return openExternalSafely(url);
},
@@ -51,6 +51,10 @@
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
AB0000010000000000000000 /* ShareInboxSafety.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000030000000000000000 /* ShareInboxSafety.swift */; };
AB0000020000000000000000 /* ShareInboxSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000040000000000000000 /* ShareInboxSafetyTests.swift */; };
AB0000060000000000000000 /* ShareInboxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000050000000000000000 /* ShareInboxModels.swift */; };
AB0000080000000000000000 /* ShareInboxConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000070000000000000000 /* ShareInboxConstants.swift */; };
AB00000A0000000000000000 /* ShareLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000090000000000000000 /* ShareLinkPreview.swift */; };
AB00000B0000000000000000 /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE1E2CCB9876006677DB /* capacitor.config.json */; };
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
@@ -139,6 +143,9 @@
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
AB0000030000000000000000 /* ShareInboxSafety.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxSafety.swift; sourceTree = "<group>"; };
AB0000040000000000000000 /* ShareInboxSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInboxSafetyTests.swift; sourceTree = "<group>"; };
AB0000050000000000000000 /* ShareInboxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxModels.swift; sourceTree = "<group>"; };
AB0000070000000000000000 /* ShareInboxConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxConstants.swift; sourceTree = "<group>"; };
AB0000090000000000000000 /* ShareLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareLinkPreview.swift; sourceTree = "<group>"; };
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AFFiNE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -344,6 +351,9 @@
AA0000020000000000000000 /* AuthDateParserTests.swift */,
AB0000030000000000000000 /* ShareInboxSafety.swift */,
AB0000040000000000000000 /* ShareInboxSafetyTests.swift */,
AB0000050000000000000000 /* ShareInboxModels.swift */,
AB0000070000000000000000 /* ShareInboxConstants.swift */,
AB0000090000000000000000 /* ShareLinkPreview.swift */,
);
path = AppTests;
sourceTree = "<group>";
@@ -510,6 +520,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AB00000B0000000000000000 /* capacitor.config.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -627,6 +638,9 @@
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
AB0000010000000000000000 /* ShareInboxSafety.swift in Sources */,
AB0000020000000000000000 /* ShareInboxSafetyTests.swift in Sources */,
AB0000060000000000000000 /* ShareInboxModels.swift in Sources */,
AB0000080000000000000000 /* ShareInboxConstants.swift in Sources */,
AB00000A0000000000000000 /* ShareLinkPreview.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -7,6 +7,7 @@ public final class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let jsName = "ShareInbox"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "listPending", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateWorkspaceMode", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateTarget", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "resolveAttachment", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "complete", returnType: CAPPluginReturnPromise),
@@ -36,6 +37,18 @@ public final class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
}
}
@objc func updateWorkspaceMode(_ call: CAPPluginCall) {
do {
guard let value = call.getString("mode"), let mode = ShareWorkspaceMode(rawValue: value) else {
throw ShareInboxError.invalidPayload
}
try store.updateWorkspaceMode(mode)
call.resolve()
} catch {
call.reject("Failed to update share privacy mode.", nil, error)
}
}
@objc func updateTarget(_ call: CAPPluginCall) {
do {
var item = try item(from: call)
@@ -1,6 +1,91 @@
import XCTest
private final class SharePreviewURLProtocol: URLProtocol {
static var onStart: ((URLProtocol, URLRequest) -> Void)?
static var onStop: (() -> Void)?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() { Self.onStart?(self, request) }
override func stopLoading() { Self.onStop?() }
}
final class ShareInboxSafetyTests: XCTestCase {
func testManifestTitleIgnoresPreviewAndOnlyAcceptsExplicitEdits() {
let originalTitle = "Original Safari title"
let serverPreviewTitle = "Untrusted server preview title"
XCTAssertEqual(
ShareInboxSafety.manifestTitle(original: originalTitle, userEdited: nil),
originalTitle
)
XCTAssertNotEqual(
ShareInboxSafety.manifestTitle(original: originalTitle, userEdited: nil),
serverPreviewTitle
)
XCTAssertEqual(
ShareInboxSafety.manifestTitle(
original: originalTitle,
userEdited: " My explicit title "
),
"My explicit title"
)
XCTAssertEqual(
ShareInboxSafety.previewTitle(
original: originalTitle, userEdited: nil, serverTitle: serverPreviewTitle),
serverPreviewTitle
)
XCTAssertEqual(
ShareInboxSafety.previewTitle(
original: originalTitle, userEdited: "My explicit title", serverTitle: serverPreviewTitle),
"My explicit title"
)
XCTAssertEqual(
ShareInboxSafety.manifestTitle(original: originalTitle, userEdited: nil),
originalTitle
)
}
func testShareExtensionActivationAcceptsSupportedRepresentationsAmongExtraAttachments() throws {
let plistURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ShareExtension/Info.plist")
let plist = try PropertyListSerialization.propertyList(
from: Data(contentsOf: plistURL),
format: nil
) as? [String: Any]
let extensionDictionary = plist?["NSExtension"] as? [String: Any]
let attributes = extensionDictionary?["NSExtensionAttributes"] as? [String: Any]
let rule = try XCTUnwrap(attributes?["NSExtensionActivationRule"] as? String)
XCTAssertTrue(rule.contains("public.url"))
XCTAssertTrue(rule.contains("public.text"))
XCTAssertTrue(rule.contains("public.image"))
XCTAssertTrue(rule.contains("com.apple.property-list"))
XCTAssertTrue(rule.contains(".@count > 0"))
XCTAssertFalse(rule.contains("TRUEPREDICATE"))
let predicate = NSPredicate(format: rule)
let youtubePayload: [String: Any] = [
"extensionItems": [[
"attachments": [
["registeredTypeIdentifiers": ["public.url", "public.data"]],
["registeredTypeIdentifiers": ["com.google.youtube.extra"]],
]
]]
]
let unsupportedPayload: [String: Any] = [
"extensionItems": [[
"attachments": [[
"registeredTypeIdentifiers": ["com.adobe.pdf", "public.movie"]
]]
]]
]
XCTAssertTrue(predicate.evaluate(with: youtubePayload))
XCTAssertFalse(predicate.evaluate(with: unsupportedPayload))
}
func testManifestIDsMustBeUUIDs() {
let id = UUID().uuidString
XCTAssertEqual(ShareInboxSafety.normalizedManifestID(id.lowercased()), id)
@@ -24,4 +109,201 @@ final class ShareInboxSafetyTests: XCTestCase {
)
XCTAssertNil(ShareInboxSafety.detectRasterImageMimeType(Data("<svg/>".utf8)))
}
func testPreviewRouteMatrixAndAllowlistBypasses() {
let publicURLs = [
"https://x.com/affine/status/123",
"https://www.twitter.com/affine/status/123",
"https://youtu.be/video-id",
"https://www.youtube.com/watch?v=video-id",
"https://m.youtube.com/shorts/video-id",
]
for mode in [ShareWorkspaceMode.selfHostedPresent, .cloudOnly, .signedOut, .unknown] {
for url in publicURLs {
XCTAssertEqual(ShareInboxSafety.previewRoute(mode: mode, url: url), .official)
}
}
let genericURL = "https://example.com/private"
XCTAssertEqual(ShareInboxSafety.previewRoute(mode: .selfHostedPresent, url: genericURL), .deferred)
XCTAssertEqual(ShareInboxSafety.previewRoute(mode: .unknown, url: genericURL), .deferred)
XCTAssertEqual(ShareInboxSafety.previewRoute(mode: .cloudOnly, url: genericURL), .official)
XCTAssertEqual(ShareInboxSafety.previewRoute(mode: .signedOut, url: genericURL), .official)
for bypass in [
"https://evil.x.com/affine/status/123",
"https://x.com/affine/status/not-a-number",
"https://x.com/affine/status/123/extra",
"https://youtube.com.evil.example/watch?v=video-id",
"https://www.youtube.com/channel/video-id",
"https://youtu.be/video-id/extra",
] {
XCTAssertFalse(ShareInboxSafety.isOfficialPreviewURL(bypass), bypass)
}
}
func testWorkspaceModeSnapshotFailsClosed() throws {
XCTAssertEqual(ShareInboxSafety.workspaceMode(from: nil), .unknown)
XCTAssertEqual(ShareInboxSafety.workspaceMode(from: Data("invalid".utf8)), .unknown)
let incompatible = Data(
"{\"mode\":\"cloudOnly\",\"schemaVersion\":2,\"updatedAt\":\"2026-08-27T00:00:00Z\"}".utf8
)
XCTAssertEqual(ShareInboxSafety.workspaceMode(from: incompatible), .unknown)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let now = Date(timeIntervalSince1970: 1_800_000_000)
let current = try encoder.encode(
ShareWorkspaceModeSnapshot(mode: .selfHostedPresent, updatedAt: now)
)
XCTAssertEqual(ShareInboxSafety.workspaceMode(from: current, now: now), .selfHostedPresent)
XCTAssertEqual(
ShareInboxSafety.workspaceMode(from: current, now: now.addingTimeInterval(24 * 60 * 60 + 1)),
.unknown
)
}
func testOldManifestDefaultsToConservativeRouteAndOriginalURLSurvives() throws {
let id = UUID().uuidString
let oldManifest = """
{
"id":"\(id)",
"documentId":"\(UUID().uuidString)",
"createdAt":"2026-08-27T00:00:00Z",
"title":"Original",
"content":{"kind":"url","url":"https://example.com/original?token=value"},
"attachments":[]
}
"""
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let item = try decoder.decode(ShareInboxItem.self, from: Data(oldManifest.utf8))
XCTAssertNil(item.previewRoute)
XCTAssertEqual(item.previewRoute ?? .deferred, .deferred)
XCTAssertEqual(item.content.url, "https://example.com/original?token=value")
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let encoded = try encoder.encode(item)
XCTAssertEqual(try decoder.decode(ShareInboxItem.self, from: encoded).content.url, item.content.url)
}
func testPreviewAndImageRequestsCarryHeadersAndCanBeCancelled() async throws {
let family = "👨‍👩‍👧"
let transcript = ShareLinkPreview.Transcript(
language: nil,
segments: [
.init(text: " Hello\n\tworld ", startSeconds: nil, durationSeconds: nil, speaker: nil),
.init(text: "again", startSeconds: nil, durationSeconds: nil, speaker: nil),
],
chapters: nil,
truncated: nil
)
XCTAssertEqual(transcript.previewText, "Hello world again")
let longTranscript = ShareLinkPreview.Transcript(
language: nil,
segments: [
.init(
text: String(repeating: family, count: 241), startSeconds: nil,
durationSeconds: nil, speaker: nil)
],
chapters: nil,
truncated: nil
)
XCTAssertEqual(longTranscript.previewText?.count, 241)
XCTAssertTrue(longTranscript.previewText?.hasSuffix("") == true)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [SharePreviewURLProtocol.self]
let client = ShareLinkPreviewClient(
session: URLSession(configuration: configuration), appVersion: "0.27.0")
let started = expectation(description: "request started")
let stopped = expectation(description: "request cancelled")
SharePreviewURLProtocol.onStart = { _, request in
XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "AFFiNE/0.27.0")
XCTAssertEqual(request.value(forHTTPHeaderField: "x-affine-version"), "0.27.0")
started.fulfill()
}
SharePreviewURLProtocol.onStop = { stopped.fulfill() }
defer {
SharePreviewURLProtocol.onStart = nil
SharePreviewURLProtocol.onStop = nil
}
let task = Task {
try await client.fetch(url: "https://www.youtube.com/watch?v=video-id")
}
await fulfillment(of: [started], timeout: 1)
task.cancel()
do {
_ = try await task.value
XCTFail("Cancelled preview unexpectedly completed")
} catch {
let urlError = error as? URLError
XCTAssertTrue(error is CancellationError || urlError?.code == .cancelled)
}
await fulfillment(of: [stopped], timeout: 1)
let imageData = try XCTUnwrap(
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
)
let imageLoaded = expectation(description: "image loaded")
SharePreviewURLProtocol.onStart = { protocolInstance, request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.absoluteString, "https://app.affine.pro/api/worker/image-proxy")
XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "AFFiNE/0.27.0")
XCTAssertEqual(request.value(forHTTPHeaderField: "x-affine-version"), "0.27.0")
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: nil,
headerFields: ["Content-Type": "image/png"]
)!
protocolInstance.client?.urlProtocol(
protocolInstance, didReceive: response, cacheStoragePolicy: .notAllowed)
protocolInstance.client?.urlProtocol(protocolInstance, didLoad: imageData)
protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
imageLoaded.fulfill()
}
SharePreviewURLProtocol.onStop = nil
_ = try await client.fetchImage(url: "/api/worker/image-proxy")
await fulfillment(of: [imageLoaded], timeout: 1)
SharePreviewURLProtocol.onStart = { protocolInstance, request in
let response = HTTPURLResponse(
url: request.url!, statusCode: 403, httpVersion: nil, headerFields: nil)!
protocolInstance.client?.urlProtocol(
protocolInstance, didReceive: response, cacheStoragePolicy: .notAllowed)
protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
}
do {
_ = try await client.fetchImage(url: "/api/worker/image-proxy")
XCTFail("Failed image response unexpectedly decoded")
} catch {
XCTAssertEqual((error as? URLError)?.code, .badServerResponse)
}
let imageStarted = expectation(description: "image request started")
let imageStopped = expectation(description: "image request cancelled")
SharePreviewURLProtocol.onStart = { _, request in
XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "AFFiNE/0.27.0")
XCTAssertEqual(request.value(forHTTPHeaderField: "x-affine-version"), "0.27.0")
imageStarted.fulfill()
}
SharePreviewURLProtocol.onStop = { imageStopped.fulfill() }
let imageTask = Task {
try await client.fetchImage(url: "/api/worker/image-proxy")
}
await fulfillment(of: [imageStarted], timeout: 1)
imageTask.cancel()
do {
_ = try await imageTask.value
XCTFail("Cancelled image request unexpectedly completed")
} catch {
let urlError = error as? URLError
XCTAssertTrue(error is CancellationError || urlError?.code == .cancelled)
}
await fulfillment(of: [imageStopped], timeout: 1)
}
}
@@ -23,18 +23,7 @@
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationDictionaryVersion</key>
<integer>2</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
<string>SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-EQUALS "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" OR ANY $attachment.registeredTypeIdentifiers UTI-EQUALS "com.apple.property-list").@count &gt; 0).@count &gt; 0</string>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>SafariPageCapture</string>
</dict>
@@ -36,44 +36,274 @@ struct ShareExtensionView: View {
}
private var content: some View {
Form {
Section {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Choose a workspace in AFFiNE. This item will stay saved until then.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
HStack(alignment: .top, spacing: 12) {
Image(systemName: viewModel.previewImage == nil ? "doc.text" : "photo")
.font(.title2)
.frame(width: 32, height: 32)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 4) {
TextField("Title", text: $viewModel.title)
.font(.headline)
if !viewModel.previewText.isEmpty {
Text(viewModel.previewText)
.lineLimit(3)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if viewModel.linkPreviewState != .idle {
linkPreviewCard
} else {
attachmentCard
}
if let image = viewModel.previewImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 180)
}
}
if let errorMessage = viewModel.errorMessage {
Section {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color(uiColor: .systemGroupedBackground))
}
private var titleField: some View {
TextField(
"Title",
text: Binding(
get: { viewModel.displayTitle },
set: viewModel.updateTitle
)
)
.font(.system(size: 17, weight: .semibold))
}
private var linkPreviewCard: some View {
VStack(alignment: .leading, spacing: 0) {
switch viewModel.linkPreviewState {
case .loading:
previewSkeleton
case let .loaded(preview):
previewContent(preview)
case .failed:
fallbackLink(showFailure: true)
case .deferred:
fallbackLink(showFailure: false)
case .idle:
EmptyView()
}
if let selectedText = viewModel.selectedText, !selectedText.isEmpty {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(height: 1)
VStack(alignment: .leading, spacing: 2) {
Text("Selected text")
.font(.footnote.weight(.semibold))
Text(selectedText)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
}
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 14)
.accessibilityElement(children: .combine)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private func previewContent(_ preview: ShareLinkPreview) -> some View {
previewMedia(viewModel.linkPreviewMediaImage)
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
if let favicon = viewModel.linkPreviewFaviconImage {
Image(uiImage: favicon)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.accessibilityHidden(true)
} else {
Image(systemName: "link")
.frame(width: 16, height: 16)
.accessibilityHidden(true)
}
Text(preview.siteName ?? previewHost)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.bottom, 2)
titleField
.lineLimit(2)
if let description = preview.description,
!description.isEmpty,
description != viewModel.displayTitle
{
Text(description)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.lineLimit(2)
}
if let metadata = previewMetadata(preview) {
Text(metadata)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
if let transcript = preview.transcript?.previewText {
transcriptPreview(transcript)
}
}
.padding(14)
}
private func transcriptPreview(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(height: 1)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "waveform")
.frame(width: 16, height: 16)
.accessibilityHidden(true)
Text("Transcript")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(text)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(viewModel.selectedText?.isEmpty == false ? 2 : 3)
}
.padding(.top, 10)
}
.padding(.top, 8)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Transcript preview: \(text)")
}
private func fallbackLink(showFailure: Bool) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "link")
.font(.title2)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
titleField
Text(previewHost)
.font(.system(size: 13))
.foregroundStyle(.secondary)
if showFailure {
Text("Preview unavailable")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
.padding(14)
}
private var previewSkeleton: some View {
VStack(alignment: .leading, spacing: 0) {
Rectangle()
.fill(.quaternary)
.frame(maxWidth: .infinity)
.frame(height: 180)
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: geometry.size.width * 0.6, height: 12)
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: geometry.size.width * 0.9, height: 16)
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: geometry.size.width * 0.55, height: 12)
}
}
.frame(height: 56)
.padding(14)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Loading link preview")
}
@ViewBuilder
private func previewMedia(_ image: UIImage?) -> some View {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 180)
.clipped()
.accessibilityHidden(true)
} else {
mediaPlaceholder
.frame(maxWidth: .infinity)
.frame(height: 180)
.accessibilityHidden(true)
}
}
private var mediaPlaceholder: some View {
ZStack {
Color(uiColor: .systemGroupedBackground)
Image(systemName: "link")
.font(.system(size: 24))
.foregroundStyle(.tertiary)
}
}
private var attachmentCard: some View {
VStack(alignment: .leading, spacing: 0) {
if let image = viewModel.previewImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.aspectRatio(16 / 9, contentMode: .fit)
.frame(maxHeight: 180)
.clipped()
.accessibilityHidden(true)
}
HStack(alignment: .top, spacing: 12) {
Image(systemName: viewModel.previewImage == nil ? "doc.text" : "photo")
.font(.title2)
.frame(width: 32, height: 32)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
titleField
if !viewModel.previewText.isEmpty {
Text(viewModel.previewText)
.lineLimit(3)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.padding(14)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var previewHost: String {
guard let value = viewModel.sharedURL, let url = URL(string: value) else { return "Link" }
return url.host ?? value
}
private func previewMetadata(_ preview: ShareLinkPreview) -> String? {
var values: [String] = []
if let author = preview.author?.name { values.append(author) }
if let duration = preview.durationSeconds {
values.append(String(format: "%d:%02d", Int(duration) / 60, Int(duration) % 60))
}
return values.prefix(2).isEmpty ? nil : values.prefix(2).joined(separator: " · ")
}
}
@@ -10,6 +10,9 @@ final class ShareViewModel: ObservableObject {
@Published var isSaving = false
@Published var hasSaved = false
@Published var errorMessage: String?
@Published var linkPreviewState: ShareLinkPreviewState = .idle
@Published var linkPreviewMediaImage: UIImage?
@Published var linkPreviewFaviconImage: UIImage?
var actionTitle: String {
"Open AFFiNE"
@@ -23,10 +26,43 @@ final class ShareViewModel: ObservableObject {
}
private var draft: SharePayloadDraft?
private var previewRoute: SharePreviewRoute = .deferred
private var previewTask: Task<Void, Never>?
private var userEditedTitle: String?
private let store: ShareInboxStore
private let previewClient: ShareLinkPreviewClient
init(store: ShareInboxStore = .shared) {
init(
store: ShareInboxStore = .shared,
previewClient: ShareLinkPreviewClient = ShareLinkPreviewClient()
) {
self.store = store
self.previewClient = previewClient
}
var linkPreview: ShareLinkPreview? {
guard case let .loaded(preview) = linkPreviewState else { return nil }
return preview
}
var displayTitle: String {
ShareInboxSafety.previewTitle(
original: title,
userEdited: userEditedTitle,
serverTitle: linkPreview?.title
)
}
var sharedURL: String? { draft?.content?.url }
var selectedText: String? {
guard draft?.content?.kind == .url else { return nil }
return draft?.content?.text
}
func updateTitle(_ value: String) {
userEditedTitle = value
title = value
}
func load(from extensionContext: NSExtensionContext?) async {
@@ -36,21 +72,54 @@ final class ShareViewModel: ObservableObject {
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let built = await SharePayloadBuilder.build(from: items)
draft = built
userEditedTitle = nil
title = built.title
previewText = built.previewText
errorMessage = built.errorMessage
linkPreviewMediaImage = nil
linkPreviewFaviconImage = nil
if let file = built.file {
previewImage = UIImage(data: file.data)?
.preparingThumbnail(of: CGSize(width: 480, height: 480))
}
guard built.content?.kind == .url, let url = built.content?.url else { return }
previewRoute = ShareInboxSafety.previewRoute(mode: store.workspaceMode(), url: url)
guard previewRoute == .official else {
linkPreviewState = .deferred
return
}
linkPreviewState = .loading
previewTask = Task { [weak self] in
guard let self else { return }
do {
let preview = try await previewClient.fetch(url: url)
guard !Task.isCancelled else { return }
linkPreviewState = .loaded(preview)
async let media = previewClient.fetchImageIfPresent(url: preview.images?.first)
async let favicon = previewClient.fetchImageIfPresent(url: preview.favicons?.first)
let images = await (media, favicon)
guard !Task.isCancelled else { return }
linkPreviewMediaImage = images.0
linkPreviewFaviconImage = images.1
} catch is CancellationError {
return
} catch {
guard !Task.isCancelled else { return }
linkPreviewState = .failed
}
}
}
func save() async -> Bool {
guard !isSaving, !hasSaved else { return false }
previewTask?.cancel()
isSaving = true
defer { isSaving = false }
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTitle = ShareInboxSafety.manifestTitle(
original: draft?.title ?? title,
userEdited: userEditedTitle
)
guard !trimmedTitle.isEmpty else {
errorMessage = "Title is required."
return false
@@ -77,6 +146,7 @@ final class ShareViewModel: ObservableObject {
id: itemId,
title: trimmedTitle,
content: content,
previewRoute: previewRoute,
previewText: draft.previewText,
attachments: attachments
)
@@ -10,5 +10,9 @@ enum ShareInboxConstants {
static let inboxDirectoryName = "ShareInbox"
static let attachmentsDirectoryName = "Attachments"
static let invalidDirectoryName = "Invalid"
static let workspaceModeFileName = "ShareWorkspaceMode.json"
static let officialLinkPreviewURL = URL(
string: "https://app.affine.pro/api/worker/link-preview"
)!
static let openInboxURL = URL(string: "affine://share-inbox")!
}
@@ -36,6 +36,7 @@ struct ShareInboxItem: Codable, Equatable, Identifiable {
var createdAt: Date
var title: String
var content: ShareInboxContent
var previewRoute: SharePreviewRoute?
var target: ShareInboxTarget?
var previewText: String?
var attachments: [ShareInboxAttachment]
@@ -48,6 +49,7 @@ struct ShareInboxItem: Codable, Equatable, Identifiable {
createdAt: Date = Date(),
title: String,
content: ShareInboxContent,
previewRoute: SharePreviewRoute? = nil,
target: ShareInboxTarget? = nil,
previewText: String? = nil,
attachments: [ShareInboxAttachment] = [],
@@ -59,6 +61,7 @@ struct ShareInboxItem: Codable, Equatable, Identifiable {
self.createdAt = createdAt
self.title = title
self.content = content
self.previewRoute = previewRoute
self.target = target
self.previewText = previewText
self.attachments = attachments
@@ -1,6 +1,44 @@
import Foundation
enum ShareWorkspaceMode: String, Codable {
case selfHostedPresent
case cloudOnly
case signedOut
case unknown
}
enum SharePreviewRoute: String, Codable {
case official
case deferred
}
struct ShareWorkspaceModeSnapshot: Codable, Equatable {
static let schemaVersion = 1
var mode: ShareWorkspaceMode
var schemaVersion: Int
var updatedAt: Date
init(mode: ShareWorkspaceMode, updatedAt: Date = Date()) {
self.mode = mode
self.schemaVersion = Self.schemaVersion
self.updatedAt = updatedAt
}
}
enum ShareInboxSafety {
private static let workspaceModeMaxAge: TimeInterval = 24 * 60 * 60
static func manifestTitle(original: String, userEdited: String?) -> String {
(userEdited ?? original).trimmingCharacters(in: .whitespacesAndNewlines)
}
static func previewTitle(original: String, userEdited: String?, serverTitle: String?) -> String {
if let userEdited { return userEdited }
guard let serverTitle, !serverTitle.isEmpty else { return original }
return serverTitle
}
static func normalizedManifestID(_ value: String) -> String? {
UUID(uuidString: value)?.uuidString
}
@@ -22,6 +60,56 @@ enum ShareInboxSafety {
return url.absoluteString
}
static func isOfficialPreviewURL(_ value: String) -> Bool {
guard let normalized = normalizedWebURL(value), let url = URL(string: normalized) else {
return false
}
let host = url.host?.lowercased()
let components = url.pathComponents.filter { $0 != "/" }
if ["x.com", "www.x.com", "twitter.com", "www.twitter.com"].contains(host) {
return components.count == 3
&& components[1] == "status"
&& !components[2].isEmpty
&& components[2].allSatisfy(\.isNumber)
}
if host == "youtu.be" {
return components.count == 1 && !components[0].isEmpty
}
if ["youtube.com", "www.youtube.com", "m.youtube.com"].contains(host) {
if url.path == "/watch" {
return !(URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "v" })?.value?.isEmpty ?? true)
}
return components.count == 2
&& ["shorts", "live", "embed"].contains(components[0])
&& !components[1].isEmpty
}
return false
}
static func previewRoute(mode: ShareWorkspaceMode, url: String) -> SharePreviewRoute {
if isOfficialPreviewURL(url) { return .official }
switch mode {
case .cloudOnly, .signedOut:
return .official
case .selfHostedPresent, .unknown:
return .deferred
}
}
static func workspaceMode(from data: Data?, now: Date = Date()) -> ShareWorkspaceMode {
guard let data else { return .unknown }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let snapshot = try? decoder.decode(ShareWorkspaceModeSnapshot.self, from: data),
snapshot.schemaVersion == ShareWorkspaceModeSnapshot.schemaVersion,
(0...workspaceModeMaxAge).contains(now.timeIntervalSince(snapshot.updatedAt))
else {
return .unknown
}
return snapshot.mode
}
static func detectRasterImageMimeType(_ data: Data) -> String? {
let bytes = [UInt8](data.prefix(12))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
@@ -102,6 +102,21 @@ final class ShareInboxStore {
try encoder.encode(item).write(to: fileURL, options: .atomic)
}
func updateWorkspaceMode(_ mode: ShareWorkspaceMode) throws {
guard let containerURL else { throw ShareInboxError.containerUnavailable }
let url = containerURL.appendingPathComponent(ShareInboxConstants.workspaceModeFileName)
try encoder.encode(ShareWorkspaceModeSnapshot(mode: mode)).write(to: url, options: .atomic)
}
func workspaceMode() -> ShareWorkspaceMode {
guard let containerURL,
let data = try? Data(
contentsOf: containerURL.appendingPathComponent(ShareInboxConstants.workspaceModeFileName)
)
else { return .unknown }
return ShareInboxSafety.workspaceMode(from: data)
}
func pendingItems() -> [ShareInboxItem] {
guard ensureDirectories(), let inboxDirectoryURL else { return [] }
guard let urls = try? fileManager.contentsOfDirectory(
@@ -0,0 +1,164 @@
import Foundation
import UIKit
struct ShareLinkPreview: Decodable, Equatable {
struct Author: Decodable, Equatable {
var name: String
var handle: String?
var avatar: String?
}
struct Transcript: Decodable, Equatable {
struct Segment: Decodable, Equatable {
var text: String
var startSeconds: Double?
var durationSeconds: Double?
var speaker: String?
}
struct Chapter: Decodable, Equatable {
var title: String
var startSeconds: Double
}
var language: String?
var segments: [Segment]
var chapters: [Chapter]?
var truncated: Bool?
}
var url: String
var title: String?
var siteName: String?
var description: String?
var images: [String]?
var favicons: [String]?
var mediaType: String?
var provider: String?
var author: Author?
var publishedAt: String?
var durationSeconds: Double?
var transcript: Transcript?
}
extension ShareLinkPreview.Transcript {
var previewText: String? {
let text = segments
.map { $0.text.split(whereSeparator: \.isWhitespace).joined(separator: " ") }
.filter { !$0.isEmpty }
.joined(separator: " ")
guard !text.isEmpty else { return nil }
guard text.count > 240 else { return text }
return String(text.prefix(240)) + ""
}
}
enum ShareLinkPreviewState: Equatable {
case idle
case deferred
case loading
case loaded(ShareLinkPreview)
case failed
}
struct ShareLinkPreviewClient {
private let session: URLSession
private let appVersion: String
init(session: URLSession? = nil, appVersion: String? = nil) {
self.appVersion = appVersion ?? Self.bundledAppVersion
if let session {
self.session = session
} else {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 4
configuration.timeoutIntervalForResource = 6
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
self.session = URLSession(configuration: configuration)
}
}
func fetch(url: String) async throws -> ShareLinkPreview {
guard let normalized = ShareInboxSafety.normalizedWebURL(url) else {
throw URLError(.badURL)
}
var request = URLRequest(url: ShareInboxConstants.officialLinkPreviewURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
addClientHeaders(to: &request)
request.httpBody = try JSONEncoder().encode(
Request(url: normalized, include: ["transcript"])
)
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(ShareLinkPreview.self, from: data)
}
func fetchImage(url value: String) async throws -> UIImage {
guard let candidate = URL(
string: value,
relativeTo: ShareInboxConstants.officialLinkPreviewURL
) else {
throw URLError(.badURL)
}
let resolved = candidate.absoluteURL
guard
let normalized = ShareInboxSafety.normalizedWebURL(resolved.absoluteString),
let url = URL(string: normalized)
else {
throw URLError(.badURL)
}
var request = URLRequest(
url: url,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 3
)
addClientHeaders(to: &request)
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard let image = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return image
}
func fetchImageIfPresent(url: String?) async -> UIImage? {
guard let url else { return nil }
return try? await fetchImage(url: url)
}
private func addClientHeaders(to request: inout URLRequest) {
request.setValue("AFFiNE/\(appVersion)", forHTTPHeaderField: "User-Agent")
request.setValue(appVersion, forHTTPHeaderField: "x-affine-version")
}
private struct Request: Encodable {
var url: String
var include: [String]
}
private struct AppConfig: Decodable {
var affineVersion: String
}
private static var bundledAppVersion: String {
if let url = Bundle.main.url(forResource: "capacitor.config", withExtension: "json"),
let data = try? Data(contentsOf: url),
let version = try? JSONDecoder().decode(AppConfig.self, from: data).affineVersion,
!version.isEmpty
{
return version
}
if let version = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String, !version.isEmpty {
return version
}
return "0.2"
}
}
@@ -20,6 +20,7 @@ const config: CapacitorConfig & AppConfig = {
ios: {
scheme: 'AFFiNE',
path: '.',
appendUserAgent: `iOS AFFiNE/${packageJson.version}`,
webContentsDebuggingEnabled: true,
// Silence Capacitor's bridge logging (⚡️ TO JS / ⚡️ To Native -> / ⚡️ [log]).
loggingBehavior: 'none',
@@ -4,6 +4,9 @@ import type {
} from '@affine/core/mobile/components/share-import-controller/types';
export interface ShareInboxPlugin {
updateWorkspaceMode(options: {
mode: 'selfHostedPresent' | 'cloudOnly' | 'signedOut' | 'unknown';
}): Promise<void>;
listPending(): Promise<{ items: PendingShareItem[] }>;
updateTarget(options: {
itemId: string;
@@ -14,6 +14,9 @@ const blobToDataURL = (blob: Blob) =>
});
export const shareInboxProvider: ShareInboxProvider = {
async updateWorkspaceMode(mode) {
await plugin.updateWorkspaceMode({ mode });
},
async listPending() {
return (await plugin.listPending()).items;
},
@@ -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),
];
}