mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat(mobile): ios selfhost server support (#11563)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { SignInPanel } from '@affine/core/components/sign-in';
|
||||
import { SignInPanel, type SignInStep } from '@affine/core/components/sign-in';
|
||||
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
@@ -7,9 +7,11 @@ import { MobileSignInLayout } from './layout';
|
||||
export const MobileSignInPanel = ({
|
||||
onClose,
|
||||
server,
|
||||
initStep,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
server?: string;
|
||||
initStep?: SignInStep;
|
||||
}) => {
|
||||
const onAuthenticated = useCallback(
|
||||
(status: AuthSessionStatus) => {
|
||||
@@ -26,6 +28,7 @@ export const MobileSignInPanel = ({
|
||||
onSkip={onClose}
|
||||
onAuthenticated={onAuthenticated}
|
||||
server={server}
|
||||
initStep={initStep}
|
||||
/>
|
||||
</MobileSignInLayout>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export const divider = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
':before': {
|
||||
content: '""',
|
||||
width: '100%',
|
||||
@@ -38,6 +39,11 @@ export const head = style([
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
]);
|
||||
export const headActions = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
});
|
||||
export const body = style({
|
||||
overflowY: 'auto',
|
||||
flexShrink: 0,
|
||||
@@ -79,3 +85,30 @@ export const wsName = style([
|
||||
textAlign: 'left',
|
||||
},
|
||||
]);
|
||||
|
||||
export const serverInfo = style({
|
||||
padding: '6px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const serverName = style([
|
||||
footnoteRegular,
|
||||
{
|
||||
color: cssVarV2.text.secondary,
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
export const serverAccount = style([
|
||||
serverName,
|
||||
{
|
||||
flexShrink: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
export const spaceX = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { Divider, IconButton, Menu, MenuItem } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { WorkspaceAvatar } from '@affine/core/components/workspace-avatar';
|
||||
import {
|
||||
type AuthAccountInfo,
|
||||
AuthService,
|
||||
type Server,
|
||||
ServersService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import {
|
||||
type WorkspaceMetadata,
|
||||
WorkspaceService,
|
||||
WorkspacesService,
|
||||
} from '@affine/core/modules/workspace';
|
||||
import { CloseIcon, CollaborationIcon } from '@blocksuite/icons/rc';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
useLiveData,
|
||||
useService,
|
||||
useServiceOptional,
|
||||
} from '@toeverything/infra';
|
||||
AccountIcon,
|
||||
CloseIcon,
|
||||
CollaborationIcon,
|
||||
DeleteIcon,
|
||||
MoreHorizontalIcon,
|
||||
SelfhostIcon,
|
||||
SignOutIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { type HTMLAttributes, useCallback, useMemo } from 'react';
|
||||
|
||||
import * as styles from './menu.css';
|
||||
|
||||
const filterByFlavour = (workspaces: WorkspaceMetadata[], flavour: string) =>
|
||||
workspaces.filter(ws => flavour === ws.flavour);
|
||||
|
||||
const WorkspaceItem = ({
|
||||
workspace,
|
||||
className,
|
||||
@@ -49,82 +60,306 @@ const WorkspaceItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceList = ({
|
||||
list,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
list: WorkspaceMetadata[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const currentWorkspace = useServiceOptional(WorkspaceService)?.workspace;
|
||||
interface WorkspaceListProps {
|
||||
items: WorkspaceMetadata[];
|
||||
onClick: (workspace: WorkspaceMetadata) => void;
|
||||
onSettingClick?: (workspace: WorkspaceMetadata) => void;
|
||||
onEnableCloudClick?: (meta: WorkspaceMetadata) => void;
|
||||
}
|
||||
export const WorkspaceList = (props: WorkspaceListProps) => {
|
||||
const workspaceList = props.items;
|
||||
|
||||
const { jumpToPage } = useNavigateHelper();
|
||||
const toggleWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
if (id !== currentWorkspace?.id) {
|
||||
jumpToPage(id, 'home');
|
||||
}
|
||||
onClose?.();
|
||||
},
|
||||
[currentWorkspace?.id, jumpToPage, onClose]
|
||||
return workspaceList.map(item => (
|
||||
<WorkspaceItem
|
||||
key={item.id}
|
||||
workspace={item}
|
||||
onClick={() => props.onClick(item)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const WorkspaceServerInfo = ({
|
||||
server,
|
||||
name,
|
||||
account,
|
||||
accountStatus,
|
||||
onDeleteServer,
|
||||
onSignOut,
|
||||
onSignIn,
|
||||
}: {
|
||||
server: string;
|
||||
name: string;
|
||||
account?: AuthAccountInfo | null;
|
||||
accountStatus?: 'authenticated' | 'unauthenticated';
|
||||
onDeleteServer?: () => void;
|
||||
onSignOut?: () => void;
|
||||
onSignIn?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const isCloud = server !== 'local';
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
server !== 'affine-cloud' && server !== 'local' && (
|
||||
<MenuItem
|
||||
prefixIcon={<DeleteIcon />}
|
||||
type="danger"
|
||||
key="delete-server"
|
||||
onClick={onDeleteServer}
|
||||
>
|
||||
{t['com.affine.server.delete']()}
|
||||
</MenuItem>
|
||||
),
|
||||
accountStatus === 'authenticated' && (
|
||||
<MenuItem
|
||||
prefixIcon={<SignOutIcon />}
|
||||
key="sign-out"
|
||||
onClick={onSignOut}
|
||||
type="danger"
|
||||
>
|
||||
{t['Sign out']()}
|
||||
</MenuItem>
|
||||
),
|
||||
accountStatus === 'unauthenticated' && (
|
||||
<MenuItem
|
||||
prefixIcon={<AccountIcon />}
|
||||
key="sign-in"
|
||||
onClick={onSignIn}
|
||||
>
|
||||
{t['Sign in']()}
|
||||
</MenuItem>
|
||||
),
|
||||
].filter(Boolean),
|
||||
[accountStatus, onDeleteServer, onSignIn, onSignOut, server, t]
|
||||
);
|
||||
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<div className={styles.serverInfo}>
|
||||
<div className={styles.serverName}>{name}</div>
|
||||
{isCloud ? (
|
||||
<div className={styles.serverAccount}>
|
||||
- {account ? account.email : 'Not signed in'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spaceX} />
|
||||
{menuItems.length ? (
|
||||
<Menu items={menuItems}>
|
||||
<IconButton icon={<MoreHorizontalIcon />} />
|
||||
</Menu>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LocalWorkspaces = ({
|
||||
workspaces,
|
||||
onClickWorkspace,
|
||||
onClickWorkspaceSetting,
|
||||
onClickEnableCloud,
|
||||
}: {
|
||||
workspaces: WorkspaceMetadata[];
|
||||
onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onClickWorkspaceSetting?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onClickEnableCloud?: (meta: WorkspaceMetadata) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
if (workspaces.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<WorkspaceServerInfo
|
||||
server="local"
|
||||
name={t['com.affine.workspaceList.workspaceListType.local']()}
|
||||
/>
|
||||
<WorkspaceList
|
||||
items={workspaces}
|
||||
onClick={onClickWorkspace}
|
||||
onSettingClick={onClickWorkspaceSetting}
|
||||
onEnableCloudClick={onClickEnableCloud}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CloudWorkSpaceList = ({
|
||||
server,
|
||||
workspaces,
|
||||
onClickWorkspace,
|
||||
onClickEnableCloud,
|
||||
}: {
|
||||
server: Server;
|
||||
workspaces: WorkspaceMetadata[];
|
||||
onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onClickEnableCloud?: (meta: WorkspaceMetadata) => void;
|
||||
}) => {
|
||||
const globalContextService = useService(GlobalContextService);
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const serverName = useLiveData(server.config$.selector(c => c.serverName));
|
||||
const authService = useService(AuthService);
|
||||
const serversService = useService(ServersService);
|
||||
const account = useLiveData(authService.session.account$);
|
||||
const accountStatus = useLiveData(authService.session.status$);
|
||||
const navigateHelper = useNavigateHelper();
|
||||
|
||||
const currentWorkspaceFlavour = useLiveData(
|
||||
globalContextService.globalContext.workspaceFlavour.$
|
||||
);
|
||||
|
||||
const handleDeleteServer = useCallback(() => {
|
||||
serversService.removeServer(server.id);
|
||||
|
||||
if (currentWorkspaceFlavour === server.id) {
|
||||
const otherWorkspace = workspaces.find(w => w.flavour !== server.id);
|
||||
if (otherWorkspace) {
|
||||
navigateHelper.openPage(otherWorkspace.id, 'all');
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentWorkspaceFlavour,
|
||||
navigateHelper,
|
||||
server.id,
|
||||
serversService,
|
||||
workspaces,
|
||||
]);
|
||||
|
||||
const handleSignOut = useAsyncCallback(async () => {
|
||||
await authService.signOut();
|
||||
}, [authService]);
|
||||
|
||||
const handleSignIn = useAsyncCallback(async () => {
|
||||
globalDialogService.open('sign-in', {
|
||||
server: server.baseUrl,
|
||||
});
|
||||
}, [globalDialogService, server.baseUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.wsListTitle}>{title}</section>
|
||||
<ul className={styles.wsList}>
|
||||
{list.map(ws => (
|
||||
<WorkspaceItem
|
||||
key={ws.id}
|
||||
workspace={ws}
|
||||
onClick={() => toggleWorkspace?.(ws.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<WorkspaceServerInfo
|
||||
server={server.id}
|
||||
name={serverName}
|
||||
account={account}
|
||||
accountStatus={accountStatus}
|
||||
onDeleteServer={handleDeleteServer}
|
||||
onSignOut={handleSignOut}
|
||||
onSignIn={handleSignIn}
|
||||
/>
|
||||
<WorkspaceList
|
||||
items={workspaces}
|
||||
onClick={onClickWorkspace}
|
||||
onEnableCloudClick={onClickEnableCloud}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddServer = () => {
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const enableMultipleServer = useLiveData(
|
||||
featureFlagService.flags.enable_multiple_cloud_servers.$
|
||||
);
|
||||
|
||||
const onAddServer = useCallback(() => {
|
||||
globalDialogService.open('sign-in', { step: 'addSelfhosted' });
|
||||
}, [globalDialogService]);
|
||||
|
||||
if (!enableMultipleServer) {
|
||||
return null;
|
||||
}
|
||||
return <IconButton onClick={onAddServer} size="24" icon={<SelfhostIcon />} />;
|
||||
};
|
||||
|
||||
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
||||
const serversService = useService(ServersService);
|
||||
const { jumpToPage } = useNavigateHelper();
|
||||
|
||||
const servers = useLiveData(serversService.servers$);
|
||||
const affineCloudServer = useMemo(
|
||||
() => servers.find(s => s.id === 'affine-cloud') as Server,
|
||||
[servers]
|
||||
);
|
||||
const selfhostServers = useMemo(
|
||||
() => servers.filter(s => s.id !== 'affine-cloud'),
|
||||
[servers]
|
||||
);
|
||||
|
||||
// TODO: support selfhosted
|
||||
const cloudWorkspaces = useMemo(
|
||||
() => filterByFlavour(workspaces, 'affine-cloud'),
|
||||
() =>
|
||||
workspaces.filter(
|
||||
({ flavour }) => flavour !== 'local'
|
||||
) as WorkspaceMetadata[],
|
||||
[workspaces]
|
||||
);
|
||||
|
||||
const localWorkspaces = useMemo(
|
||||
() => filterByFlavour(workspaces, 'local'),
|
||||
() =>
|
||||
workspaces.filter(
|
||||
({ flavour }) => flavour === 'local'
|
||||
) as WorkspaceMetadata[],
|
||||
[workspaces]
|
||||
);
|
||||
|
||||
const handleClickWorkspace = useCallback(
|
||||
(workspaceMetadata: WorkspaceMetadata) => {
|
||||
const id = workspaceMetadata.id;
|
||||
if (id !== currentWorkspace?.id) {
|
||||
jumpToPage(id, 'home');
|
||||
}
|
||||
onClose?.();
|
||||
},
|
||||
[onClose, jumpToPage]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<header className={styles.head}>
|
||||
Workspace
|
||||
<IconButton onClick={onClose} size="24" icon={<CloseIcon />} />
|
||||
<div className={styles.headActions}>
|
||||
<AddServer />
|
||||
<IconButton onClick={onClose} size="24" icon={<CloseIcon />} />
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.divider} />
|
||||
<main className={styles.body}>
|
||||
<WorkspaceList
|
||||
onClose={onClose}
|
||||
title="Cloud Sync"
|
||||
list={cloudWorkspaces}
|
||||
/>
|
||||
{cloudWorkspaces.length && localWorkspaces.length ? (
|
||||
<div className={styles.divider} />
|
||||
) : null}
|
||||
<WorkspaceList
|
||||
onClose={onClose}
|
||||
title="Local Storage"
|
||||
list={localWorkspaces}
|
||||
{/* 1. affine-cloud */}
|
||||
<FrameworkScope
|
||||
key={affineCloudServer.id}
|
||||
scope={affineCloudServer.scope}
|
||||
>
|
||||
<CloudWorkSpaceList
|
||||
server={affineCloudServer}
|
||||
workspaces={cloudWorkspaces.filter(
|
||||
({ flavour }) => flavour === affineCloudServer.id
|
||||
)}
|
||||
onClickWorkspace={handleClickWorkspace}
|
||||
/>
|
||||
</FrameworkScope>
|
||||
{(localWorkspaces.length > 0 || selfhostServers.length > 0) && (
|
||||
<Divider size="thinner" />
|
||||
)}
|
||||
<LocalWorkspaces
|
||||
workspaces={localWorkspaces}
|
||||
onClickWorkspace={handleClickWorkspace}
|
||||
/>
|
||||
{selfhostServers.length > 0 && <Divider size="thinner" />}
|
||||
|
||||
{/* 3. selfhost */}
|
||||
{selfhostServers.map((server, index) => (
|
||||
<FrameworkScope key={server.id} scope={server.scope}>
|
||||
<CloudWorkSpaceList
|
||||
server={server}
|
||||
workspaces={cloudWorkspaces.filter(
|
||||
({ flavour }) => flavour === server.id
|
||||
)}
|
||||
onClickWorkspace={handleClickWorkspace}
|
||||
/>
|
||||
{index !== selfhostServers.length - 1 && <Divider size="thinner" />}
|
||||
</FrameworkScope>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconButton, Modal, SafeArea } from '@affine/component';
|
||||
import type { SignInStep } from '@affine/core/components/sign-in';
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
GLOBAL_DIALOG_SCHEMA,
|
||||
@@ -11,6 +12,7 @@ import { MobileSignInPanel } from '../../components/sign-in';
|
||||
export const SignInDialog = ({
|
||||
close,
|
||||
server: initialServerBaseUrl,
|
||||
step,
|
||||
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['sign-in']>) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -27,7 +29,11 @@ export const SignInDialog = ({
|
||||
}}
|
||||
withoutCloseButton
|
||||
>
|
||||
<MobileSignInPanel onClose={close} server={initialServerBaseUrl} />
|
||||
<MobileSignInPanel
|
||||
onClose={close}
|
||||
server={initialServerBaseUrl}
|
||||
initStep={step as SignInStep}
|
||||
/>
|
||||
<SafeArea
|
||||
top
|
||||
style={{ position: 'absolute', top: 0, right: 0, paddingRight: 16 }}
|
||||
@@ -38,7 +44,10 @@ export const SignInDialog = ({
|
||||
variant="solid"
|
||||
icon={<CloseIcon />}
|
||||
style={{ borderRadius: 8, padding: 4 }}
|
||||
onClick={() => close()}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</SafeArea>
|
||||
</Modal>
|
||||
|
||||
@@ -195,7 +195,7 @@ export const AFFINE_FLAGS = {
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-multiple-cloud-servers.description',
|
||||
configurable: false,
|
||||
defaultState: isDesktopEnvironment,
|
||||
defaultState: isDesktopEnvironment || BUILD_CONFIG.isIOS,
|
||||
},
|
||||
enable_mobile_edgeless_editing: {
|
||||
category: 'affine',
|
||||
|
||||
Reference in New Issue
Block a user