feat(core): add open link in app to doc menu (#8597)

add "open in desktop app" menu item for editor

fix AF-1547
This commit is contained in:
pengx17 2024-10-29 05:49:49 +00:00 committed by Peng Xiao
parent 5690720652
commit 835fdc33c0
No known key found for this signature in database
GPG Key ID: 831CF66D46EB83D0
21 changed files with 222 additions and 117 deletions

View File

@ -5,7 +5,7 @@ declare global {
// eslint-disable-next-line no-var
var __appInfo: {
electron: boolean;
schema: string;
scheme: string;
windowName: string;
};
}

View File

@ -11,7 +11,7 @@ import { I18nProvider } from '@affine/core/modules/i18n';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
import {
ClientSchemaProvider,
ClientSchemeProvider,
PopupWindowProvider,
} from '@affine/core/modules/url';
import { configureSqliteUserspaceStorageProvider } from '@affine/core/modules/userspace';
@ -71,9 +71,9 @@ framework.impl(PopupWindowProvider, {
});
},
});
framework.impl(ClientSchemaProvider, {
getClientSchema() {
return appInfo?.schema;
framework.impl(ClientSchemeProvider, {
getClientScheme() {
return appInfo?.scheme;
},
});
framework.impl(ValidatorProvider, {

View File

@ -6,6 +6,7 @@ import { buildType, isDev } from './config';
import { logger } from './logger';
import { uiSubjects } from './ui';
import {
addTab,
getMainWindow,
openUrlInHiddenWindow,
openUrlInMainWindow,
@ -81,6 +82,24 @@ async function handleAffineUrl(url: string) {
method,
payload,
});
} else if (
urlObj.searchParams.get('new-tab') &&
urlObj.pathname.startsWith('/workspace')
) {
// @todo(@forehalo): refactor router utilities
// basename of /workspace/xxx/yyy is /workspace/xxx
const basename = urlObj.pathname.split('/').slice(0, 3).join('/');
const pathname = '/' + urlObj.pathname.split('/').slice(3).join('/');
await addTab({
basename,
show: true,
view: {
path: {
pathname: pathname,
},
},
});
} else {
const hiddenWindow = urlObj.searchParams.get('hidden')
? await openUrlInHiddenWindow(urlObj)

View File

@ -41,9 +41,9 @@ const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const isDev = process.env.NODE_ENV === 'development';
let schema =
let scheme =
buildType === 'stable' ? 'affine' : (`affine-${envBuildType}` as Schema);
schema = isDev ? 'affine-dev' : schema;
scheme = isDev ? 'affine-dev' : scheme;
export const appInfo = {
electron: true,
@ -53,7 +53,7 @@ export const appInfo = {
viewId:
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
'unknown',
schema,
scheme,
};
function getMainAPIs() {

View File

@ -7,10 +7,8 @@ import { configureCommonModules } from '@affine/core/modules';
import { AuthService, WebSocketAuthProvider } from '@affine/core/modules/cloud';
import { I18nProvider } from '@affine/core/modules/i18n';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import {
ClientSchemaProvider,
PopupWindowProvider,
} from '@affine/core/modules/url';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema';
import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import {
@ -51,8 +49,8 @@ framework.impl(PopupWindowProvider, {
}).catch(console.error);
},
});
framework.impl(ClientSchemaProvider, {
getClientSchema() {
framework.impl(ClientSchemeProvider, {
getClientScheme() {
return 'affine';
},
});

View File

@ -35,7 +35,7 @@ export function OAuth({ redirectUrl }: { redirectUrl?: string }) {
const oauthProviders = useLiveData(
serverConfig.config$.map(r => r?.oauthProviders)
);
const schema = urlService.getClientSchema();
const scheme = urlService.getClientScheme();
if (!oauth) {
return <Skeleton height={50} />;
@ -46,7 +46,7 @@ export function OAuth({ redirectUrl }: { redirectUrl?: string }) {
key={provider}
provider={provider}
redirectUrl={redirectUrl}
schema={schema}
scheme={scheme}
popupWindow={url => {
urlService.openPopupWindow(url);
}}
@ -57,12 +57,12 @@ export function OAuth({ redirectUrl }: { redirectUrl?: string }) {
function OAuthProvider({
provider,
redirectUrl,
schema,
scheme,
popupWindow,
}: {
provider: OAuthProviderType;
redirectUrl?: string;
schema?: string;
scheme?: string;
popupWindow: (url: string) => void;
}) {
const { icon } = OAuthProviderMap[provider];
@ -76,8 +76,8 @@ function OAuthProvider({
params.set('redirect_uri', redirectUrl);
}
if (schema) {
params.set('client', schema);
if (scheme) {
params.set('client', scheme);
}
// TODO: Android app scheme not implemented
@ -87,7 +87,7 @@ function OAuthProvider({
BUILD_CONFIG.serverUrlPrefix + `/oauth/login?${params.toString()}`;
popupWindow(oauthUrl);
}, [popupWindow, provider, redirectUrl, schema]);
}, [popupWindow, provider, redirectUrl, scheme]);
return (
<Button

View File

@ -5,6 +5,10 @@ import {
SettingWrapper,
} from '@affine/component/setting-components';
import { useAppUpdater } from '@affine/core/components/hooks/use-app-updater';
import {
appIconMap,
appNames,
} from '@affine/core/modules/open-in-app/constant';
import { UrlService } from '@affine/core/modules/url';
import { useI18n } from '@affine/i18n';
import { mixpanel } from '@affine/track';
@ -13,7 +17,6 @@ import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useAppSettingHelper } from '../../../../../components/hooks/affine/use-app-setting-helper';
import { appIconMap, appNames } from '../../../../../desktop/pages/open-app';
import { relatedLinks } from './config';
import * as styles from './style.css';
import { UpdateCheckSection } from './update-check-section';

View File

@ -20,8 +20,8 @@ const UpgradeSuccessLayout = ({
const { jumpToIndex, openInApp } = useNavigateHelper();
const openAffine = useCallback(() => {
if (params.get('schema')) {
openInApp(params.get('schema') ?? 'affine', 'bring-to-front');
if (params.get('scheme')) {
openInApp(params.get('scheme') ?? 'affine', 'bring-to-front');
} else {
jumpToIndex();
}

View File

@ -19,6 +19,7 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive';
import { DocInfoService } from '@affine/core/modules/doc-info';
import { EditorService } from '@affine/core/modules/editor';
import { getOpenUrlInDesktopAppLink } from '@affine/core/modules/open-in-app/utils';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { WorkspaceFlavour } from '@affine/env/workspace';
@ -33,6 +34,7 @@ import {
HistoryIcon,
ImportIcon,
InformationIcon,
LocalWorkspaceIcon,
OpenInNewIcon,
PageIcon,
SaveIcon,
@ -258,6 +260,13 @@ export const PageHeaderMenuButton = ({
</>
);
const onOpenInDesktop = useCallback(() => {
const url = getOpenUrlInDesktopAppLink(window.location.href, true);
if (url) {
window.open(url, '_blank');
}
}, []);
const EditMenu = (
<>
{showResponsiveMenu ? ResponsiveMenuItems : null}
@ -362,6 +371,15 @@ export const PageHeaderMenuButton = ({
data-testid="editor-option-menu-delete"
onSelect={handleOpenTrashModal}
/>
{BUILD_CONFIG.isWeb ? (
<MenuItem
prefixIcon={<LocalWorkspaceIcon />}
data-testid="editor-option-menu-link"
onSelect={onOpenInDesktop}
>
{t['com.affine.header.option.open-in-desktop']()}
</MenuItem>
) : null}
</>
);
if (isInTrash) {

View File

@ -160,9 +160,9 @@ export function useNavigateHelper() {
);
const openInApp = useCallback(
(schema: string, path: string) => {
const encodedUrl = encodeURIComponent(`${schema}://${path}`);
return navigate(`/open-app/url?schema=${schema}&url=${encodedUrl}`);
(scheme: string, path: string) => {
const encodedUrl = encodeURIComponent(`${scheme}://${path}`);
return navigate(`/open-app/url?scheme=${scheme}&url=${encodedUrl}`);
},
[navigate]
);

View File

@ -1,52 +1,22 @@
import { Button } from '@affine/component/ui/button';
import {
appIconMap,
appNames,
appSchemes,
type Channel,
schemeToChannel,
} from '@affine/core/modules/open-in-app/constant';
import type { GetCurrentUserQuery } from '@affine/graphql';
import { fetcher, getCurrentUserQuery } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { Logo1Icon } from '@blocksuite/icons/rc';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { useLoaderData, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import * as styles from './open-app.css';
let lastOpened = '';
const appSchemas = z.enum([
'affine',
'affine-canary',
'affine-beta',
'affine-internal',
'affine-dev',
]);
const appChannelSchema = z.enum(['stable', 'canary', 'beta', 'internal']);
type Schema = z.infer<typeof appSchemas>;
type Channel = z.infer<typeof appChannelSchema>;
const schemaToChanel = {
affine: 'stable',
'affine-canary': 'canary',
'affine-beta': 'beta',
'affine-internal': 'internal',
'affine-dev': 'canary', // dev does not have a dedicated app. use canary as the placeholder.
} as Record<Schema, Channel>;
export const appIconMap = {
stable: '/imgs/app-icon-stable.ico',
canary: '/imgs/app-icon-canary.ico',
beta: '/imgs/app-icon-beta.ico',
internal: '/imgs/app-icon-internal.ico',
} satisfies Record<Channel, string>;
export const appNames = {
stable: 'AFFiNE',
canary: 'AFFiNE Canary',
beta: 'AFFiNE Beta',
internal: 'AFFiNE Internal',
} satisfies Record<Channel, string>;
interface OpenAppProps {
urlToOpen?: string | null;
channel: Channel;
@ -65,10 +35,8 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
}, [channel]);
const appIcon = appIconMap[channel];
const appName = appNames[channel];
const [params] = useSearchParams();
const autoOpen = useMemo(() => params.get('open') !== 'false', [params]);
if (urlToOpen && lastOpened !== urlToOpen && autoOpen) {
if (urlToOpen && lastOpened !== urlToOpen) {
lastOpened = urlToOpen;
location.href = urlToOpen;
}
@ -152,9 +120,9 @@ const OpenUrl = () => {
params.delete('url');
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
const maybeScheme = appSchemes.safeParse(urlObj.protocol.replace(':', ''));
const channel =
schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
schemeToChannel[maybeScheme.success ? maybeScheme.data : 'affine'];
params.forEach((v, k) => {
urlObj.searchParams.set(k, v);
@ -170,16 +138,16 @@ const OpenOAuthJwt = () => {
const { currentUser } = useLoaderData() as LoaderData;
const [params] = useSearchParams();
const maybeSchema = appSchemas.safeParse(params.get('schema'));
const schema = maybeSchema.success ? maybeSchema.data : 'affine';
const maybeScheme = appSchemes.safeParse(params.get('scheme'));
const scheme = maybeScheme.success ? maybeScheme.data : 'affine';
const next = params.get('next');
const channel = schemaToChanel[schema as Schema];
const channel = schemeToChannel[scheme];
if (!currentUser || !currentUser?.token?.sessionToken) {
return null;
}
const urlToOpen = `${schema}://signin-redirect?token=${
const urlToOpen = `${scheme}://signin-redirect?token=${
currentUser.token.sessionToken
}&next=${next || ''}`;
@ -199,12 +167,19 @@ export const Component = () => {
export const loader: LoaderFunction = async args => {
const action = args.params.action || '';
const res = await fetcher({
query: getCurrentUserQuery,
}).catch(console.error);
return {
action,
currentUser: res?.currentUser || null,
};
if (action === 'signin-redirect') {
const res = await fetcher({
query: getCurrentUserQuery,
}).catch(console.error);
return {
action,
currentUser: res?.currentUser || null,
};
} else {
return {
action,
};
}
};

View File

@ -119,7 +119,7 @@ export class AuthService extends Service {
) {
track.$.$.auth.signIn({ method: 'magic-link' });
try {
const scheme = this.urlService.getClientSchema();
const scheme = this.urlService.getClientScheme();
const magicLinkUrlParams = new URLSearchParams();
if (redirectUrl) {
magicLinkUrlParams.set('redirect_uri', redirectUrl);

View File

@ -23,14 +23,14 @@ const SUBSCRIPTION_CACHE_KEY = 'subscription:';
const getDefaultSubscriptionSuccessCallbackLink = (
plan: SubscriptionPlan | null,
schema?: string
scheme?: string
) => {
const path =
plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : '/upgrade-success';
const urlString = getAffineCloudBaseUrl() + path;
const url = new URL(urlString);
if (schema) {
url.searchParams.set('schema', schema);
if (scheme) {
url.searchParams.set('scheme', scheme);
}
return url.toString();
};
@ -133,7 +133,7 @@ export class SubscriptionStore extends Store {
input.successCallbackLink ||
getDefaultSubscriptionSuccessCallbackLink(
input.plan,
this.urlService.getClientSchema()
this.urlService.getClientScheme()
),
},
},

View File

@ -0,0 +1,44 @@
import { z } from 'zod';
export const appSchemes = z.enum([
'affine',
'affine-canary',
'affine-beta',
'affine-internal',
'affine-dev',
]);
const appChannelSchemes = z.enum(['stable', 'canary', 'beta', 'internal']);
export type Scheme = z.infer<typeof appSchemes>;
export type Channel = z.infer<typeof appChannelSchemes>;
export const schemeToChannel = {
affine: 'stable',
'affine-canary': 'canary',
'affine-beta': 'beta',
'affine-internal': 'internal',
'affine-dev': 'canary', // dev does not have a dedicated app. use canary as the placeholder.
} as Record<Scheme, Channel>;
export const channelToScheme = {
stable: 'affine',
canary:
process.env.NODE_ENV === 'development' ? 'affine-dev' : 'affine-canary',
beta: 'affine-beta',
internal: 'affine-internal',
} as Record<Channel, Scheme>;
export const appIconMap = {
stable: '/imgs/app-icon-stable.ico',
canary: '/imgs/app-icon-canary.ico',
beta: '/imgs/app-icon-beta.ico',
internal: '/imgs/app-icon-internal.ico',
} satisfies Record<Channel, string>;
export const appNames = {
stable: 'AFFiNE',
canary: 'AFFiNE Canary',
beta: 'AFFiNE Beta',
internal: 'AFFiNE Internal',
} satisfies Record<Channel, string>;

View File

@ -0,0 +1,32 @@
import { DebugLogger } from '@affine/debug';
import { channelToScheme } from './constant';
const logger = new DebugLogger('open-in-app');
// return an AFFiNE app's url to be opened in desktop app
export const getOpenUrlInDesktopAppLink = (
url: string,
newTab = false,
scheme = channelToScheme[BUILD_CONFIG.appBuildType]
) => {
if (!scheme) {
return null;
}
const urlObject = new URL(url);
const params = urlObject.searchParams;
if (newTab) {
params.set('new-tab', '1');
}
try {
return new URL(
`${scheme}://${urlObject.host}${urlObject.pathname}?${params.toString()}#${urlObject.hash}`
).toString();
} catch (e) {
logger.error('Failed to get open url in desktop app link', e);
return null;
}
};

View File

@ -1,10 +1,10 @@
import type { Framework } from '@toeverything/infra';
import { ClientSchemaProvider } from './providers/client-schema';
import { ClientSchemeProvider } from './providers/client-schema';
import { PopupWindowProvider } from './providers/popup-window';
import { UrlService } from './services/url';
export { ClientSchemaProvider } from './providers/client-schema';
export { ClientSchemeProvider } from './providers/client-schema';
export { PopupWindowProvider } from './providers/popup-window';
export { UrlService } from './services/url';
@ -14,7 +14,7 @@ export const configureUrlModule = (container: Framework) => {
f =>
new UrlService(
f.getOptional(PopupWindowProvider),
f.getOptional(ClientSchemaProvider)
f.getOptional(ClientSchemeProvider)
)
);
};

View File

@ -1,12 +1,12 @@
import { createIdentifier } from '@toeverything/infra';
export interface ClientSchemaProvider {
export interface ClientSchemeProvider {
/**
* Get the client schema in the current environment, used for the user to complete the authentication process in the browser and redirect back to the app.
*/
getClientSchema(): string | undefined;
getClientScheme(): string | undefined;
}
export const ClientSchemaProvider = createIdentifier<ClientSchemaProvider>(
'ClientSchemaProvider'
export const ClientSchemeProvider = createIdentifier<ClientSchemeProvider>(
'ClientSchemeProvider'
);

View File

@ -1,19 +1,19 @@
import { Service } from '@toeverything/infra';
import type { ClientSchemaProvider } from '../providers/client-schema';
import type { ClientSchemeProvider } from '../providers/client-schema';
import type { PopupWindowProvider } from '../providers/popup-window';
export class UrlService extends Service {
constructor(
// those providers are optional, because they are not always available in some environments
private readonly popupWindowProvider?: PopupWindowProvider,
private readonly clientSchemaProvider?: ClientSchemaProvider
private readonly clientSchemeProvider?: ClientSchemeProvider
) {
super();
}
getClientSchema() {
return this.clientSchemaProvider?.getClientSchema();
getClientScheme() {
return this.clientSchemeProvider?.getClientScheme();
}
/**

View File

@ -1,3 +1,4 @@
import { DebugLogger } from '@affine/debug';
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { DocCollection } from '@blocksuite/affine/store';
@ -27,6 +28,32 @@ export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
'affine-local-workspace-changed';
const logger = new DebugLogger('local-workspace');
export function getLocalWorkspaceIds(): string[] {
try {
return JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
);
} catch (e) {
logger.error('Failed to get local workspace ids', e);
return [];
}
}
export function setLocalWorkspaceIds(
idsOrUpdater: string[] | ((ids: string[]) => string[])
) {
localStorage.setItem(
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
JSON.stringify(
typeof idsOrUpdater === 'function'
? idsOrUpdater(getLocalWorkspaceIds())
: idsOrUpdater
)
);
}
export class LocalWorkspaceFlavourProvider
extends Service
implements WorkspaceFlavourProvider
@ -43,13 +70,7 @@ export class LocalWorkspaceFlavourProvider
);
async deleteWorkspace(id: string): Promise<void> {
const allWorkspaceIDs: string[] = JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
);
localStorage.setItem(
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
JSON.stringify(allWorkspaceIDs.filter(x => x !== id))
);
setLocalWorkspaceIds(ids => ids.filter(x => x !== id));
if (BUILD_CONFIG.isElectron && apis) {
await apis.workspace.delete(id);
@ -88,14 +109,7 @@ export class LocalWorkspaceFlavourProvider
}
// save workspace id to local storage
const allWorkspaceIDs: string[] = JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
);
allWorkspaceIDs.push(id);
localStorage.setItem(
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
JSON.stringify(allWorkspaceIDs)
);
setLocalWorkspaceIds(ids => [...ids, id]);
// notify all browser tabs, so they can update their workspace list
this.notifyChannel.postMessage(id);
@ -106,9 +120,10 @@ export class LocalWorkspaceFlavourProvider
new Observable<WorkspaceMetadata[]>(subscriber => {
let last: WorkspaceMetadata[] | null = null;
const emit = () => {
const value = JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL }));
const value = getLocalWorkspaceIds().map(id => ({
id,
flavour: WorkspaceFlavour.LOCAL,
}));
if (isEqual(last, value)) return;
subscriber.next(value);
last = value;

View File

@ -1,5 +1,5 @@
{
"ar": 85,
"ar": 84,
"ca": 6,
"da": 6,
"de": 31,
@ -14,7 +14,7 @@
"ko": 89,
"pl": 0,
"pt-BR": 97,
"ru": 83,
"ru": 82,
"sv-SE": 5,
"ur": 3,
"zh-Hans": 99,

View File

@ -478,6 +478,7 @@
"com.affine.filterList.button.add": "Add filter",
"com.affine.header.option.add-tag": "Add tag",
"com.affine.header.option.duplicate": "Duplicate",
"com.affine.header.option.open-in-desktop": "Open in desktop app",
"com.affine.header.option.view-frame": "View all frames",
"com.affine.header.option.view-toc": "View table of contents",
"com.affine.helpIsland.contactUs": "Contact us",