From 65b910868f35a5256e4156bd704f95b9afce60a9 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Mon, 19 May 2025 10:15:42 +0000 Subject: [PATCH] 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) ## 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. --- .../core/src/commands/affine-navigation.tsx | 9 +- .../src/components/affine/auth/style.css.ts | 20 +- .../affine/auth/user-plan-button.tsx | 1 + .../core/src/components/atoms/index.ts | 2 - .../hooks/use-register-workspace-commands.ts | 38 ++- .../components/root-app-sidebar/index.css.ts | 80 +---- .../src/components/root-app-sidebar/index.tsx | 17 +- .../components/root-app-sidebar/user-info.tsx | 312 ------------------ .../user-info/account-menu.tsx | 63 ++++ .../root-app-sidebar/user-info/account.tsx | 40 +++ .../root-app-sidebar/user-info/ai-usage.tsx | 116 +++++++ .../user-info/cloud-usage.tsx | 75 +++++ .../root-app-sidebar/user-info/index.css.ts | 148 +++++++++ .../root-app-sidebar/user-info/index.tsx | 82 +++++ .../root-app-sidebar/user-info/team-list.tsx | 169 ++++++++++ .../{ => user-info}/unknow-user.tsx | 0 .../components/workspace-selector/index.tsx | 27 +- .../src/modules/cloud/entities/user-quota.ts | 6 +- .../modules/workbench/entities/workbench.ts | 21 ++ .../modules/workspace/services/workspaces.ts | 6 + .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/i18n.gen.ts | 20 ++ packages/frontend/i18n/src/resources/en.json | 5 + 23 files changed, 832 insertions(+), 427 deletions(-) delete mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/account-menu.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/account.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/ai-usage.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/cloud-usage.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/index.css.ts create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/index.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info/team-list.tsx rename packages/frontend/core/src/components/root-app-sidebar/{ => user-info}/unknow-user.tsx (100%) diff --git a/packages/frontend/core/src/commands/affine-navigation.tsx b/packages/frontend/core/src/commands/affine-navigation.tsx index 360c50ceb..b8a1877b8 100644 --- a/packages/frontend/core/src/commands/affine-navigation.tsx +++ b/packages/frontend/core/src/commands/affine-navigation.tsx @@ -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; - store: ReturnType; navigationHelper: ReturnType; 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(); }, }) ); diff --git a/packages/frontend/core/src/components/affine/auth/style.css.ts b/packages/frontend/core/src/components/affine/auth/style.css.ts index 5fcab0b3f..09ccb0a43 100644 --- a/packages/frontend/core/src/components/affine/auth/style.css.ts +++ b/packages/frontend/core/src/components/affine/auth/style.css.ts @@ -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'), }, }, }); diff --git a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx index 34352d91b..4e11dee6e 100644 --- a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx +++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx @@ -49,6 +49,7 @@ export const UserPlanButton = ({
('docs'); - -export const openWorkspaceListModalAtom = atom(false); diff --git a/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts index fc7e183f1..4ffa56f08 100644 --- a/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts @@ -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 diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.css.ts b/packages/frontend/core/src/components/root-app-sidebar/index.css.ts index 8e1ba94d1..e4f17c295 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.css.ts +++ b/packages/frontend/core/src/components/root-app-sidebar/index.css.ts @@ -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, }); diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index c5352366e..bbecdc139 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -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 => {
- +
diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info.tsx deleted file mode 100644 index 1a2f29226..000000000 --- a/packages/frontend/core/src/components/root-app-sidebar/user-info.tsx +++ /dev/null @@ -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 ? ( - - ) : ( - - ); -}; - -const menuContentOptions: MenuProps['contentOptions'] = { - className: styles.operationMenu, -}; -const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => { - return ( - } contentOptions={menuContentOptions}> - - - - - ); -}; - -const UnauthorizedUserInfo = () => { - const globalDialogService = useService(GlobalDialogService); - - const openSignInModal = useCallback(() => { - globalDialogService.open('sign-in', {}); - }, [globalDialogService]); - - return ( - - - - ); -}; - -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 ( - <> - } - data-testid="workspace-modal-account-settings-option" - onClick={onOpenAccountSetting} - > - {t['com.affine.workspace.cloud.account.settings']()} - - {isAFFiNEAdmin ? ( - } - data-testid="workspace-modal-account-admin-option" - onClick={onOpenAdminPanel} - > - {t['com.affine.workspace.cloud.account.admin']()} - - ) : null} - } - data-testid="workspace-modal-sign-out-option" - onClick={openSignOutModal} - > - {t['com.affine.workspace.cloud.account.logout']()} - - - ); -}; - -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 Failed to load quota; - } - return ( -
- - -
- ); - } - - return ( -
-
-
- - {t['com.affine.user-info.usage.cloud']()} - - {usedFormatted} -  /  - {maxFormatted} -
- -
- -
-
-
-
- ); -}; - -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 ( -
-
-
- {t['com.affine.user-info.usage.ai']()} -
-
-
- {t['com.affine.payment.ai.usage-description-purchased']()} -
-
- ); - } - - const percent = Math.min( - 100, - Math.max( - 0.5, - Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4)) - ) - ); - - const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor'); - - return ( -
-
-
- - {t['com.affine.user-info.usage.ai']()} - - {copilotActionUsed} -  /  - {copilotActionLimit} -
- -
Free
-
- -
-
-
-
- ); -}; - -const OperationMenu = () => { - const serverService = useService(ServerService); - const serverFeatures = useLiveData(serverService.server.features$); - - return ( - <> - {serverFeatures?.copilot ? : null} - - - - - ); -}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/account-menu.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/account-menu.tsx new file mode 100644 index 000000000..560c1388c --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/account-menu.tsx @@ -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 ( + <> + } + data-testid="workspace-modal-account-settings-option" + onClick={onOpenAccountSetting} + > + {t['com.affine.workspace.cloud.account.settings']()} + + {isAFFiNEAdmin ? ( + } + data-testid="workspace-modal-account-admin-option" + onClick={onOpenAdminPanel} + > + {t['com.affine.workspace.cloud.account.admin']()} + + ) : null} + } + data-testid="workspace-modal-sign-out-option" + onClick={openSignOutModal} + > + {t['com.affine.workspace.cloud.account.logout']()} + + + ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/account.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/account.tsx new file mode 100644 index 000000000..d09e8ba4f --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/account.tsx @@ -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 ( +
+ + +
+
+ {account.label} +
+
+ {account.email} +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/ai-usage.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/ai-usage.tsx new file mode 100644 index 000000000..363a3e1f5 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/ai-usage.tsx @@ -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 ( +
+
+
+ {t['com.affine.user-info.usage.ai']()} +
+
+
+ {t['com.affine.payment.ai.usage-description-purchased']()} +
+
+ ); + } + + const percent = Math.min( + 100, + Math.max( + 0.5, + Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4)) + ) + ); + + const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor'); + + return ( +
+
+
+ + {t['com.affine.user-info.usage.ai']()} + + {copilotActionUsed} +  /  + {copilotActionLimit} +
+ +
Free
+
+ +
+
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/cloud-usage.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/cloud-usage.tsx new file mode 100644 index 000000000..3fa057870 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/cloud-usage.tsx @@ -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 Failed to load quota; + } + return ( +
+ + +
+ ); + } + + return ( +
+
+
+ + {t['com.affine.user-info.usage.cloud']()} + + {usedFormatted} +  /  + {maxFormatted} +
+ +
+ +
+
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/index.css.ts b/packages/frontend/core/src/components/root-app-sidebar/user-info/index.css.ts new file mode 100644 index 000000000..f28e3f8fb --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/index.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/index.tsx new file mode 100644 index 000000000..9e52ccb23 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/index.tsx @@ -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 ? ( + + ) : ( + + ); +} + +const menuContentOptions: MenuProps['contentOptions'] = { + className: styles.operationMenu, +}; +const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => { + return ( + } contentOptions={menuContentOptions}> + + + + + ); +}; + +const UnauthorizedUserInfo = () => { + const globalDialogService = useService(GlobalDialogService); + + const openSignInModal = useCallback(() => { + globalDialogService.open('sign-in', {}); + }, [globalDialogService]); + + return ( + + + + ); +}; + +const OperationMenu = () => { + const serverService = useService(ServerService); + const serverFeatures = useLiveData(serverService.server.features$); + + return ( + <> + + + + {serverFeatures?.copilot ? : null} + + + + + ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/user-info/team-list.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/team-list.tsx new file mode 100644 index 000000000..e62d358b9 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info/team-list.tsx @@ -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; +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 ( + +
+
+ {workspaces.map((workspace, index) => ( + + ))} +
+ {displayName} + {badgeText} +
+
+ ); +}); + +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 && ( + + )} + {teamWorkspaces.memberWorkspaces.length > 0 && ( + + )} + + + ); +}); + +TeamList.displayName = 'TeamList'; diff --git a/packages/frontend/core/src/components/root-app-sidebar/unknow-user.tsx b/packages/frontend/core/src/components/root-app-sidebar/user-info/unknow-user.tsx similarity index 100% rename from packages/frontend/core/src/components/root-app-sidebar/unknow-user.tsx rename to packages/frontend/core/src/components/root-app-sidebar/user-info/unknow-user.tsx diff --git a/packages/frontend/core/src/components/workspace-selector/index.tsx b/packages/frontend/core/src/components/workspace-selector/index.tsx index 018f05292..442358122 100644 --- a/packages/frontend/core/src/components/workspace-selector/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/index.tsx @@ -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 = ({ percent !== null ? percent > 80 - ? cssVar('errorColor') - : cssVar('processingColor') + ? cssVarV2('toast/iconState/error') + : cssVarV2('toast/iconState/regular') : null ); diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index 60f92b76a..1e28c1c02 100644 --- a/packages/frontend/core/src/modules/workbench/entities/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/entities/workbench.ts @@ -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(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, { diff --git a/packages/frontend/core/src/modules/workspace/services/workspaces.ts b/packages/frontend/core/src/modules/workspace/services/workspaces.ts index b478f66cd..6a05f5d37 100644 --- a/packages/frontend/core/src/modules/workspace/services/workspaces.ts +++ b/packages/frontend/core/src/modules/workspace/services/workspaces.ts @@ -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; + } } diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 387e88f48..2fb2beb2f 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -6,7 +6,7 @@ "el-GR": 95, "en": 100, "es-AR": 95, - "es-CL": 97, + "es-CL": 96, "es": 95, "fa": 95, "fr": 95, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index b84d63d5d..7707beb35 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -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` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f5ca73463..edf0cd700 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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",