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