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