feat(core): add team badge to user info menu (#12144)

close AF-2545

fix(core): go to workspace command does not work using CMDK

feat(core): add team badge to user info

![CleanShot 2025-05-06 at 16 35 00@2x](https://github.com/user-attachments/assets/ff915ea5-761b-4851-9429-86cbb3041776)

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

## Summary by CodeRabbit

- **New Features**
  - Introduced a redesigned user info section in the sidebar, including enhanced account details, cloud and AI usage indicators, and team workspace listing with improved navigation.
  - Workspace selector now supports controlled open/close state for smoother sidebar integration.
  - Team workspaces are grouped and displayed with role-based badges and tooltips.
  - Added new localization for team roles and workspace tooltips.

- **Improvements**
  - Updated theming and styling for user plan buttons and usage indicators for a more consistent look.
  - Sidebar user info UI is now more modular and responsive.

- **Bug Fixes**
  - Improved error handling and loading states for quota and usage displays.

- **Chores**
  - Refactored internal state management and code structure for better maintainability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
JimmFly
2025-05-19 10:15:42 +00:00
parent 9651969ff8
commit 65b910868f
23 changed files with 832 additions and 427 deletions
@@ -2,25 +2,24 @@ import type { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { Workspace } from '@blocksuite/affine/store';
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
import type { createStore } from 'jotai';
import { openWorkspaceListModalAtom } from '../components/atoms';
import type { useNavigateHelper } from '../components/hooks/use-navigate-helper';
import type { WorkspaceDialogService } from '../modules/dialogs';
import type { WorkbenchService } from '../modules/workbench';
import { registerAffineCommand } from './registry';
export function registerAffineNavigationCommands({
t,
store,
docCollection,
navigationHelper,
workspaceDialogService,
workbenchService,
}: {
t: ReturnType<typeof useI18n>;
store: ReturnType<typeof createStore>;
navigationHelper: ReturnType<typeof useNavigateHelper>;
docCollection: Workspace;
workspaceDialogService: WorkspaceDialogService;
workbenchService?: WorkbenchService;
}) {
const unsubs: Array<() => void> = [];
unsubs.push(
@@ -82,7 +81,7 @@ export function registerAffineNavigationCommands({
to: 'workspace',
});
store.set(openWorkspaceListModalAtom, true);
workbenchService?.workbench.openWorkspaceSelector();
},
})
);
@@ -1,23 +1,25 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const userPlanButton = style({
display: 'flex',
fontSize: cssVar('fontXs'),
height: 20,
fontWeight: 500,
fontSize: '10px',
height: 16,
fontWeight: 400,
cursor: 'pointer',
color: cssVar('pureWhite'),
backgroundColor: cssVar('brandColor'),
color: cssVarV2('button/pureWhiteText'),
backgroundColor: cssVarV2('badge/free'),
padding: '0 4px',
borderRadius: 4,
borderRadius: 2,
justifyContent: 'center',
alignItems: 'center',
selectors: {
'&[data-is-believer="true"]': {
// TODO(@CatsJuice): this color is new `Figma token` value without dark mode support.
backgroundColor: '#374151',
backgroundColor: cssVarV2('badge/believer'),
},
'&[data-is-pro="true"]': {
backgroundColor: cssVarV2('badge/pro'),
},
},
});
@@ -49,6 +49,7 @@ export const UserPlanButton = ({
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
<div
data-is-believer={isBeliever ? 'true' : undefined}
data-is-pro={plan === SubscriptionPlan.Pro ? 'true' : undefined}
className={styles.userPlanButton}
onClick={onClick}
data-event-props="$.settingsPanel.profileAndBadge.viewPlans"
@@ -7,5 +7,3 @@ export const openQuotaModalAtom = atom(false);
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
export const openWorkspaceListModalAtom = atom(false);
@@ -6,9 +6,14 @@ import {
} from '@affine/core/modules/dialogs';
import { I18nService } from '@affine/core/modules/i18n';
import { UrlService } from '@affine/core/modules/url';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { useService, useServiceOptional } from '@toeverything/infra';
import {
useService,
useServiceOptional,
useServices,
} from '@toeverything/infra';
import { useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
@@ -53,24 +58,36 @@ export function useRegisterWorkspaceCommands() {
const urlService = useService(UrlService);
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const navigationHelper = useNavigateHelper();
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
const editorSettingService = useService(EditorSettingService);
const workspaceDialogService = useService(WorkspaceDialogService);
const globalDialogService = useService(GlobalDialogService);
const appSidebarService = useService(AppSidebarService);
const i18n = useService(I18nService).i18n;
const {
cMDKQuickSearchService,
editorSettingService,
workspaceDialogService,
globalDialogService,
appSidebarService,
i18nService,
} = useServices({
CMDKQuickSearchService,
EditorSettingService,
WorkspaceDialogService,
GlobalDialogService,
AppSidebarService,
I18nService,
});
const i18n = i18nService.i18n;
const desktopApiService = useServiceOptional(DesktopApiService);
const workbenchService = useServiceOptional(WorkbenchService);
const quitAndInstall = desktopApiService?.handler.updater.quitAndInstall;
useEffect(() => {
const unsub = registerCMDKCommand(cmdkQuickSearchService);
const unsub = registerCMDKCommand(cMDKQuickSearchService);
return () => {
unsub();
};
}, [cmdkQuickSearchService]);
}, [cMDKQuickSearchService]);
// register AffineUpdatesCommands
useEffect(() => {
@@ -92,11 +109,11 @@ export function useRegisterWorkspaceCommands() {
// register AffineNavigationCommands
useEffect(() => {
const unsub = registerAffineNavigationCommands({
store,
t,
docCollection: currentWorkspace.docCollection,
navigationHelper,
workspaceDialogService,
workbenchService,
});
return () => {
@@ -109,6 +126,7 @@ export function useRegisterWorkspaceCommands() {
navigationHelper,
globalDialogService,
workspaceDialogService,
workbenchService,
]);
// register AffineSettingsCommands
@@ -1,7 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
export const progressColorVar = createVar();
import { style } from '@vanilla-extract/css';
export const workspaceAndUserWrapper = style({
display: 'flex',
@@ -25,81 +22,6 @@ export const workspaceWrapper = style({
flex: 1,
});
export const operationMenu = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
// TODO: refactor menu, use `gap` to replace `margin`
globalStyle(`.${operationMenu} > div:not([data-divider])`, {
marginBottom: '0 !important',
marginTop: '0 !important',
});
export const usageBlock = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
borderRadius: 4,
});
export const aiUsageBlock = style({
padding: 12,
cursor: 'pointer',
selectors: {
'&[data-pro]': {
padding: '12px 12px 2px 12px',
},
},
});
export const cloudUsageBlock = style({
padding: '4px 12px',
});
export const usageLabel = style({
fontWeight: 400,
lineHeight: '20px',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const usageLabelTitle = style({
color: cssVar('textPrimaryColor'),
marginRight: '0.5em',
});
export const cloudUsageBar = style({
height: 10,
borderRadius: 5,
overflow: 'hidden',
position: 'relative',
minWidth: 260,
'::before': {
position: 'absolute',
inset: 0,
content: '""',
backgroundColor: cssVar('black'),
opacity: 0.04,
},
});
export const cloudUsageBarInner = style({
height: '100%',
borderRadius: 'inherit',
backgroundColor: progressColorVar,
});
export const freeTag = style({
height: 20,
padding: '0px 4px',
borderRadius: 4,
fontWeight: 500,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVar('pureWhite'),
background: cssVar('primaryColor'),
});
export const bottomContainer = style({
gap: 8,
});
@@ -50,7 +50,7 @@ import { SidebarAudioPlayer } from './sidebar-audio-player';
import { TemplateDocEntrance } from './template-doc-entrance';
import { TrashButton } from './trash-button';
import { UpdaterButton } from './updater-button';
import { UserInfo } from './user-info';
import UserInfo from './user-info';
export type RootAppSidebarProps = {
isPublicWorkspace: boolean;
@@ -103,10 +103,18 @@ export const RootAppSidebar = memo((): ReactElement => {
const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService);
const workbench = workbenchService.workbench;
const workspaceSelectorOpen = useLiveData(workbench.workspaceSelectorOpen$);
const onOpenQuickSearchModal = useCallback(() => {
cMDKQuickSearchService.toggle();
}, [cMDKQuickSearchService]);
const onWorkspaceSelectorOpenChange = useCallback(
(open: boolean) => {
workbench.setWorkspaceSelectorOpen(open);
},
[workbench]
);
const onOpenSettingModal = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'appearance',
@@ -153,7 +161,12 @@ export const RootAppSidebar = memo((): ReactElement => {
<SidebarContainer>
<div className={workspaceAndUserWrapper}>
<div className={workspaceWrapper}>
<WorkspaceNavigator showEnableCloudButton showSyncStatus />
<WorkspaceNavigator
showEnableCloudButton
showSyncStatus
open={workspaceSelectorOpen}
onOpenChange={onWorkspaceSelectorOpenChange}
/>
</div>
<UserInfo />
</div>
@@ -1,312 +0,0 @@
import {
Avatar,
Divider,
ErrorMessage,
IconButton,
Menu,
MenuItem,
type MenuProps,
Skeleton,
} from '@affine/component';
import {
GlobalDialogService,
WorkspaceDialogService,
} from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AccountIcon, AdminIcon, SignOutIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useCallback, useEffect } from 'react';
import {
type AuthAccountInfo,
AuthService,
ServerService,
SubscriptionService,
UserCopilotQuotaService,
UserFeatureService,
UserQuotaService,
} from '../../modules/cloud';
import { UserPlanButton } from '../affine/auth/user-plan-button';
import { useSignOut } from '../hooks/affine/use-sign-out';
import { useCatchEventCallback } from '../hooks/use-catch-event-hook';
import * as styles from './index.css';
import { UnknownUserIcon } from './unknow-user';
export const UserInfo = () => {
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
return account ? (
<AuthorizedUserInfo account={account} />
) : (
<UnauthorizedUserInfo />
);
};
const menuContentOptions: MenuProps['contentOptions'] = {
className: styles.operationMenu,
};
const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
return (
<Menu items={<OperationMenu />} contentOptions={menuContentOptions}>
<IconButton data-testid="sidebar-user-avatar" variant="plain" size="24">
<Avatar size={24} name={account.label} url={account.avatar} />
</IconButton>
</Menu>
);
};
const UnauthorizedUserInfo = () => {
const globalDialogService = useService(GlobalDialogService);
const openSignInModal = useCallback(() => {
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
return (
<IconButton
onClick={openSignInModal}
data-testid="sidebar-user-avatar"
variant="plain"
size="24"
>
<UnknownUserIcon />
</IconButton>
);
};
const AccountMenu = () => {
const workspaceDialogService = useService(WorkspaceDialogService);
const openSignOutModal = useSignOut();
const serverService = useService(ServerService);
const userFeatureService = useService(UserFeatureService);
const isAFFiNEAdmin = useLiveData(userFeatureService.userFeature.isAdmin$);
const onOpenAccountSetting = useCallback(() => {
track.$.navigationPanel.profileAndBadge.openSettings({ to: 'account' });
workspaceDialogService.open('setting', {
activeTab: 'account',
});
}, [workspaceDialogService]);
const onOpenAdminPanel = useCallback(() => {
window.open(`${serverService.server.baseUrl}/admin`, '_blank');
}, [serverService.server.baseUrl]);
const t = useI18n();
useEffect(() => {
userFeatureService.userFeature.revalidate();
}, [userFeatureService]);
return (
<>
<MenuItem
prefixIcon={<AccountIcon />}
data-testid="workspace-modal-account-settings-option"
onClick={onOpenAccountSetting}
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
{isAFFiNEAdmin ? (
<MenuItem
prefixIcon={<AdminIcon />}
data-testid="workspace-modal-account-admin-option"
onClick={onOpenAdminPanel}
>
{t['com.affine.workspace.cloud.account.admin']()}
</MenuItem>
) : null}
<MenuItem
prefixIcon={<SignOutIcon />}
data-testid="workspace-modal-sign-out-option"
onClick={openSignOutModal}
>
{t['com.affine.workspace.cloud.account.logout']()}
</MenuItem>
</>
);
};
const CloudUsage = () => {
const t = useI18n();
const quota = useService(UserQuotaService).quota;
const quotaError = useLiveData(quota.error$);
const workspaceDialogService = useService(WorkspaceDialogService);
const handleClick = useCatchEventCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [workspaceDialogService]);
useEffect(() => {
// revalidate quota to get the latest status
quota.revalidate();
}, [quota]);
const color = useLiveData(quota.color$);
const usedFormatted = useLiveData(quota.usedFormatted$);
const maxFormatted = useLiveData(quota.maxFormatted$);
const percent = useLiveData(quota.percent$);
if (percent === null) {
if (quotaError) {
return <ErrorMessage>Failed to load quota</ErrorMessage>;
}
return (
<div>
<Skeleton height={15} width={50} />
<Skeleton height={10} style={{ marginTop: 4 }} />
</div>
);
}
return (
<div
className={clsx(styles.usageBlock, styles.cloudUsageBlock)}
style={assignInlineVars({
[styles.progressColorVar]: color,
})}
>
<div className={styles.usageLabel}>
<div>
<span className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.cloud']()}
</span>
<span>{usedFormatted}</span>
<span>&nbsp;/&nbsp;</span>
<span>{maxFormatted}</span>
</div>
<UserPlanButton onClick={handleClick} />
</div>
<div className={styles.cloudUsageBar}>
<div
className={styles.cloudUsageBarInner}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
const AIUsage = () => {
const t = useI18n();
const copilotQuotaService = useService(UserCopilotQuotaService);
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
// revalidate latest subscription status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
useEffect(() => {
copilotQuotaService.copilotQuota.revalidate();
}, [copilotQuotaService]);
const copilotActionLimit = useLiveData(
copilotQuotaService.copilotQuota.copilotActionLimit$
);
const copilotActionUsed = useLiveData(
copilotQuotaService.copilotQuota.copilotActionUsed$
);
const loading = copilotActionLimit === null || copilotActionUsed === null;
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
const workspaceDialogService = useService(WorkspaceDialogService);
const goToAIPlanPage = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'aiPricingPlan',
});
}, [workspaceDialogService]);
const goToAccountSetting = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'account',
});
}, [workspaceDialogService]);
if (loading) {
if (loadError) console.error(loadError);
return null;
}
// unlimited
if (copilotActionLimit === 'unlimited') {
return (
<div
onClick={goToAccountSetting}
data-pro
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
>
<div className={styles.usageLabel}>
<div className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.ai']()}
</div>
</div>
<div className={styles.usageLabel}>
{t['com.affine.payment.ai.usage-description-purchased']()}
</div>
</div>
);
}
const percent = Math.min(
100,
Math.max(
0.5,
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
)
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
return (
<div
onClick={goToAIPlanPage}
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
style={assignInlineVars({
[styles.progressColorVar]: color,
})}
>
<div className={styles.usageLabel}>
<div>
<span className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.ai']()}
</span>
<span>{copilotActionUsed}</span>
<span>&nbsp;/&nbsp;</span>
<span>{copilotActionLimit}</span>
</div>
<div className={styles.freeTag}>Free</div>
</div>
<div className={styles.cloudUsageBar}>
<div
className={styles.cloudUsageBarInner}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
const OperationMenu = () => {
const serverService = useService(ServerService);
const serverFeatures = useLiveData(serverService.server.features$);
return (
<>
{serverFeatures?.copilot ? <AIUsage /> : null}
<CloudUsage />
<Divider />
<AccountMenu />
</>
);
};
@@ -0,0 +1,63 @@
import { MenuItem } from '@affine/component';
import { ServerService, UserFeatureService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AccountIcon, AdminIcon, SignOutIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { useSignOut } from '../../hooks/affine/use-sign-out';
export const AccountMenu = () => {
const workspaceDialogService = useService(WorkspaceDialogService);
const openSignOutModal = useSignOut();
const serverService = useService(ServerService);
const userFeatureService = useService(UserFeatureService);
const isAFFiNEAdmin = useLiveData(userFeatureService.userFeature.isAdmin$);
const onOpenAccountSetting = useCallback(() => {
track.$.navigationPanel.profileAndBadge.openSettings({ to: 'account' });
workspaceDialogService.open('setting', {
activeTab: 'account',
});
}, [workspaceDialogService]);
const onOpenAdminPanel = useCallback(() => {
window.open(`${serverService.server.baseUrl}/admin`, '_blank');
}, [serverService.server.baseUrl]);
const t = useI18n();
useEffect(() => {
userFeatureService.userFeature.revalidate();
}, [userFeatureService]);
return (
<>
<MenuItem
prefixIcon={<AccountIcon />}
data-testid="workspace-modal-account-settings-option"
onClick={onOpenAccountSetting}
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
{isAFFiNEAdmin ? (
<MenuItem
prefixIcon={<AdminIcon />}
data-testid="workspace-modal-account-admin-option"
onClick={onOpenAdminPanel}
>
{t['com.affine.workspace.cloud.account.admin']()}
</MenuItem>
) : null}
<MenuItem
prefixIcon={<SignOutIcon />}
data-testid="workspace-modal-sign-out-option"
onClick={openSignOutModal}
>
{t['com.affine.workspace.cloud.account.logout']()}
</MenuItem>
</>
);
};
@@ -0,0 +1,40 @@
import { Avatar } from '@affine/component';
import { AuthService } from '@affine/core/modules/cloud';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './index.css';
export const Account = () => {
const account = useLiveData(useService(AuthService).session.account$);
if (!account) {
// TODO(@JimmFly): loading ui
return null;
}
return (
<div data-testid="user-info-card" className={styles.account}>
<Avatar
size={28}
rounded={50}
name={account.label}
url={account.avatar}
/>
<div className={styles.content}>
<div
className={styles.name}
title={account.label}
content={account.label}
>
{account.label}
</div>
<div
className={styles.email}
title={account.email}
content={account.email}
>
{account.email}
</div>
</div>
</div>
);
};
@@ -0,0 +1,116 @@
import {
SubscriptionService,
UserCopilotQuotaService,
} from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useCallback, useEffect } from 'react';
import * as styles from './index.css';
export const AIUsage = () => {
const t = useI18n();
const copilotQuotaService = useService(UserCopilotQuotaService);
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
// revalidate latest subscription status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
useEffect(() => {
copilotQuotaService.copilotQuota.revalidate();
}, [copilotQuotaService]);
const copilotActionLimit = useLiveData(
copilotQuotaService.copilotQuota.copilotActionLimit$
);
const copilotActionUsed = useLiveData(
copilotQuotaService.copilotQuota.copilotActionUsed$
);
const loading = copilotActionLimit === null || copilotActionUsed === null;
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
const workspaceDialogService = useService(WorkspaceDialogService);
const goToAIPlanPage = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'aiPricingPlan',
});
}, [workspaceDialogService]);
const goToAccountSetting = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'account',
});
}, [workspaceDialogService]);
if (loading) {
if (loadError) console.error(loadError);
return null;
}
// unlimited
if (copilotActionLimit === 'unlimited') {
return (
<div
onClick={goToAccountSetting}
data-pro
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
>
<div className={styles.usageLabel}>
<div className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.ai']()}
</div>
</div>
<div className={styles.usageLabel}>
{t['com.affine.payment.ai.usage-description-purchased']()}
</div>
</div>
);
}
const percent = Math.min(
100,
Math.max(
0.5,
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
)
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
return (
<div
onClick={goToAIPlanPage}
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
style={assignInlineVars({
[styles.progressColorVar]: color,
})}
>
<div className={styles.usageLabel}>
<div>
<span className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.ai']()}
</span>
<span>{copilotActionUsed}</span>
<span>&nbsp;/&nbsp;</span>
<span>{copilotActionLimit}</span>
</div>
<div className={styles.freeTag}>Free</div>
</div>
<div className={styles.cloudUsageBar}>
<div
className={styles.cloudUsageBarInner}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
@@ -0,0 +1,75 @@
import { ErrorMessage, Skeleton } from '@affine/component';
import { UserQuotaService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useEffect } from 'react';
import { UserPlanButton } from '../../affine/auth/user-plan-button';
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
import * as styles from './index.css';
export const CloudUsage = () => {
const t = useI18n();
const quota = useService(UserQuotaService).quota;
const quotaError = useLiveData(quota.error$);
const workspaceDialogService = useService(WorkspaceDialogService);
const handleClick = useCatchEventCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [workspaceDialogService]);
useEffect(() => {
// revalidate quota to get the latest status
quota.revalidate();
}, [quota]);
const color = useLiveData(quota.color$);
const usedFormatted = useLiveData(quota.usedFormatted$);
const maxFormatted = useLiveData(quota.maxFormatted$);
const percent = useLiveData(quota.percent$);
if (percent === null) {
if (quotaError) {
return <ErrorMessage>Failed to load quota</ErrorMessage>;
}
return (
<div>
<Skeleton height={15} width={50} />
<Skeleton height={10} style={{ marginTop: 4 }} />
</div>
);
}
return (
<div
className={clsx(styles.usageBlock, styles.cloudUsageBlock)}
style={assignInlineVars({
[styles.progressColorVar]: color,
})}
>
<div className={styles.usageLabel}>
<div>
<span className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.cloud']()}
</span>
<span>{usedFormatted}</span>
<span>&nbsp;/&nbsp;</span>
<span>{maxFormatted}</span>
</div>
<UserPlanButton onClick={handleClick} />
</div>
<div className={styles.cloudUsageBar}>
<div
className={styles.cloudUsageBarInner}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
@@ -0,0 +1,148 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const progressColorVar = createVar();
export const operationMenu = style({
display: 'flex',
flexDirection: 'column',
gap: 0,
});
export const account = style({
padding: '4px 12px',
userSelect: 'none',
display: 'flex',
gap: '12px',
justifyContent: 'space-between',
alignItems: 'center',
});
export const content = style({
flexGrow: 1,
minWidth: 0,
maxWidth: '220px',
});
export const name = style({
fontSize: cssVar('fontSm'),
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: '22px',
});
export const email = style({
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flexGrow: 1,
height: '20px',
});
export const usageBlock = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
borderRadius: 4,
});
export const aiUsageBlock = style({
padding: '0px 6px 12px',
cursor: 'pointer',
});
export const cloudUsageBlock = style({
padding: '0px 6px 12px',
});
export const usageLabel = style({
fontWeight: 400,
lineHeight: '20px',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const usageLabelTitle = style({
color: cssVarV2('text/primary'),
marginRight: '0.5em',
});
export const cloudUsageBar = style({
height: 10,
borderRadius: 5,
overflow: 'hidden',
position: 'relative',
minWidth: 260,
'::before': {
position: 'absolute',
inset: 0,
content: '""',
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const cloudUsageBarInner = style({
height: '100%',
borderRadius: 'inherit',
backgroundColor: progressColorVar,
});
export const freeTag = style({
height: 16,
padding: '0px 4px',
borderRadius: 2,
fontWeight: 400,
fontSize: '10px',
lineHeight: '16px',
color: cssVarV2('button/pureWhiteText'),
background: cssVarV2('badge/free'),
});
export const teamWorkspace = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
borderRadius: 4,
cursor: 'pointer',
padding: '2px 6px',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const teamAvatarStack = style({
display: 'flex',
alignItems: 'center',
});
export const workspaceAvatar = style({
borderRadius: 4,
border: `1px solid ${cssVarV2('layer/white')}`,
selectors: {
'&.multi-avatar': {
marginLeft: -4,
},
},
});
export const teamName = style({
flex: 1,
overflow: 'hidden',
fontSize: cssVar('fontXs'),
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '170px',
lineHeight: '20px',
});
export const teamBadge = style({
marginLeft: 'auto',
marginRight: '0px',
padding: '0px 4px',
borderRadius: '2px',
fontSize: '10px',
color: cssVarV2('button/pureWhiteText'),
background: cssVarV2('badge/believer'),
height: '16px',
lineHeight: '16px',
});
@@ -0,0 +1,82 @@
import {
Avatar,
Divider,
IconButton,
Menu,
type MenuProps,
} from '@affine/component';
import {
type AuthAccountInfo,
AuthService,
ServerService,
} from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { Account } from './account';
import { AccountMenu } from './account-menu';
import { AIUsage } from './ai-usage';
import { CloudUsage } from './cloud-usage';
import * as styles from './index.css';
import { TeamList } from './team-list';
import { UnknownUserIcon } from './unknow-user';
export default function UserInfo() {
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
return account ? (
<AuthorizedUserInfo account={account} />
) : (
<UnauthorizedUserInfo />
);
}
const menuContentOptions: MenuProps['contentOptions'] = {
className: styles.operationMenu,
};
const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
return (
<Menu items={<OperationMenu />} contentOptions={menuContentOptions}>
<IconButton data-testid="sidebar-user-avatar" variant="plain" size="24">
<Avatar size={24} name={account.label} url={account.avatar} />
</IconButton>
</Menu>
);
};
const UnauthorizedUserInfo = () => {
const globalDialogService = useService(GlobalDialogService);
const openSignInModal = useCallback(() => {
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
return (
<IconButton
onClick={openSignInModal}
data-testid="sidebar-user-avatar"
variant="plain"
size="24"
>
<UnknownUserIcon />
</IconButton>
);
};
const OperationMenu = () => {
const serverService = useService(ServerService);
const serverFeatures = useLiveData(serverService.server.features$);
return (
<>
<Account />
<Divider />
<CloudUsage />
{serverFeatures?.copilot ? <AIUsage /> : null}
<Divider />
<TeamList />
<AccountMenu />
</>
);
};
@@ -0,0 +1,169 @@
import { Divider, Tooltip } from '@affine/component';
import { WorkbenchService } from '@affine/core/modules/workbench';
import {
type WorkspaceMetadata,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import {
useLiveData,
useService,
useServiceOptional,
} from '@toeverything/infra';
import clsx from 'clsx';
import { memo, useCallback, useMemo } from 'react';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkspaceAvatar } from '../../workspace-avatar';
import * as styles from './index.css';
type WorkspaceProfile = ReturnType<WorkspacesService['getProfile']>;
interface WorkspaceInfo {
profile: WorkspaceProfile;
meta: WorkspaceMetadata;
}
interface TeamItemProps {
workspaces: WorkspaceInfo[];
badgeText: string;
}
const TeamItem = memo(({ workspaces, badgeText }: TeamItemProps) => {
const t = useI18n();
const { jumpToPage } = useNavigateHelper();
const workbench = useServiceOptional(WorkbenchService)?.workbench;
const handleJumpToWorkspace = useCallback(() => {
const closeInactiveViews = () =>
workbench?.views$.value.forEach(view => {
if (workbench?.activeView$.value !== view) {
workbench?.close(view);
}
});
if (workspaces.length !== 1) {
return;
}
if (document.startViewTransition) {
document.startViewTransition(() => {
closeInactiveViews();
jumpToPage(workspaces[0].profile.id, 'all');
return new Promise(resolve =>
setTimeout(resolve, 150)
); /* start transition after 150ms */
});
} else {
closeInactiveViews();
jumpToPage(workspaces[0].profile.id, 'all');
}
}, [jumpToPage, workbench, workspaces]);
const handleClick = useCallback(() => {
if (workspaces.length === 1) {
handleJumpToWorkspace();
} else {
workbench?.setWorkspaceSelectorOpen(true);
}
}, [handleJumpToWorkspace, workbench, workspaces.length]);
if (workspaces.length === 0) return null;
const displayName =
workspaces.length > 1
? t['com.affine.workspace.cloud.account.team.multi']()
: workspaces[0].profile.profile$.value?.name || 'Team';
const tooltipContent =
workspaces.length > 1
? t['com.affine.workspace.cloud.account.team.tips-2']()
: t['com.affine.workspace.cloud.account.team.tips-1']();
return (
<Tooltip content={tooltipContent}>
<div className={styles.teamWorkspace} onClick={handleClick}>
<div className={styles.teamAvatarStack}>
{workspaces.map((workspace, index) => (
<WorkspaceAvatar
key={workspace.profile.id}
meta={workspace.meta}
rounded={3}
size={16}
name={workspace.profile.profile$.value?.name}
colorfulFallback
className={clsx(styles.workspaceAvatar, {
['multi-avatar']: index !== 0,
})}
/>
))}
</div>
<span className={styles.teamName}>{displayName}</span>
<span className={styles.teamBadge}>{badgeText}</span>
</div>
</Tooltip>
);
});
TeamItem.displayName = 'TeamItem';
export const TeamList = memo(() => {
const t = useI18n();
const workspacesService = useService(WorkspacesService);
const workspaceMetas = useLiveData(workspacesService.list.workspaces$);
const workspaceProfiles = workspacesService.getAllWorkspaceProfile();
const teamWorkspaces = useMemo(() => {
const ownerWorkspaces: WorkspaceInfo[] = [];
const memberWorkspaces: WorkspaceInfo[] = [];
const metaMap = new Map(workspaceMetas.map(meta => [meta.id, meta]));
for (const profile of workspaceProfiles) {
if (!profile.profile$.value?.isTeam) continue;
const meta = metaMap.get(profile.id);
if (!meta) continue;
if (profile.profile$.value.isOwner) {
ownerWorkspaces.push({ profile, meta });
} else {
memberWorkspaces.push({ profile, meta });
}
}
// only show avatar stack when multiple workspaces
const slicedOwnerWorkspaces = ownerWorkspaces.slice(0, 3);
const slicedMemberWorkspaces = memberWorkspaces.slice(0, 3);
return {
ownerWorkspaces: slicedOwnerWorkspaces,
memberWorkspaces: slicedMemberWorkspaces,
};
}, [workspaceProfiles, workspaceMetas]);
if (
teamWorkspaces.ownerWorkspaces.length === 0 &&
teamWorkspaces.memberWorkspaces.length === 0
) {
return null;
}
return (
<>
{teamWorkspaces.ownerWorkspaces.length > 0 && (
<TeamItem
workspaces={teamWorkspaces.ownerWorkspaces}
badgeText={t['com.affine.workspace.cloud.account.team.owner']()}
/>
)}
{teamWorkspaces.memberWorkspaces.length > 0 && (
<TeamItem
workspaces={teamWorkspaces.memberWorkspaces}
badgeText={t['com.affine.workspace.cloud.account.team.member']()}
/>
)}
<Divider />
</>
);
});
TeamList.displayName = 'TeamList';
@@ -19,6 +19,7 @@ import { WorkspaceCard } from './workspace-card';
interface WorkspaceSelectorProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
workspaceMetadata?: WorkspaceMetadata;
onSelectWorkspace?: (workspaceMetadata: WorkspaceMetadata) => void;
onCreatedWorkspace?: (payload: {
@@ -40,6 +41,7 @@ export const WorkspaceSelector = ({
showArrowDownIcon,
disable,
open: outerOpen,
onOpenChange: outerOnOpenChange,
showEnableCloudButton,
showSyncStatus,
className,
@@ -51,13 +53,29 @@ export const WorkspaceSelector = ({
});
const [innerOpen, setOpened] = useState(false);
const open = outerOpen ?? innerOpen;
const onOpenChange = useCallback(
(open: boolean) => {
outerOnOpenChange !== undefined
? outerOnOpenChange?.(open)
: setOpened(open);
},
[outerOnOpenChange]
);
const closeUserWorkspaceList = useCallback(() => {
setOpened(false);
}, []);
if (outerOnOpenChange) {
outerOnOpenChange(false);
} else {
setOpened(false);
}
}, [outerOnOpenChange]);
const openUserWorkspaceList = useCallback(() => {
track.$.navigationPanel.workspaceList.open();
setOpened(true);
}, []);
if (outerOnOpenChange) {
outerOnOpenChange(true);
} else {
setOpened(true);
}
}, [outerOnOpenChange]);
const currentWorkspaceId = useLiveData(
globalContextService.globalContext.workspaceId.$
@@ -80,6 +98,7 @@ export const WorkspaceSelector = ({
<Menu
rootOptions={{
open,
onOpenChange,
}}
items={
<UserWithWorkspaceList
@@ -10,7 +10,7 @@ import {
onStart,
smartRetry,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes';
import { map, tap } from 'rxjs';
@@ -48,8 +48,8 @@ export class UserQuota extends Entity {
color$ = this.percent$.map(percent =>
percent !== null
? percent > 80
? cssVar('errorColor')
: cssVar('processingColor')
? cssVarV2('toast/iconState/error')
: cssVarV2('toast/iconState/regular')
: null
);
@@ -21,6 +21,7 @@ export type WorkbenchOpenOptions = {
const sidebarOpenKey = 'workbenchSidebarOpen';
const sidebarWidthKey = 'workbenchSidebarWidth';
const workspaceSelectorOpenKey = 'workspaceSelectorOpen';
export class Workbench extends Entity {
constructor(
@@ -70,6 +71,14 @@ export class Workbench extends Entity {
this.globalState.set(sidebarWidthKey, width);
}
workspaceSelectorOpen$ = LiveData.from(
this.globalState.watch<boolean>(workspaceSelectorOpenKey),
false
);
setWorkspaceSelectorOpen(open: boolean) {
this.globalState.set(workspaceSelectorOpenKey, open);
}
active(index: number | View) {
if (typeof index === 'number') {
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
@@ -114,6 +123,18 @@ export class Workbench extends Entity {
this.setSidebarOpen(!this.sidebarOpen$.value);
}
openWorkspaceSelector() {
this.setWorkspaceSelectorOpen(true);
}
closeWorkspaceSelector() {
this.setWorkspaceSelectorOpen(false);
}
toggleWorkspaceSelector() {
this.setWorkspaceSelectorOpen(!this.workspaceSelectorOpen$.value);
}
open(to: To, option: WorkbenchOpenOptions = {}) {
if (option.at === 'new-tab') {
this.newTab(to, {
@@ -61,4 +61,10 @@ export class WorkspacesService extends Service {
x => x.flavour === meta.flavour
);
}
getAllWorkspaceProfile() {
const list = this.listService.list.workspaces$.value;
const profiles = list.map(meta => this.getProfile(meta));
return profiles;
}
}
@@ -6,7 +6,7 @@
"el-GR": 95,
"en": 100,
"es-AR": 95,
"es-CL": 97,
"es-CL": 96,
"es": 95,
"fa": 95,
"fr": 95,
+20
View File
@@ -6892,6 +6892,26 @@ export function useAFFiNEI18N(): {
* `Admin panel`
*/
["com.affine.workspace.cloud.account.admin"](): string;
/**
* `Team owner`
*/
["com.affine.workspace.cloud.account.team.owner"](): string;
/**
* `Team member`
*/
["com.affine.workspace.cloud.account.team.member"](): string;
/**
* `Multiple teams`
*/
["com.affine.workspace.cloud.account.team.multi"](): string;
/**
* `Click to open workspace`
*/
["com.affine.workspace.cloud.account.team.tips-1"](): string;
/**
* `Click to open workspace list`
*/
["com.affine.workspace.cloud.account.team.tips-2"](): string;
/**
* `Sign up/ Sign in`
*/
@@ -1718,6 +1718,11 @@
"com.affine.workspace.cloud.account.logout": "Sign out",
"com.affine.workspace.cloud.account.settings": "Account settings",
"com.affine.workspace.cloud.account.admin": "Admin panel",
"com.affine.workspace.cloud.account.team.owner": "Team owner",
"com.affine.workspace.cloud.account.team.member": "Team member",
"com.affine.workspace.cloud.account.team.multi": "Multiple teams",
"com.affine.workspace.cloud.account.team.tips-1": "Click to open workspace",
"com.affine.workspace.cloud.account.team.tips-2": "Click to open workspace list",
"com.affine.workspace.cloud.auth": "Sign up/ Sign in",
"com.affine.workspace.cloud.description": "Sync with AFFiNE Cloud",
"com.affine.workspace.cloud.join": "Join workspace",