diff --git a/blocksuite/affine/blocks/list/src/list-block.ts b/blocksuite/affine/blocks/list/src/list-block.ts index fdb2202d5f..7e9ea080a2 100644 --- a/blocksuite/affine/blocks/list/src/list-block.ts +++ b/blocksuite/affine/blocks/list/src/list-block.ts @@ -154,7 +154,9 @@ export class ListBlockComponent extends CaptionedBlockComponent textAlign: this.model.props.textAlign$?.value, }); + const childrenId = `list-children-${this.model.id}`; const children = html`
? html` { if (this.store.readonly) { this._readonlyCollapsed = value; diff --git a/blocksuite/affine/blocks/paragraph/src/paragraph-block.ts b/blocksuite/affine/blocks/paragraph/src/paragraph-block.ts index 77020e09b7..8128829933 100644 --- a/blocksuite/affine/blocks/paragraph/src/paragraph-block.ts +++ b/blocksuite/affine/blocks/paragraph/src/paragraph-block.ts @@ -270,7 +270,9 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent { if (this.store.readonly) { this._readonlyCollapsed = value; diff --git a/blocksuite/affine/components/src/toggle-button/toggle-button.ts b/blocksuite/affine/components/src/toggle-button/toggle-button.ts index 196ce3cbcb..9c1b44088e 100644 --- a/blocksuite/affine/components/src/toggle-button/toggle-button.ts +++ b/blocksuite/affine/components/src/toggle-button/toggle-button.ts @@ -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` -
this.updateCollapsed(!this.collapsed)} - > - ${ToggleDownIcon({ - width: '16px', - height: '16px', - })} -
- `; - - const toggleRightTemplate = html` -
this.updateCollapsed(!this.collapsed)} > - ${ToggleRightIcon({ + ${(this.collapsed ? ToggleRightIcon : ToggleDownIcon)({ width: '16px', height: '16px', })} -
+ `; - - 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 { diff --git a/blocksuite/affine/shared/src/consts/index.ts b/blocksuite/affine/shared/src/consts/index.ts index 8907007fec..b0744b5109 100644 --- a/blocksuite/affine/shared/src/consts/index.ts +++ b/blocksuite/affine/shared/src/consts/index.ts @@ -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', diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts index a9bdbc5d85..ee53eb0bdd 100644 --- a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts @@ -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> => { - 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> => { - 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> => { - 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); diff --git a/packages/backend/native/src/runtime/backend_runtime/search/worker/anti_entropy.rs b/packages/backend/native/src/runtime/backend_runtime/search/worker/anti_entropy.rs index 958e46d6fb..8020108f71 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/worker/anti_entropy.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/worker/anti_entropy.rs @@ -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::>()) +} + fn provider_field_string(node: &Value, field: &str) -> Option { 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"])); } } diff --git a/packages/backend/server/src/__tests__/worker.e2e.ts b/packages/backend/server/src/__tests__/worker.e2e.ts index 32ee91ab88..7fdfe83183 100644 --- a/packages/backend/server/src/__tests__/worker.e2e.ts +++ b/packages/backend/server/src/__tests__/worker.e2e.ts @@ -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; 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: 'Safe log test', + 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 = [ { diff --git a/packages/backend/server/src/plugins/worker/controller.ts b/packages/backend/server/src/plugins/worker/controller.ts index fcffb52285..5c456430d7 100644 --- a/packages/backend/server/src/plugins/worker/controller.ts +++ b/packages/backend/server/src/plugins/worker/controller.ts @@ -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(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(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'); } diff --git a/packages/backend/server/src/plugins/worker/types.ts b/packages/backend/server/src/plugins/worker/types.ts index 004767a943..bf87dff4a6 100644 --- a/packages/backend/server/src/plugins/worker/types.ts +++ b/packages/backend/server/src/plugins/worker/types.ts @@ -1,6 +1,7 @@ export type LinkPreviewRequest = { url: string; head?: boolean; + include?: Array<'transcript'>; }; export type LinkPreviewResponse = { diff --git a/packages/frontend/apps/electron/src/main/ui/handlers.ts b/packages/frontend/apps/electron/src/main/ui/handlers.ts index 88dfec4173..893ecff2fe 100644 --- a/packages/frontend/apps/electron/src/main/ui/handlers.ts +++ b/packages/frontend/apps/electron/src/main/ui/handlers.ts @@ -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(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); }, diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index b78bc8ab24..93782c3e44 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; AB0000030000000000000000 /* ShareInboxSafety.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxSafety.swift; sourceTree = ""; }; AB0000040000000000000000 /* ShareInboxSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInboxSafetyTests.swift; sourceTree = ""; }; + AB0000050000000000000000 /* ShareInboxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxModels.swift; sourceTree = ""; }; + AB0000070000000000000000 /* ShareInboxConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxConstants.swift; sourceTree = ""; }; + AB0000090000000000000000 /* ShareLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareLinkPreview.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; @@ -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; }; diff --git a/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift index 8cb1ad95e7..0a5cf64a66 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift @@ -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) diff --git a/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift b/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift index 89b3608ae3..9baf7bb6dc 100644 --- a/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift +++ b/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift @@ -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("".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) + } } diff --git a/packages/frontend/apps/ios/App/ShareExtension/Info.plist b/packages/frontend/apps/ios/App/ShareExtension/Info.plist index 57f71bee5b..42ca120025 100644 --- a/packages/frontend/apps/ios/App/ShareExtension/Info.plist +++ b/packages/frontend/apps/ios/App/ShareExtension/Info.plist @@ -23,18 +23,7 @@ NSExtensionAttributes NSExtensionActivationRule - - NSExtensionActivationDictionaryVersion - 2 - NSExtensionActivationSupportsText - - NSExtensionActivationSupportsWebURLWithMaxCount - 1 - NSExtensionActivationSupportsWebPageWithMaxCount - 1 - NSExtensionActivationSupportsImageWithMaxCount - 1 - + 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 NSExtensionJavaScriptPreprocessingFile SafariPageCapture diff --git a/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift b/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift index d87ccbd7e7..dce3246cec 100644 --- a/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift +++ b/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift @@ -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: " · ") + } } diff --git a/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift b/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift index 28af46390a..8863beeef1 100644 --- a/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift +++ b/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift @@ -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? + 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 ) diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift index 87327610c6..98d9eb1d30 100644 --- a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift @@ -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")! } diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift index 2a292869a4..8e186eb6ab 100644 --- a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift @@ -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 diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift index a58f4c0024..4982db463d 100644 --- a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift @@ -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]) { diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift index b5b2649f5f..bfced89a48 100644 --- a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift @@ -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( diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareLinkPreview.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareLinkPreview.swift new file mode 100644 index 0000000000..88f9d6f7c9 --- /dev/null +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareLinkPreview.swift @@ -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" + } +} diff --git a/packages/frontend/apps/ios/capacitor.config.ts b/packages/frontend/apps/ios/capacitor.config.ts index c1e830e957..ee9b6c3ff7 100644 --- a/packages/frontend/apps/ios/capacitor.config.ts +++ b/packages/frontend/apps/ios/capacitor.config.ts @@ -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', diff --git a/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts b/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts index f40cb9ea7d..5c5646e2c3 100644 --- a/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts @@ -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; listPending(): Promise<{ items: PendingShareItem[] }>; updateTarget(options: { itemId: string; diff --git a/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts b/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts index c88ce21d11..a375f8ff50 100644 --- a/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts +++ b/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts @@ -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; }, diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index 36fce743f1..e92d2b29ca 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -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'), + }); +} diff --git a/packages/frontend/core/src/blocksuite/view-extensions/link-preview-service/link-preview-service.ts b/packages/frontend/core/src/blocksuite/view-extensions/link-preview-service/link-preview-service.ts index d338db9abb..5c94c37ef1 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/link-preview-service/link-preview-service.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/link-preview-service/link-preview-service.ts @@ -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) => { diff --git a/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx b/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx index c05ee199c8..86e2326a06 100644 --- a/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx +++ b/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx @@ -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 | undefined; + try { + return await Promise.race([ + request.catch(() => undefined), + new Promise(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(); const [page, setPage] = useState('main'); - const [selectedWorkspaceKey, setSelectedWorkspaceKey] = useState(''); - const [tagIds, setTagIds] = useState([]); - const [collectionId, setCollectionId] = useState(''); + const [selection, setSelection] = useState(); const [destinations, setDestinations] = useState(); const [isLoadingDestinations, setIsLoadingDestinations] = useState(false); const [isSaving, setIsSaving] = useState(false); const [attachmentPreview, setAttachmentPreview] = useState(); + const [linkPreview, setLinkPreview] = useState(); 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 = ({
-
-
- {attachmentPreview ? ( - - ) : ( - - )} -
-
-
{source.title}
- {source.detail ? ( -
{source.detail}
- ) : null} -
-
+ {item.content.kind === 'url' && previewOwner ? ( + + ) : ( +
+
+ {attachmentPreview ? ( + + ) : ( + + )} +
+
+
{source.title}
+ {source.detail ? ( +
{source.detail}
+ ) : null} +
+
+ )}