feat(mobile): ios selfhost server support (#11563)

This commit is contained in:
CatsJuice
2025-04-14 01:13:23 +00:00
parent e1cb8198f1
commit d6b4ee8172
9 changed files with 363 additions and 73 deletions

View File

@@ -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>
);

View File

@@ -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,
});

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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',