feat(ios): add share to support (#15340)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added iOS sharing for URLs, text, web pages, and images.
* Shared content can be reviewed, titled, and saved to an AFFiNE
workspace.
  * Choose destinations including workspaces, tags, and collections.
* Added previews, attachment handling, import status, retry support, and
success/error feedback.
* Pending shares are processed when AFFiNE opens or returns to the
foreground.
* **Bug Fixes**
* Improved workspace profile handling across different workspace types.
* Onboarding completion is now recorded immediately after successful
sign-in verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
keepClamDown
2026-08-27 19:09:51 +08:00
committed by GitHub
parent ca056ae7b9
commit 329839e467
33 changed files with 2723 additions and 48 deletions
@@ -0,0 +1,549 @@
import { Button, Modal, notify, SafeArea, Scrollable } from '@affine/component';
import {
ImportClipperService,
type ShareDestinationOptions,
} from '@affine/core/modules/import-clipper';
import {
type WorkspaceMetadata,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { ImageIcon, LinkIcon, TextIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { PageHeader } from '../page-header';
import { SelectionPage, type SelectionPageOption } from './selection-page';
import * as styles from './style.css';
import type {
PendingShareItem,
ShareImportTarget,
ShareInboxProvider,
} from './types';
export type { ShareInboxProvider } from './types';
type Page = 'main' | 'workspace' | 'tags' | 'collection' | 'offline';
const errorMessage = (error?: string) => {
switch (error) {
case 'workspace-not-found':
return 'The selected workspace is no longer available. Choose another workspace.';
case 'permission-denied':
return 'You no longer have permission to create documents in this workspace.';
case 'destination-not-found':
return 'One or more selected tags or the collection no longer exist.';
case 'offline-confirmation-required':
return 'AFFiNE could not confirm the latest workspace state.';
case 'attachment-missing':
return 'The shared image is no longer available.';
default:
return undefined;
}
};
const workspaceKey = (workspace: WorkspaceMetadata) =>
`${workspace.flavour}:${workspace.id}`;
const sourceDetails = (item: PendingShareItem) => {
if (item.content.kind === 'url') {
return {
title: item.title,
detail: item.content.url?.replace(/^https?:\/\//, '').split('/')[0],
};
}
if (item.content.kind === 'image') {
return {
title: item.title,
detail: item.attachments?.[0]?.fileName ?? 'Shared image',
};
}
return {
title: item.title,
detail: `${item.content.text?.length ?? 0} characters`,
};
};
const SourceIcon = ({
kind,
}: {
kind: PendingShareItem['content']['kind'];
}) => {
switch (kind) {
case 'url':
return <LinkIcon />;
case 'image':
return <ImageIcon />;
case 'text':
return <TextIcon />;
}
};
export const ShareImportController = ({
provider,
}: {
provider: ShareInboxProvider;
}) => {
const workspacesService = useService(WorkspacesService);
const importer = useService(ImportClipperService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
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 [destinations, setDestinations] = useState<ShareDestinationOptions>();
const [isLoadingDestinations, setIsLoadingDestinations] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [attachmentPreview, setAttachmentPreview] = useState<string>();
const refreshing = useRef(false);
const itemId = item?.id;
const selectedWorkspace = workspaces.find(
workspace => workspaceKey(workspace) === selectedWorkspaceKey
);
const selectedWorkspaceName = selectedWorkspace
? workspacesService.getProfile(selectedWorkspace).name$.value ||
selectedWorkspace.id
: undefined;
const setManualItem = useCallback((next: PendingShareItem) => {
setItem(next);
setPage('main');
setSelectedWorkspaceKey(
next.target
? `${next.target.workspaceFlavour}:${next.target.workspaceId}`
: ''
);
setTagIds(next.target?.tagIds ?? []);
setCollectionId(next.target?.collectionId ?? '');
}, []);
const importItem = useCallback(
async (
pending: PendingShareItem,
target: ShareImportTarget,
allowOffline: boolean
) => {
await provider.updateTarget(pending.id, target);
const workspace = workspacesService.list.workspaces$.value.find(
metadata =>
metadata.id === target.workspaceId &&
metadata.flavour === target.workspaceFlavour
);
if (!workspace) {
await provider.setError(pending.id, 'workspace-not-found');
return false;
}
const attachmentUrl =
pending.content.kind === 'image'
? await provider.resolveAttachment(pending.id)
: undefined;
if (pending.content.kind === 'image' && !attachmentUrl) {
await provider.setError(pending.id, 'attachment-missing');
return false;
}
const result = await importer.importShareToWorkspace(
workspace,
{
documentId: pending.documentId,
title: pending.title,
content: pending.content,
attachmentUrl,
tagIds: target.tagIds,
collectionId: target.collectionId,
},
{ allowOffline }
);
if (result.status !== 'imported') {
await provider.setError(pending.id, result.status);
return false;
}
await provider.complete(pending.id, result.docId);
return true;
},
[importer, provider, workspacesService]
);
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try {
const pending = await provider.listPending();
let importedCount = 0;
let nextItem: PendingShareItem | undefined;
for (const candidate of pending) {
if (candidate.target && !candidate.lastError) {
const imported = await importItem(candidate, candidate.target, false);
if (imported) {
importedCount += 1;
continue;
}
const latest = await provider.listPending();
nextItem = latest.find(item => item.id === candidate.id);
break;
}
nextItem = candidate;
break;
}
if (importedCount > 0) {
notify.success({
title: `${importedCount} shared ${importedCount === 1 ? 'item' : 'items'} saved`,
});
}
if (nextItem) {
setManualItem(nextItem);
} else {
setItem(undefined);
}
} finally {
refreshing.current = false;
}
}, [importItem, provider, setManualItem]);
useEffect(() => {
void refresh().catch(console.error);
const handleRefresh = () => {
void refresh().catch(console.error);
};
window.addEventListener('affine:share-inbox', handleRefresh);
return () =>
window.removeEventListener('affine:share-inbox', handleRefresh);
}, [refresh]);
useEffect(() => {
let active = true;
setAttachmentPreview(undefined);
if (item?.content.kind === 'image') {
void provider
.resolveAttachment(item.id)
.then(preview => {
if (active) setAttachmentPreview(preview);
})
.catch(console.error);
}
return () => {
active = false;
};
}, [item?.content.kind, item?.id, provider]);
useEffect(() => {
if (!selectedWorkspace) {
setDestinations(undefined);
return;
}
let active = true;
setDestinations(undefined);
setIsLoadingDestinations(true);
void importer
.getShareDestinationOptions(selectedWorkspace)
.then(async options => {
if (!active) return;
if (!options) {
if (itemId) {
await provider.setError(itemId, 'workspace-not-found');
setItem(current =>
current?.id === itemId &&
current.lastError !== 'workspace-not-found'
? { ...current, lastError: 'workspace-not-found' }
: current
);
}
return;
}
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
: ''
);
})
.catch(console.error)
.finally(() => {
if (active) setIsLoadingDestinations(false);
});
return () => {
active = false;
};
}, [importer, itemId, provider, selectedWorkspace]);
const save = async (allowOffline: boolean) => {
if (!item || !selectedWorkspace || isSaving) return;
setIsSaving(true);
try {
const imported = await importItem(
item,
{
workspaceId: selectedWorkspace.id,
workspaceFlavour: selectedWorkspace.flavour,
tagIds,
collectionId: collectionId || undefined,
},
allowOffline
);
if (imported) {
notify.success({ title: 'Shared content saved' });
}
await refresh();
} finally {
setIsSaving(false);
}
};
if (!item) return null;
const workspaceOptions: SelectionPageOption[] = workspaces.map(workspace => ({
id: workspaceKey(workspace),
label: workspacesService.getProfile(workspace).name$.value || workspace.id,
detail: workspace.flavour === 'local' ? 'On this device' : 'Cloud',
}));
const tagOptions: SelectionPageOption[] =
destinations?.tags.map(tag => ({
id: tag.id,
label: tag.name,
color: tag.color,
})) ?? [];
const collectionOptions: SelectionPageOption[] = [
{ id: '', label: 'No collection' },
...(destinations?.collections.map(collection => ({
id: collection.id,
label: collection.name,
})) ?? []),
];
const selectedTagNames =
destinations?.tags
.filter(tag => tagIds.includes(tag.id))
.map(tag => tag.name) ?? [];
const collectionName =
destinations?.collections.find(collection => collection.id === collectionId)
?.name ?? 'None';
const requiresOfflineConfirmation =
item.lastError === 'offline-confirmation-required' ||
destinations?.verification === 'unavailable';
const source = sourceDetails(item);
const content = (() => {
if (page === 'workspace') {
return (
<SelectionPage
title="Workspace"
options={workspaceOptions}
selectedIds={selectedWorkspaceKey ? [selectedWorkspaceKey] : []}
onBack={() => setPage('main')}
onSelect={id => {
setSelectedWorkspaceKey(id);
setTagIds([]);
setCollectionId('');
setItem(current =>
current ? { ...current, lastError: undefined } : current
);
setPage('main');
}}
/>
);
}
if (page === 'tags') {
return (
<SelectionPage
title="Tags"
multiple
options={tagOptions}
selectedIds={tagIds}
onBack={() => setPage('main')}
onSelect={id =>
setTagIds(ids =>
ids.includes(id)
? ids.filter(current => current !== id)
: [...ids, id]
)
}
onConfirm={() => setPage('main')}
/>
);
}
if (page === 'collection') {
return (
<SelectionPage
title="Collection"
options={collectionOptions}
selectedIds={[collectionId]}
onBack={() => setPage('main')}
onSelect={id => {
setCollectionId(id);
setPage('main');
}}
/>
);
}
if (page === 'offline') {
return (
<div className={styles.page}>
<PageHeader back backAction={() => setPage('main')}>
<span className={styles.headerTitle}>
Use local workspace data?
</span>
</PageHeader>
<main className={styles.confirmation}>
<h2 className={styles.confirmationTitle}>
{selectedWorkspaceName}
</h2>
<p className={styles.confirmationText}>
AFFiNE could not confirm that this workspace, your permissions,
and its destinations are current online. Saving will use the most
recent data available on this device.
</p>
</main>
<SafeArea bottom className={styles.footer}>
<Button
className={styles.action}
variant="primary"
disabled={isSaving}
onClick={() => void save(true).catch(console.error)}
>
{isSaving ? 'Saving…' : 'Save using local data'}
</Button>
</SafeArea>
</div>
);
}
return (
<div className={styles.page}>
<PageHeader
suffix={
<Button variant="plain" onClick={() => setItem(undefined)}>
Not now
</Button>
}
>
<span className={styles.headerTitle}>Choose where to save</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<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>
<section className={styles.destinationGroup}>
<button
className={styles.destinationRow}
type="button"
onClick={() => setPage('workspace')}
>
<span className={styles.rowLabel}>Workspace</span>
<span className={styles.rowValue}>
{selectedWorkspaceName ?? 'Choose'}
<span className={styles.rowArrow}></span>
</span>
</button>
<button
className={styles.destinationRow}
type="button"
disabled={!destinations || isLoadingDestinations}
onClick={() => setPage('tags')}
>
<span className={styles.rowLabel}>
Tags <span className={styles.optional}>Optional</span>
</span>
<span className={styles.rowValue}>
{selectedTagNames.length
? `${selectedTagNames.length} selected`
: 'None'}
<span className={styles.rowArrow}></span>
</span>
</button>
<button
className={styles.destinationRow}
type="button"
disabled={!destinations || isLoadingDestinations}
onClick={() => setPage('collection')}
>
<span className={styles.rowLabel}>
Collection <span className={styles.optional}>Optional</span>
</span>
<span className={styles.rowValue}>
{collectionName}
<span className={styles.rowArrow}></span>
</span>
</button>
</section>
{isLoadingDestinations ? (
<div className={styles.status}>Checking workspace</div>
) : requiresOfflineConfirmation ? (
<div className={styles.warning}>
The latest online workspace state could not be confirmed.
</div>
) : null}
{errorMessage(item.lastError) ? (
<div className={styles.error}>
{errorMessage(item.lastError)}
</div>
) : null}
</main>
</Scrollable.Viewport>
</Scrollable.Root>
<SafeArea bottom className={styles.footer}>
<Button
className={styles.action}
variant="primary"
disabled={
!selectedWorkspace ||
!destinations ||
isSaving ||
isLoadingDestinations
}
onClick={() => {
if (requiresOfflineConfirmation) {
setPage('offline');
} else {
void save(false).catch(console.error);
}
}}
>
{isSaving ? 'Saving…' : 'Save'}
</Button>
</SafeArea>
</div>
);
})();
return (
<Modal
fullScreen
animation="slideBottom"
open
withoutCloseButton
onOpenChange={() => setItem(undefined)}
contentOptions={{ style: { padding: 0 } }}
>
{content}
</Modal>
);
};
@@ -0,0 +1,93 @@
import { Button, SafeArea, Scrollable } from '@affine/component';
import { PageHeader } from '../page-header';
import * as styles from './style.css';
export interface SelectionPageOption {
id: string;
label: string;
detail?: string;
color?: string;
}
export const SelectionPage = ({
title,
options,
selectedIds,
multiple = false,
onBack,
onSelect,
onConfirm,
}: {
title: string;
options: SelectionPageOption[];
selectedIds: string[];
multiple?: boolean;
onBack: () => void;
onSelect: (id: string) => void;
onConfirm?: () => void;
}) => {
return (
<div className={styles.page}>
<PageHeader back backAction={onBack}>
<span className={styles.headerTitle}>{title}</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<ul className={styles.selectionList}>
{options.map(option => {
const selected = selectedIds.includes(option.id);
return (
<li key={option.id}>
<button
className={styles.selectionRow}
type="button"
aria-pressed={selected}
onClick={() => onSelect(option.id)}
>
{option.color ? (
<span
className={styles.colorDot}
style={{ backgroundColor: option.color }}
/>
) : null}
<span className={styles.selectionLabel}>
{option.label}
{option.detail ? (
<span className={styles.selectionDetail}>
{option.detail}
</span>
) : null}
</span>
{selected ? (
<span className={styles.checkmark} aria-hidden="true">
</span>
) : (
<span
className={styles.checkmarkPlaceholder}
aria-hidden="true"
/>
)}
</button>
</li>
);
})}
</ul>
</Scrollable.Viewport>
</Scrollable.Root>
{multiple ? (
<SafeArea bottom className={styles.footer}>
<Button
className={styles.action}
variant="primary"
onClick={onConfirm}
>
Done
</Button>
</SafeArea>
) : null}
</div>
);
};
@@ -0,0 +1,252 @@
import {
bodyEmphasized,
bodyRegular,
footnoteRegular,
subHeadlineRegular,
} from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const page = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: cssVarV2('layer/background/secondary'),
});
export const headerTitle = style([
bodyEmphasized,
{ color: cssVarV2('text/primary') },
]);
export const scrollArea = style({
height: 0,
flex: 1,
});
export const main = style({
display: 'flex',
flexDirection: 'column',
gap: 20,
padding: '20px 16px',
});
export const source = style({
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 12,
background: cssVarV2('layer/background/primary'),
});
export const sourceIcon = style({
width: 40,
height: 40,
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
fontSize: 20,
color: cssVarV2('icon/primary'),
background: cssVarV2('layer/background/secondary'),
overflow: 'hidden',
});
export const sourceImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const sourceContent = style({
minWidth: 0,
flex: 1,
});
export const sourceTitle = style([
bodyEmphasized,
{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/primary'),
},
]);
export const sourceDetail = style([
footnoteRegular,
{
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/secondary'),
},
]);
export const destinationGroup = style({
overflow: 'hidden',
borderRadius: 12,
background: cssVarV2('layer/background/primary'),
});
export const destinationRow = style({
width: '100%',
minHeight: 52,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '0 12px',
border: 0,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
color: cssVarV2('text/primary'),
background: 'transparent',
textAlign: 'left',
selectors: {
'&:last-child': { borderBottom: 0 },
'&:disabled': { opacity: 0.5 },
},
});
export const rowLabel = style([
bodyRegular,
{ display: 'flex', alignItems: 'baseline', gap: 6 },
]);
export const optional = style([
footnoteRegular,
{ color: cssVarV2('text/tertiary') },
]);
export const rowValue = style([
bodyRegular,
{
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 8,
overflow: 'hidden',
color: cssVarV2('text/secondary'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
]);
export const rowArrow = style({
fontSize: 24,
lineHeight: 1,
color: cssVarV2('icon/secondary'),
});
export const status = style([
footnoteRegular,
{ color: cssVarV2('text/secondary') },
]);
export const warning = style([
footnoteRegular,
{
padding: 12,
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: 8,
color: cssVarV2('text/primary'),
background: cssVarV2('layer/background/primary'),
},
]);
export const error = style([
footnoteRegular,
{ color: cssVarV2('status/error') },
]);
export const footer = style({
width: '100%',
padding: '8px 16px',
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
background: cssVarV2('layer/background/primary'),
});
export const action = style({
width: '100%',
height: 44,
borderRadius: 8,
fontSize: 17,
fontWeight: 400,
});
export const selectionList = style({
margin: 0,
padding: '8px 16px',
listStyle: 'none',
});
export const selectionRow = style({
width: '100%',
minHeight: 52,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '6px 8px',
border: 0,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
color: cssVarV2('text/primary'),
background: 'transparent',
textAlign: 'left',
});
export const selectionLabel = style([
bodyRegular,
{
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
},
]);
export const selectionDetail = style([
footnoteRegular,
{ color: cssVarV2('text/secondary') },
]);
export const colorDot = style({
width: 12,
height: 12,
flex: '0 0 auto',
borderRadius: '50%',
});
export const checkmark = style({
width: 32,
textAlign: 'center',
fontSize: 18,
color: cssVarV2('button/primary'),
});
export const checkmarkPlaceholder = style({
width: 32,
});
export const confirmation = style({
padding: '32px 20px',
});
export const confirmationTitle = style([
subHeadlineRegular,
{
margin: 0,
color: cssVarV2('text/primary'),
},
]);
export const confirmationText = style([
bodyRegular,
{
margin: '12px 0 0',
color: cssVarV2('text/secondary'),
},
]);
@@ -0,0 +1,29 @@
export interface PendingShareItem {
id: string;
documentId: string;
title: string;
content: {
kind: 'url' | 'text' | 'image';
url?: string;
text?: string;
};
target?: ShareImportTarget;
previewText?: string;
attachments?: { fileName: string; mimeType: string }[];
lastError?: string;
}
export interface ShareImportTarget {
workspaceId: string;
workspaceFlavour: string;
tagIds: string[];
collectionId?: string;
}
export interface ShareInboxProvider {
listPending(): Promise<PendingShareItem[]>;
updateTarget(itemId: string, target: ShareImportTarget): Promise<void>;
resolveAttachment(itemId: string): Promise<string | undefined>;
complete(itemId: string, docId: string): Promise<void>;
setError(itemId: string, error: string): Promise<void>;
}
@@ -3,7 +3,13 @@ import { type Framework } from '@toeverything/infra';
import { WorkspacesService } from '../workspace';
import { ImportClipperService } from './services/import';
export { type ClipperInput, ImportClipperService } from './services/import';
export {
type ClipperInput,
ImportClipperService,
type ShareDestinationOptions,
type ShareImportInput,
type ShareImportResult,
} from './services/import';
export function configureImportClipperModule(framework: Framework) {
framework.service(ImportClipperService, [WorkspacesService]);
@@ -2,7 +2,10 @@ import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import { Service } from '@toeverything/infra';
import { CollectionService } from '../../collection';
import { DocsService } from '../../doc';
import { GuardService } from '../../permissions';
import { TagService } from '../../tag';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceMetadata,
@@ -17,11 +20,308 @@ export interface ClipperInput {
workspace?: 'select-by-user' | 'last-open-workspace';
}
export interface ShareImportInput {
documentId: string;
title: string;
content: {
kind: 'url' | 'text' | 'image';
url?: string;
text?: string;
};
attachmentUrl?: string;
tagIds: string[];
collectionId?: string;
}
export type ShareImportResult =
| { status: 'imported'; docId: string }
| {
status:
| 'workspace-not-found'
| 'permission-denied'
| 'destination-not-found'
| 'offline-confirmation-required';
missingTagIds?: string[];
};
export interface ShareDestinationOptions {
verification: 'confirmed' | 'unavailable';
tags: { id: string; name: string; color: string }[];
collections: { id: string; name: string }[];
}
type WorkspaceVerification = 'confirmed' | 'missing' | 'unavailable';
export class ImportClipperService extends Service {
constructor(private readonly workspacesService: WorkspacesService) {
super();
}
async importShareToWorkspace(
workspaceMetadata: WorkspaceMetadata,
input: ShareImportInput,
options: { allowOffline?: boolean } = {}
): Promise<ShareImportResult> {
const verification = await this.revalidateWorkspace(workspaceMetadata);
if (verification === 'missing') {
return { status: 'workspace-not-found' };
}
const currentMetadata = this.workspacesService.list.workspaces$.value.find(
workspace =>
workspace.id === workspaceMetadata.id &&
workspace.flavour === workspaceMetadata.flavour
);
if (!currentMetadata) {
return { status: 'workspace-not-found' };
}
const workspaceRef = this.workspacesService.open({
metadata: currentMetadata,
});
if (!workspaceRef) {
return { status: 'workspace-not-found' };
}
try {
const { workspace } = workspaceRef;
await workspace.engine.doc.waitForDocReady(workspace.id);
const rootSynced =
workspace.meta.flavour === 'local' ||
(verification === 'confirmed' &&
(await this.waitForRootSync(workspace)));
if (!rootSynced && !options.allowOffline) {
return { status: 'offline-confirmation-required' };
}
const guard = workspace.scope.get(GuardService);
if (!(await guard.can('Workspace_CreateDoc'))) {
return { status: 'permission-denied' };
}
const tagService = workspace.scope.get(TagService);
const tags = tagService.tagList.tags$.value;
const missingTagIds = input.tagIds.filter(
id => !tags.some(tag => tag.id === id)
);
const collectionService = workspace.scope.get(CollectionService);
if (
missingTagIds.length > 0 ||
(input.collectionId &&
!collectionService.collectionMetas$.value.some(
collection => collection.id === input.collectionId
))
) {
return { status: 'destination-not-found', missingTagIds };
}
const docsService = workspace.scope.get(DocsService);
let record = docsService.list.doc$(input.documentId).value;
if (!record) {
record = docsService.createDoc({
id: input.documentId,
primaryMode: 'page',
});
}
const { doc, release } = docsService.open(record.id);
try {
await doc.waitForSyncReady();
const page = doc.blockSuiteDoc.getBlocksByFlavour('affine:page')[0];
if (!page) {
throw new Error('Failed to initialize shared doc');
}
page.model.children.forEach(child => {
doc.blockSuiteDoc.deleteBlock(child);
});
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 markdown = this.shareMarkdown(input);
if (markdown) {
await MarkdownTransformer.importMarkdownToBlock({
doc: doc.blockSuiteDoc,
blockId: noteId,
markdown,
extensions: getStoreManager().config.init().value.get('store'),
});
}
} finally {
release();
}
await docsService.changeDocTitle(input.documentId, input.title);
const existingTagIds = new Set(record.meta$.value.tags ?? []);
const selectedTagIds = new Set(input.tagIds);
for (const tagId of existingTagIds) {
if (!selectedTagIds.has(tagId)) {
tagService.tagList.tagByTagId$(tagId).value?.untag(input.documentId);
}
}
for (const tagId of input.tagIds) {
if (!existingTagIds.has(tagId)) {
tagService.tagList.tagByTagId$(tagId).value?.tag(input.documentId);
}
}
for (const collection of collectionService.collections$.value.values()) {
if (
collection.id !== input.collectionId &&
collection.allowList$.value.includes(input.documentId)
) {
collectionService.removeDocFromCollection(
collection.id,
input.documentId
);
}
}
if (input.collectionId) {
collectionService.addDocToCollection(
input.collectionId,
input.documentId
);
}
workspace.engine.doc.addPriority(workspace.id, 100);
workspace.engine.doc.addPriority(input.documentId, 100);
await Promise.all([
workspace.engine.doc.waitForSynced(workspace.id),
workspace.engine.doc.waitForSynced(input.documentId),
]);
return { status: 'imported', docId: input.documentId };
} finally {
workspaceRef.dispose();
}
}
async getShareDestinationOptions(
workspaceMetadata: WorkspaceMetadata
): Promise<ShareDestinationOptions | null> {
const verification = await this.revalidateWorkspace(workspaceMetadata);
if (verification === 'missing') return null;
const currentMetadata = this.workspacesService.list.workspaces$.value.find(
workspace =>
workspace.id === workspaceMetadata.id &&
workspace.flavour === workspaceMetadata.flavour
);
if (!currentMetadata) return null;
const workspaceRef = this.workspacesService.open({
metadata: currentMetadata,
});
if (!workspaceRef) return null;
try {
const { workspace } = workspaceRef;
await workspace.engine.doc.waitForDocReady(workspace.id);
const rootConfirmed =
workspace.meta.flavour === 'local' ||
(verification === 'confirmed' &&
(await this.waitForRootSync(workspace)));
return {
verification: rootConfirmed ? 'confirmed' : 'unavailable',
tags: workspace.scope
.get(TagService)
.tagList.tagMetas$.value.map(tag => ({
id: tag.id,
name: tag.name,
color: tag.color,
})),
collections: workspace.scope
.get(CollectionService)
.collectionMetas$.value.map(collection => ({
id: collection.id,
name: collection.name,
})),
};
} finally {
workspaceRef.dispose();
}
}
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> ')}`
);
}
} else if (input.content.kind === 'image') {
if (input.attachmentUrl) {
parts.push(`![Shared image](<${input.attachmentUrl}>)`);
}
if (input.content.text) {
parts.push(this.escapeMarkdown(input.content.text));
}
if (input.content.url) {
parts.push(`[Source](<${input.content.url}>)`);
}
} else if (input.content.text) {
parts.push(this.escapeMarkdown(input.content.text));
}
return parts.join('\n\n');
}
private escapeMarkdown(value: string) {
return value.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, '\\$&');
}
private async revalidateWorkspace(
metadata: WorkspaceMetadata
): Promise<WorkspaceVerification> {
if (metadata.flavour === 'local') {
return this.hasWorkspace(metadata) ? 'confirmed' : 'missing';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
await this.workspacesService.list.waitForRevalidation(controller.signal);
if (!this.hasWorkspace(metadata)) return 'missing';
const provider =
this.workspacesService.getWorkspaceFlavourProvider(metadata);
if (!provider) return 'unavailable';
const profile = await provider.getWorkspaceProfile(
metadata.id,
controller.signal
);
return profile ? 'confirmed' : 'missing';
} catch {
return this.hasWorkspace(metadata) ? 'unavailable' : 'missing';
} finally {
clearTimeout(timeout);
}
}
private hasWorkspace(metadata: WorkspaceMetadata) {
return this.workspacesService.list.workspaces$.value.some(
workspace =>
workspace.id === metadata.id && workspace.flavour === metadata.flavour
);
}
private async waitForRootSync(workspace: {
id: string;
engine: { doc: { waitForSynced(id: string): Promise<unknown> } };
}) {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
workspace.engine.doc.waitForSynced(workspace.id),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('Sync timed out')), 5000);
}),
]);
return true;
} catch {
return false;
} finally {
if (timeout) clearTimeout(timeout);
}
}
async importToWorkspace(
workspaceMetadata: WorkspaceMetadata,
clipperInput: ClipperInput
@@ -40,7 +40,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
}
profile$ = LiveData.from<WorkspaceProfileInfo | null>(
this.cache.watchProfileCache(this.props.metadata.id),
this.cache.watchProfileCache(this.props.metadata),
null
);
@@ -65,7 +65,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
if (isEqual(this.profile$.value, info)) {
return;
}
this.cache.setProfileCache(this.props.metadata.id, info);
this.cache.setProfileCache(this.props.metadata, info);
}
revalidate = effect(
@@ -3,3 +3,6 @@ export type WorkspaceMetadata = {
flavour: string;
initialized?: boolean;
};
export const workspaceMetadataKey = (metadata: WorkspaceMetadata) =>
`${metadata.flavour}:${metadata.id}`;
@@ -1,13 +1,14 @@
import { ObjectPool, Service } from '@toeverything/infra';
import { WorkspaceProfile } from '../entities/profile';
import type { WorkspaceMetadata } from '../metadata';
import { type WorkspaceMetadata, workspaceMetadataKey } from '../metadata';
export class WorkspaceProfileService extends Service {
pool = new ObjectPool<string, WorkspaceProfile>();
getProfile = (metadata: WorkspaceMetadata): WorkspaceProfile => {
const exists = this.pool.get(metadata.id);
const key = workspaceMetadataKey(metadata);
const exists = this.pool.get(key);
if (exists) {
return exists.obj;
}
@@ -16,6 +17,6 @@ export class WorkspaceProfileService extends Service {
metadata,
});
return this.pool.put(metadata.id, profile).obj;
return this.pool.put(key, profile).obj;
};
}
@@ -4,7 +4,7 @@ import { ObjectPool, Service } from '@toeverything/infra';
import type { Workspace } from '../entities/workspace';
import { WorkspaceInitialized } from '../events';
import type { WorkspaceMetadata } from '../metadata';
import { workspaceMetadataKey } from '../metadata';
import type { WorkspaceOpenOptions } from '../open-options';
import { WorkspaceScope } from '../scopes/workspace';
import type { WorkspaceFlavoursService } from './flavours';
@@ -14,9 +14,6 @@ import { WorkspaceService } from './workspace';
const logger = new DebugLogger('affine:workspace-repository');
const getWorkspacePoolKey = (metadata: WorkspaceMetadata) =>
`${metadata.flavour}:${metadata.id}`;
export class WorkspaceRepositoryService extends Service {
constructor(
private readonly flavoursService: WorkspaceFlavoursService,
@@ -62,7 +59,7 @@ export class WorkspaceRepositoryService extends Service {
};
}
const exist = this.pool.get(getWorkspacePoolKey(options.metadata));
const exist = this.pool.get(workspaceMetadataKey(options.metadata));
if (exist) {
return {
workspace: exist.obj,
@@ -72,7 +69,7 @@ export class WorkspaceRepositoryService extends Service {
const workspace = this.instantiate(options, customEngineWorkerInitOptions);
const ref = this.pool.put(getWorkspacePoolKey(workspace.meta), workspace);
const ref = this.pool.put(workspaceMetadataKey(workspace.meta), workspace);
return {
workspace: ref.obj,
@@ -3,6 +3,7 @@ import { map } from 'rxjs';
import type { GlobalCache } from '../../storage';
import type { WorkspaceProfileInfo } from '../entities/profile';
import { type WorkspaceMetadata, workspaceMetadataKey } from '../metadata';
const WORKSPACE_PROFILE_CACHE_KEY = 'workspace-information:';
@@ -11,20 +12,25 @@ export class WorkspaceProfileCacheStore extends Store {
super();
}
watchProfileCache(workspaceId: string) {
return this.cache.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceId).pipe(
map(data => {
if (!data || typeof data !== 'object') {
return null;
}
watchProfileCache(metadata: WorkspaceMetadata) {
return this.cache
.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceMetadataKey(metadata))
.pipe(
map(data => {
if (!data || typeof data !== 'object') {
return null;
}
const info = data as WorkspaceProfileInfo;
return info;
})
const info = data as WorkspaceProfileInfo;
return info;
})
);
}
setProfileCache(metadata: WorkspaceMetadata, info: WorkspaceProfileInfo) {
this.cache.set(
WORKSPACE_PROFILE_CACHE_KEY + workspaceMetadataKey(metadata),
info
);
}
setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) {
this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info);
}
}