From 06fda3b62ce4552e5e6b8e5526c6bc3d9f1ae84d Mon Sep 17 00:00:00 2001 From: EYHN Date: Wed, 17 Apr 2024 14:12:29 +0800 Subject: [PATCH] feat(infra): framework --- .eslintrc.js | 1 - .github/labeler.yml | 5 - docs/contributing/tutorial.md | 7 - .../src/core/workspaces/resolvers/page.ts | 20 +- packages/backend/server/src/schema.gql | 5 +- packages/common/env/src/global.ts | 1 - .../common/infra/src/di/__tests__/di.spec.ts | 357 ------------ .../common/infra/src/di/core/collection.ts | 481 ---------------- packages/common/infra/src/di/core/consts.ts | 4 - packages/common/infra/src/di/core/error.ts | 59 -- packages/common/infra/src/di/core/index.ts | 7 - packages/common/infra/src/di/core/provider.ts | 216 ------- packages/common/infra/src/di/core/scope.ts | 13 - packages/common/infra/src/di/core/types.ts | 38 -- packages/common/infra/src/di/react/index.ts | 30 - .../src/framework/__tests__/framework.spec.ts | 539 ++++++++++++++++++ .../framework/core/components/component.ts | 27 + .../src/framework/core/components/entity.ts | 6 + .../src/framework/core/components/scope.ts | 43 ++ .../src/framework/core/components/service.ts | 6 + .../src/framework/core/components/store.ts | 6 + .../src/framework/core/constructor-context.ts | 23 + .../common/infra/src/framework/core/consts.ts | 6 + .../common/infra/src/framework/core/error.ts | 59 ++ .../common/infra/src/framework/core/event.ts | 111 ++++ .../infra/src/framework/core/framework.ts | 527 +++++++++++++++++ .../src/{di => framework}/core/identifier.ts | 41 +- .../common/infra/src/framework/core/index.ts | 10 + .../infra/src/framework/core/provider.ts | 321 +++++++++++ .../common/infra/src/framework/core/scope.ts | 5 + .../common/infra/src/framework/core/types.ts | 36 ++ .../infra/src/{di => framework}/index.ts | 0 .../infra/src/framework/react/index.tsx | 126 ++++ packages/common/infra/src/index.ts | 46 +- .../src/lifecycle/__test__/lifecycle.spec.ts | 15 - packages/common/infra/src/lifecycle/index.ts | 10 - .../src/livedata/__tests__/livedata.spec.ts | 7 + .../common/infra/src/livedata/effect/index.ts | 81 ++- packages/common/infra/src/livedata/index.ts | 10 +- .../common/infra/src/livedata/livedata.ts | 26 + packages/common/infra/src/livedata/ops.ts | 126 +++- .../infra/src/modules/doc/entities/doc.ts | 28 + .../src/modules/doc/entities/record-list.ts | 40 ++ .../infra/src/modules/doc/entities/record.ts | 45 ++ .../common/infra/src/modules/doc/index.ts | 33 ++ .../infra/src/modules/doc/scopes/doc.ts | 10 + .../infra/src/modules/doc/services/doc.ts | 6 + .../infra/src/modules/doc/services/docs.ts | 49 ++ .../infra/src/modules/doc/stores/docs.ts | 85 +++ .../global-context/entities/global-context.ts | 24 + .../infra/src/modules/global-context/index.ts | 9 + .../global-context/services/global-context.ts | 6 + .../infra/src/modules/lifecycle/index.ts | 12 + .../modules/lifecycle/service/lifecycle.ts | 26 + .../common/infra/src/modules/storage/index.ts | 17 + .../src/modules/storage/providers/global.ts | 20 + .../src/modules/storage/services/global.ts | 14 + .../workspace/__tests__/workspace.spec.ts | 32 ++ .../src/modules/workspace/entities/engine.ts | 72 +++ .../src/modules/workspace/entities/list.ts | 27 + .../src/modules/workspace/entities/profile.ts | 89 +++ .../src/modules/workspace/entities/upgrade.ts | 135 +++++ .../modules/workspace/entities/workspace.ts | 101 ++++ .../{ => modules}/workspace/global-schema.ts | 0 .../src/modules/workspace/impls/storage.ts | 75 +++ .../infra/src/modules/workspace/index.ts | 96 ++++ .../src/{ => modules}/workspace/metadata.ts | 0 .../src/modules/workspace/open-options.ts | 6 + .../modules/workspace/providers/flavour.ts | 58 ++ .../modules/workspace/providers/storage.ts | 13 + .../src/modules/workspace/scopes/workspace.ts | 10 + .../src/modules/workspace/services/destroy.ts | 17 + .../src/modules/workspace/services/engine.ts | 22 + .../src/modules/workspace/services/factory.ts | 33 ++ .../src/modules/workspace/services/list.ts | 6 + .../src/modules/workspace/services/profile.ts | 21 + .../src/modules/workspace/services/repo.ts | 114 ++++ .../modules/workspace/services/transform.ts | 57 ++ .../src/modules/workspace/services/upgrade.ts | 6 + .../modules/workspace/services/workspace.ts | 13 + .../modules/workspace/services/workspaces.ts | 53 ++ .../modules/workspace/stores/profile-cache.ts | 35 ++ .../workspace/testing/testing-provider.ts | 134 +++++ packages/common/infra/src/page/context.ts | 24 - packages/common/infra/src/page/index.ts | 27 - packages/common/infra/src/page/manager.ts | 50 -- packages/common/infra/src/page/page.ts | 28 - packages/common/infra/src/page/record-list.ts | 55 -- packages/common/infra/src/page/record.ts | 64 --- .../common/infra/src/page/service-scope.ts | 5 - .../src/storage/__tests__/memento.spec.ts | 17 +- packages/common/infra/src/storage/memento.ts | 19 - packages/common/infra/src/sync/awareness.ts | 16 + .../{workspace/engine => sync/blob}/blob.ts | 62 +- .../{workspace/engine => sync/blob}/error.ts | 0 .../{workspace/engine => sync}/doc/README.md | 0 .../doc/__tests__/priority-queue.spec.ts | 0 .../doc/__tests__/sync.spec.ts | 2 +- .../doc/async-priority-queue.ts | 0 .../{workspace/engine => sync}/doc/clock.ts | 0 .../{workspace/engine => sync}/doc/event.ts | 0 .../{workspace/engine => sync}/doc/index.ts | 9 +- .../{workspace/engine => sync}/doc/local.ts | 4 +- .../engine => sync}/doc/priority-queue.ts | 0 .../{workspace/engine => sync}/doc/remote.ts | 4 +- .../{workspace/engine => sync}/doc/server.ts | 0 .../{workspace/engine => sync}/doc/storage.ts | 6 +- .../{workspace/engine => sync}/doc/utils.ts | 0 packages/common/infra/src/sync/index.ts | 6 + .../src/workspace/__tests__/workspace.spec.ts | 38 -- .../common/infra/src/workspace/context.ts | 77 --- .../infra/src/workspace/engine/awareness.ts | 21 - .../infra/src/workspace/engine/index.ts | 87 --- .../common/infra/src/workspace/factory.ts | 16 - packages/common/infra/src/workspace/index.ts | 102 ---- .../common/infra/src/workspace/list/cache.ts | 25 - .../common/infra/src/workspace/list/index.ts | 302 ---------- .../infra/src/workspace/list/information.ts | 92 --- .../common/infra/src/workspace/manager.ts | 200 ------- .../infra/src/workspace/service-scope.ts | 3 - .../common/infra/src/workspace/storage.ts | 8 - .../common/infra/src/workspace/testing.ts | 209 ------- .../common/infra/src/workspace/upgrade.ts | 143 ----- .../common/infra/src/workspace/workspace.ts | 133 ----- .../auth-components/onboarding-page.tsx | 5 +- .../auth-components/sign-up-page.tsx | 3 +- .../src/components/auth-components/type.ts | 6 +- .../not-found-page/not-found-page.tsx | 4 +- .../components/resize-panel/resize-panel.tsx | 2 +- .../src/components/workspace-list/index.tsx | 4 +- packages/frontend/component/src/index.ts | 1 + .../src/ui/error-message/error-message.tsx | 28 + .../component/src/ui/error-message/index.ts | 1 + .../src/ui/error-message/style.css.ts | 8 + packages/frontend/core/package.json | 4 +- .../core/src/bootstrap/first-app-data.ts | 38 +- .../error-basic/fallback-creator.tsx | 2 +- .../error-basic/info-logger.tsx | 17 +- .../affine/ai-onboarding/edgeless.dialog.tsx | 21 +- .../affine/ai-onboarding/general.dialog.tsx | 16 +- .../affine/auth/after-sign-in-send-email.tsx | 90 ++- .../affine/auth/after-sign-up-send-email.tsx | 58 +- .../core/src/components/affine/auth/index.tsx | 5 +- .../src/components/affine/auth/no-access.tsx | 54 -- .../core/src/components/affine/auth/oauth.tsx | 51 +- .../src/components/affine/auth/send-email.tsx | 60 +- .../affine/auth/sign-in-with-password.tsx | 67 +-- .../src/components/affine/auth/sign-in.tsx | 157 ++--- .../affine/auth/subscription-redirect.tsx | 159 ------ .../src/components/affine/auth/use-auth.ts | 145 ----- .../components/affine/auth/use-captcha.tsx | 3 +- .../affine/auth/use-subscription.ts | 53 -- .../affine/auth/user-plan-button.tsx | 54 +- .../src/components/affine/awareness/index.tsx | 22 +- .../affine/create-workspace-modal/index.tsx | 28 +- .../affine/history-tips-modal/index.tsx | 4 +- .../affine/page-history-modal/data.ts | 17 +- .../page-history-modal/history-modal.tsx | 58 +- .../confirm-delete-property-modal.tsx | 4 +- .../affine/page-properties/icons-mapping.tsx | 2 +- .../affine/page-properties/menu-items.tsx | 2 +- .../page-properties-manager.ts | 6 +- .../property-row-value-renderer.tsx | 17 +- .../affine/page-properties/table.tsx | 2 +- .../page-properties/tags-inline-editor.tsx | 30 +- .../quota-reached-modal/cloud-quota-modal.tsx | 93 +-- .../quota-reached-modal/local-quota-modal.tsx | 4 +- .../account-setting/ai-usage-panel.tsx | 171 +++--- .../setting-modal/account-setting/index.tsx | 111 ++-- .../account-setting/storage-progress.tsx | 78 ++- .../general-setting/billing/index.tsx | 386 +++++++------ .../setting-modal/general-setting/index.tsx | 11 +- .../general-setting/plans/actions.tsx | 70 +-- .../plans/ai/actions/cancel.tsx | 42 +- .../plans/ai/actions/login.tsx | 4 +- .../plans/ai/actions/resume.tsx | 58 +- .../plans/ai/actions/subscribe.tsx | 107 ++-- .../general-setting/plans/ai/ai-plan.tsx | 85 +-- .../general-setting/plans/ai/types.ts | 8 - .../plans/ai/use-affine-ai-price.ts | 14 - .../plans/ai/use-affine-ai-subscription.ts | 48 -- .../general-setting/plans/index.tsx | 89 +-- .../general-setting/plans/plan-card.tsx | 215 +++---- .../components/affine/setting-modal/index.tsx | 10 +- .../setting-modal/setting-sidebar/index.tsx | 60 +- .../setting-modal/workspace-setting/index.tsx | 10 +- .../delete-leave-workspace/index.tsx | 61 +- .../enable-cloud.tsx | 25 +- .../new-workspace-setting-detail/export.tsx | 2 +- .../new-workspace-setting-detail/index.tsx | 26 +- .../new-workspace-setting-detail/labels.tsx | 22 +- .../new-workspace-setting-detail/members.tsx | 106 ++-- .../new-workspace-setting-detail/profile.tsx | 17 +- .../new-workspace-setting-detail/types.ts | 1 - .../workspace-setting/properties/index.tsx | 34 +- .../share-menu/share-export.tsx | 6 +- .../share-menu/share-menu.tsx | 10 +- .../share-menu/share-page.tsx | 206 +++++-- .../share-menu/use-share-url.ts | 4 +- .../core/src/components/app-sidebar/index.tsx | 4 +- .../blocksuite-editor-container.tsx | 4 +- .../block-suite-header/favorite/index.tsx | 6 +- .../block-suite-header/menu/index.tsx | 36 +- .../block-suite-mode-switch/index.tsx | 30 +- .../block-suite-page-list/utils.tsx | 12 +- .../authenticated-item.tsx | 14 +- .../cloud/share-header-right-item/index.tsx | 8 +- .../share-header-right-item/user-avatar.tsx | 49 +- .../src/components/page-detail-editor.tsx | 8 +- .../virtualized-collection-list.tsx | 4 +- .../page-list/docs/page-list-header.tsx | 16 +- .../page-list/docs/page-list-item.tsx | 4 +- .../page-list/docs/virtualized-page-list.tsx | 6 +- .../page-list/group-definitions.tsx | 6 +- .../components/page-list/operation-cell.tsx | 10 +- .../src/components/page-list/page-group.tsx | 10 +- .../components/page-list/tags/create-tag.tsx | 10 +- .../page-list/tags/virtualized-tag-list.tsx | 4 +- .../use-all-doc-display-properties.ts | 4 +- .../page-list/use-filtered-page-metas.tsx | 31 +- .../page-list/view/collection-operations.tsx | 6 +- .../view/edit-collection/pages-mode.tsx | 2 +- .../view/edit-collection/rules-mode.tsx | 2 +- .../view/edit-collection/select-page.tsx | 2 +- .../src/components/pure/cmdk/data-hooks.tsx | 100 ++-- .../core/src/components/pure/cmdk/types.ts | 4 +- .../core/src/components/pure/footer/index.tsx | 98 ---- .../core/src/components/pure/footer/styles.ts | 148 ----- .../src/components/pure/help-island/index.tsx | 18 +- .../pure/trash-page-footer/index.tsx | 27 +- .../pure/workspace-mode-filter-tab/index.tsx | 4 +- .../collections/collections-list.tsx | 17 +- .../collections/{page.tsx => doc.tsx} | 54 +- .../collections/index.tsx | 2 +- .../components/operation-menu-button.tsx | 6 +- .../components/reference-page.tsx | 8 +- .../favorite/add-favourite-button.tsx | 2 +- .../favorite/favorite-list.tsx | 4 +- .../favorite/favourite-nav-item.tsx | 14 +- .../add-workspace/index.tsx | 4 +- .../user-with-workspace-list/index.tsx | 29 +- .../workspace-list/index.tsx | 60 +- .../workspace-card/index.tsx | 25 +- .../src/components/root-app-sidebar/index.tsx | 8 +- .../root-app-sidebar/journal-button.tsx | 4 +- .../components/root-app-sidebar/user-info.tsx | 76 +-- .../frontend/core/src/components/top-tip.tsx | 6 +- .../components/workspace-upgrade/upgrade.tsx | 29 +- .../core/src/hooks/__tests__/gql.spec.tsx | 146 ----- .../use-block-suite-workspace-helper.spec.tsx | 24 +- ...-block-suite-workspace-page-title.spec.tsx | 78 ++- .../hooks/affine/use-all-page-list-config.tsx | 33 +- .../affine/use-block-suite-meta-helper.ts | 10 +- .../hooks/affine/use-cloud-storage-usage.ts | 56 -- .../hooks/affine/use-current-login-status.ts | 6 - .../core/src/hooks/affine/use-current-user.ts | 178 ------ .../affine/use-delete-collection-info.ts | 10 +- .../hooks/affine/use-doc-engine-status.tsx | 4 +- .../src/hooks/affine/use-enable-cloud.tsx | 16 +- .../src/hooks/affine/use-global-dnd-helper.ts | 6 +- .../src/hooks/affine/use-is-shared-page.tsx | 16 +- .../hooks/affine/use-is-workspace-owner.ts | 24 - ...se-register-blocksuite-editor-commands.tsx | 40 +- .../src/hooks/affine/use-server-config.ts | 89 --- .../src/hooks/affine/use-user-features.ts | 24 - .../core/src/hooks/use-affine-adapter.ts | 13 +- .../core/src/hooks/use-navigate-helper.ts | 46 +- packages/frontend/core/src/hooks/use-quota.ts | 22 - .../hooks/use-register-workspace-commands.ts | 4 +- .../core/src/hooks/use-subscription.ts | 49 -- .../core/src/hooks/use-workspace-blob.ts | 8 +- .../core/src/hooks/use-workspace-info.ts | 25 +- .../core/src/hooks/use-workspace-quota.ts | 35 -- .../core/src/hooks/use-workspace-status.ts | 36 -- .../frontend/core/src/hooks/use-workspace.ts | 8 +- packages/frontend/core/src/index.tsx | 1 - .../core/src/layouts/workspace-layout.tsx | 31 +- .../modules/cloud/entities/server-config.ts | 70 +++ .../src/modules/cloud/entities/session.ts | 134 +++++ .../cloud/entities/subscription-prices.ts | 69 +++ .../modules/cloud/entities/subscription.ts | 176 ++++++ .../modules/cloud/entities/user-feature.ts | 94 +++ .../src/modules/cloud/entities/user-quota.ts | 131 +++++ .../frontend/core/src/modules/cloud/error.ts | 21 + .../frontend/core/src/modules/cloud/index.ts | 64 +++ .../core/src/modules/cloud/services/auth.ts | 161 ++++++ .../core/src/modules/cloud/services/fetch.ts | 84 +++ .../src/modules/cloud/services/graphql.ts | 53 ++ .../modules/cloud/services/server-config.ts | 12 + .../modules/cloud/services/subscription.ts | 25 + .../modules/cloud/services/user-feature.ts | 13 + .../src/modules/cloud/services/user-quota.ts | 13 + .../src/modules/cloud/services/websocket.ts | 37 ++ .../core/src/modules/cloud/stores/auth.ts | 97 ++++ .../src/modules/cloud/stores/server-config.ts | 39 ++ .../src/modules/cloud/stores/subscription.ts | 130 +++++ .../src/modules/cloud/stores/user-feature.ts | 23 + .../src/modules/cloud/stores/user-quota.ts | 30 + .../core/src/modules/collection/index.ts | 16 +- .../{service.ts => services/collection.ts} | 18 +- packages/frontend/core/src/modules/index.ts | 33 ++ .../modules/infra-web/global-scope/index.tsx | 27 - .../src/modules/multi-tab-sidebar/index.ts | 4 +- .../{entities => multi-tabs}/sidebar-tab.ts | 0 .../{entities => multi-tabs}/sidebar-tabs.ts | 0 .../{entities => multi-tabs}/tabs/chat.css.ts | 0 .../{entities => multi-tabs}/tabs/chat.tsx | 0 .../tabs/frame.css.ts | 0 .../{entities => multi-tabs}/tabs/frame.tsx | 0 .../tabs/journal.css.ts | 0 .../{entities => multi-tabs}/tabs/journal.tsx | 92 ++- .../tabs/outline.css.ts | 0 .../{entities => multi-tabs}/tabs/outline.tsx | 0 .../modules/multi-tab-sidebar/view/body.tsx | 2 +- .../view/header-switcher.tsx | 8 +- .../core/src/modules/navigation/README.md | 3 + .../modules/navigation/entities/navigator.ts | 12 +- .../core/src/modules/navigation/index.ts | 13 + .../modules/navigation/services/navigator.ts | 7 + .../navigation/view/navigation-buttons.tsx | 4 +- .../view/use-register-navigation-commands.ts | 4 +- .../permissions/entities/permission.ts | 65 +++ .../core/src/modules/permissions/index.ts | 20 + .../permissions/services/permission.ts | 7 + .../modules/permissions/stores/permission.ts | 21 + .../core/src/modules/properties/index.ts | 25 + .../services}/adapter.ts | 27 +- .../services}/legacy-properties.ts | 27 +- .../services}/schema.ts | 0 .../core/src/modules/quota/entities/quota.ts | 61 ++ .../frontend/core/src/modules/quota/index.ts | 20 + .../core/src/modules/quota/services/quota.ts | 7 + .../core/src/modules/quota/stores/quota.ts | 22 + .../entities/right-sidebar-view.ts | 4 +- .../right-sidebar/entities/right-sidebar.ts | 15 +- .../core/src/modules/right-sidebar/index.ts | 19 + .../right-sidebar/services/right-sidebar.ts | 7 + .../modules/right-sidebar/view/container.tsx | 6 +- .../right-sidebar/view/view-island.tsx | 22 +- .../frontend/core/src/modules/services.ts | 44 -- .../share-doc/entities/share-docs-list.ts | 68 +++ .../modules/share-doc/entities/share-info.ts | 92 +++ .../core/src/modules/share-doc/index.ts | 35 ++ .../modules/share-doc/services/share-docs.ts | 7 + .../src/modules/share-doc/services/share.ts | 7 + .../modules/share-doc/stores/share-docs.ts | 22 + .../src/modules/share-doc/stores/share.ts | 69 +++ .../index.ts => storage/impls/storage.ts} | 0 .../core/src/modules/storage/index.ts | 11 + .../core/src/modules/tag/entities/tag-list.ts | 79 +++ .../core/src/modules/tag/entities/tag.ts | 42 +- .../frontend/core/src/modules/tag/index.ts | 21 + .../core/src/modules/tag/service/tag.ts | 89 +-- .../core/src/modules/tag/stores/tag.ts | 59 ++ .../src/modules/tag/view/delete-tag-modal.tsx | 4 +- .../core/src/modules/telemetry/index.ts | 8 + .../modules/telemetry/services/telemetry.ts | 32 ++ .../src/modules/workbench/entities/view.ts | 17 +- .../modules/workbench/entities/workbench.ts | 18 +- .../core/src/modules/workbench/index.ts | 21 +- .../core/src/modules/workbench/scopes/view.ts | 7 + .../src/modules/workbench/services/view.ts | 7 + .../modules/workbench/services/workbench.ts | 7 + .../workbench/view/route-container.tsx | 14 +- .../workbench/view/split-view/panel.tsx | 6 +- .../workbench/view/split-view/split-view.tsx | 4 +- .../workbench/view/use-is-active-view.tsx | 11 +- .../workbench/view/use-view-position.tsx | 8 +- .../src/modules/workbench/view/use-view.tsx | 15 - .../workbench/view/view-body-island.tsx | 6 +- .../workbench/view/view-header-island.tsx | 6 +- .../src/modules/workbench/view/view-root.tsx | 7 +- .../modules/workbench/view/workbench-link.tsx | 4 +- .../modules/workbench/view/workbench-root.tsx | 6 +- .../modules/workspace-engine/impls/cloud.ts | 276 +++++++++ .../engine/awareness-broadcast-channel.ts} | 7 +- .../impls/engine/awareness-cloud.ts} | 13 +- .../impls/engine/blob-cloud.ts} | 9 +- .../impls/engine}/blob-indexeddb.ts | 2 +- .../impls/engine}/blob-sqlite.ts | 4 +- .../impls/engine}/blob-static.ts | 0 .../impls/engine}/doc-broadcast-channel.ts | 0 .../impls/engine/doc-cloud-static.ts} | 10 +- .../impls/engine/doc-cloud.ts} | 15 +- .../impls/engine}/doc-indexeddb.ts | 0 .../impls/engine}/doc-sqlite.ts | 0 .../modules/workspace-engine/impls/local.ts | 180 ++++++ .../src/modules/workspace-engine/index.ts | 79 +++ .../workspace-engine/providers/engine.ts | 15 + .../utils/__tests__/buffer-to-blob.spec.ts | 0 .../modules/workspace-engine}/utils/base64.ts | 0 .../workspace-engine}/utils/buffer-to-blob.ts | 0 .../modules/workspace/current-workspace.ts | 24 - .../core/src/modules/workspace/index.ts | 2 - .../src/modules/workspace/properties/index.ts | 2 - packages/frontend/core/src/pages/404.tsx | 18 +- packages/frontend/core/src/pages/auth.tsx | 43 +- .../core/src/pages/desktop-signin.tsx | 19 +- packages/frontend/core/src/pages/index.tsx | 48 +- packages/frontend/core/src/pages/invite.tsx | 15 +- .../src/pages/share/share-detail-page.tsx | 202 +++---- .../core/src/pages/share/share-header.tsx | 4 +- packages/frontend/core/src/pages/sign-in.tsx | 35 +- .../pages/workspace/all-collection/index.tsx | 4 +- .../workspace/all-page/all-page-filter.tsx | 4 +- .../workspace/all-page/all-page-header.tsx | 5 +- .../src/pages/workspace/all-page/all-page.tsx | 8 +- .../src/pages/workspace/all-tag/index.tsx | 6 +- .../src/pages/workspace/collection/index.tsx | 6 +- .../workspace/detail-page/detail-page.tsx | 160 +++--- .../core/src/pages/workspace/index.tsx | 122 ++-- .../core/src/pages/workspace/tag/index.tsx | 8 +- .../core/src/pages/workspace/trash-page.tsx | 6 +- .../core/src/providers/modal-provider.tsx | 37 +- .../core/src/providers/session-provider.tsx | 54 -- packages/frontend/core/src/router.tsx | 15 +- packages/frontend/core/src/testing.ts | 58 +- .../frontend/core/src/utils/cloud-utils.tsx | 144 ----- packages/frontend/core/src/utils/popup.ts | 1 + packages/frontend/core/src/web.ts | 15 - packages/frontend/core/tsconfig.json | 3 - packages/frontend/electron/renderer/app.tsx | 66 ++- .../src/main/security-restrictions.ts | 2 +- .../graphql/src/__tests__/fetcher.spec.ts | 51 +- packages/frontend/graphql/src/fetcher.ts | 68 +-- .../graphql/src/graphql/blob-check-size.gql | 5 - .../graphql/src/graphql/blob-size.gql | 5 - .../graphql/src/graphql/blobs-size.gql | 5 - .../graphql/src/graphql/early-access-add.gql | 3 - .../graphql/get-members-by-workspace-id.gql | 1 + .../graphql/src/graphql/get-user-features.gql | 1 + .../get-workspace-public-page-by-id.gql | 8 + .../graphql/src/graphql/get-workspaces.gql | 3 + .../frontend/graphql/src/graphql/index.ts | 84 ++- .../frontend/graphql/src/graphql/quota.gql | 10 + .../graphql/src/graphql/subscription.gql | 1 + packages/frontend/graphql/src/index.ts | 5 +- packages/frontend/graphql/src/schema.ts | 107 ++-- packages/frontend/graphql/src/utils.ts | 209 ------- packages/frontend/i18n/src/resources/en.json | 2 +- packages/frontend/web/src/app.tsx | 61 +- packages/frontend/workspace-impl/.gitignore | 1 - packages/frontend/workspace-impl/package.json | 34 -- .../workspace-impl/src/cloud/consts.ts | 2 - .../workspace-impl/src/cloud/index.ts | 4 - .../frontend/workspace-impl/src/cloud/list.ts | 192 ------- .../src/cloud/workspace-factory.ts | 51 -- packages/frontend/workspace-impl/src/index.ts | 45 -- .../workspace-impl/src/local-state.ts | 38 -- .../workspace-impl/src/local/consts.ts | 3 - .../workspace-impl/src/local/index.ts | 3 - .../frontend/workspace-impl/src/local/list.ts | 158 ----- .../src/local/workspace-factory.ts | 50 -- .../workspace-impl/src/utils/affine-io.ts | 26 - .../frontend/workspace-impl/tsconfig.json | 16 - .../e2e/local-first-delete-workspace.spec.ts | 4 +- .../e2e/local-first-workspace-list.spec.ts | 1 - tests/storybook/.storybook/preview.tsx | 86 +-- tests/storybook/package.json | 1 - tests/storybook/src/stories/core.stories.tsx | 7 +- .../stories/image-preview-modal.stories.tsx | 42 +- .../src/stories/share-menu.stories.tsx | 10 +- .../src/stories/workspace-list.stories.tsx | 8 +- tools/cli/src/webpack/runtime-config.ts | 9 +- tsconfig.json | 6 +- vitest.config.ts | 7 +- yarn.lock | 32 +- 467 files changed, 9996 insertions(+), 8697 deletions(-) delete mode 100644 packages/common/infra/src/di/__tests__/di.spec.ts delete mode 100644 packages/common/infra/src/di/core/collection.ts delete mode 100644 packages/common/infra/src/di/core/consts.ts delete mode 100644 packages/common/infra/src/di/core/error.ts delete mode 100644 packages/common/infra/src/di/core/index.ts delete mode 100644 packages/common/infra/src/di/core/provider.ts delete mode 100644 packages/common/infra/src/di/core/scope.ts delete mode 100644 packages/common/infra/src/di/core/types.ts delete mode 100644 packages/common/infra/src/di/react/index.ts create mode 100644 packages/common/infra/src/framework/__tests__/framework.spec.ts create mode 100644 packages/common/infra/src/framework/core/components/component.ts create mode 100644 packages/common/infra/src/framework/core/components/entity.ts create mode 100644 packages/common/infra/src/framework/core/components/scope.ts create mode 100644 packages/common/infra/src/framework/core/components/service.ts create mode 100644 packages/common/infra/src/framework/core/components/store.ts create mode 100644 packages/common/infra/src/framework/core/constructor-context.ts create mode 100644 packages/common/infra/src/framework/core/consts.ts create mode 100644 packages/common/infra/src/framework/core/error.ts create mode 100644 packages/common/infra/src/framework/core/event.ts create mode 100644 packages/common/infra/src/framework/core/framework.ts rename packages/common/infra/src/{di => framework}/core/identifier.ts (70%) create mode 100644 packages/common/infra/src/framework/core/index.ts create mode 100644 packages/common/infra/src/framework/core/provider.ts create mode 100644 packages/common/infra/src/framework/core/scope.ts create mode 100644 packages/common/infra/src/framework/core/types.ts rename packages/common/infra/src/{di => framework}/index.ts (100%) create mode 100644 packages/common/infra/src/framework/react/index.tsx delete mode 100644 packages/common/infra/src/lifecycle/__test__/lifecycle.spec.ts delete mode 100644 packages/common/infra/src/lifecycle/index.ts create mode 100644 packages/common/infra/src/modules/doc/entities/doc.ts create mode 100644 packages/common/infra/src/modules/doc/entities/record-list.ts create mode 100644 packages/common/infra/src/modules/doc/entities/record.ts create mode 100644 packages/common/infra/src/modules/doc/index.ts create mode 100644 packages/common/infra/src/modules/doc/scopes/doc.ts create mode 100644 packages/common/infra/src/modules/doc/services/doc.ts create mode 100644 packages/common/infra/src/modules/doc/services/docs.ts create mode 100644 packages/common/infra/src/modules/doc/stores/docs.ts create mode 100644 packages/common/infra/src/modules/global-context/entities/global-context.ts create mode 100644 packages/common/infra/src/modules/global-context/index.ts create mode 100644 packages/common/infra/src/modules/global-context/services/global-context.ts create mode 100644 packages/common/infra/src/modules/lifecycle/index.ts create mode 100644 packages/common/infra/src/modules/lifecycle/service/lifecycle.ts create mode 100644 packages/common/infra/src/modules/storage/index.ts create mode 100644 packages/common/infra/src/modules/storage/providers/global.ts create mode 100644 packages/common/infra/src/modules/storage/services/global.ts create mode 100644 packages/common/infra/src/modules/workspace/__tests__/workspace.spec.ts create mode 100644 packages/common/infra/src/modules/workspace/entities/engine.ts create mode 100644 packages/common/infra/src/modules/workspace/entities/list.ts create mode 100644 packages/common/infra/src/modules/workspace/entities/profile.ts create mode 100644 packages/common/infra/src/modules/workspace/entities/upgrade.ts create mode 100644 packages/common/infra/src/modules/workspace/entities/workspace.ts rename packages/common/infra/src/{ => modules}/workspace/global-schema.ts (100%) create mode 100644 packages/common/infra/src/modules/workspace/impls/storage.ts create mode 100644 packages/common/infra/src/modules/workspace/index.ts rename packages/common/infra/src/{ => modules}/workspace/metadata.ts (100%) create mode 100644 packages/common/infra/src/modules/workspace/open-options.ts create mode 100644 packages/common/infra/src/modules/workspace/providers/flavour.ts create mode 100644 packages/common/infra/src/modules/workspace/providers/storage.ts create mode 100644 packages/common/infra/src/modules/workspace/scopes/workspace.ts create mode 100644 packages/common/infra/src/modules/workspace/services/destroy.ts create mode 100644 packages/common/infra/src/modules/workspace/services/engine.ts create mode 100644 packages/common/infra/src/modules/workspace/services/factory.ts create mode 100644 packages/common/infra/src/modules/workspace/services/list.ts create mode 100644 packages/common/infra/src/modules/workspace/services/profile.ts create mode 100644 packages/common/infra/src/modules/workspace/services/repo.ts create mode 100644 packages/common/infra/src/modules/workspace/services/transform.ts create mode 100644 packages/common/infra/src/modules/workspace/services/upgrade.ts create mode 100644 packages/common/infra/src/modules/workspace/services/workspace.ts create mode 100644 packages/common/infra/src/modules/workspace/services/workspaces.ts create mode 100644 packages/common/infra/src/modules/workspace/stores/profile-cache.ts create mode 100644 packages/common/infra/src/modules/workspace/testing/testing-provider.ts delete mode 100644 packages/common/infra/src/page/context.ts delete mode 100644 packages/common/infra/src/page/index.ts delete mode 100644 packages/common/infra/src/page/manager.ts delete mode 100644 packages/common/infra/src/page/page.ts delete mode 100644 packages/common/infra/src/page/record-list.ts delete mode 100644 packages/common/infra/src/page/record.ts delete mode 100644 packages/common/infra/src/page/service-scope.ts create mode 100644 packages/common/infra/src/sync/awareness.ts rename packages/common/infra/src/{workspace/engine => sync/blob}/blob.ts (82%) rename packages/common/infra/src/{workspace/engine => sync/blob}/error.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/README.md (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/__tests__/priority-queue.spec.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/__tests__/sync.spec.ts (99%) rename packages/common/infra/src/{workspace/engine => sync}/doc/async-priority-queue.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/clock.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/event.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/index.ts (94%) rename packages/common/infra/src/{workspace/engine => sync}/doc/local.ts (98%) rename packages/common/infra/src/{workspace/engine => sync}/doc/priority-queue.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/remote.ts (99%) rename packages/common/infra/src/{workspace/engine => sync}/doc/server.ts (100%) rename packages/common/infra/src/{workspace/engine => sync}/doc/storage.ts (98%) rename packages/common/infra/src/{workspace/engine => sync}/doc/utils.ts (100%) create mode 100644 packages/common/infra/src/sync/index.ts delete mode 100644 packages/common/infra/src/workspace/__tests__/workspace.spec.ts delete mode 100644 packages/common/infra/src/workspace/context.ts delete mode 100644 packages/common/infra/src/workspace/engine/awareness.ts delete mode 100644 packages/common/infra/src/workspace/engine/index.ts delete mode 100644 packages/common/infra/src/workspace/factory.ts delete mode 100644 packages/common/infra/src/workspace/index.ts delete mode 100644 packages/common/infra/src/workspace/list/cache.ts delete mode 100644 packages/common/infra/src/workspace/list/index.ts delete mode 100644 packages/common/infra/src/workspace/list/information.ts delete mode 100644 packages/common/infra/src/workspace/manager.ts delete mode 100644 packages/common/infra/src/workspace/service-scope.ts delete mode 100644 packages/common/infra/src/workspace/storage.ts delete mode 100644 packages/common/infra/src/workspace/testing.ts delete mode 100644 packages/common/infra/src/workspace/upgrade.ts delete mode 100644 packages/common/infra/src/workspace/workspace.ts create mode 100644 packages/frontend/component/src/ui/error-message/error-message.tsx create mode 100644 packages/frontend/component/src/ui/error-message/index.ts create mode 100644 packages/frontend/component/src/ui/error-message/style.css.ts delete mode 100644 packages/frontend/core/src/components/affine/auth/no-access.tsx delete mode 100644 packages/frontend/core/src/components/affine/auth/subscription-redirect.tsx delete mode 100644 packages/frontend/core/src/components/affine/auth/use-auth.ts delete mode 100644 packages/frontend/core/src/components/affine/auth/use-subscription.ts delete mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts delete mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts delete mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts delete mode 100644 packages/frontend/core/src/components/pure/footer/index.tsx delete mode 100644 packages/frontend/core/src/components/pure/footer/styles.ts rename packages/frontend/core/src/components/pure/workspace-slider-bar/collections/{page.tsx => doc.tsx} (71%) delete mode 100644 packages/frontend/core/src/hooks/__tests__/gql.spec.tsx delete mode 100644 packages/frontend/core/src/hooks/affine/use-cloud-storage-usage.ts delete mode 100644 packages/frontend/core/src/hooks/affine/use-current-login-status.ts delete mode 100644 packages/frontend/core/src/hooks/affine/use-current-user.ts delete mode 100644 packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts delete mode 100644 packages/frontend/core/src/hooks/affine/use-server-config.ts delete mode 100644 packages/frontend/core/src/hooks/affine/use-user-features.ts delete mode 100644 packages/frontend/core/src/hooks/use-quota.ts delete mode 100644 packages/frontend/core/src/hooks/use-subscription.ts delete mode 100644 packages/frontend/core/src/hooks/use-workspace-quota.ts delete mode 100644 packages/frontend/core/src/hooks/use-workspace-status.ts delete mode 100644 packages/frontend/core/src/index.tsx create mode 100644 packages/frontend/core/src/modules/cloud/entities/server-config.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/session.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/subscription.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/user-feature.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/user-quota.ts create mode 100644 packages/frontend/core/src/modules/cloud/error.ts create mode 100644 packages/frontend/core/src/modules/cloud/index.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/auth.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/fetch.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/graphql.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/server-config.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/subscription.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/user-feature.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/user-quota.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/websocket.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/auth.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/server-config.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/subscription.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/user-feature.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/user-quota.ts rename packages/frontend/core/src/modules/collection/{service.ts => services/collection.ts} (90%) create mode 100644 packages/frontend/core/src/modules/index.ts delete mode 100644 packages/frontend/core/src/modules/infra-web/global-scope/index.tsx rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/sidebar-tab.ts (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/sidebar-tabs.ts (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/chat.css.ts (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/chat.tsx (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/frame.css.ts (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/frame.tsx (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/journal.css.ts (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/journal.tsx (84%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/outline.css.ts (100%) rename packages/frontend/core/src/modules/multi-tab-sidebar/{entities => multi-tabs}/tabs/outline.tsx (100%) create mode 100644 packages/frontend/core/src/modules/navigation/README.md create mode 100644 packages/frontend/core/src/modules/navigation/services/navigator.ts create mode 100644 packages/frontend/core/src/modules/permissions/entities/permission.ts create mode 100644 packages/frontend/core/src/modules/permissions/index.ts create mode 100644 packages/frontend/core/src/modules/permissions/services/permission.ts create mode 100644 packages/frontend/core/src/modules/permissions/stores/permission.ts create mode 100644 packages/frontend/core/src/modules/properties/index.ts rename packages/frontend/core/src/modules/{workspace/properties => properties/services}/adapter.ts (90%) rename packages/frontend/core/src/modules/{workspace/properties => properties/services}/legacy-properties.ts (66%) rename packages/frontend/core/src/modules/{workspace/properties => properties/services}/schema.ts (100%) create mode 100644 packages/frontend/core/src/modules/quota/entities/quota.ts create mode 100644 packages/frontend/core/src/modules/quota/index.ts create mode 100644 packages/frontend/core/src/modules/quota/services/quota.ts create mode 100644 packages/frontend/core/src/modules/quota/stores/quota.ts create mode 100644 packages/frontend/core/src/modules/right-sidebar/services/right-sidebar.ts delete mode 100644 packages/frontend/core/src/modules/services.ts create mode 100644 packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts create mode 100644 packages/frontend/core/src/modules/share-doc/entities/share-info.ts create mode 100644 packages/frontend/core/src/modules/share-doc/index.ts create mode 100644 packages/frontend/core/src/modules/share-doc/services/share-docs.ts create mode 100644 packages/frontend/core/src/modules/share-doc/services/share.ts create mode 100644 packages/frontend/core/src/modules/share-doc/stores/share-docs.ts create mode 100644 packages/frontend/core/src/modules/share-doc/stores/share.ts rename packages/frontend/core/src/modules/{infra-web/storage/index.ts => storage/impls/storage.ts} (100%) create mode 100644 packages/frontend/core/src/modules/storage/index.ts create mode 100644 packages/frontend/core/src/modules/tag/entities/tag-list.ts create mode 100644 packages/frontend/core/src/modules/tag/stores/tag.ts create mode 100644 packages/frontend/core/src/modules/telemetry/index.ts create mode 100644 packages/frontend/core/src/modules/telemetry/services/telemetry.ts create mode 100644 packages/frontend/core/src/modules/workbench/scopes/view.ts create mode 100644 packages/frontend/core/src/modules/workbench/services/view.ts create mode 100644 packages/frontend/core/src/modules/workbench/services/workbench.ts delete mode 100644 packages/frontend/core/src/modules/workbench/view/use-view.tsx create mode 100644 packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts rename packages/frontend/{workspace-impl/src/local/awareness.ts => core/src/modules/workspace-engine/impls/engine/awareness-broadcast-channel.ts} (92%) rename packages/frontend/{workspace-impl/src/cloud/awareness.ts => core/src/modules/workspace-engine/impls/engine/awareness-cloud.ts} (91%) rename packages/frontend/{workspace-impl/src/cloud/blob.ts => core/src/modules/workspace-engine/impls/engine/blob-cloud.ts} (86%) rename packages/frontend/{workspace-impl/src/local => core/src/modules/workspace-engine/impls/engine}/blob-indexeddb.ts (93%) rename packages/frontend/{workspace-impl/src/local => core/src/modules/workspace-engine/impls/engine}/blob-sqlite.ts (88%) rename packages/frontend/{workspace-impl/src/local => core/src/modules/workspace-engine/impls/engine}/blob-static.ts (100%) rename packages/frontend/{workspace-impl/src/local => core/src/modules/workspace-engine/impls/engine}/doc-broadcast-channel.ts (100%) rename packages/frontend/{workspace-impl/src/cloud/doc-static.ts => core/src/modules/workspace-engine/impls/engine/doc-cloud-static.ts} (69%) rename packages/frontend/{workspace-impl/src/cloud/doc.ts => core/src/modules/workspace-engine/impls/engine/doc-cloud.ts} (92%) rename packages/frontend/{workspace-impl/src/local => core/src/modules/workspace-engine/impls/engine}/doc-indexeddb.ts (100%) rename packages/frontend/{workspace-impl/src/local => core/src/modules/workspace-engine/impls/engine}/doc-sqlite.ts (100%) create mode 100644 packages/frontend/core/src/modules/workspace-engine/impls/local.ts create mode 100644 packages/frontend/core/src/modules/workspace-engine/index.ts create mode 100644 packages/frontend/core/src/modules/workspace-engine/providers/engine.ts rename packages/frontend/{workspace-impl/src => core/src/modules/workspace-engine}/utils/__tests__/buffer-to-blob.spec.ts (100%) rename packages/frontend/{workspace-impl/src => core/src/modules/workspace-engine}/utils/base64.ts (100%) rename packages/frontend/{workspace-impl/src => core/src/modules/workspace-engine}/utils/buffer-to-blob.ts (100%) delete mode 100644 packages/frontend/core/src/modules/workspace/current-workspace.ts delete mode 100644 packages/frontend/core/src/modules/workspace/index.ts delete mode 100644 packages/frontend/core/src/modules/workspace/properties/index.ts delete mode 100644 packages/frontend/core/src/providers/session-provider.tsx delete mode 100644 packages/frontend/core/src/utils/cloud-utils.tsx delete mode 100644 packages/frontend/core/src/web.ts delete mode 100644 packages/frontend/graphql/src/graphql/blob-check-size.gql delete mode 100644 packages/frontend/graphql/src/graphql/blob-size.gql delete mode 100644 packages/frontend/graphql/src/graphql/blobs-size.gql delete mode 100644 packages/frontend/graphql/src/graphql/early-access-add.gql create mode 100644 packages/frontend/graphql/src/graphql/get-workspace-public-page-by-id.gql delete mode 100644 packages/frontend/graphql/src/utils.ts delete mode 100644 packages/frontend/workspace-impl/.gitignore delete mode 100644 packages/frontend/workspace-impl/package.json delete mode 100644 packages/frontend/workspace-impl/src/cloud/consts.ts delete mode 100644 packages/frontend/workspace-impl/src/cloud/index.ts delete mode 100644 packages/frontend/workspace-impl/src/cloud/list.ts delete mode 100644 packages/frontend/workspace-impl/src/cloud/workspace-factory.ts delete mode 100644 packages/frontend/workspace-impl/src/index.ts delete mode 100644 packages/frontend/workspace-impl/src/local-state.ts delete mode 100644 packages/frontend/workspace-impl/src/local/consts.ts delete mode 100644 packages/frontend/workspace-impl/src/local/index.ts delete mode 100644 packages/frontend/workspace-impl/src/local/list.ts delete mode 100644 packages/frontend/workspace-impl/src/local/workspace-factory.ts delete mode 100644 packages/frontend/workspace-impl/src/utils/affine-io.ts delete mode 100644 packages/frontend/workspace-impl/tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js index 6eaac4811a..e1bf84d8a0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,7 +48,6 @@ const allPackages = [ 'packages/frontend/i18n', 'packages/frontend/native', 'packages/frontend/templates', - 'packages/frontend/workspace-impl', 'packages/common/debug', 'packages/common/env', 'packages/common/infra', diff --git a/.github/labeler.yml b/.github/labeler.yml index ee96cc8099..84575c4288 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -29,11 +29,6 @@ mod:plugin-cli: - any-glob-to-any-file: - 'tools/plugin-cli/**/*' -mod:workspace-impl: - - changed-files: - - any-glob-to-any-file: - - 'packages/frontend/workspace-impl/**/*' - mod:i18n: - changed-files: - any-glob-to-any-file: diff --git a/docs/contributing/tutorial.md b/docs/contributing/tutorial.md index 334455946a..854dadaae6 100644 --- a/docs/contributing/tutorial.md +++ b/docs/contributing/tutorial.md @@ -29,13 +29,6 @@ It includes the global constants, browser and system check. This package should be imported at the very beginning of the entry point. -### `@affine/workspace-impl` - -Current we have two workspace plugin: - -- `local` for local workspace, which is the default workspace type. -- `affine` for cloud workspace, which is the workspace type for AFFiNE Cloud with OctoBase backend. - #### Design principles - Each workspace plugin has its state and is isolated from other workspace plugins. diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index 68c9504672..efd3c3f27c 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -78,12 +78,30 @@ export class PagePermissionResolver { }); } + @ResolveField(() => WorkspacePage, { + description: 'Get public page of a workspace by page id.', + complexity: 2, + nullable: true, + }) + async publicPage( + @Parent() workspace: WorkspaceType, + @Args('pageId') pageId: string + ) { + return this.prisma.workspacePage.findFirst({ + where: { + workspaceId: workspace.id, + pageId, + public: true, + }, + }); + } + /** * @deprecated */ @Mutation(() => Boolean, { name: 'sharePage', - deprecationReason: 'renamed to publicPage', + deprecationReason: 'renamed to publishPage', }) async deprecatedSharePage( @CurrentUser() user: CurrentUser, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index e5b13e4068..cf112257c4 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -219,7 +219,7 @@ type Mutation { sendVerifyEmail(callbackUrl: String!): Boolean! setBlob(blob: Upload!, workspaceId: String!): String! setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean! - sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publicPage") + sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publishPage") signIn(email: String!, password: String!): UserType! signUp(email: String!, name: String!, password: String!): UserType! updateProfile(input: UpdateUserInput!): UserType! @@ -530,6 +530,9 @@ type WorkspaceType { """is Public workspace""" public: Boolean! + """Get public page of a workspace by page id.""" + publicPage(pageId: String!): WorkspacePage + """Public pages of a workspace""" publicPages: [WorkspacePage!]! diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index fb329ac6a3..3a4922ca65 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -18,7 +18,6 @@ export const runtimeFlagsSchema = z.object({ enablePreloading: z.boolean(), enableNewSettingModal: z.boolean(), enableNewSettingUnstableApi: z.boolean(), - enableSQLiteProvider: z.boolean(), enableCloud: z.boolean(), enableCaptcha: z.boolean(), enableEnhanceShareMode: z.boolean(), diff --git a/packages/common/infra/src/di/__tests__/di.spec.ts b/packages/common/infra/src/di/__tests__/di.spec.ts deleted file mode 100644 index 6828d5ba1c..0000000000 --- a/packages/common/infra/src/di/__tests__/di.spec.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { - CircularDependencyError, - createIdentifier, - createScope, - DuplicateServiceDefinitionError, - MissingDependencyError, - RecursionLimitError, - ServiceCollection, - ServiceNotFoundError, - ServiceProvider, -} from '../'; - -describe('di', () => { - test('basic', () => { - const serviceCollection = new ServiceCollection(); - class TestService { - a = 'b'; - } - - serviceCollection.add(TestService); - - const provider = serviceCollection.provider(); - expect(provider.get(TestService)).toEqual({ a: 'b' }); - }); - - test('size', () => { - const serviceCollection = new ServiceCollection(); - class TestService { - a = 'b'; - } - - serviceCollection.add(TestService); - - expect(serviceCollection.size).toEqual(1); - }); - - test('dependency', () => { - const serviceCollection = new ServiceCollection(); - - class A { - value = 'hello world'; - } - - class B { - constructor(public a: A) {} - } - - class C { - constructor(public b: B) {} - } - - serviceCollection.add(A).add(B, [A]).add(C, [B]); - - const provider = serviceCollection.provider(); - - expect(provider.get(C).b.a.value).toEqual('hello world'); - }); - - test('identifier', () => { - interface Animal { - name: string; - } - const Animal = createIdentifier('Animal'); - - class Cat { - constructor() {} - name = 'cat'; - } - - class Zoo { - constructor(public animal: Animal) {} - } - - const serviceCollection = new ServiceCollection(); - serviceCollection.addImpl(Animal, Cat).add(Zoo, [Animal]); - - const provider = serviceCollection.provider(); - expect(provider.get(Zoo).animal.name).toEqual('cat'); - }); - - test('variant', () => { - const serviceCollection = new ServiceCollection(); - - interface USB { - speed: number; - } - - const USB = createIdentifier('USB'); - - class TypeA implements USB { - speed = 100; - } - class TypeC implements USB { - speed = 300; - } - - class PC { - constructor( - public typeA: USB, - public ports: USB[] - ) {} - } - - serviceCollection - .addImpl(USB('A'), TypeA) - .addImpl(USB('C'), TypeC) - .add(PC, [USB('A'), [USB]]); - - const provider = serviceCollection.provider(); - expect(provider.get(USB('A')).speed).toEqual(100); - expect(provider.get(USB('C')).speed).toEqual(300); - expect(provider.get(PC).typeA.speed).toEqual(100); - expect(provider.get(PC).ports.length).toEqual(2); - }); - - test('lazy initialization', () => { - const serviceCollection = new ServiceCollection(); - interface Command { - shortcut: string; - callback: () => void; - } - const Command = createIdentifier('command'); - - let pageSystemInitialized = false; - - class PageSystem { - mode = 'page'; - name = 'helloworld'; - - constructor() { - pageSystemInitialized = true; - } - - switchToEdgeless() { - this.mode = 'edgeless'; - } - - rename() { - this.name = 'foobar'; - } - } - - class CommandSystem { - constructor(public commands: Command[]) {} - - execute(shortcut: string) { - const command = this.commands.find(c => c.shortcut === shortcut); - if (command) { - command.callback(); - } - } - } - - serviceCollection.add(PageSystem); - serviceCollection.add(CommandSystem, [[Command]]); - serviceCollection.addImpl(Command('switch'), p => ({ - shortcut: 'option+s', - callback: () => p.get(PageSystem).switchToEdgeless(), - })); - serviceCollection.addImpl(Command('rename'), p => ({ - shortcut: 'f2', - callback: () => p.get(PageSystem).rename(), - })); - - const provider = serviceCollection.provider(); - const commandSystem = provider.get(CommandSystem); - - expect( - pageSystemInitialized, - "PageSystem won't be initialized until command executed" - ).toEqual(false); - - commandSystem.execute('option+s'); - expect(pageSystemInitialized).toEqual(true); - expect(provider.get(PageSystem).mode).toEqual('edgeless'); - - expect(provider.get(PageSystem).name).toEqual('helloworld'); - expect(commandSystem.commands.length).toEqual(2); - commandSystem.execute('f2'); - expect(provider.get(PageSystem).name).toEqual('foobar'); - }); - - test('duplicate, override', () => { - const serviceCollection = new ServiceCollection(); - - const something = createIdentifier('USB'); - - class A { - a = 'i am A'; - } - - class B { - b = 'i am B'; - } - - serviceCollection.addImpl(something, A).override(something, B); - - const provider = serviceCollection.provider(); - expect(provider.get(something)).toEqual({ b: 'i am B' }); - }); - - test('scope', () => { - const services = new ServiceCollection(); - - const workspaceScope = createScope('workspace'); - const pageScope = createScope('page', workspaceScope); - const editorScope = createScope('editor', pageScope); - - class System { - appName = 'affine'; - } - - services.add(System); - - class Workspace { - name = 'workspace'; - constructor(public system: System) {} - } - - services.scope(workspaceScope).add(Workspace, [System]); - class Page { - name = 'page'; - constructor( - public system: System, - public workspace: Workspace - ) {} - } - - services.scope(pageScope).add(Page, [System, Workspace]); - - class Editor { - name = 'editor'; - constructor(public page: Page) {} - } - - services.scope(editorScope).add(Editor, [Page]); - - const root = services.provider(); - expect(root.get(System).appName).toEqual('affine'); - expect(() => root.get(Workspace)).toThrowError(ServiceNotFoundError); - - const workspace = services.provider(workspaceScope, root); - expect(workspace.get(Workspace).name).toEqual('workspace'); - expect(workspace.get(System).appName).toEqual('affine'); - expect(() => root.get(Page)).toThrowError(ServiceNotFoundError); - - const page = services.provider(pageScope, workspace); - expect(page.get(Page).name).toEqual('page'); - expect(page.get(Workspace).name).toEqual('workspace'); - expect(page.get(System).appName).toEqual('affine'); - - const editor = services.provider(editorScope, page); - expect(editor.get(Editor).name).toEqual('editor'); - }); - - test('service not found', () => { - const serviceCollection = new ServiceCollection(); - - const provider = serviceCollection.provider(); - expect(() => provider.get(createIdentifier('SomeService'))).toThrowError( - ServiceNotFoundError - ); - }); - - test('missing dependency', () => { - const serviceCollection = new ServiceCollection(); - - class A { - value = 'hello world'; - } - - class B { - constructor(public a: A) {} - } - - serviceCollection.add(B, [A]); - - const provider = serviceCollection.provider(); - expect(() => provider.get(B)).toThrowError(MissingDependencyError); - }); - - test('circular dependency', () => { - const serviceCollection = new ServiceCollection(); - - class A { - constructor(public c: C) {} - } - - class B { - constructor(public a: A) {} - } - - class C { - constructor(public b: B) {} - } - - serviceCollection.add(A, [C]).add(B, [A]).add(C, [B]); - - const provider = serviceCollection.provider(); - expect(() => provider.get(A)).toThrowError(CircularDependencyError); - expect(() => provider.get(B)).toThrowError(CircularDependencyError); - expect(() => provider.get(C)).toThrowError(CircularDependencyError); - }); - - test('duplicate service definition', () => { - const serviceCollection = new ServiceCollection(); - - class A {} - - serviceCollection.add(A); - expect(() => serviceCollection.add(A)).toThrowError( - DuplicateServiceDefinitionError - ); - - class B {} - const Something = createIdentifier('something'); - serviceCollection.addImpl(Something, A); - expect(() => serviceCollection.addImpl(Something, B)).toThrowError( - DuplicateServiceDefinitionError - ); - }); - - test('recursion limit', () => { - // maxmium resolve depth is 100 - const serviceCollection = new ServiceCollection(); - const Something = createIdentifier('something'); - let i = 0; - for (; i < 100; i++) { - const next = i + 1; - - class Test { - constructor(_next: any) {} - } - - serviceCollection.addImpl(Something(i.toString()), Test, [ - Something(next.toString()), - ]); - } - - class Final { - a = 'b'; - } - serviceCollection.addImpl(Something(i.toString()), Final); - const provider = serviceCollection.provider(); - expect(() => provider.get(Something('0'))).toThrowError( - RecursionLimitError - ); - }); - - test('self resolve', () => { - const serviceCollection = new ServiceCollection(); - const provider = serviceCollection.provider(); - expect(provider.get(ServiceProvider)).toEqual(provider); - }); -}); diff --git a/packages/common/infra/src/di/core/collection.ts b/packages/common/infra/src/di/core/collection.ts deleted file mode 100644 index 640cef0f93..0000000000 --- a/packages/common/infra/src/di/core/collection.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { DEFAULT_SERVICE_VARIANT, ROOT_SCOPE } from './consts'; -import { DuplicateServiceDefinitionError } from './error'; -import { parseIdentifier } from './identifier'; -import type { ServiceProvider } from './provider'; -import { BasicServiceProvider } from './provider'; -import { stringifyScope } from './scope'; -import type { - GeneralServiceIdentifier, - ServiceFactory, - ServiceIdentifier, - ServiceIdentifierType, - ServiceIdentifierValue, - ServiceScope, - ServiceVariant, - Type, - TypesToDeps, -} from './types'; - -/** - * A collection of services. - * - * ServiceCollection basically is a tuple of `[scope, identifier, variant, factory]` with some helper methods. - * It just stores the definitions of services. It never holds any instances of services. - * - * # Usage - * - * ```ts - * const services = new ServiceCollection(); - * class ServiceA { - * // ... - * } - * // add a service - * services.add(ServiceA); - * - * class ServiceB { - * constructor(serviceA: ServiceA) {} - * } - * // add a service with dependency - * services.add(ServiceB, [ServiceA]); - * ^ dependency class/identifier, match ServiceB's constructor - * - * const FeatureA = createIdentifier('Config'); - * - * // add a implementation for a service identifier - * services.addImpl(FeatureA, ServiceA); - * - * // override a service - * services.override(ServiceA, NewServiceA); - * - * // create a service provider - * const provider = services.provider(); - * ``` - * - * # The data structure - * - * The data structure of ServiceCollection is a three-layer nested Map, used to represent the tuple of - * `[scope, identifier, variant, factory]`. - * Such a data structure ensures that a service factory can be uniquely determined by `[scope, identifier, variant]`. - * - * When a service added: - * - * ```ts - * services.add(ServiceClass) - * ``` - * - * The data structure will be: - * - * ```ts - * Map { - * '': Map { // scope - * 'ServiceClass': Map { // identifier - * 'default': // variant - * () => new ServiceClass() // factory - * } - * } - * ``` - * - * # Dependency relationship - * - * The dependency relationships of services are not actually stored in the ServiceCollection, - * but are transformed into a factory function when the service is added. - * - * For example: - * - * ```ts - * services.add(ServiceB, [ServiceA]); - * - * // is equivalent to - * services.addFactory(ServiceB, (provider) => new ServiceB(provider.get(ServiceA))); - * ``` - * - * For multiple implementations of the same service identifier, can be defined as: - * - * ```ts - * services.add(ServiceB, [[FeatureA]]); - * - * // is equivalent to - * services.addFactory(ServiceB, (provider) => new ServiceB(provider.getAll(FeatureA))); - * ``` - */ -export class ServiceCollection { - private readonly services: Map< - string, - Map> - > = new Map(); - - /** - * Create an empty service collection. - * - * same as `new ServiceCollection()` - */ - static get EMPTY() { - return new ServiceCollection(); - } - - /** - * The number of services in the collection. - */ - get size() { - let size = 0; - for (const [, identifiers] of this.services) { - for (const [, variants] of identifiers) { - size += variants.size; - } - } - return size; - } - - /** - * @see {@link ServiceCollectionEditor.add} - */ - get add() { - return new ServiceCollectionEditor(this).add; - } - - /** - * @see {@link ServiceCollectionEditor.addImpl} - */ - get addImpl() { - return new ServiceCollectionEditor(this).addImpl; - } - - /** - * @see {@link ServiceCollectionEditor.scope} - */ - get scope() { - return new ServiceCollectionEditor(this).scope; - } - - /** - * @see {@link ServiceCollectionEditor.scope} - */ - get override() { - return new ServiceCollectionEditor(this).override; - } - - /** - * @internal Use {@link addImpl} instead. - */ - addValue( - identifier: GeneralServiceIdentifier, - value: T, - { scope, override }: { scope?: ServiceScope; override?: boolean } = {} - ) { - this.addFactory( - parseIdentifier(identifier) as ServiceIdentifier, - () => value, - { - scope, - override, - } - ); - } - - /** - * @internal Use {@link addImpl} instead. - */ - addFactory( - identifier: GeneralServiceIdentifier, - factory: ServiceFactory, - { scope, override }: { scope?: ServiceScope; override?: boolean } = {} - ) { - // convert scope to string - const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE); - const normalizedIdentifier = parseIdentifier(identifier); - const normalizedVariant = - normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT; - - const services = - this.services.get(normalizedScope) ?? - new Map>(); - - const variants = - services.get(normalizedIdentifier.identifierName) ?? - new Map(); - - // throw if service already exists, unless it is an override - if (variants.has(normalizedVariant) && !override) { - throw new DuplicateServiceDefinitionError(normalizedIdentifier); - } - variants.set(normalizedVariant, factory); - services.set(normalizedIdentifier.identifierName, variants); - this.services.set(normalizedScope, services); - } - - remove(identifier: ServiceIdentifierValue, scope: ServiceScope = ROOT_SCOPE) { - const normalizedScope = stringifyScope(scope); - const normalizedIdentifier = parseIdentifier(identifier); - const normalizedVariant = - normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT; - - const services = this.services.get(normalizedScope); - if (!services) { - return; - } - - const variants = services.get(normalizedIdentifier.identifierName); - if (!variants) { - return; - } - - variants.delete(normalizedVariant); - } - - /** - * Create a service provider from the collection. - * - * @example - * ```ts - * provider() // create a service provider for root scope - * provider(ScopeA, parentProvider) // create a service provider for scope A - * ``` - * - * @param scope The scope of the service provider, default to the root scope. - * @param parent The parent service provider, it is required if the scope is not the root scope. - */ - provider( - scope: ServiceScope = ROOT_SCOPE, - parent: ServiceProvider | null = null - ): ServiceProvider { - return new BasicServiceProvider(this, scope, parent); - } - - /** - * @internal - */ - getFactory( - identifier: ServiceIdentifierValue, - scope: ServiceScope = ROOT_SCOPE - ): ServiceFactory | undefined { - return this.services - .get(stringifyScope(scope)) - ?.get(identifier.identifierName) - ?.get(identifier.variant ?? DEFAULT_SERVICE_VARIANT); - } - - /** - * @internal - */ - getFactoryAll( - identifier: ServiceIdentifierValue, - scope: ServiceScope = ROOT_SCOPE - ): Map { - return new Map( - this.services.get(stringifyScope(scope))?.get(identifier.identifierName) - ); - } - - /** - * Clone the entire service collection. - * - * This method is quite cheap as it only clones the references. - * - * @returns A new service collection with the same services. - */ - clone(): ServiceCollection { - const di = new ServiceCollection(); - for (const [scope, identifiers] of this.services) { - const s = new Map(); - for (const [identifier, variants] of identifiers) { - s.set(identifier, new Map(variants)); - } - di.services.set(scope, s); - } - return di; - } -} - -/** - * A helper class to edit a service collection. - */ -class ServiceCollectionEditor { - private currentScope: ServiceScope = ROOT_SCOPE; - - constructor(private readonly collection: ServiceCollection) {} - - /** - * Add a service to the collection. - * - * @see {@link ServiceCollection} - * - * @example - * ```ts - * add(ServiceClass, [dependencies, ...]) - * ``` - */ - add = < - T extends new (...args: any) => any, - const Deps extends TypesToDeps> = TypesToDeps< - ConstructorParameters - >, - >( - cls: T, - ...[deps]: Deps extends [] ? [] : [Deps] - ): this => { - this.collection.addFactory( - cls as any, - dependenciesToFactory(cls, deps as any), - { scope: this.currentScope } - ); - - return this; - }; - - /** - * Add an implementation for identifier to the collection. - * - * @see {@link ServiceCollection} - * - * @example - * ```ts - * addImpl(ServiceIdentifier, ServiceClass, [dependencies, ...]) - * or - * addImpl(ServiceIdentifier, Instance) - * or - * addImpl(ServiceIdentifier, Factory) - * ``` - */ - addImpl = < - Arg1 extends ServiceIdentifier | (new (...args: any) => any), - Arg2 extends Type | ServiceFactory | Trait, - Trait = ServiceIdentifierType, - Deps extends Arg2 extends Type - ? TypesToDeps> - : [] = Arg2 extends Type - ? TypesToDeps> - : [], - Arg3 extends Deps = Deps, - >( - identifier: Arg1, - arg2: Arg2, - ...[arg3]: Arg3 extends [] ? [] : [Arg3] - ): this => { - if (arg2 instanceof Function) { - this.collection.addFactory( - identifier, - dependenciesToFactory(arg2, arg3 as any[]), - { scope: this.currentScope } - ); - } else { - this.collection.addValue(identifier, arg2 as any, { - scope: this.currentScope, - }); - } - - return this; - }; - - /** - * same as {@link addImpl} but this method will override the service if it exists. - * - * @see {@link ServiceCollection} - * - * @example - * ```ts - * override(OriginServiceClass, NewServiceClass, [dependencies, ...]) - * or - * override(ServiceIdentifier, ServiceClass, [dependencies, ...]) - * or - * override(ServiceIdentifier, Instance) - * or - * override(ServiceIdentifier, Factory) - * ``` - */ - override = < - Arg1 extends ServiceIdentifier, - Arg2 extends Type | ServiceFactory | Trait | null, - Trait = ServiceIdentifierType, - Deps extends Arg2 extends Type - ? TypesToDeps> - : [] = Arg2 extends Type - ? TypesToDeps> - : [], - Arg3 extends Deps = Deps, - >( - identifier: Arg1, - arg2: Arg2, - ...[arg3]: Arg3 extends [] ? [] : [Arg3] - ): this => { - if (arg2 === null) { - this.collection.remove(identifier, this.currentScope); - return this; - } else if (arg2 instanceof Function) { - this.collection.addFactory( - identifier, - dependenciesToFactory(arg2, arg3 as any[]), - { scope: this.currentScope, override: true } - ); - } else { - this.collection.addValue(identifier, arg2 as any, { - scope: this.currentScope, - override: true, - }); - } - - return this; - }; - - /** - * Set the scope for the service registered subsequently - * - * @example - * - * ```ts - * const ScopeA = createScope('a'); - * - * services.scope(ScopeA).add(XXXService, ...); - * ``` - */ - scope = (scope: ServiceScope): ServiceCollectionEditor => { - this.currentScope = scope; - return this; - }; -} - -/** - * Convert dependencies definition to a factory function. - */ -function dependenciesToFactory( - cls: any, - deps: any[] = [] -): ServiceFactory { - return (provider: ServiceProvider) => { - const args = []; - for (const dep of deps) { - let isAll; - let identifier; - if (Array.isArray(dep)) { - if (dep.length !== 1) { - throw new Error('Invalid dependency'); - } - isAll = true; - identifier = dep[0]; - } else { - isAll = false; - identifier = dep; - } - if (isAll) { - args.push(Array.from(provider.getAll(identifier).values())); - } else { - args.push(provider.get(identifier)); - } - } - if (isConstructor(cls)) { - return new cls(...args, provider); - } else { - return cls(...args, provider); - } - }; -} - -// a hack to check if a function is a constructor -// https://github.com/zloirock/core-js/blob/232c8462c26c75864b4397b7f643a4f57c6981d5/packages/core-js/internals/is-constructor.js#L15 -function isConstructor(cls: any) { - try { - Reflect.construct(function () {}, [], cls); - return true; - } catch (error) { - return false; - } -} diff --git a/packages/common/infra/src/di/core/consts.ts b/packages/common/infra/src/di/core/consts.ts deleted file mode 100644 index dc43ed8953..0000000000 --- a/packages/common/infra/src/di/core/consts.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { ServiceVariant } from './types'; - -export const DEFAULT_SERVICE_VARIANT: ServiceVariant = 'default'; -export const ROOT_SCOPE = []; diff --git a/packages/common/infra/src/di/core/error.ts b/packages/common/infra/src/di/core/error.ts deleted file mode 100644 index 90fab9c35c..0000000000 --- a/packages/common/infra/src/di/core/error.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { DEFAULT_SERVICE_VARIANT } from './consts'; -import type { ServiceIdentifierValue } from './types'; - -export class RecursionLimitError extends Error { - constructor() { - super('Dynamic resolve recursion limit reached'); - } -} - -export class CircularDependencyError extends Error { - constructor(public readonly dependencyStack: ServiceIdentifierValue[]) { - super( - `A circular dependency was detected.\n` + - stringifyDependencyStack(dependencyStack) - ); - } -} - -export class ServiceNotFoundError extends Error { - constructor(public readonly identifier: ServiceIdentifierValue) { - super(`Service ${stringifyIdentifier(identifier)} not found in container`); - } -} - -export class MissingDependencyError extends Error { - constructor( - public readonly from: ServiceIdentifierValue, - public readonly target: ServiceIdentifierValue, - public readonly dependencyStack: ServiceIdentifierValue[] - ) { - super( - `Missing dependency ${stringifyIdentifier( - target - )} in creating service ${stringifyIdentifier( - from - )}.\n${stringifyDependencyStack(dependencyStack)}` - ); - } -} - -export class DuplicateServiceDefinitionError extends Error { - constructor(public readonly identifier: ServiceIdentifierValue) { - super(`Service ${stringifyIdentifier(identifier)} already exists`); - } -} - -function stringifyIdentifier(identifier: ServiceIdentifierValue) { - return `[${identifier.identifierName}]${ - identifier.variant !== DEFAULT_SERVICE_VARIANT - ? `(${identifier.variant})` - : '' - }`; -} - -function stringifyDependencyStack(dependencyStack: ServiceIdentifierValue[]) { - return dependencyStack - .map(identifier => `${stringifyIdentifier(identifier)}`) - .join(' -> '); -} diff --git a/packages/common/infra/src/di/core/index.ts b/packages/common/infra/src/di/core/index.ts deleted file mode 100644 index f86d0240c0..0000000000 --- a/packages/common/infra/src/di/core/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './collection'; -export * from './consts'; -export * from './error'; -export * from './identifier'; -export * from './provider'; -export * from './scope'; -export * from './types'; diff --git a/packages/common/infra/src/di/core/provider.ts b/packages/common/infra/src/di/core/provider.ts deleted file mode 100644 index eafe8516fd..0000000000 --- a/packages/common/infra/src/di/core/provider.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { ServiceCollection } from './collection'; -import { - CircularDependencyError, - MissingDependencyError, - RecursionLimitError, - ServiceNotFoundError, -} from './error'; -import { parseIdentifier } from './identifier'; -import type { - GeneralServiceIdentifier, - ServiceIdentifierValue, - ServiceVariant, -} from './types'; - -export interface ResolveOptions { - sameScope?: boolean; - optional?: boolean; -} - -export abstract class ServiceProvider { - abstract collection: ServiceCollection; - abstract getRaw( - identifier: ServiceIdentifierValue, - options?: ResolveOptions - ): any; - abstract getAllRaw( - identifier: ServiceIdentifierValue, - options?: ResolveOptions - ): Map; - - get(identifier: GeneralServiceIdentifier, options?: ResolveOptions): T { - return this.getRaw(parseIdentifier(identifier), { - ...options, - optional: false, - }); - } - - getAll( - identifier: GeneralServiceIdentifier, - options?: ResolveOptions - ): Map { - return this.getAllRaw(parseIdentifier(identifier), { - ...options, - }); - } - - getOptional( - identifier: GeneralServiceIdentifier, - options?: ResolveOptions - ): T | null { - return this.getRaw(parseIdentifier(identifier), { - ...options, - optional: true, - }); - } -} - -export class ServiceCachePool { - cache: Map> = new Map(); - - getOrInsert(identifier: ServiceIdentifierValue, insert: () => any) { - const cache = this.cache.get(identifier.identifierName) ?? new Map(); - if (!cache.has(identifier.variant)) { - cache.set(identifier.variant, insert()); - } - const cached = cache.get(identifier.variant); - this.cache.set(identifier.identifierName, cache); - return cached; - } -} - -export class ServiceResolver extends ServiceProvider { - constructor( - public readonly provider: BasicServiceProvider, - public readonly depth = 0, - public readonly stack: ServiceIdentifierValue[] = [] - ) { - super(); - } - - collection = this.provider.collection; - - getRaw( - identifier: ServiceIdentifierValue, - { sameScope = false, optional = false }: ResolveOptions = {} - ) { - const factory = this.provider.collection.getFactory( - identifier, - this.provider.scope - ); - if (!factory) { - if (this.provider.parent && !sameScope) { - return this.provider.parent.getRaw(identifier, { - sameScope, - optional, - }); - } - - if (optional) { - return undefined; - } - throw new ServiceNotFoundError(identifier); - } - - return this.provider.cache.getOrInsert(identifier, () => { - const nextResolver = this.track(identifier); - try { - return factory(nextResolver); - } catch (err) { - if (err instanceof ServiceNotFoundError) { - throw new MissingDependencyError( - identifier, - err.identifier, - this.stack - ); - } - throw err; - } - }); - } - - getAllRaw( - identifier: ServiceIdentifierValue, - { sameScope = false }: ResolveOptions = {} - ): Map { - const vars = this.provider.collection.getFactoryAll( - identifier, - this.provider.scope - ); - - if (vars === undefined) { - if (this.provider.parent && !sameScope) { - return this.provider.parent.getAllRaw(identifier); - } - - return new Map(); - } - - const result = new Map(); - - for (const [variant, factory] of vars) { - const service = this.provider.cache.getOrInsert( - { identifierName: identifier.identifierName, variant }, - () => { - const nextResolver = this.track(identifier); - try { - return factory(nextResolver); - } catch (err) { - if (err instanceof ServiceNotFoundError) { - throw new MissingDependencyError( - identifier, - err.identifier, - this.stack - ); - } - throw err; - } - } - ); - result.set(variant, service); - } - - return result; - } - - track(identifier: ServiceIdentifierValue): ServiceResolver { - const depth = this.depth + 1; - if (depth >= 100) { - throw new RecursionLimitError(); - } - const circular = this.stack.find( - i => - i.identifierName === identifier.identifierName && - i.variant === identifier.variant - ); - if (circular) { - throw new CircularDependencyError([...this.stack, identifier]); - } - - return new ServiceResolver(this.provider, depth, [ - ...this.stack, - identifier, - ]); - } -} - -export class BasicServiceProvider extends ServiceProvider { - public readonly cache = new ServiceCachePool(); - public readonly collection: ServiceCollection; - - constructor( - collection: ServiceCollection, - public readonly scope: string[], - public readonly parent: ServiceProvider | null - ) { - super(); - this.collection = collection.clone(); - this.collection.addValue(ServiceProvider, this, { - scope: scope, - override: true, - }); - } - - getRaw(identifier: ServiceIdentifierValue, options?: ResolveOptions) { - const resolver = new ServiceResolver(this); - return resolver.getRaw(identifier, options); - } - - getAllRaw( - identifier: ServiceIdentifierValue, - options?: ResolveOptions - ): Map { - const resolver = new ServiceResolver(this); - return resolver.getAllRaw(identifier, options); - } -} diff --git a/packages/common/infra/src/di/core/scope.ts b/packages/common/infra/src/di/core/scope.ts deleted file mode 100644 index 190bbd7d8d..0000000000 --- a/packages/common/infra/src/di/core/scope.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ROOT_SCOPE } from './consts'; -import type { ServiceScope } from './types'; - -export function createScope( - name: string, - base: ServiceScope = ROOT_SCOPE -): ServiceScope { - return [...base, name]; -} - -export function stringifyScope(scope: ServiceScope): string { - return scope.join('/'); -} diff --git a/packages/common/infra/src/di/core/types.ts b/packages/common/infra/src/di/core/types.ts deleted file mode 100644 index 6756767186..0000000000 --- a/packages/common/infra/src/di/core/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ServiceProvider } from './provider'; - -// eslint-disable-next-line @typescript-eslint/ban-types -export type Type = abstract new (...args: any) => T; - -export type ServiceFactory = (provider: ServiceProvider) => T; -export type ServiceVariant = string; - -/** - * - */ -export type ServiceScope = string[]; - -export type ServiceIdentifierValue = { - identifierName: string; - variant: ServiceVariant; -}; - -export type GeneralServiceIdentifier = ServiceIdentifier | Type; - -export type ServiceIdentifier = { - identifierName: string; - variant: ServiceVariant; - __TYPE__: T; -}; - -export type ServiceIdentifierType = - T extends ServiceIdentifier - ? R - : T extends Type - ? R - : never; - -export type TypesToDeps = { - [index in keyof T]: - | GeneralServiceIdentifier - | (T[index] extends (infer I)[] ? [GeneralServiceIdentifier] : never); -}; diff --git a/packages/common/infra/src/di/react/index.ts b/packages/common/infra/src/di/react/index.ts deleted file mode 100644 index 52a00c0025..0000000000 --- a/packages/common/infra/src/di/react/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import React, { useContext } from 'react'; - -import type { GeneralServiceIdentifier, ServiceProvider } from '../core'; -import { ServiceCollection } from '../core'; - -export const ServiceProviderContext = React.createContext( - ServiceCollection.EMPTY.provider() -); - -export function useService( - identifier: GeneralServiceIdentifier, - { provider }: { provider?: ServiceProvider } = {} -): T { - const contextServiceProvider = useContext(ServiceProviderContext); - - const serviceProvider = provider ?? contextServiceProvider; - - return serviceProvider.get(identifier); -} - -export function useServiceOptional( - identifier: GeneralServiceIdentifier, - { provider }: { provider?: ServiceProvider } = {} -): T | null { - const contextServiceProvider = useContext(ServiceProviderContext); - - const serviceProvider = provider ?? contextServiceProvider; - - return serviceProvider.getOptional(identifier); -} diff --git a/packages/common/infra/src/framework/__tests__/framework.spec.ts b/packages/common/infra/src/framework/__tests__/framework.spec.ts new file mode 100644 index 0000000000..d022b2599c --- /dev/null +++ b/packages/common/infra/src/framework/__tests__/framework.spec.ts @@ -0,0 +1,539 @@ +import { describe, expect, test } from 'vitest'; + +import { + CircularDependencyError, + ComponentNotFoundError, + createEvent, + createIdentifier, + DuplicateDefinitionError, + Entity, + Framework, + MissingDependencyError, + RecursionLimitError, + Scope, + Service, +} from '..'; +import { OnEvent } from '../core/event'; + +describe('framework', () => { + test('basic', () => { + const framework = new Framework(); + class TestService extends Service { + a = 'b'; + } + + framework.service(TestService); + + const provider = framework.provider(); + expect(provider.get(TestService).a).toBe('b'); + }); + + test('entity', () => { + const framework = new Framework(); + class TestService extends Service { + a = 'b'; + } + + class TestEntity extends Entity<{ name: string }> { + constructor(readonly test: TestService) { + super(); + } + } + + framework.service(TestService).entity(TestEntity, [TestService]); + + const provider = framework.provider(); + const entity = provider.createEntity(TestEntity, { + name: 'test', + }); + expect(entity.test.a).toBe('b'); + expect(entity.props.name).toBe('test'); + }); + + test('componentCount', () => { + const framework = new Framework(); + class TestService extends Service { + a = 'b'; + } + + framework.service(TestService); + + expect(framework.componentCount).toEqual(1); + }); + + test('dependency', () => { + const framework = new Framework(); + + class A extends Service { + value = 'hello world'; + } + + class B extends Service { + constructor(public a: A) { + super(); + } + } + + class C extends Service { + constructor(public b: B) { + super(); + } + } + + framework.service(A).service(B, [A]).service(C, [B]); + + const provider = framework.provider(); + + expect(provider.get(C).b.a.value).toEqual('hello world'); + }); + + test('identifier', () => { + interface Animal extends Service { + name: string; + } + const Animal = createIdentifier('Animal'); + + class Cat extends Service { + name = 'cat'; + } + + class Zoo extends Service { + constructor(public animal: Animal) { + super(); + } + } + + const serviceCollection = new Framework(); + serviceCollection.impl(Animal, Cat).service(Zoo, [Animal]); + + const provider = serviceCollection.provider(); + expect(provider.get(Zoo).animal.name).toEqual('cat'); + }); + + test('variant', () => { + const framework = new Framework(); + + interface USB extends Service { + speed: number; + } + + const USB = createIdentifier('USB'); + + class TypeA extends Service implements USB { + speed = 100; + } + class TypeC extends Service implements USB { + speed = 300; + } + + class PC extends Service { + constructor( + public typeA: USB, + public ports: USB[] + ) { + super(); + } + } + + framework + .impl(USB('A'), TypeA) + .impl(USB('C'), TypeC) + .service(PC, [USB('A'), [USB]]); + + const provider = framework.provider(); + expect(provider.get(USB('A')).speed).toEqual(100); + expect(provider.get(USB('C')).speed).toEqual(300); + expect(provider.get(PC).typeA.speed).toEqual(100); + expect(provider.get(PC).ports.length).toEqual(2); + }); + + test('lazy initialization', () => { + const framework = new Framework(); + interface Command { + shortcut: string; + callback: () => void; + } + const Command = createIdentifier('command'); + + let pageSystemInitialized = false; + + class PageSystem extends Service { + mode = 'page'; + name = 'helloworld'; + + constructor() { + super(); + pageSystemInitialized = true; + } + + switchToEdgeless() { + this.mode = 'edgeless'; + } + + rename() { + this.name = 'foobar'; + } + } + + class CommandSystem extends Service { + constructor(public commands: Command[]) { + super(); + } + + execute(shortcut: string) { + const command = this.commands.find(c => c.shortcut === shortcut); + if (command) { + command.callback(); + } + } + } + + framework.service(PageSystem); + framework.service(CommandSystem, [[Command]]); + framework.impl(Command('switch'), p => ({ + shortcut: 'option+s', + callback: () => p.get(PageSystem).switchToEdgeless(), + })); + framework.impl(Command('rename'), p => ({ + shortcut: 'f2', + callback: () => p.get(PageSystem).rename(), + })); + + const provider = framework.provider(); + const commandSystem = provider.get(CommandSystem); + + expect( + pageSystemInitialized, + "PageSystem won't be initialized until command executed" + ).toEqual(false); + + commandSystem.execute('option+s'); + expect(pageSystemInitialized).toEqual(true); + expect(provider.get(PageSystem).mode).toEqual('edgeless'); + + expect(provider.get(PageSystem).name).toEqual('helloworld'); + expect(commandSystem.commands.length).toEqual(2); + commandSystem.execute('f2'); + expect(provider.get(PageSystem).name).toEqual('foobar'); + }); + + test('duplicate, override', () => { + const framework = new Framework(); + + const something = createIdentifier('USB'); + + class A { + a = 'i am A'; + } + + class B { + b = 'i am B'; + } + + framework.impl(something, A).override(something, B); + + const provider = framework.provider(); + expect(provider.get(something)).toEqual({ b: 'i am B' }); + }); + + test('event', () => { + const framework = new Framework(); + + const event = createEvent<{ value: number }>('test-event'); + + @OnEvent(event, p => p.onTestEvent) + class TestService extends Service { + value = 0; + + onTestEvent(payload: { value: number }) { + this.value = payload.value; + } + } + + framework.service(TestService); + + const provider = framework.provider(); + provider.emitEvent(event, { value: 123 }); + expect(provider.get(TestService).value).toEqual(123); + }); + + test('scope', () => { + const framework = new Framework(); + + class SystemService extends Service { + appName = 'affine'; + } + + framework.service(SystemService); + + class WorkspaceScope extends Scope {} + + class WorkspaceService extends Service { + constructor(public system: SystemService) { + super(); + } + } + + framework.scope(WorkspaceScope).service(WorkspaceService, [SystemService]); + + class PageScope extends Scope<{ pageId: string }> {} + + class PageService extends Service { + constructor( + public workspace: WorkspaceService, + public system: SystemService + ) { + super(); + } + } + + framework + .scope(WorkspaceScope) + .scope(PageScope) + .service(PageService, [WorkspaceService, SystemService]); + + class EditorScope extends Scope { + get pageId() { + return this.framework.get(PageScope).props.pageId; + } + } + + class EditorService extends Service { + constructor(public page: PageService) { + super(); + } + } + + framework + .scope(WorkspaceScope) + .scope(PageScope) + .scope(EditorScope) + .service(EditorService, [PageService]); + + const root = framework.provider(); + expect(root.get(SystemService).appName).toEqual('affine'); + expect(() => root.get(WorkspaceService)).toThrowError( + ComponentNotFoundError + ); + + const workspaceScope = root.createScope(WorkspaceScope); + const workspaceService = workspaceScope.get(WorkspaceService); + expect(workspaceService.system.appName).toEqual('affine'); + expect(() => workspaceScope.get(PageService)).toThrowError( + ComponentNotFoundError + ); + + const pageScope = workspaceScope.createScope(PageScope, { + pageId: 'test-page', + }); + expect(pageScope.props.pageId).toEqual('test-page'); + const pageService = pageScope.get(PageService); + expect(pageService.workspace).toBe(workspaceService); + expect(pageService.system.appName).toEqual('affine'); + + const editorScope = pageScope.createScope(EditorScope); + expect(editorScope.pageId).toEqual('test-page'); + const editorService = editorScope.get(EditorService); + expect(editorService.page).toBe(pageService); + }); + + test('scope event', () => { + const framework = new Framework(); + + const event = createEvent<{ value: number }>('test-event'); + + @OnEvent(event, p => p.onTestEvent) + class TestService extends Service { + value = 0; + + onTestEvent(payload: { value: number }) { + this.value = payload.value; + } + } + + class TestScope extends Scope {} + + @OnEvent(event, p => p.onTestEvent) + class TestScopeService extends Service { + value = 0; + + onTestEvent(payload: { value: number }) { + this.value = payload.value; + } + } + + framework.service(TestService).scope(TestScope).service(TestScopeService); + + const provider = framework.provider(); + const scope = provider.createScope(TestScope); + scope.emitEvent(event, { value: 123 }); + expect(provider.get(TestService).value).toEqual(0); + expect(scope.get(TestScopeService).value).toEqual(123); + }); + + test('dispose', () => { + const framework = new Framework(); + + let isSystemDisposed = false; + class System extends Service { + appName = 'affine'; + + override dispose(): void { + super.dispose(); + isSystemDisposed = true; + } + } + + framework.service(System); + + let isWorkspaceDisposed = false; + class WorkspaceScope extends Scope { + override dispose(): void { + super.dispose(); + isWorkspaceDisposed = true; + } + } + + let isWorkspacePageServiceDisposed = false; + class WorkspacePageService extends Service { + constructor( + public workspace: WorkspaceScope, + public sysmte: System + ) { + super(); + } + override dispose(): void { + super.dispose(); + isWorkspacePageServiceDisposed = true; + } + } + + framework + .scope(WorkspaceScope) + .service(WorkspacePageService, [WorkspaceScope, System]); + + { + using root = framework.provider(); + + { + // create a workspace + using workspaceScope = root.createScope(WorkspaceScope); + const pageService = workspaceScope.get(WorkspacePageService); + + expect(pageService).instanceOf(WorkspacePageService); + + expect( + isSystemDisposed || + isWorkspaceDisposed || + isWorkspacePageServiceDisposed + ).toBe(false); + } + expect(isWorkspaceDisposed && isWorkspacePageServiceDisposed).toBe(true); + + expect(isSystemDisposed).toBe(false); + } + expect(isSystemDisposed).toBe(true); + }); + + test('service not found', () => { + const framework = new Framework(); + + const provider = framework.provider(); + expect(() => provider.get(createIdentifier('SomeService'))).toThrowError( + ComponentNotFoundError + ); + }); + + test('missing dependency', () => { + const framework = new Framework(); + + class A extends Service { + value = 'hello world'; + } + + class B extends Service { + constructor(public a: A) { + super(); + } + } + + framework.service(B, [A]); + + const provider = framework.provider(); + expect(() => provider.get(B)).toThrowError(MissingDependencyError); + }); + + test('circular dependency', () => { + const framework = new Framework(); + + class A extends Service { + constructor(public c: C) { + super(); + } + } + + class B extends Service { + constructor(public a: A) { + super(); + } + } + + class C extends Service { + constructor(public b: B) { + super(); + } + } + + framework.service(A, [C]).service(B, [A]).service(C, [B]); + + const provider = framework.provider(); + expect(() => provider.get(A)).toThrowError(CircularDependencyError); + expect(() => provider.get(B)).toThrowError(CircularDependencyError); + expect(() => provider.get(C)).toThrowError(CircularDependencyError); + }); + + test('duplicate service definition', () => { + const serviceCollection = new Framework(); + + class A extends Service {} + + serviceCollection.service(A); + expect(() => serviceCollection.service(A)).toThrowError( + DuplicateDefinitionError + ); + + class B {} + const Something = createIdentifier('something'); + serviceCollection.impl(Something, A); + expect(() => serviceCollection.impl(Something, B)).toThrowError( + DuplicateDefinitionError + ); + }); + + test('recursion limit', () => { + // maxmium resolve depth is 100 + const serviceCollection = new Framework(); + const Something = createIdentifier('something'); + let i = 0; + for (; i < 100; i++) { + const next = i + 1; + + class Test { + constructor(_next: any) {} + } + + serviceCollection.impl(Something(i.toString()), Test, [ + Something(next.toString()), + ]); + } + + class Final { + a = 'b'; + } + serviceCollection.impl(Something(i.toString()), Final); + const provider = serviceCollection.provider(); + expect(() => provider.get(Something('0'))).toThrowError( + RecursionLimitError + ); + }); +}); diff --git a/packages/common/infra/src/framework/core/components/component.ts b/packages/common/infra/src/framework/core/components/component.ts new file mode 100644 index 0000000000..4f1446984d --- /dev/null +++ b/packages/common/infra/src/framework/core/components/component.ts @@ -0,0 +1,27 @@ +import { CONSTRUCTOR_CONTEXT } from '../constructor-context'; +import type { FrameworkProvider } from '../provider'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export class Component { + readonly framework: FrameworkProvider; + readonly props: Props; + + get eventBus() { + return this.framework.eventBus; + } + + constructor() { + if (!CONSTRUCTOR_CONTEXT.current.provider) { + throw new Error('Component must be created in the context of a provider'); + } + this.framework = CONSTRUCTOR_CONTEXT.current.provider; + this.props = CONSTRUCTOR_CONTEXT.current.props; + CONSTRUCTOR_CONTEXT.current = {}; + } + + dispose() {} + + [Symbol.dispose]() { + this.dispose(); + } +} diff --git a/packages/common/infra/src/framework/core/components/entity.ts b/packages/common/infra/src/framework/core/components/entity.ts new file mode 100644 index 0000000000..d6c8e2bc96 --- /dev/null +++ b/packages/common/infra/src/framework/core/components/entity.ts @@ -0,0 +1,6 @@ +import { Component } from './component'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export class Entity extends Component { + readonly __isEntity = true; +} diff --git a/packages/common/infra/src/framework/core/components/scope.ts b/packages/common/infra/src/framework/core/components/scope.ts new file mode 100644 index 0000000000..2c4a054c3a --- /dev/null +++ b/packages/common/infra/src/framework/core/components/scope.ts @@ -0,0 +1,43 @@ +import { Component } from './component'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export class Scope extends Component { + readonly __injectable = true; + + get collection() { + return this.framework.collection; + } + + get scope() { + return this.framework.scope; + } + + get get() { + return this.framework.get; + } + + get getAll() { + return this.framework.getAll; + } + + get getOptional() { + return this.framework.getOptional; + } + + get createEntity() { + return this.framework.createEntity; + } + + get createScope() { + return this.framework.createScope; + } + + get emitEvent() { + return this.framework.emitEvent; + } + + override dispose(): void { + super.dispose(); + this.framework.dispose(); + } +} diff --git a/packages/common/infra/src/framework/core/components/service.ts b/packages/common/infra/src/framework/core/components/service.ts new file mode 100644 index 0000000000..07e10b1ea2 --- /dev/null +++ b/packages/common/infra/src/framework/core/components/service.ts @@ -0,0 +1,6 @@ +import { Component } from './component'; + +export class Service extends Component { + readonly __isService = true; + readonly __injectable = true; +} diff --git a/packages/common/infra/src/framework/core/components/store.ts b/packages/common/infra/src/framework/core/components/store.ts new file mode 100644 index 0000000000..0442dbc274 --- /dev/null +++ b/packages/common/infra/src/framework/core/components/store.ts @@ -0,0 +1,6 @@ +import { Component } from './component'; + +export class Store extends Component { + readonly __isStore = true; + readonly __injectable = true; +} diff --git a/packages/common/infra/src/framework/core/constructor-context.ts b/packages/common/infra/src/framework/core/constructor-context.ts new file mode 100644 index 0000000000..8d68cb6375 --- /dev/null +++ b/packages/common/infra/src/framework/core/constructor-context.ts @@ -0,0 +1,23 @@ +import type { FrameworkProvider } from './provider'; + +interface Context { + provider?: FrameworkProvider; + props?: any; +} + +export const CONSTRUCTOR_CONTEXT: { + current: Context; +} = { current: {} }; + +/** + * @internal + */ +export function withContext(cb: () => T, context: Context): T { + const pre = CONSTRUCTOR_CONTEXT.current; + try { + CONSTRUCTOR_CONTEXT.current = context; + return cb(); + } finally { + CONSTRUCTOR_CONTEXT.current = pre; + } +} diff --git a/packages/common/infra/src/framework/core/consts.ts b/packages/common/infra/src/framework/core/consts.ts new file mode 100644 index 0000000000..4426282d42 --- /dev/null +++ b/packages/common/infra/src/framework/core/consts.ts @@ -0,0 +1,6 @@ +import type { ComponentVariant } from './types'; + +export const DEFAULT_VARIANT: ComponentVariant = 'default'; +export const ROOT_SCOPE = []; + +export const SUB_COMPONENTS = Symbol('subComponents'); diff --git a/packages/common/infra/src/framework/core/error.ts b/packages/common/infra/src/framework/core/error.ts new file mode 100644 index 0000000000..ae0aa10b3d --- /dev/null +++ b/packages/common/infra/src/framework/core/error.ts @@ -0,0 +1,59 @@ +import { DEFAULT_VARIANT } from './consts'; +import type { IdentifierValue } from './types'; + +export class RecursionLimitError extends Error { + constructor() { + super('Dynamic resolve recursion limit reached'); + } +} + +export class CircularDependencyError extends Error { + constructor(public readonly dependencyStack: IdentifierValue[]) { + super( + `A circular dependency was detected.\n` + + stringifyDependencyStack(dependencyStack) + ); + } +} + +export class ComponentNotFoundError extends Error { + constructor(public readonly identifier: IdentifierValue) { + super( + `Component ${stringifyIdentifier(identifier)} not found in container` + ); + } +} + +export class MissingDependencyError extends Error { + constructor( + public readonly from: IdentifierValue, + public readonly target: IdentifierValue, + public readonly dependencyStack: IdentifierValue[] + ) { + super( + `Missing dependency ${stringifyIdentifier( + target + )} in creating ${stringifyIdentifier( + from + )}.\n${stringifyDependencyStack(dependencyStack)}` + ); + } +} + +export class DuplicateDefinitionError extends Error { + constructor(public readonly identifier: IdentifierValue) { + super(`${stringifyIdentifier(identifier)} already exists`); + } +} + +function stringifyIdentifier(identifier: IdentifierValue) { + return `[${identifier.identifierName}]${ + identifier.variant !== DEFAULT_VARIANT ? `(${identifier.variant})` : '' + }`; +} + +function stringifyDependencyStack(dependencyStack: IdentifierValue[]) { + return dependencyStack + .map(identifier => `${stringifyIdentifier(identifier)}`) + .join(' -> '); +} diff --git a/packages/common/infra/src/framework/core/event.ts b/packages/common/infra/src/framework/core/event.ts new file mode 100644 index 0000000000..19c0731682 --- /dev/null +++ b/packages/common/infra/src/framework/core/event.ts @@ -0,0 +1,111 @@ +import { DebugLogger } from '@affine/debug'; + +import { stableHash } from '../../utils'; +import type { FrameworkProvider } from '.'; +import type { Service } from './components/service'; +import { SUB_COMPONENTS } from './consts'; +import { createIdentifier } from './identifier'; +import type { SubComponent } from './types'; + +export interface FrameworkEvent { + id: string; + _type: T; +} + +export function createEvent(id: string): FrameworkEvent { + return { id, _type: {} as T }; +} + +export type FrameworkEventType = + T extends FrameworkEvent ? E : never; + +const logger = new DebugLogger('affine:event-bus'); + +export class EventBus { + private listeners: Record void>> = {}; + + constructor( + provider: FrameworkProvider, + private readonly parent?: EventBus + ) { + const handlers = provider.getAll(EventHandler, { + sameScope: true, + }); + + for (const handler of handlers.values()) { + this.on(handler.event.id, handler.handler); + } + } + + on(id: string, listener: (event: FrameworkEvent) => void) { + if (!this.listeners[id]) { + this.listeners[id] = []; + } + this.listeners[id].push(listener); + const off = this.parent?.on(id, listener); + return () => { + this.off(id, listener); + off?.(); + }; + } + + off(id: string, listener: (event: FrameworkEvent) => void) { + if (!this.listeners[id]) { + return; + } + this.listeners[id] = this.listeners[id].filter(l => l !== listener); + } + + emit(event: FrameworkEvent, payload: T) { + logger.debug('Emitting event', event.id, payload); + const listeners = this.listeners[event.id]; + if (!listeners) { + return; + } + listeners.forEach(listener => { + try { + listener(payload); + } catch (e) { + console.error(e); + } + }); + } +} + +interface EventHandler { + event: FrameworkEvent; + handler: (payload: any) => void; +} + +export const EventHandler = createIdentifier('EventHandler'); + +export const OnEvent = < + E extends FrameworkEvent, + C extends abstract new (...args: any) => any, + I = InstanceType, +>( + e: E, + pick: I extends Service ? (i: I) => (e: FrameworkEventType) => void : never +) => { + return (target: C): C => { + const handlers = (target as any)[SUB_COMPONENTS] ?? []; + (target as any)[SUB_COMPONENTS] = [ + ...handlers, + { + identifier: EventHandler( + target.name + stableHash(e) + stableHash(pick) + ), + factory: provider => { + return { + event: e, + handler: (payload: any) => { + const i = provider.get(target); + pick(i).apply(i, [payload]); + }, + } satisfies EventHandler; + }, + } satisfies SubComponent, + ]; + return target; + }; +}; diff --git a/packages/common/infra/src/framework/core/framework.ts b/packages/common/infra/src/framework/core/framework.ts new file mode 100644 index 0000000000..7dc2c7e207 --- /dev/null +++ b/packages/common/infra/src/framework/core/framework.ts @@ -0,0 +1,527 @@ +import type { Component } from './components/component'; +import type { Entity } from './components/entity'; +import type { Scope } from './components/scope'; +import type { Service } from './components/service'; +import type { Store } from './components/store'; +import { DEFAULT_VARIANT, ROOT_SCOPE, SUB_COMPONENTS } from './consts'; +import { DuplicateDefinitionError } from './error'; +import { parseIdentifier } from './identifier'; +import type { FrameworkProvider } from './provider'; +import { BasicFrameworkProvider } from './provider'; +import { stringifyScope } from './scope'; +import type { + ComponentFactory, + ComponentVariant, + FrameworkScopeStack, + GeneralIdentifier, + Identifier, + IdentifierType, + IdentifierValue, + SubComponent, + Type, + TypesToDeps, +} from './types'; + +export class Framework { + private readonly components: Map< + string, + Map> + > = new Map(); + + /** + * Create an empty framework. + * + * same as `new Framework()` + */ + static get EMPTY() { + return new Framework(); + } + + /** + * The number of components in the framework. + */ + get componentCount() { + let count = 0; + for (const [, identifiers] of this.components) { + for (const [, variants] of identifiers) { + count += variants.size; + } + } + return count; + } + + /** + * @see {@link FrameworkEditor.service} + */ + get service() { + return new FrameworkEditor(this).service; + } + + /** + * @see {@link FrameworkEditor.impl} + */ + get impl() { + return new FrameworkEditor(this).impl; + } + + /** + * @see {@link FrameworkEditor.entity} + */ + get entity() { + return new FrameworkEditor(this).entity; + } + + /** + * @see {@link FrameworkEditor.scope} + */ + get scope() { + return new FrameworkEditor(this).scope; + } + + /** + * @see {@link FrameworkEditor.override} + */ + get override() { + return new FrameworkEditor(this).override; + } + + /** + * @see {@link FrameworkEditor.store} + */ + get store() { + return new FrameworkEditor(this).store; + } + + /** + * @internal Use {@link impl} instead. + */ + addValue( + identifier: GeneralIdentifier, + value: T, + { + scope, + override, + }: { scope?: FrameworkScopeStack; override?: boolean } = {} + ) { + this.addFactory(parseIdentifier(identifier) as Identifier, () => value, { + scope, + override, + }); + } + + /** + * @internal Use {@link impl} instead. + */ + addFactory( + identifier: GeneralIdentifier, + factory: ComponentFactory, + { + scope, + override, + }: { scope?: FrameworkScopeStack; override?: boolean } = {} + ) { + // convert scope to string + const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE); + const normalizedIdentifier = parseIdentifier(identifier); + const normalizedVariant = normalizedIdentifier.variant ?? DEFAULT_VARIANT; + + const services = + this.components.get(normalizedScope) ?? + new Map>(); + + const variants = + services.get(normalizedIdentifier.identifierName) ?? + new Map(); + + // throw if service already exists, unless it is an override + if (variants.has(normalizedVariant) && !override) { + throw new DuplicateDefinitionError(normalizedIdentifier); + } + variants.set(normalizedVariant, factory); + services.set(normalizedIdentifier.identifierName, variants); + this.components.set(normalizedScope, services); + } + + remove(identifier: IdentifierValue, scope: FrameworkScopeStack = ROOT_SCOPE) { + const normalizedScope = stringifyScope(scope); + const normalizedIdentifier = parseIdentifier(identifier); + const normalizedVariant = normalizedIdentifier.variant ?? DEFAULT_VARIANT; + + const services = this.components.get(normalizedScope); + if (!services) { + return; + } + + const variants = services.get(normalizedIdentifier.identifierName); + if (!variants) { + return; + } + + variants.delete(normalizedVariant); + } + + /** + * Create a service provider from the collection. + * + * @example + * ```ts + * provider() // create a service provider for root scope + * provider(ScopeA, parentProvider) // create a service provider for scope A + * ``` + * + * @param scope The scope of the service provider, default to the root scope. + * @param parent The parent service provider, it is required if the scope is not the root scope. + */ + provider( + scope: FrameworkScopeStack = ROOT_SCOPE, + parent: FrameworkProvider | null = null + ): FrameworkProvider { + return new BasicFrameworkProvider(this, scope, parent); + } + + /** + * @internal + */ + getFactory( + identifier: IdentifierValue, + scope: FrameworkScopeStack = ROOT_SCOPE + ): ComponentFactory | undefined { + return this.components + .get(stringifyScope(scope)) + ?.get(identifier.identifierName) + ?.get(identifier.variant ?? DEFAULT_VARIANT); + } + + /** + * @internal + */ + getFactoryAll( + identifier: IdentifierValue, + scope: FrameworkScopeStack = ROOT_SCOPE + ): Map { + return new Map( + this.components.get(stringifyScope(scope))?.get(identifier.identifierName) + ); + } + + /** + * Clone the entire service collection. + * + * This method is quite cheap as it only clones the references. + * + * @returns A new service collection with the same services. + */ + clone(): Framework { + const di = new Framework(); + for (const [scope, identifiers] of this.components) { + const s = new Map(); + for (const [identifier, variants] of identifiers) { + s.set(identifier, new Map(variants)); + } + di.components.set(scope, s); + } + return di; + } +} + +/** + * A helper class to edit a framework. + */ +class FrameworkEditor { + private currentScopeStack: FrameworkScopeStack = ROOT_SCOPE; + + constructor(private readonly collection: Framework) {} + + /** + * Add a service to the framework. + * + * @see {@link Framework} + * + * @example + * ```ts + * service(ServiceClass, [dependencies, ...]) + * ``` + */ + service = < + Arg1 extends Type, + Arg2 extends Deps | ComponentFactory | ServiceType, + ServiceType = IdentifierType, + Deps = Arg1 extends Type + ? TypesToDeps> + : [], + >( + service: Arg1, + ...[arg2]: Arg2 extends [] ? [] : [Arg2] + ): this => { + if (arg2 instanceof Function) { + this.collection.addFactory(service as any, arg2 as any, { + scope: this.currentScopeStack, + }); + } else if (arg2 instanceof Array || arg2 === undefined) { + this.collection.addFactory( + service as any, + dependenciesToFactory(service, arg2 as any), + { scope: this.currentScopeStack } + ); + } else { + this.collection.addValue(service as any, arg2, { + scope: this.currentScopeStack, + }); + } + + if (SUB_COMPONENTS in service) { + const subComponents = (service as any)[SUB_COMPONENTS] as SubComponent[]; + for (const { identifier, factory } of subComponents) { + this.collection.addFactory(identifier, factory, { + scope: this.currentScopeStack, + }); + } + } + + return this; + }; + + /** + * Add a store to the framework. + * + * @see {@link Framework} + * + * @example + * ```ts + * store(StoreClass, [dependencies, ...]) + * ``` + */ + store = < + Arg1 extends Type, + Arg2 extends Deps | ComponentFactory | StoreType, + StoreType = IdentifierType, + Deps = Arg1 extends Type + ? TypesToDeps> + : [], + >( + store: Arg1, + ...[arg2]: Arg2 extends [] ? [] : [Arg2] + ): this => { + if (arg2 instanceof Function) { + this.collection.addFactory(store as any, arg2 as any, { + scope: this.currentScopeStack, + }); + } else if (arg2 instanceof Array || arg2 === undefined) { + this.collection.addFactory( + store as any, + dependenciesToFactory(store, arg2 as any), + { scope: this.currentScopeStack } + ); + } else { + this.collection.addValue(store as any, arg2, { + scope: this.currentScopeStack, + }); + } + + if (SUB_COMPONENTS in store) { + const subComponents = (store as any)[SUB_COMPONENTS] as SubComponent[]; + for (const { identifier, factory } of subComponents) { + this.collection.addFactory(identifier, factory, { + scope: this.currentScopeStack, + }); + } + } + + return this; + }; + + /** + * Add an entity to the framework. + */ + entity = < + Arg1 extends Type>, + Arg2 extends Deps | ComponentFactory, + EntityType = IdentifierType, + Deps = Arg1 extends Type + ? TypesToDeps> + : [], + >( + entity: Arg1, + ...[arg2]: Arg2 extends [] ? [] : [Arg2] + ): this => { + if (arg2 instanceof Function) { + this.collection.addFactory(entity as any, arg2 as any, { + scope: this.currentScopeStack, + }); + } else { + this.collection.addFactory( + entity as any, + dependenciesToFactory(entity, arg2 as any), + { scope: this.currentScopeStack } + ); + } + + return this; + }; + + /** + * Add an implementation for identifier to the collection. + * + * @see {@link Framework} + * + * @example + * ```ts + * addImpl(Identifier, Class, [dependencies, ...]) + * or + * addImpl(Identifier, Instance) + * or + * addImpl(Identifier, Factory) + * ``` + */ + impl = < + Arg1 extends Identifier, + Arg2 extends Type | ComponentFactory | Trait, + Arg3 extends Deps, + Trait = IdentifierType, + Deps = Arg2 extends Type + ? TypesToDeps> + : [], + >( + identifier: Arg1, + arg2: Arg2, + ...[arg3]: Arg3 extends [] ? [] : [Arg3] + ): this => { + if (arg2 instanceof Function) { + this.collection.addFactory( + identifier, + dependenciesToFactory(arg2, arg3 as any[]), + { scope: this.currentScopeStack } + ); + } else { + this.collection.addValue(identifier, arg2 as any, { + scope: this.currentScopeStack, + }); + } + + return this; + }; + + /** + * same as {@link impl} but this method will override the component if it exists. + * + * @see {@link Framework} + * + * @example + * ```ts + * override(OriginClass, NewClass, [dependencies, ...]) + * or + * override(Identifier, Class, [dependencies, ...]) + * or + * override(Identifier, Instance) + * or + * override(Identifier, Factory) + * ``` + */ + override = < + Arg1 extends GeneralIdentifier, + Arg2 extends Type | ComponentFactory | Trait | null, + Arg3 extends Deps, + Trait extends Component = IdentifierType, + Deps = Arg2 extends Type + ? TypesToDeps> + : [], + >( + identifier: Arg1, + arg2: Arg2, + ...[arg3]: Arg3 extends [] ? [] : [Arg3] + ): this => { + if (arg2 === null) { + this.collection.remove( + parseIdentifier(identifier), + this.currentScopeStack + ); + return this; + } else if (arg2 instanceof Function) { + this.collection.addFactory( + identifier, + dependenciesToFactory(arg2, arg3 as any[]), + { scope: this.currentScopeStack, override: true } + ); + } else { + this.collection.addValue(identifier, arg2 as any, { + scope: this.currentScopeStack, + override: true, + }); + } + + return this; + }; + + /** + * Set the scope for the service registered subsequently + * + * @example + * + * ```ts + * const ScopeA = createScope('a'); + * + * services.scope(ScopeA).add(XXXService, ...); + * ``` + */ + scope = (scope: Type>): this => { + this.currentScopeStack = [ + ...this.currentScopeStack, + parseIdentifier(scope).identifierName, + ]; + + this.collection.addFactory( + scope as any, + dependenciesToFactory(scope, [] as any), + { scope: this.currentScopeStack, override: true } + ); + + return this; + }; +} + +/** + * Convert dependencies definition to a factory function. + */ +function dependenciesToFactory( + cls: any, + deps: any[] = [] +): ComponentFactory { + return (provider: FrameworkProvider) => { + const args = []; + for (const dep of deps) { + let isAll; + let identifier; + if (Array.isArray(dep)) { + if (dep.length !== 1) { + throw new Error('Invalid dependency'); + } + isAll = true; + identifier = dep[0]; + } else { + isAll = false; + identifier = dep; + } + if (isAll) { + args.push(Array.from(provider.getAll(identifier).values())); + } else { + args.push(provider.get(identifier)); + } + } + if (isConstructor(cls)) { + return new cls(...args, provider); + } else { + return cls(...args, provider); + } + }; +} + +// a hack to check if a function is a constructor +// https://github.com/zloirock/core-js/blob/232c8462c26c75864b4397b7f643a4f57c6981d5/packages/core-js/internals/is-constructor.js#L15 +function isConstructor(cls: any) { + try { + Reflect.construct(function () {}, [], cls); + return true; + } catch (error) { + return false; + } +} diff --git a/packages/common/infra/src/di/core/identifier.ts b/packages/common/infra/src/framework/core/identifier.ts similarity index 70% rename from packages/common/infra/src/di/core/identifier.ts rename to packages/common/infra/src/framework/core/identifier.ts index 5812207e2d..044e616e2d 100644 --- a/packages/common/infra/src/di/core/identifier.ts +++ b/packages/common/infra/src/framework/core/identifier.ts @@ -1,16 +1,17 @@ import { stableHash } from '../../utils/stable-hash'; -import { DEFAULT_SERVICE_VARIANT } from './consts'; +import type { Component } from './components/component'; +import { DEFAULT_VARIANT } from './consts'; import type { - ServiceIdentifier, - ServiceIdentifierValue, - ServiceVariant, + ComponentVariant, + Identifier, + IdentifierValue, Type, } from './types'; /** - * create a ServiceIdentifier. + * create a Identifier. * - * ServiceIdentifier is used to identify a certain type of service. With the identifier, you can reference one or more services + * Identifier is used to identify a certain type of service. With the identifier, you can reference one or more services * without knowing the specific implementation, thereby achieving * [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control). * @@ -38,10 +39,10 @@ import type { * } * * // register the implementation to the identifier - * services.addImpl(Storage, LocalStorage); + * framework.impl(Storage, LocalStorage); * * // get the implementation from the identifier - * const storage = services.provider().get(Storage); + * const storage = framework.provider().get(Storage); * storage.set('foo', 'bar'); * ``` * @@ -63,13 +64,13 @@ import type { * const LocalStorage = Storage('local'); * const SessionStorage = Storage('session'); * - * services.addImpl(LocalStorage, LocalStorageImpl); - * services.addImpl(SessionStorage, SessionStorageImpl); + * framework.impl(LocalStorage, LocalStorageImpl); + * framework.impl(SessionStorage, SessionStorageImpl); * * // get the implementation from the identifier - * const localStorage = services.provider().get(LocalStorage); - * const sessionStorage = services.provider().get(SessionStorage); - * const storage = services.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl } + * const localStorage = framework.provider().get(LocalStorage); + * const sessionStorage = framework.provider().get(SessionStorage); + * const storage = framework.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl } * ``` * * @param name unique name of the identifier. @@ -77,10 +78,10 @@ import type { */ export function createIdentifier( name: string, - variant: ServiceVariant = DEFAULT_SERVICE_VARIANT -): ServiceIdentifier & ((variant: ServiceVariant) => ServiceIdentifier) { + variant: ComponentVariant = DEFAULT_VARIANT +): Identifier & ((variant: ComponentVariant) => Identifier) { return Object.assign( - (variant: ServiceVariant) => { + (variant: ComponentVariant) => { return createIdentifier(name, variant); }, { @@ -96,15 +97,15 @@ export function createIdentifier( * * @internal */ -export function createIdentifierFromConstructor( +export function createIdentifierFromConstructor( target: Type -): ServiceIdentifier { +): Identifier { return createIdentifier(`${target.name}${stableHash(target)}`); } -export function parseIdentifier(input: any): ServiceIdentifierValue { +export function parseIdentifier(input: any): IdentifierValue { if (input.identifierName) { - return input as ServiceIdentifierValue; + return input as IdentifierValue; } else if (typeof input === 'function' && input.name) { return createIdentifierFromConstructor(input); } else { diff --git a/packages/common/infra/src/framework/core/index.ts b/packages/common/infra/src/framework/core/index.ts new file mode 100644 index 0000000000..b5b1fca9d9 --- /dev/null +++ b/packages/common/infra/src/framework/core/index.ts @@ -0,0 +1,10 @@ +export { Entity } from './components/entity'; +export { Scope } from './components/scope'; +export { Service } from './components/service'; +export { Store } from './components/store'; +export * from './error'; +export { createEvent, OnEvent } from './event'; +export { Framework } from './framework'; +export { createIdentifier } from './identifier'; +export type { FrameworkProvider, ResolveOptions } from './provider'; +export type { GeneralIdentifier } from './types'; diff --git a/packages/common/infra/src/framework/core/provider.ts b/packages/common/infra/src/framework/core/provider.ts new file mode 100644 index 0000000000..58939f77ae --- /dev/null +++ b/packages/common/infra/src/framework/core/provider.ts @@ -0,0 +1,321 @@ +import type { Component } from './components/component'; +import type { Entity } from './components/entity'; +import type { Scope } from './components/scope'; +import { withContext } from './constructor-context'; +import { + CircularDependencyError, + ComponentNotFoundError, + MissingDependencyError, + RecursionLimitError, +} from './error'; +import { EventBus, type FrameworkEvent } from './event'; +import type { Framework } from './framework'; +import { parseIdentifier } from './identifier'; +import type { + ComponentVariant, + FrameworkScopeStack, + GeneralIdentifier, + IdentifierValue, +} from './types'; + +export interface ResolveOptions { + sameScope?: boolean; + optional?: boolean; + noCache?: boolean; + props?: any; +} + +export abstract class FrameworkProvider { + abstract collection: Framework; + abstract scope: FrameworkScopeStack; + abstract getRaw(identifier: IdentifierValue, options?: ResolveOptions): any; + abstract getAllRaw( + identifier: IdentifierValue, + options?: ResolveOptions + ): Map; + abstract dispose(): void; + abstract eventBus: EventBus; + + get = (identifier: GeneralIdentifier, options?: ResolveOptions): T => { + return this.getRaw(parseIdentifier(identifier), { + ...options, + optional: false, + }); + }; + + getAll = ( + identifier: GeneralIdentifier, + options?: ResolveOptions + ): Map => { + return this.getAllRaw(parseIdentifier(identifier), { + ...options, + }); + }; + + getOptional = ( + identifier: GeneralIdentifier, + options?: ResolveOptions + ): T | null => { + return this.getRaw(parseIdentifier(identifier), { + ...options, + optional: true, + }); + }; + + createEntity = < + T extends Entity, + Props extends T extends Component ? P : never, + >( + identifier: GeneralIdentifier, + ...[props]: Props extends Record ? [] : [Props] + ): T => { + return this.getRaw(parseIdentifier(identifier), { + noCache: true, + sameScope: true, + props, + }); + }; + + createScope = < + T extends Scope, + Props extends T extends Component ? P : never, + >( + root: GeneralIdentifier, + ...[props]: Props extends Record ? [] : [Props] + ): T => { + const newProvider = this.collection.provider( + [...this.scope, parseIdentifier(root).identifierName], + this + ); + return newProvider.getRaw(parseIdentifier(root), { + sameScope: true, + props, + }); + }; + + emitEvent = (event: FrameworkEvent, payload: T) => { + this.eventBus.emit(event, payload); + }; + + [Symbol.dispose]() { + this.dispose(); + } +} + +export class ComponentCachePool { + cache: Map> = new Map(); + + getOrInsert(identifier: IdentifierValue, insert: () => any) { + const cache = this.cache.get(identifier.identifierName) ?? new Map(); + if (!cache.has(identifier.variant)) { + cache.set(identifier.variant, insert()); + } + const cached = cache.get(identifier.variant); + this.cache.set(identifier.identifierName, cache); + return cached; + } + + dispose() { + for (const t of this.cache.values()) { + for (const i of t.values()) { + if (typeof i === 'object' && typeof i[Symbol.dispose] === 'function') { + try { + i[Symbol.dispose](); + } catch (err) { + setImmediate(() => { + throw err; + }); + } + } + } + } + } + + [Symbol.dispose]() { + this.dispose(); + } +} + +class Resolver extends FrameworkProvider { + constructor( + public readonly provider: BasicFrameworkProvider, + public readonly depth = 0, + public readonly stack: IdentifierValue[] = [] + ) { + super(); + } + + scope = this.provider.scope; + collection = this.provider.collection; + eventBus = this.provider.eventBus; + + getRaw( + identifier: IdentifierValue, + { + sameScope = false, + optional = false, + noCache = false, + props, + }: ResolveOptions = {} + ) { + const factory = this.provider.collection.getFactory( + identifier, + this.provider.scope + ); + if (!factory) { + if (this.provider.parent && !sameScope) { + return this.provider.parent.getRaw(identifier, { + sameScope: sameScope, + optional, + noCache, + props, + }); + } + + if (optional) { + return undefined; + } + throw new ComponentNotFoundError(identifier); + } + + const runFactory = () => { + const nextResolver = this.track(identifier); + try { + return withContext(() => factory(nextResolver), { + provider: this.provider, + props, + }); + } catch (err) { + if (err instanceof ComponentNotFoundError) { + throw new MissingDependencyError( + identifier, + err.identifier, + this.stack + ); + } + throw err; + } + }; + + if (noCache) { + return runFactory(); + } + + return this.provider.cache.getOrInsert(identifier, runFactory); + } + + getAllRaw( + identifier: IdentifierValue, + { sameScope = false, noCache, props }: ResolveOptions = {} + ): Map { + const vars = this.provider.collection.getFactoryAll( + identifier, + this.provider.scope + ); + + if (vars === undefined) { + if (this.provider.parent && !sameScope) { + return this.provider.parent.getAllRaw(identifier); + } + + return new Map(); + } + + const result = new Map(); + + for (const [variant, factory] of vars) { + // eslint-disable-next-line sonarjs/no-identical-functions + const runFactory = () => { + const nextResolver = this.track(identifier); + try { + return withContext(() => factory(nextResolver), { + provider: this.provider, + props, + }); + } catch (err) { + if (err instanceof ComponentNotFoundError) { + throw new MissingDependencyError( + identifier, + err.identifier, + this.stack + ); + } + throw err; + } + }; + let service; + if (noCache) { + service = runFactory(); + } else { + service = this.provider.cache.getOrInsert( + { + identifierName: identifier.identifierName, + variant, + }, + runFactory + ); + } + result.set(variant, service); + } + + return result; + } + + track(identifier: IdentifierValue): Resolver { + const depth = this.depth + 1; + if (depth >= 100) { + throw new RecursionLimitError(); + } + const circular = this.stack.find( + i => + i.identifierName === identifier.identifierName && + i.variant === identifier.variant + ); + if (circular) { + throw new CircularDependencyError([...this.stack, identifier]); + } + + return new Resolver(this.provider, depth, [...this.stack, identifier]); + } + + override dispose(): void {} +} + +export class BasicFrameworkProvider extends FrameworkProvider { + public readonly cache = new ComponentCachePool(); + public readonly collection: Framework; + public readonly eventBus: EventBus; + + disposed = false; + + constructor( + collection: Framework, + public readonly scope: string[], + public readonly parent: FrameworkProvider | null + ) { + super(); + this.collection = collection; + this.eventBus = new EventBus(this, this.parent?.eventBus); + } + + getRaw(identifier: IdentifierValue, options?: ResolveOptions) { + const resolver = new Resolver(this); + return resolver.getRaw(identifier, options); + } + + getAllRaw( + identifier: IdentifierValue, + options?: ResolveOptions + ): Map { + const resolver = new Resolver(this); + return resolver.getAllRaw(identifier, options); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.cache.dispose(); + } +} diff --git a/packages/common/infra/src/framework/core/scope.ts b/packages/common/infra/src/framework/core/scope.ts new file mode 100644 index 0000000000..5dd466cc2b --- /dev/null +++ b/packages/common/infra/src/framework/core/scope.ts @@ -0,0 +1,5 @@ +import type { FrameworkScopeStack } from './types'; + +export function stringifyScope(scope: FrameworkScopeStack): string { + return scope.join('/'); +} diff --git a/packages/common/infra/src/framework/core/types.ts b/packages/common/infra/src/framework/core/types.ts new file mode 100644 index 0000000000..7af5412a24 --- /dev/null +++ b/packages/common/infra/src/framework/core/types.ts @@ -0,0 +1,36 @@ +import type { FrameworkProvider } from './provider'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type Type = abstract new (...args: any) => T; + +export type ComponentFactory = (provider: FrameworkProvider) => T; +export type ComponentVariant = string; + +export type FrameworkScopeStack = string[]; + +export type IdentifierValue = { + identifierName: string; + variant: ComponentVariant; +}; + +export type GeneralIdentifier = Identifier | Type; + +export type Identifier = { + identifierName: string; + variant: ComponentVariant; + __TYPE__: T; +}; + +export type IdentifierType = + T extends Identifier ? R : T extends Type ? R : never; + +export type TypesToDeps = { + [index in keyof T]: + | GeneralIdentifier + | (T[index] extends (infer I)[] ? [GeneralIdentifier] : never); +}; + +export type SubComponent = { + identifier: Identifier; + factory: ComponentFactory; +}; diff --git a/packages/common/infra/src/di/index.ts b/packages/common/infra/src/framework/index.ts similarity index 100% rename from packages/common/infra/src/di/index.ts rename to packages/common/infra/src/framework/index.ts diff --git a/packages/common/infra/src/framework/react/index.tsx b/packages/common/infra/src/framework/react/index.tsx new file mode 100644 index 0000000000..ff09c0dc96 --- /dev/null +++ b/packages/common/infra/src/framework/react/index.tsx @@ -0,0 +1,126 @@ +import React, { useContext, useMemo } from 'react'; + +import type { FrameworkProvider, Scope, Service } from '../core'; +import { ComponentNotFoundError, Framework } from '../core'; +import { parseIdentifier } from '../core/identifier'; +import type { GeneralIdentifier, IdentifierType, Type } from '../core/types'; + +export const FrameworkStackContext = React.createContext([ + Framework.EMPTY.provider(), +]); + +export function useService( + identifier: GeneralIdentifier +): T { + const stack = useContext(FrameworkStackContext); + + let service: T | null = null; + + for (let i = stack.length - 1; i >= 0; i--) { + service = stack[i].getOptional(identifier, { + sameScope: true, + }); + + if (service) { + break; + } + } + + if (!service) { + throw new ComponentNotFoundError(parseIdentifier(identifier)); + } + + return service; +} + +/** + * Hook to get services from the current framework stack. + * + * Automatically converts the service name to camelCase. + * + * @example + * ```ts + * const { authService, userService } = useServices({ AuthService, UserService }); + * ``` + */ +export function useServices< + const T extends { [key in string]: GeneralIdentifier }, +>( + identifiers: T +): keyof T extends string + ? { [key in Uncapitalize]: IdentifierType]> } + : never { + const stack = useContext(FrameworkStackContext); + + const services: any = {}; + + for (const [key, value] of Object.entries(identifiers)) { + let service; + for (let i = stack.length - 1; i >= 0; i--) { + service = stack[i].getOptional(value, { + sameScope: true, + }); + + if (service) { + break; + } + } + + if (!service) { + throw new ComponentNotFoundError(parseIdentifier(value)); + } + + services[key.charAt(0).toLowerCase() + key.slice(1)] = service; + } + + return services; +} + +export function useServiceOptional( + identifier: Type +): T | null { + const stack = useContext(FrameworkStackContext); + + let service: T | null = null; + + for (let i = stack.length - 1; i >= 0; i--) { + service = stack[i].getOptional(identifier, { + sameScope: true, + }); + + if (service) { + break; + } + } + + return service; +} + +export const FrameworkRoot = ({ + framework, + children, +}: React.PropsWithChildren<{ framework: FrameworkProvider }>) => { + return ( + + {children} + + ); +}; + +export const FrameworkScope = ({ + scope, + children, +}: React.PropsWithChildren<{ scope?: Scope }>) => { + const stack = useContext(FrameworkStackContext); + + const nextStack = useMemo(() => { + if (!scope) return stack; + return [...stack, scope.framework]; + }, [stack, scope]); + + return ( + + {children} + + ); +}; diff --git a/packages/common/infra/src/index.ts b/packages/common/infra/src/index.ts index ba2e4bf9f8..03155ccf94 100644 --- a/packages/common/infra/src/index.ts +++ b/packages/common/infra/src/index.ts @@ -2,32 +2,40 @@ export * from './app-config-storage'; export * from './atom'; export * from './blocksuite'; export * from './command'; -export * from './di'; +export * from './framework'; export * from './initialization'; -export * from './lifecycle'; export * from './livedata'; -export * from './page'; +export * from './modules/doc'; +export * from './modules/global-context'; +export * from './modules/lifecycle'; +export * from './modules/storage'; +export * from './modules/workspace'; export * from './storage'; +export * from './sync'; export * from './utils'; -export * from './workspace'; -import type { ServiceCollection } from './di'; -import { CleanupService } from './lifecycle'; -import { configurePageServices } from './page'; -import { GlobalCache, GlobalState, MemoryMemento } from './storage'; +import type { Framework } from './framework'; +import { configureDocModule } from './modules/doc'; +import { configureGlobalContextModule } from './modules/global-context'; +import { configureLifecycleModule } from './modules/lifecycle'; import { - configureTestingWorkspaceServices, - configureWorkspaceServices, -} from './workspace'; + configureGlobalStorageModule, + configureTestingGlobalStorage, +} from './modules/storage'; +import { + configureTestingWorkspaceProvider, + configureWorkspaceModule, +} from './modules/workspace'; -export function configureInfraServices(services: ServiceCollection) { - services.add(CleanupService); - configureWorkspaceServices(services); - configurePageServices(services); +export function configureInfraModules(framework: Framework) { + configureWorkspaceModule(framework); + configureDocModule(framework); + configureGlobalStorageModule(framework); + configureGlobalContextModule(framework); + configureLifecycleModule(framework); } -export function configureTestingInfraServices(services: ServiceCollection) { - configureTestingWorkspaceServices(services); - services.override(GlobalCache, MemoryMemento); - services.override(GlobalState, MemoryMemento); +export function configureTestingInfraModules(framework: Framework) { + configureTestingGlobalStorage(framework); + configureTestingWorkspaceProvider(framework); } diff --git a/packages/common/infra/src/lifecycle/__test__/lifecycle.spec.ts b/packages/common/infra/src/lifecycle/__test__/lifecycle.spec.ts deleted file mode 100644 index e615d7b2ff..0000000000 --- a/packages/common/infra/src/lifecycle/__test__/lifecycle.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { CleanupService } from '..'; - -describe('lifecycle', () => { - test('cleanup', () => { - const cleanup = new CleanupService(); - let cleaned = false; - cleanup.add(() => { - cleaned = true; - }); - cleanup.cleanup(); - expect(cleaned).toBe(true); - }); -}); diff --git a/packages/common/infra/src/lifecycle/index.ts b/packages/common/infra/src/lifecycle/index.ts deleted file mode 100644 index 77ce5ebf30..0000000000 --- a/packages/common/infra/src/lifecycle/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class CleanupService { - private readonly cleanupCallbacks: (() => void)[] = []; - constructor() {} - add(fn: () => void) { - this.cleanupCallbacks.push(fn); - } - cleanup() { - this.cleanupCallbacks.forEach(fn => fn()); - } -} diff --git a/packages/common/infra/src/livedata/__tests__/livedata.spec.ts b/packages/common/infra/src/livedata/__tests__/livedata.spec.ts index 3e4d1d6e63..258d7b6e05 100644 --- a/packages/common/infra/src/livedata/__tests__/livedata.spec.ts +++ b/packages/common/infra/src/livedata/__tests__/livedata.spec.ts @@ -263,6 +263,13 @@ describe('livedata', () => { inner$.next(4); expect(flatten$.value).toEqual([4, 3]); } + + { + const wrapped$ = new LiveData([] as LiveData[]); + const flatten$ = wrapped$.flat(); + + expect(flatten$.value).toEqual([]); + } }); test('computed', () => { diff --git a/packages/common/infra/src/livedata/effect/index.ts b/packages/common/infra/src/livedata/effect/index.ts index b28561815e..2cfccf139a 100644 --- a/packages/common/infra/src/livedata/effect/index.ts +++ b/packages/common/infra/src/livedata/effect/index.ts @@ -4,10 +4,43 @@ import { type OperatorFunction, Subject } from 'rxjs'; const logger = new DebugLogger('effect'); -export interface Effect { - (value: T): void; -} +export type Effect = (T | undefined extends T // hack to detect if T is unknown + ? () => void + : (value: T) => void) & { + // unsubscribe effect, all ongoing effects will be cancelled. + unsubscribe: () => void; +}; +/** + * Create an effect. + * + * `effect( op1, op2, op3, ... )` + * + * You can think of an effect as a pipeline. When the effect is called, argument will be sent to the pipeline, + * and the operators in the pipeline can be triggered. + * + * + * + * @example + * ```ts + * const loadUser = effect( + * switchMap((id: number) => + * from(fetchUser(id)).pipe( + * mapInto(user$), + * catchErrorInto(error$), + * onStart(() => isLoading$.next(true)), + * onComplete(() => isLoading$.next(false)) + * ) + * ) + * ); + * + * // emit value to effect + * loadUser(1); + * + * // unsubscribe effect, will stop all ongoing processes + * loadUser.unsubscribe(); + * ``` + */ export function effect(op1: OperatorFunction): Effect; export function effect( op1: OperatorFunction, @@ -42,23 +75,47 @@ export function effect( export function effect(...args: any[]) { const subject$ = new Subject(); + const effectLocation = environment.isDebug + ? `(${new Error().stack?.split('\n')[2].trim()})` + : ''; + + class EffectError extends Unreachable { + constructor(message: string, value?: any) { + logger.error(`effect ${effectLocation} ${message}`, value); + super( + `effect ${effectLocation} ${message}` + + ` ${value ? (value instanceof Error ? value.stack ?? value.message : value + '') : ''}` + ); + } + } + // eslint-disable-next-line prefer-spread - subject$.pipe.apply(subject$, args as any).subscribe({ + const subscription = subject$.pipe.apply(subject$, args as any).subscribe({ next(value) { - logger.error('effect should not emit value', value); - throw new Unreachable('effect should not emit value'); + const error = new EffectError('should not emit value', value); + setImmediate(() => { + throw error; + }); }, complete() { - logger.error('effect unexpected complete'); - throw new Unreachable('effect unexpected complete'); + const error = new EffectError('effect unexpected complete'); + setImmediate(() => { + throw error; + }); }, error(error) { - logger.error('effect uncatched error', error); - throw new Unreachable('effect uncatched error'); + const effectError = new EffectError('effect uncaught error', error); + setImmediate(() => { + throw effectError; + }); }, }); - return ((value: unknown) => { + const fn = (value: unknown) => { subject$.next(value); - }) as never; + }; + + fn.unsubscribe = () => subscription.unsubscribe(); + + return fn as never; } diff --git a/packages/common/infra/src/livedata/index.ts b/packages/common/infra/src/livedata/index.ts index 089038bccd..0328d8443a 100644 --- a/packages/common/infra/src/livedata/index.ts +++ b/packages/common/infra/src/livedata/index.ts @@ -1,4 +1,12 @@ export { type Effect, effect } from './effect'; export { LiveData, PoisonedError } from './livedata'; -export { catchErrorInto, mapInto, onComplete, onStart } from './ops'; +export { + backoffRetry, + catchErrorInto, + exhaustMapSwitchUntilChanged, + fromPromise, + mapInto, + onComplete, + onStart, +} from './ops'; export { useEnsureLiveData, useLiveData } from './react'; diff --git a/packages/common/infra/src/livedata/livedata.ts b/packages/common/infra/src/livedata/livedata.ts index cb3cd819af..a5a5db0266 100644 --- a/packages/common/infra/src/livedata/livedata.ts +++ b/packages/common/infra/src/livedata/livedata.ts @@ -428,6 +428,9 @@ export class LiveData if (v instanceof LiveData) { return (v as LiveData).flat(); } else if (Array.isArray(v)) { + if (v.length === 0) { + return of([]); + } return combineLatest( v.map(v => { if (v instanceof LiveData) { @@ -446,6 +449,29 @@ export class LiveData ) as any; } + waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const subscription = this.subscribe(v => { + if (predicate(v)) { + resolve(v as any); + setImmediate(() => { + subscription.unsubscribe(); + }); + } + }); + signal?.addEventListener('abort', reason => { + subscription.unsubscribe(); + reject(reason); + }); + }); + } + + waitForNonNull(signal?: AbortSignal) { + return this.waitFor(v => v !== null && v !== undefined, signal) as Promise< + NonNullable + >; + } + reactSubscribe = (cb: () => void) => { if (this.isPoisoned) { throw this.poisonedError; diff --git a/packages/common/infra/src/livedata/ops.ts b/packages/common/infra/src/livedata/ops.ts index 7e848b955e..a066b6b459 100644 --- a/packages/common/infra/src/livedata/ops.ts +++ b/packages/common/infra/src/livedata/ops.ts @@ -1,14 +1,28 @@ import { catchError, + connect, + distinctUntilChanged, EMPTY, + exhaustMap, + merge, mergeMap, Observable, + type ObservableInput, + type ObservedValueOf, + of, type OperatorFunction, pipe, + retry, + switchMap, + throwError, + timer, } from 'rxjs'; import type { LiveData } from './livedata'; +/** + * An operator that maps the value to the `LiveData`. + */ export function mapInto(l$: LiveData) { return pipe( mergeMap((value: T) => { @@ -18,15 +32,30 @@ export function mapInto(l$: LiveData) { ); } -export function catchErrorInto(l$: LiveData) { +/** + * An operator that catches the error and sends it to the `LiveData`. + * + * The `LiveData` will be set to `null` when the observable completes. This is useful for error state recovery. + * + * @param cb A callback that will be called when an error occurs. + */ +export function catchErrorInto( + l$: LiveData, + cb?: (error: Error) => void +) { return pipe( + onComplete(() => l$.next(null)), catchError((error: any) => { l$.next(error); + cb?.(error); return EMPTY; }) ); } +/** + * An operator that calls the callback when the observable starts. + */ export function onStart(cb: () => void): OperatorFunction { return observable$ => new Observable(subscribe => { @@ -35,6 +64,9 @@ export function onStart(cb: () => void): OperatorFunction { }); } +/** + * An operator that calls the callback when the observable completes. + */ export function onComplete(cb: () => void): OperatorFunction { return observable$ => new Observable(subscribe => { @@ -52,3 +84,95 @@ export function onComplete(cb: () => void): OperatorFunction { }); }); } + +/** + * Convert a promise to an observable. + * + * like `from` but support `AbortSignal`. + */ +export function fromPromise( + promise: Promise | ((signal: AbortSignal) => Promise) +): Observable { + return new Observable(subscriber => { + const abortController = new AbortController(); + + const rawPromise = + promise instanceof Function ? promise(abortController.signal) : promise; + + rawPromise + .then(value => { + subscriber.next(value); + subscriber.complete(); + }) + .catch(error => { + subscriber.error(error); + }); + + return () => abortController.abort('Aborted'); + }); +} + +/** + * An operator that retries the source observable when an error occurs. + * + * https://en.wikipedia.org/wiki/Exponential_backoff + */ +export function backoffRetry({ + when, + count = 3, + delay = 200, + maxDelay = 15000, +}: { + when?: (err: any) => boolean; + count?: number; + delay?: number; + maxDelay?: number; +} = {}) { + return (obs$: Observable) => + obs$.pipe( + retry({ + count, + delay: (err, retryIndex) => { + if (when && !when(err)) { + return throwError(() => err); + } + const d = Math.pow(2, retryIndex - 1) * delay; + return timer(Math.min(d, maxDelay)); + }, + }) + ); +} + +/** + * An operator that combines `exhaustMap` and `switchMap`. + * + * This operator executes the `comparator` on each input, acting as an `exhaustMap` when the `comparator` returns `true` + * and acting as a `switchMap` when the comparator returns `false`. + * + * It is more useful for async processes that are relatively stable in results but sensitive to input. + * For example, when requesting the user's subscription status, `exhaustMap` is used because the user's subscription + * does not change often, but when switching users, the request should be made immediately like `switchMap`. + * + * @param onSwitch callback will be executed when `switchMap` occurs (including the first execution). + */ +export function exhaustMapSwitchUntilChanged>( + comparator: (previous: T, current: T) => boolean, + project: (value: T, index: number) => O, + onSwitch?: (value: T) => void +): OperatorFunction> { + return pipe( + connect(shared$ => + shared$.pipe( + distinctUntilChanged(comparator), + switchMap(value => { + onSwitch?.(value); + return merge(of(value), shared$).pipe( + exhaustMap((value, index) => { + return project(value, index); + }) + ); + }) + ) + ) + ); +} diff --git a/packages/common/infra/src/modules/doc/entities/doc.ts b/packages/common/infra/src/modules/doc/entities/doc.ts new file mode 100644 index 0000000000..fe7656711a --- /dev/null +++ b/packages/common/infra/src/modules/doc/entities/doc.ts @@ -0,0 +1,28 @@ +import { Entity } from '../../../framework'; +import type { DocScope } from '../scopes/doc'; +import type { DocMode } from './record'; + +export class Doc extends Entity { + constructor(public readonly scope: DocScope) { + super(); + } + + get id() { + return this.scope.props.docId; + } + + public readonly blockSuiteDoc = this.scope.props.blockSuiteDoc; + public readonly record = this.scope.props.record; + + readonly meta$ = this.record.meta$; + readonly mode$ = this.record.mode$; + readonly title$ = this.record.title$; + + setMode(mode: DocMode) { + this.record.setMode(mode); + } + + toggleMode() { + this.record.toggleMode(); + } +} diff --git a/packages/common/infra/src/modules/doc/entities/record-list.ts b/packages/common/infra/src/modules/doc/entities/record-list.ts new file mode 100644 index 0000000000..3749c9add1 --- /dev/null +++ b/packages/common/infra/src/modules/doc/entities/record-list.ts @@ -0,0 +1,40 @@ +import { map } from 'rxjs'; + +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import type { DocsStore } from '../stores/docs'; +import { DocRecord } from './record'; + +export class DocRecordList extends Entity { + constructor(private readonly store: DocsStore) { + super(); + } + + private readonly pool = new Map(); + + public readonly docs$ = LiveData.from( + this.store.watchDocIds().pipe( + map(ids => + ids.map(id => { + const exists = this.pool.get(id); + if (exists) { + return exists; + } + const record = this.framework.createEntity(DocRecord, { id }); + this.pool.set(id, record); + return record; + }) + ) + ), + [] + ); + + public readonly isReady$ = LiveData.from( + this.store.watchDocListReady(), + false + ); + + public doc$(id: string) { + return this.docs$.map(record => record.find(record => record.id === id)); + } +} diff --git a/packages/common/infra/src/modules/doc/entities/record.ts b/packages/common/infra/src/modules/doc/entities/record.ts new file mode 100644 index 0000000000..d7ce3b1394 --- /dev/null +++ b/packages/common/infra/src/modules/doc/entities/record.ts @@ -0,0 +1,45 @@ +import type { DocMeta } from '@blocksuite/store'; + +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import type { DocsStore } from '../stores/docs'; + +export type DocMode = 'edgeless' | 'page'; + +/** + * # DocRecord + * + * Some data you can use without open a doc. + */ +export class DocRecord extends Entity<{ id: string }> { + id: string = this.props.id; + meta: Partial | null = null; + constructor(private readonly docsStore: DocsStore) { + super(); + } + + meta$ = LiveData.from>( + this.docsStore.watchDocMeta(this.id), + {} + ); + + setMeta(meta: Partial): void { + this.docsStore.setDocMeta(this.id, meta); + } + + mode$: LiveData = LiveData.from( + this.docsStore.watchDocModeSetting(this.id), + 'page' + ).map(mode => (mode === 'edgeless' ? 'edgeless' : 'page')); + + setMode(mode: DocMode) { + this.docsStore.setDocModeSetting(this.id, mode); + } + + toggleMode() { + this.setMode(this.mode$.value === 'edgeless' ? 'page' : 'edgeless'); + return this.mode$.value; + } + + title$ = this.meta$.map(meta => meta.title ?? ''); +} diff --git a/packages/common/infra/src/modules/doc/index.ts b/packages/common/infra/src/modules/doc/index.ts new file mode 100644 index 0000000000..d0024eba40 --- /dev/null +++ b/packages/common/infra/src/modules/doc/index.ts @@ -0,0 +1,33 @@ +export { Doc } from './entities/doc'; +export type { DocMode } from './entities/record'; +export { DocRecord } from './entities/record'; +export { DocRecordList } from './entities/record-list'; +export { DocScope } from './scopes/doc'; +export { DocService } from './services/doc'; +export { DocsService } from './services/docs'; + +import type { Framework } from '../../framework'; +import { + WorkspaceLocalState, + WorkspaceScope, + WorkspaceService, +} from '../workspace'; +import { Doc } from './entities/doc'; +import { DocRecord } from './entities/record'; +import { DocRecordList } from './entities/record-list'; +import { DocScope } from './scopes/doc'; +import { DocService } from './services/doc'; +import { DocsService } from './services/docs'; +import { DocsStore } from './stores/docs'; + +export function configureDocModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(DocsService, [DocsStore]) + .store(DocsStore, [WorkspaceService, WorkspaceLocalState]) + .entity(DocRecord, [DocsStore]) + .entity(DocRecordList, [DocsStore]) + .scope(DocScope) + .entity(Doc, [DocScope]) + .service(DocService); +} diff --git a/packages/common/infra/src/modules/doc/scopes/doc.ts b/packages/common/infra/src/modules/doc/scopes/doc.ts new file mode 100644 index 0000000000..d49f0ddf8f --- /dev/null +++ b/packages/common/infra/src/modules/doc/scopes/doc.ts @@ -0,0 +1,10 @@ +import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; + +import { Scope } from '../../../framework'; +import type { DocRecord } from '../entities/record'; + +export class DocScope extends Scope<{ + docId: string; + record: DocRecord; + blockSuiteDoc: BlockSuiteDoc; +}> {} diff --git a/packages/common/infra/src/modules/doc/services/doc.ts b/packages/common/infra/src/modules/doc/services/doc.ts new file mode 100644 index 0000000000..cd79eb1105 --- /dev/null +++ b/packages/common/infra/src/modules/doc/services/doc.ts @@ -0,0 +1,6 @@ +import { Service } from '../../../framework'; +import { Doc } from '../entities/doc'; + +export class DocService extends Service { + public readonly doc = this.framework.createEntity(Doc); +} diff --git a/packages/common/infra/src/modules/doc/services/docs.ts b/packages/common/infra/src/modules/doc/services/docs.ts new file mode 100644 index 0000000000..3e1b38108a --- /dev/null +++ b/packages/common/infra/src/modules/doc/services/docs.ts @@ -0,0 +1,49 @@ +import { Service } from '../../../framework'; +import { ObjectPool } from '../../../utils'; +import type { Doc } from '../entities/doc'; +import { DocRecordList } from '../entities/record-list'; +import { DocScope } from '../scopes/doc'; +import type { DocsStore } from '../stores/docs'; +import { DocService } from './doc'; + +export class DocsService extends Service { + list = this.framework.createEntity(DocRecordList); + + pool = new ObjectPool({ + onDelete(obj) { + obj.scope.dispose(); + }, + }); + + constructor(private readonly store: DocsStore) { + super(); + } + + open(docId: string) { + const docRecord = this.list.doc$(docId).value; + if (!docRecord) { + throw new Error('Doc record not found'); + } + const blockSuiteDoc = this.store.getBlockSuiteDoc(docId); + if (!blockSuiteDoc) { + throw new Error('Doc not found'); + } + + const exists = this.pool.get(docId); + if (exists) { + return { doc: exists.obj, release: exists.release }; + } + + const docScope = this.framework.createScope(DocScope, { + docId, + blockSuiteDoc, + record: docRecord, + }); + + const doc = docScope.get(DocService).doc; + + const { obj, release } = this.pool.put(docId, doc); + + return { doc: obj, release }; + } +} diff --git a/packages/common/infra/src/modules/doc/stores/docs.ts b/packages/common/infra/src/modules/doc/stores/docs.ts new file mode 100644 index 0000000000..407e0a1481 --- /dev/null +++ b/packages/common/infra/src/modules/doc/stores/docs.ts @@ -0,0 +1,85 @@ +import { type DocMeta } from '@blocksuite/store'; +import { isEqual } from 'lodash-es'; +import { distinctUntilChanged, Observable } from 'rxjs'; + +import { Store } from '../../../framework'; +import type { WorkspaceLocalState, WorkspaceService } from '../../workspace'; +import type { DocMode } from '../entities/record'; + +export class DocsStore extends Store { + constructor( + private readonly workspaceService: WorkspaceService, + private readonly localState: WorkspaceLocalState + ) { + super(); + } + + getBlockSuiteDoc(id: string) { + return this.workspaceService.workspace.docCollection.getDoc(id); + } + + watchDocIds() { + return new Observable(subscriber => { + const emit = () => { + subscriber.next( + this.workspaceService.workspace.docCollection.meta.docMetas.map( + v => v.id + ) + ); + }; + + emit(); + + const dispose = + this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on( + emit + ).dispose; + return () => { + dispose(); + }; + }).pipe(distinctUntilChanged((p, c) => isEqual(p, c))); + } + + watchDocMeta(id: string) { + let meta: DocMeta | null = null; + return new Observable>(subscriber => { + const emit = () => { + if (meta === null) { + // getDocMeta is heavy, so we cache the doc meta reference + meta = + this.workspaceService.workspace.docCollection.meta.getDocMeta(id) || + null; + } + subscriber.next({ ...meta }); + }; + + emit(); + + const dispose = + this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on( + emit + ).dispose; + return () => { + dispose(); + }; + }).pipe(distinctUntilChanged((p, c) => isEqual(p, c))); + } + + watchDocListReady() { + return this.workspaceService.workspace.engine.rootDocState$ + .map(state => !state.syncing) + .asObservable(); + } + + setDocMeta(id: string, meta: Partial) { + this.workspaceService.workspace.docCollection.setDocMeta(id, meta); + } + + setDocModeSetting(id: string, mode: DocMode) { + this.localState.set(`page:${id}:mode`, mode); + } + + watchDocModeSetting(id: string) { + return this.localState.watch(`page:${id}:mode`); + } +} diff --git a/packages/common/infra/src/modules/global-context/entities/global-context.ts b/packages/common/infra/src/modules/global-context/entities/global-context.ts new file mode 100644 index 0000000000..92f3861c76 --- /dev/null +++ b/packages/common/infra/src/modules/global-context/entities/global-context.ts @@ -0,0 +1,24 @@ +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import { MemoryMemento } from '../../../storage'; +import type { DocMode } from '../../doc'; + +export class GlobalContext extends Entity { + memento = new MemoryMemento(); + + workspaceId = this.define('workspaceId'); + + docId = this.define('docId'); + + docMode = this.define('docMode'); + + define(key: string) { + this.memento.set(key, null); + const livedata$ = LiveData.from(this.memento.watch(key), null); + return { + get: () => this.memento.get(key) as T | null, + set: (value: T | null) => this.memento.set(key, value), + $: livedata$, + }; + } +} diff --git a/packages/common/infra/src/modules/global-context/index.ts b/packages/common/infra/src/modules/global-context/index.ts new file mode 100644 index 0000000000..f93aa05a0e --- /dev/null +++ b/packages/common/infra/src/modules/global-context/index.ts @@ -0,0 +1,9 @@ +export { GlobalContextService } from './services/global-context'; + +import type { Framework } from '../../framework'; +import { GlobalContext } from './entities/global-context'; +import { GlobalContextService } from './services/global-context'; + +export function configureGlobalContextModule(framework: Framework) { + framework.service(GlobalContextService).entity(GlobalContext); +} diff --git a/packages/common/infra/src/modules/global-context/services/global-context.ts b/packages/common/infra/src/modules/global-context/services/global-context.ts new file mode 100644 index 0000000000..a0a8db0dab --- /dev/null +++ b/packages/common/infra/src/modules/global-context/services/global-context.ts @@ -0,0 +1,6 @@ +import { Service } from '../../../framework'; +import { GlobalContext } from '../entities/global-context'; + +export class GlobalContextService extends Service { + globalContext = this.framework.createEntity(GlobalContext); +} diff --git a/packages/common/infra/src/modules/lifecycle/index.ts b/packages/common/infra/src/modules/lifecycle/index.ts new file mode 100644 index 0000000000..aeea99c451 --- /dev/null +++ b/packages/common/infra/src/modules/lifecycle/index.ts @@ -0,0 +1,12 @@ +import type { Framework } from '../../framework'; +import { LifecycleService } from './service/lifecycle'; + +export { + ApplicationFocused, + ApplicationStarted, + LifecycleService, +} from './service/lifecycle'; + +export function configureLifecycleModule(framework: Framework) { + framework.service(LifecycleService); +} diff --git a/packages/common/infra/src/modules/lifecycle/service/lifecycle.ts b/packages/common/infra/src/modules/lifecycle/service/lifecycle.ts new file mode 100644 index 0000000000..51d1148b68 --- /dev/null +++ b/packages/common/infra/src/modules/lifecycle/service/lifecycle.ts @@ -0,0 +1,26 @@ +import { createEvent, Service } from '../../../framework'; + +/** + * Event that is emitted when application is started. + */ +export const ApplicationStarted = createEvent('ApplicationStartup'); + +/** + * Event that is emitted when browser tab or windows is focused again, after being blurred. + * Can be used to actively refresh some data. + */ +export const ApplicationFocused = createEvent('ApplicationFocused'); + +export class LifecycleService extends Service { + constructor() { + super(); + } + + applicationStart() { + this.eventBus.emit(ApplicationStarted, true); + } + + applicationFocus() { + this.eventBus.emit(ApplicationFocused, true); + } +} diff --git a/packages/common/infra/src/modules/storage/index.ts b/packages/common/infra/src/modules/storage/index.ts new file mode 100644 index 0000000000..f718f1f6c6 --- /dev/null +++ b/packages/common/infra/src/modules/storage/index.ts @@ -0,0 +1,17 @@ +export { GlobalCache, GlobalState } from './providers/global'; +export { GlobalCacheService, GlobalStateService } from './services/global'; + +import type { Framework } from '../../framework'; +import { MemoryMemento } from '../../storage'; +import { GlobalCache, GlobalState } from './providers/global'; +import { GlobalCacheService, GlobalStateService } from './services/global'; + +export const configureGlobalStorageModule = (framework: Framework) => { + framework.service(GlobalStateService, [GlobalState]); + framework.service(GlobalCacheService, [GlobalCache]); +}; + +export const configureTestingGlobalStorage = (framework: Framework) => { + framework.impl(GlobalCache, MemoryMemento); + framework.impl(GlobalState, MemoryMemento); +}; diff --git a/packages/common/infra/src/modules/storage/providers/global.ts b/packages/common/infra/src/modules/storage/providers/global.ts new file mode 100644 index 0000000000..e320cab98c --- /dev/null +++ b/packages/common/infra/src/modules/storage/providers/global.ts @@ -0,0 +1,20 @@ +import { createIdentifier } from '../../../framework'; +import type { Memento } from '../../../storage'; + +/** + * A memento object that stores the entire application state. + * + * State is persisted, even the application is closed. + */ +export interface GlobalState extends Memento {} + +export const GlobalState = createIdentifier('GlobalState'); + +/** + * A memento object that stores the entire application cache. + * + * Cache may be deleted from time to time, business logic should not rely on cache. + */ +export interface GlobalCache extends Memento {} + +export const GlobalCache = createIdentifier('GlobalCache'); diff --git a/packages/common/infra/src/modules/storage/services/global.ts b/packages/common/infra/src/modules/storage/services/global.ts new file mode 100644 index 0000000000..2c5ffda4bd --- /dev/null +++ b/packages/common/infra/src/modules/storage/services/global.ts @@ -0,0 +1,14 @@ +import { Service } from '../../../framework'; +import type { GlobalCache, GlobalState } from '../providers/global'; + +export class GlobalStateService extends Service { + constructor(public readonly globalState: GlobalState) { + super(); + } +} + +export class GlobalCacheService extends Service { + constructor(public readonly globalCache: GlobalCache) { + super(); + } +} diff --git a/packages/common/infra/src/modules/workspace/__tests__/workspace.spec.ts b/packages/common/infra/src/modules/workspace/__tests__/workspace.spec.ts new file mode 100644 index 0000000000..44ceeb92e6 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/__tests__/workspace.spec.ts @@ -0,0 +1,32 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { describe, expect, test } from 'vitest'; + +import { Framework } from '../../../framework'; +import { configureTestingGlobalStorage } from '../../storage'; +import { + configureTestingWorkspaceProvider, + configureWorkspaceModule, + Workspace, + WorkspacesService, +} from '..'; + +describe('Workspace System', () => { + test('create workspace', async () => { + const framework = new Framework(); + configureTestingGlobalStorage(framework); + configureWorkspaceModule(framework); + configureTestingWorkspaceProvider(framework); + + const provider = framework.provider(); + const workspaceService = provider.get(WorkspacesService); + expect(workspaceService.list.workspaces$.value.length).toBe(0); + + const workspace = workspaceService.open({ + metadata: await workspaceService.create(WorkspaceFlavour.LOCAL), + }); + + expect(workspace.workspace).toBeInstanceOf(Workspace); + + expect(workspaceService.list.workspaces$.value.length).toBe(1); + }); +}); diff --git a/packages/common/infra/src/modules/workspace/entities/engine.ts b/packages/common/infra/src/modules/workspace/entities/engine.ts new file mode 100644 index 0000000000..f8fa62973d --- /dev/null +++ b/packages/common/infra/src/modules/workspace/entities/engine.ts @@ -0,0 +1,72 @@ +import type { Doc as YDoc } from 'yjs'; + +import { Entity } from '../../../framework'; +import { AwarenessEngine, BlobEngine, DocEngine } from '../../../sync'; +import { throwIfAborted } from '../../../utils'; +import type { WorkspaceEngineProvider } from '../providers/flavour'; +import type { WorkspaceService } from '../services/workspace'; + +export class WorkspaceEngine extends Entity<{ + engineProvider: WorkspaceEngineProvider; +}> { + doc = new DocEngine( + this.props.engineProvider.getDocStorage(), + this.props.engineProvider.getDocServer() + ); + + blob = new BlobEngine( + this.props.engineProvider.getLocalBlobStorage(), + this.props.engineProvider.getRemoteBlobStorages() + ); + + awareness = new AwarenessEngine( + this.props.engineProvider.getAwarenessConnections() + ); + + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } + + setRootDoc(yDoc: YDoc) { + this.doc.setPriority(yDoc.guid, 100); + this.doc.addDoc(yDoc); + } + + start() { + this.doc.start(); + this.awareness.connect(); + this.blob.start(); + } + + canGracefulStop() { + return this.doc.engineState$.value.saving === 0; + } + + async waitForGracefulStop(abort?: AbortSignal) { + await this.doc.waitForSaved(); + throwIfAborted(abort); + this.forceStop(); + } + + forceStop() { + this.doc.stop(); + this.awareness.disconnect(); + this.blob.stop(); + } + + docEngineState$ = this.doc.engineState$; + + rootDocState$ = this.doc.docState$(this.workspaceService.workspace.id); + + waitForDocSynced() { + return this.doc.waitForSynced(); + } + + waitForRootDocReady() { + return this.doc.waitForReady(this.workspaceService.workspace.id); + } + + override dispose(): void { + this.forceStop(); + } +} diff --git a/packages/common/infra/src/modules/workspace/entities/list.ts b/packages/common/infra/src/modules/workspace/entities/list.ts new file mode 100644 index 0000000000..472149ca35 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/entities/list.ts @@ -0,0 +1,27 @@ +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; + +export class WorkspaceList extends Entity { + workspaces$ = new LiveData(this.providers.map(p => p.workspaces$)) + .map(v => { + return v; + }) + .flat() + .map(workspaces => { + return workspaces.flat(); + }); + isLoading$ = new LiveData( + this.providers.map(p => p.isLoading$ ?? new LiveData(false)) + ) + .flat() + .map(isLoadings => isLoadings.some(isLoading => isLoading)); + + constructor(private readonly providers: WorkspaceFlavourProvider[]) { + super(); + } + + revalidate() { + this.providers.forEach(provider => provider.revalidate?.()); + } +} diff --git a/packages/common/infra/src/modules/workspace/entities/profile.ts b/packages/common/infra/src/modules/workspace/entities/profile.ts new file mode 100644 index 0000000000..0453570e45 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/entities/profile.ts @@ -0,0 +1,89 @@ +import { DebugLogger } from '@affine/debug'; +import { catchError, EMPTY, from, mergeMap, switchMap } from 'rxjs'; + +import { Entity } from '../../../framework'; +import { effect, LiveData, onComplete, onStart } from '../../../livedata'; +import type { WorkspaceMetadata } from '../metadata'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; +import type { WorkspaceProfileCacheStore } from '../stores/profile-cache'; +import type { Workspace } from './workspace'; + +const logger = new DebugLogger('affine:workspace-profile'); + +export interface WorkspaceProfileInfo { + avatar?: string; + name?: string; + isOwner?: boolean; +} + +/** + * # WorkspaceProfile + * + * This class take care of workspace avatar and name + */ +export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> { + private readonly provider: WorkspaceFlavourProvider | null; + + get id() { + return this.props.metadata.id; + } + + profile$ = LiveData.from( + this.cache.watchProfileCache(this.props.metadata.id), + null + ); + + avatar$ = this.profile$.map(v => v?.avatar); + name$ = this.profile$.map(v => v?.name); + + isLoading$ = new LiveData(false); + + constructor( + private readonly cache: WorkspaceProfileCacheStore, + providers: WorkspaceFlavourProvider[] + ) { + super(); + + this.provider = + providers.find(p => p.flavour === this.props.metadata.flavour) ?? null; + } + + private setCache(info: WorkspaceProfileInfo) { + this.cache.setProfileCache(this.props.metadata.id, info); + } + + revalidate = effect( + switchMap(() => { + if (!this.provider) { + return EMPTY; + } + return from( + this.provider.getWorkspaceProfile(this.props.metadata.id) + ).pipe( + mergeMap(info => { + if (info) { + this.setCache({ ...this.profile$.value, ...info }); + } + return EMPTY; + }), + catchError(err => { + logger.error(err); + return EMPTY; + }), + onStart(() => this.isLoading$.next(true)), + onComplete(() => this.isLoading$.next(false)) + ); + }) + ); + + syncWithWorkspace(workspace: Workspace) { + workspace.name$.subscribe(name => { + const old = this.profile$.value; + this.setCache({ ...old, name: name ?? old?.name }); + }); + workspace.avatar$.subscribe(avatar => { + const old = this.profile$.value; + this.setCache({ ...old, avatar: avatar ?? old?.avatar }); + }); + } +} diff --git a/packages/common/infra/src/modules/workspace/entities/upgrade.ts b/packages/common/infra/src/modules/workspace/entities/upgrade.ts new file mode 100644 index 0000000000..04c2012f32 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/entities/upgrade.ts @@ -0,0 +1,135 @@ +import { Unreachable } from '@affine/env/constant'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; + +import { + checkWorkspaceCompatibility, + forceUpgradePages, + migrateGuidCompatibility, + MigrationPoint, + upgradeV1ToV2, +} from '../../../blocksuite'; +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import type { WorkspaceMetadata } from '../metadata'; +import type { WorkspaceDestroyService } from '../services/destroy'; +import type { WorkspaceFactoryService } from '../services/factory'; +import type { WorkspaceService } from '../services/workspace'; + +export class WorkspaceUpgrade extends Entity { + needUpgrade$ = new LiveData(false); + upgrading$ = new LiveData(false); + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly workspaceFactory: WorkspaceFactoryService, + private readonly workspaceDestroy: WorkspaceDestroyService + ) { + super(); + this.checkIfNeedUpgrade(); + workspaceService.workspace.docCollection.doc.on('update', () => { + this.checkIfNeedUpgrade(); + }); + } + + checkIfNeedUpgrade() { + const needUpgrade = !!checkWorkspaceCompatibility( + this.workspaceService.workspace.docCollection, + this.workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD + ); + this.needUpgrade$.next(needUpgrade); + return needUpgrade; + } + + async upgrade(): Promise { + if (this.upgrading$.value) { + return null; + } + + this.upgrading$.next(true); + + try { + await this.workspaceService.workspace.engine.waitForDocSynced(); + + const step = checkWorkspaceCompatibility( + this.workspaceService.workspace.docCollection, + this.workspaceService.workspace.flavour === + WorkspaceFlavour.AFFINE_CLOUD + ); + + if (!step) { + return null; + } + + // Clone a new doc to prevent change events. + const clonedDoc = new YDoc({ + guid: this.workspaceService.workspace.docCollection.doc.guid, + }); + applyDoc(clonedDoc, this.workspaceService.workspace.docCollection.doc); + + if (step === MigrationPoint.SubDoc) { + const newWorkspace = await this.workspaceFactory.create( + WorkspaceFlavour.LOCAL, + async (workspace, blobStorage) => { + await upgradeV1ToV2(clonedDoc, workspace.doc); + migrateGuidCompatibility(clonedDoc); + await forceUpgradePages( + workspace.doc, + this.workspaceService.workspace.docCollection.schema + ); + const blobList = + await this.workspaceService.workspace.docCollection.blob.list(); + + for (const blobKey of blobList) { + const blob = + await this.workspaceService.workspace.docCollection.blob.get( + blobKey + ); + if (blob) { + await blobStorage.set(blobKey, blob); + } + } + } + ); + await this.workspaceDestroy.deleteWorkspace( + this.workspaceService.workspace.meta + ); + return newWorkspace; + } else if (step === MigrationPoint.GuidFix) { + migrateGuidCompatibility(clonedDoc); + await forceUpgradePages( + clonedDoc, + this.workspaceService.workspace.docCollection.schema + ); + applyDoc(this.workspaceService.workspace.docCollection.doc, clonedDoc); + await this.workspaceService.workspace.engine.waitForDocSynced(); + return null; + } else if (step === MigrationPoint.BlockVersion) { + await forceUpgradePages( + clonedDoc, + this.workspaceService.workspace.docCollection.schema + ); + applyDoc(this.workspaceService.workspace.docCollection.doc, clonedDoc); + await this.workspaceService.workspace.engine.waitForDocSynced(); + return null; + } else { + throw new Unreachable(); + } + } finally { + this.upgrading$.next(false); + } + } +} + +function applyDoc(target: YDoc, result: YDoc) { + applyUpdate(target, encodeStateAsUpdate(result)); + for (const targetSubDoc of target.subdocs.values()) { + const resultSubDocs = Array.from(result.subdocs.values()); + const resultSubDoc = resultSubDocs.find( + item => item.guid === targetSubDoc.guid + ); + if (resultSubDoc) { + applyDoc(targetSubDoc, resultSubDoc); + } + } +} diff --git a/packages/common/infra/src/modules/workspace/entities/workspace.ts b/packages/common/infra/src/modules/workspace/entities/workspace.ts new file mode 100644 index 0000000000..cf74482420 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/entities/workspace.ts @@ -0,0 +1,101 @@ +import { DocCollection } from '@blocksuite/store'; +import { nanoid } from 'nanoid'; +import { Observable } from 'rxjs'; +import type { Awareness } from 'y-protocols/awareness.js'; + +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import { globalBlockSuiteSchema } from '../global-schema'; +import type { WorkspaceScope } from '../scopes/workspace'; +import { WorkspaceEngineService } from '../services/engine'; +import { WorkspaceUpgradeService } from '../services/upgrade'; + +export class Workspace extends Entity { + constructor(public readonly scope: WorkspaceScope) { + super(); + } + + readonly id = this.scope.props.openOptions.metadata.id; + + readonly openOptions = this.scope.props.openOptions; + + readonly meta = this.scope.props.openOptions.metadata; + + readonly flavour = this.meta.flavour; + + _docCollection: DocCollection | null = null; + + get docCollection() { + if (!this._docCollection) { + this._docCollection = new DocCollection({ + id: this.openOptions.metadata.id, + blobStorages: [ + () => ({ + crud: { + get: key => { + return this.engine.blob.get(key); + }, + set: (key, value) => { + return this.engine.blob.set(key, value); + }, + list: () => { + return this.engine.blob.list(); + }, + delete: key => { + return this.engine.blob.delete(key); + }, + }, + }), + ], + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + }); + } + return this._docCollection; + } + + get awareness() { + return this.docCollection.awarenessStore.awareness as Awareness; + } + + get rootYDoc() { + return this.docCollection.doc; + } + + get canGracefulStop() { + // TODO + return true; + } + + get engine() { + return this.framework.get(WorkspaceEngineService).engine; + } + + get upgrade() { + return this.framework.get(WorkspaceUpgradeService).upgrade; + } + + get flavourProvider() { + return this.scope.props.flavourProvider; + } + + name$ = LiveData.from( + new Observable(subscriber => { + subscriber.next(this.docCollection.meta.name); + return this.docCollection.meta.commonFieldsUpdated.on(() => { + subscriber.next(this.docCollection.meta.name); + }).dispose; + }), + undefined + ); + + avatar$ = LiveData.from( + new Observable(subscriber => { + subscriber.next(this.docCollection.meta.avatar); + return this.docCollection.meta.commonFieldsUpdated.on(() => { + subscriber.next(this.docCollection.meta.avatar); + }).dispose; + }), + undefined + ); +} diff --git a/packages/common/infra/src/workspace/global-schema.ts b/packages/common/infra/src/modules/workspace/global-schema.ts similarity index 100% rename from packages/common/infra/src/workspace/global-schema.ts rename to packages/common/infra/src/modules/workspace/global-schema.ts diff --git a/packages/common/infra/src/modules/workspace/impls/storage.ts b/packages/common/infra/src/modules/workspace/impls/storage.ts new file mode 100644 index 0000000000..24223987dc --- /dev/null +++ b/packages/common/infra/src/modules/workspace/impls/storage.ts @@ -0,0 +1,75 @@ +import { type Memento, wrapMemento } from '../../../storage'; +import type { GlobalCache, GlobalState } from '../../storage'; +import type { + WorkspaceLocalCache, + WorkspaceLocalState, +} from '../providers/storage'; +import type { WorkspaceService } from '../services/workspace'; + +export class WorkspaceLocalStateImpl implements WorkspaceLocalState { + wrapped: Memento; + constructor(workspaceService: WorkspaceService, globalState: GlobalState) { + this.wrapped = wrapMemento( + globalState, + `workspace-state:${workspaceService.workspace.id}:` + ); + } + + keys(): string[] { + return this.wrapped.keys(); + } + + get(key: string): T | null { + return this.wrapped.get(key); + } + + watch(key: string) { + return this.wrapped.watch(key); + } + + set(key: string, value: T | null): void { + return this.wrapped.set(key, value); + } + + del(key: string): void { + return this.wrapped.del(key); + } + + clear(): void { + return this.wrapped.clear(); + } +} + +export class WorkspaceLocalCacheImpl implements WorkspaceLocalCache { + wrapped: Memento; + constructor(workspaceService: WorkspaceService, globalCache: GlobalCache) { + this.wrapped = wrapMemento( + globalCache, + `workspace-cache:${workspaceService.workspace.id}:` + ); + } + + keys(): string[] { + return this.wrapped.keys(); + } + + get(key: string): T | null { + return this.wrapped.get(key); + } + + watch(key: string) { + return this.wrapped.watch(key); + } + + set(key: string, value: T | null): void { + return this.wrapped.set(key, value); + } + + del(key: string): void { + return this.wrapped.del(key); + } + + clear(): void { + return this.wrapped.clear(); + } +} diff --git a/packages/common/infra/src/modules/workspace/index.ts b/packages/common/infra/src/modules/workspace/index.ts new file mode 100644 index 0000000000..302437878c --- /dev/null +++ b/packages/common/infra/src/modules/workspace/index.ts @@ -0,0 +1,96 @@ +export type { WorkspaceProfileInfo } from './entities/profile'; +export { Workspace } from './entities/workspace'; +export { globalBlockSuiteSchema } from './global-schema'; +export type { WorkspaceMetadata } from './metadata'; +export type { WorkspaceOpenOptions } from './open-options'; +export type { WorkspaceEngineProvider } from './providers/flavour'; +export { WorkspaceFlavourProvider } from './providers/flavour'; +export { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage'; +export { WorkspaceScope } from './scopes/workspace'; +export { WorkspaceService } from './services/workspace'; +export { WorkspacesService } from './services/workspaces'; + +import type { Framework } from '../../framework'; +import { GlobalCache, GlobalState } from '../storage'; +import { WorkspaceEngine } from './entities/engine'; +import { WorkspaceList } from './entities/list'; +import { WorkspaceProfile } from './entities/profile'; +import { WorkspaceUpgrade } from './entities/upgrade'; +import { Workspace } from './entities/workspace'; +import { + WorkspaceLocalCacheImpl, + WorkspaceLocalStateImpl, +} from './impls/storage'; +import { WorkspaceFlavourProvider } from './providers/flavour'; +import { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage'; +import { WorkspaceScope } from './scopes/workspace'; +import { WorkspaceDestroyService } from './services/destroy'; +import { WorkspaceEngineService } from './services/engine'; +import { WorkspaceFactoryService } from './services/factory'; +import { WorkspaceListService } from './services/list'; +import { WorkspaceProfileService } from './services/profile'; +import { WorkspaceRepositoryService } from './services/repo'; +import { WorkspaceTransformService } from './services/transform'; +import { WorkspaceUpgradeService } from './services/upgrade'; +import { WorkspaceService } from './services/workspace'; +import { WorkspacesService } from './services/workspaces'; +import { WorkspaceProfileCacheStore } from './stores/profile-cache'; +import { TestingWorkspaceLocalProvider } from './testing/testing-provider'; + +export function configureWorkspaceModule(framework: Framework) { + framework + .service(WorkspacesService, [ + [WorkspaceFlavourProvider], + WorkspaceListService, + WorkspaceProfileService, + WorkspaceTransformService, + WorkspaceRepositoryService, + WorkspaceFactoryService, + WorkspaceDestroyService, + ]) + .service(WorkspaceDestroyService, [[WorkspaceFlavourProvider]]) + .service(WorkspaceListService) + .entity(WorkspaceList, [[WorkspaceFlavourProvider]]) + .service(WorkspaceProfileService) + .store(WorkspaceProfileCacheStore, [GlobalCache]) + .entity(WorkspaceProfile, [ + WorkspaceProfileCacheStore, + [WorkspaceFlavourProvider], + ]) + .service(WorkspaceFactoryService, [[WorkspaceFlavourProvider]]) + .service(WorkspaceTransformService, [ + WorkspaceFactoryService, + WorkspaceDestroyService, + ]) + .service(WorkspaceRepositoryService, [ + [WorkspaceFlavourProvider], + WorkspaceProfileService, + ]) + .scope(WorkspaceScope) + .service(WorkspaceService) + .entity(Workspace, [WorkspaceScope]) + .service(WorkspaceEngineService, [WorkspaceService]) + .entity(WorkspaceEngine, [WorkspaceService]) + .service(WorkspaceUpgradeService) + .entity(WorkspaceUpgrade, [ + WorkspaceService, + WorkspaceFactoryService, + WorkspaceDestroyService, + ]) + .impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [ + WorkspaceService, + GlobalState, + ]) + .impl(WorkspaceLocalCache, WorkspaceLocalCacheImpl, [ + WorkspaceService, + GlobalCache, + ]); +} + +export function configureTestingWorkspaceProvider(framework: Framework) { + framework.impl( + WorkspaceFlavourProvider('LOCAL'), + TestingWorkspaceLocalProvider, + [GlobalState] + ); +} diff --git a/packages/common/infra/src/workspace/metadata.ts b/packages/common/infra/src/modules/workspace/metadata.ts similarity index 100% rename from packages/common/infra/src/workspace/metadata.ts rename to packages/common/infra/src/modules/workspace/metadata.ts diff --git a/packages/common/infra/src/modules/workspace/open-options.ts b/packages/common/infra/src/modules/workspace/open-options.ts new file mode 100644 index 0000000000..be04afbc2c --- /dev/null +++ b/packages/common/infra/src/modules/workspace/open-options.ts @@ -0,0 +1,6 @@ +import type { WorkspaceMetadata } from './metadata'; + +export interface WorkspaceOpenOptions { + metadata: WorkspaceMetadata; + isSharedMode?: boolean; +} diff --git a/packages/common/infra/src/modules/workspace/providers/flavour.ts b/packages/common/infra/src/modules/workspace/providers/flavour.ts new file mode 100644 index 0000000000..bdb3a4549e --- /dev/null +++ b/packages/common/infra/src/modules/workspace/providers/flavour.ts @@ -0,0 +1,58 @@ +import type { WorkspaceFlavour } from '@affine/env/workspace'; +import type { DocCollection } from '@blocksuite/store'; + +import { createIdentifier } from '../../../framework'; +import type { LiveData } from '../../../livedata'; +import type { + AwarenessConnection, + BlobStorage, + DocServer, + DocStorage, +} from '../../../sync'; +import type { WorkspaceProfileInfo } from '../entities/profile'; +import type { Workspace } from '../entities/workspace'; +import type { WorkspaceMetadata } from '../metadata'; + +export interface WorkspaceEngineProvider { + getDocServer(): DocServer | null; + getDocStorage(): DocStorage; + getLocalBlobStorage(): BlobStorage; + getRemoteBlobStorages(): BlobStorage[]; + getAwarenessConnections(): AwarenessConnection[]; +} + +export interface WorkspaceFlavourProvider { + flavour: WorkspaceFlavour; + + deleteWorkspace(id: string): Promise; + + createWorkspace( + initial: ( + docCollection: DocCollection, + blobStorage: BlobStorage + ) => Promise + ): Promise; + + workspaces$: LiveData; + + /** + * means the workspace list is loading. if it's true, the workspace page will show loading spinner. + */ + isLoading$?: LiveData; + + /** + * revalidate the workspace list. + * + * will be called when user open workspace list, or workspace not found. + */ + revalidate?: () => void; + + getWorkspaceProfile(id: string): Promise; + + getWorkspaceBlob(id: string, blob: string): Promise; + + getEngineProvider(workspace: Workspace): WorkspaceEngineProvider; +} + +export const WorkspaceFlavourProvider = + createIdentifier('WorkspaceFlavourProvider'); diff --git a/packages/common/infra/src/modules/workspace/providers/storage.ts b/packages/common/infra/src/modules/workspace/providers/storage.ts new file mode 100644 index 0000000000..08090d671c --- /dev/null +++ b/packages/common/infra/src/modules/workspace/providers/storage.ts @@ -0,0 +1,13 @@ +import { createIdentifier } from '../../../framework'; +import type { Memento } from '../../../storage'; + +export interface WorkspaceLocalState extends Memento {} +export interface WorkspaceLocalCache extends Memento {} + +export const WorkspaceLocalState = createIdentifier( + 'WorkspaceLocalState' +); + +export const WorkspaceLocalCache = createIdentifier( + 'WorkspaceLocalCache' +); diff --git a/packages/common/infra/src/modules/workspace/scopes/workspace.ts b/packages/common/infra/src/modules/workspace/scopes/workspace.ts new file mode 100644 index 0000000000..9fae92f612 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/scopes/workspace.ts @@ -0,0 +1,10 @@ +import { Scope } from '../../../framework'; +import type { WorkspaceOpenOptions } from '../open-options'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; + +export type { DocCollection } from '@blocksuite/store'; + +export class WorkspaceScope extends Scope<{ + openOptions: WorkspaceOpenOptions; + flavourProvider: WorkspaceFlavourProvider; +}> {} diff --git a/packages/common/infra/src/modules/workspace/services/destroy.ts b/packages/common/infra/src/modules/workspace/services/destroy.ts new file mode 100644 index 0000000000..90639a3283 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/destroy.ts @@ -0,0 +1,17 @@ +import { Service } from '../../../framework'; +import type { WorkspaceMetadata } from '../metadata'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; + +export class WorkspaceDestroyService extends Service { + constructor(private readonly providers: WorkspaceFlavourProvider[]) { + super(); + } + + deleteWorkspace = async (metadata: WorkspaceMetadata) => { + const provider = this.providers.find(p => p.flavour === metadata.flavour); + if (!provider) { + throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); + } + return provider.deleteWorkspace(metadata.id); + }; +} diff --git a/packages/common/infra/src/modules/workspace/services/engine.ts b/packages/common/infra/src/modules/workspace/services/engine.ts new file mode 100644 index 0000000000..633ec87521 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/engine.ts @@ -0,0 +1,22 @@ +import { Service } from '../../../framework'; +import { WorkspaceEngine } from '../entities/engine'; +import type { WorkspaceService } from './workspace'; + +export class WorkspaceEngineService extends Service { + private _engine: WorkspaceEngine | null = null; + get engine() { + if (!this._engine) { + this._engine = this.framework.createEntity(WorkspaceEngine, { + engineProvider: + this.workspaceService.workspace.flavourProvider.getEngineProvider( + this.workspaceService.workspace + ), + }); + } + return this._engine; + } + + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } +} diff --git a/packages/common/infra/src/modules/workspace/services/factory.ts b/packages/common/infra/src/modules/workspace/services/factory.ts new file mode 100644 index 0000000000..37509fdf83 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/factory.ts @@ -0,0 +1,33 @@ +import type { WorkspaceFlavour } from '@affine/env/workspace'; +import type { DocCollection } from '@blocksuite/store'; + +import { Service } from '../../../framework'; +import type { BlobStorage } from '../../../sync'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; + +export class WorkspaceFactoryService extends Service { + constructor(private readonly providers: WorkspaceFlavourProvider[]) { + super(); + } + + /** + * create workspace + * @param flavour workspace flavour + * @param initial callback to put initial data to workspace + * @returns workspace id + */ + create = async ( + flavour: WorkspaceFlavour, + initial: ( + docCollection: DocCollection, + blobStorage: BlobStorage + ) => Promise = () => Promise.resolve() + ) => { + const provider = this.providers.find(x => x.flavour === flavour); + if (!provider) { + throw new Error(`Unknown workspace flavour: ${flavour}`); + } + const metadata = await provider.createWorkspace(initial); + return metadata; + }; +} diff --git a/packages/common/infra/src/modules/workspace/services/list.ts b/packages/common/infra/src/modules/workspace/services/list.ts new file mode 100644 index 0000000000..7521f8b60c --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/list.ts @@ -0,0 +1,6 @@ +import { Service } from '../../../framework'; +import { WorkspaceList } from '../entities/list'; + +export class WorkspaceListService extends Service { + list = this.framework.createEntity(WorkspaceList); +} diff --git a/packages/common/infra/src/modules/workspace/services/profile.ts b/packages/common/infra/src/modules/workspace/services/profile.ts new file mode 100644 index 0000000000..01f06f760f --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/profile.ts @@ -0,0 +1,21 @@ +import { Service } from '../../../framework'; +import { ObjectPool } from '../../../utils'; +import { WorkspaceProfile } from '../entities/profile'; +import type { WorkspaceMetadata } from '../metadata'; + +export class WorkspaceProfileService extends Service { + pool = new ObjectPool(); + + getProfile = (metadata: WorkspaceMetadata): WorkspaceProfile => { + const exists = this.pool.get(metadata.id); + if (exists) { + return exists.obj; + } + + const profile = this.framework.createEntity(WorkspaceProfile, { + metadata, + }); + + return this.pool.put(metadata.id, profile).obj; + }; +} diff --git a/packages/common/infra/src/modules/workspace/services/repo.ts b/packages/common/infra/src/modules/workspace/services/repo.ts new file mode 100644 index 0000000000..d5acc577bf --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/repo.ts @@ -0,0 +1,114 @@ +import { DebugLogger } from '@affine/debug'; + +import { setupEditorFlags } from '../../../atom'; +import { fixWorkspaceVersion } from '../../../blocksuite'; +import { Service } from '../../../framework'; +import { ObjectPool } from '../../../utils'; +import type { Workspace } from '../entities/workspace'; +import type { WorkspaceOpenOptions } from '../open-options'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; +import { WorkspaceScope } from '../scopes/workspace'; +import type { WorkspaceProfileService } from './profile'; +import { WorkspaceService } from './workspace'; + +const logger = new DebugLogger('affine:workspace-repository'); + +export class WorkspaceRepositoryService extends Service { + constructor( + private readonly providers: WorkspaceFlavourProvider[], + private readonly profileRepo: WorkspaceProfileService + ) { + super(); + } + pool = new ObjectPool({ + onDelete(workspace) { + workspace.scope.dispose(); + }, + onDangling(workspace) { + return workspace.canGracefulStop; + }, + }); + + /** + * open workspace reference by metadata. + * + * You basically don't need to call this function directly, use the react hook `useWorkspace(metadata)` instead. + * + * @returns the workspace reference and a release function, don't forget to call release function when you don't + * need the workspace anymore. + */ + open = ( + options: WorkspaceOpenOptions, + customProvider?: WorkspaceFlavourProvider + ): { + workspace: Workspace; + dispose: () => void; + } => { + if (options.isSharedMode) { + const workspace = this.instantiate(options, customProvider); + return { + workspace, + dispose: () => { + workspace.dispose(); + }, + }; + } + + const exist = this.pool.get(options.metadata.id); + if (exist) { + return { + workspace: exist.obj, + dispose: exist.release, + }; + } + + const workspace = this.instantiate(options, customProvider); + // sync information with workspace list, when workspace's avatar and name changed, information will be updated + // this.list.getInformation(metadata).syncWithWorkspace(workspace); + + const ref = this.pool.put(workspace.meta.id, workspace); + + return { + workspace: ref.obj, + dispose: ref.release, + }; + }; + + instantiate( + openOptions: WorkspaceOpenOptions, + customProvider?: WorkspaceFlavourProvider + ) { + logger.info( + `open workspace [${openOptions.metadata.flavour}] ${openOptions.metadata.id} ` + ); + const provider = + customProvider ?? + this.providers.find(p => p.flavour === openOptions.metadata.flavour); + if (!provider) { + throw new Error( + `Unknown workspace flavour: ${openOptions.metadata.flavour}` + ); + } + + const workspaceScope = this.framework.createScope(WorkspaceScope, { + openOptions, + flavourProvider: provider, + }); + + const workspace = workspaceScope.get(WorkspaceService).workspace; + + workspace.engine.setRootDoc(workspace.docCollection.doc); + workspace.engine.start(); + + // apply compatibility fix + fixWorkspaceVersion(workspace.docCollection.doc); + + setupEditorFlags(workspace.docCollection); + + this.profileRepo + .getProfile(openOptions.metadata) + .syncWithWorkspace(workspace); + + return workspace; + } +} diff --git a/packages/common/infra/src/modules/workspace/services/transform.ts b/packages/common/infra/src/modules/workspace/services/transform.ts new file mode 100644 index 0000000000..ef29199657 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/transform.ts @@ -0,0 +1,57 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { assertEquals } from '@blocksuite/global/utils'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import { Service } from '../../../framework'; +import type { Workspace } from '../entities/workspace'; +import type { WorkspaceMetadata } from '../metadata'; +import type { WorkspaceDestroyService } from './destroy'; +import type { WorkspaceFactoryService } from './factory'; + +export class WorkspaceTransformService extends Service { + constructor( + private readonly factory: WorkspaceFactoryService, + private readonly destroy: WorkspaceDestroyService + ) { + super(); + } + + /** + * helper function to transform local workspace to cloud workspace + */ + transformLocalToCloud = async ( + local: Workspace + ): Promise => { + assertEquals(local.flavour, WorkspaceFlavour.LOCAL); + + await local.engine.waitForDocSynced(); + + const newMetadata = await this.factory.create( + WorkspaceFlavour.AFFINE_CLOUD, + async (ws, bs) => { + applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc)); + + for (const subdoc of local.docCollection.doc.getSubdocs()) { + for (const newSubdoc of ws.doc.getSubdocs()) { + if (newSubdoc.guid === subdoc.guid) { + applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc)); + } + } + } + + const blobList = await local.engine.blob.list(); + + for (const blobKey of blobList) { + const blob = await local.engine.blob.get(blobKey); + if (blob) { + await bs.set(blobKey, blob); + } + } + } + ); + + await this.destroy.deleteWorkspace(local.meta); + + return newMetadata; + }; +} diff --git a/packages/common/infra/src/modules/workspace/services/upgrade.ts b/packages/common/infra/src/modules/workspace/services/upgrade.ts new file mode 100644 index 0000000000..b2539c62e1 --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/upgrade.ts @@ -0,0 +1,6 @@ +import { Service } from '../../../framework'; +import { WorkspaceUpgrade } from '../entities/upgrade'; + +export class WorkspaceUpgradeService extends Service { + upgrade = this.framework.createEntity(WorkspaceUpgrade); +} diff --git a/packages/common/infra/src/modules/workspace/services/workspace.ts b/packages/common/infra/src/modules/workspace/services/workspace.ts new file mode 100644 index 0000000000..f431deef3b --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/workspace.ts @@ -0,0 +1,13 @@ +import { Service } from '../../../framework'; +import { Workspace } from '../entities/workspace'; + +export class WorkspaceService extends Service { + _workspace: Workspace | null = null; + + get workspace() { + if (!this._workspace) { + this._workspace = this.framework.createEntity(Workspace); + } + return this._workspace; + } +} diff --git a/packages/common/infra/src/modules/workspace/services/workspaces.ts b/packages/common/infra/src/modules/workspace/services/workspaces.ts new file mode 100644 index 0000000000..56cf35cc1f --- /dev/null +++ b/packages/common/infra/src/modules/workspace/services/workspaces.ts @@ -0,0 +1,53 @@ +import { Service } from '../../../framework'; +import type { WorkspaceMetadata } from '..'; +import type { WorkspaceFlavourProvider } from '../providers/flavour'; +import type { WorkspaceDestroyService } from './destroy'; +import type { WorkspaceFactoryService } from './factory'; +import type { WorkspaceListService } from './list'; +import type { WorkspaceProfileService } from './profile'; +import type { WorkspaceRepositoryService } from './repo'; +import type { WorkspaceTransformService } from './transform'; + +export class WorkspacesService extends Service { + get list() { + return this.listService.list; + } + + constructor( + private readonly providers: WorkspaceFlavourProvider[], + private readonly listService: WorkspaceListService, + private readonly profileRepo: WorkspaceProfileService, + private readonly transform: WorkspaceTransformService, + private readonly workspaceRepo: WorkspaceRepositoryService, + private readonly workspaceFactory: WorkspaceFactoryService, + private readonly destroy: WorkspaceDestroyService + ) { + super(); + } + + get deleteWorkspace() { + return this.destroy.deleteWorkspace; + } + + get getProfile() { + return this.profileRepo.getProfile; + } + + get transformLocalToCloud() { + return this.transform.transformLocalToCloud; + } + + get open() { + return this.workspaceRepo.open; + } + + get create() { + return this.workspaceFactory.create; + } + + async getWorkspaceBlob(meta: WorkspaceMetadata, blob: string) { + return await this.providers + .find(x => x.flavour === meta.flavour) + ?.getWorkspaceBlob(meta.id, blob); + } +} diff --git a/packages/common/infra/src/modules/workspace/stores/profile-cache.ts b/packages/common/infra/src/modules/workspace/stores/profile-cache.ts new file mode 100644 index 0000000000..4ae9d84c3d --- /dev/null +++ b/packages/common/infra/src/modules/workspace/stores/profile-cache.ts @@ -0,0 +1,35 @@ +import { map } from 'rxjs'; + +import { Store } from '../../../framework'; +import type { GlobalCache } from '../../storage'; +import type { WorkspaceProfileInfo } from '../entities/profile'; + +const WORKSPACE_PROFILE_CACHE_KEY = 'workspace-information:'; + +export class WorkspaceProfileCacheStore extends Store { + constructor(private readonly cache: GlobalCache) { + super(); + } + + watchProfileCache(workspaceId: string) { + return this.cache.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceId).pipe( + map(data => { + if (!data || typeof data !== 'object') { + return null; + } + + const info = data as WorkspaceProfileInfo; + + return { + avatar: info.avatar, + name: info.name, + isOwner: info.isOwner, + }; + }) + ); + } + + setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) { + this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info); + } +} diff --git a/packages/common/infra/src/modules/workspace/testing/testing-provider.ts b/packages/common/infra/src/modules/workspace/testing/testing-provider.ts new file mode 100644 index 0000000000..df45eb41fb --- /dev/null +++ b/packages/common/infra/src/modules/workspace/testing/testing-provider.ts @@ -0,0 +1,134 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { DocCollection, nanoid } from '@blocksuite/store'; +import { map } from 'rxjs'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import { Service } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import { wrapMemento } from '../../../storage'; +import { type BlobStorage, MemoryDocStorage } from '../../../sync'; +import { MemoryBlobStorage } from '../../../sync/blob/blob'; +import type { GlobalState } from '../../storage'; +import type { WorkspaceProfileInfo } from '../entities/profile'; +import type { Workspace } from '../entities/workspace'; +import { globalBlockSuiteSchema } from '../global-schema'; +import type { WorkspaceMetadata } from '../metadata'; +import type { + WorkspaceEngineProvider, + WorkspaceFlavourProvider, +} from '../providers/flavour'; + +export class TestingWorkspaceLocalProvider + extends Service + implements WorkspaceFlavourProvider +{ + flavour: WorkspaceFlavour = WorkspaceFlavour.LOCAL; + + store = wrapMemento(this.globalStore, 'testing/'); + workspaceListStore = wrapMemento(this.store, 'workspaces/'); + docStorage = new MemoryDocStorage(wrapMemento(this.store, 'docs/')); + + constructor(private readonly globalStore: GlobalState) { + super(); + } + + async deleteWorkspace(id: string): Promise { + const list = this.workspaceListStore.get('list') ?? []; + const newList = list.filter(meta => meta.id !== id); + this.workspaceListStore.set('list', newList); + } + async createWorkspace( + initial: ( + docCollection: DocCollection, + blobStorage: BlobStorage + ) => Promise + ): Promise { + const id = nanoid(); + const meta = { id, flavour: WorkspaceFlavour.LOCAL }; + + const blobStorage = new MemoryBlobStorage( + wrapMemento(this.store, id + '/blobs/') + ); + + const docCollection = new DocCollection({ + id: id, + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + blobStorages: [ + () => { + return { + crud: blobStorage, + }; + }, + ], + }); + + // apply initial state + await initial(docCollection, blobStorage); + + // save workspace to storage + await this.docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc)); + for (const subdocs of docCollection.doc.getSubdocs()) { + await this.docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + } + + const list = this.workspaceListStore.get('list') ?? []; + this.workspaceListStore.set('list', [...list, meta]); + + return { id, flavour: WorkspaceFlavour.LOCAL }; + } + workspaces$ = LiveData.from( + this.workspaceListStore + .watch('list') + .pipe(map(m => m ?? [])), + [] + ); + async getWorkspaceProfile( + id: string + ): Promise { + const data = await this.docStorage.doc.get(id); + + if (!data) { + return; + } + + const bs = new DocCollection({ + id, + schema: globalBlockSuiteSchema, + }); + + applyUpdate(bs.doc, data); + + return { + name: bs.meta.name, + avatar: bs.meta.avatar, + isOwner: true, + }; + } + getWorkspaceBlob(id: string, blob: string): Promise { + return new MemoryBlobStorage(wrapMemento(this.store, id + '/blobs/')).get( + blob + ); + } + getEngineProvider(workspace: Workspace): WorkspaceEngineProvider { + return { + getDocStorage: () => { + return this.docStorage; + }, + getAwarenessConnections() { + return []; + }, + getDocServer() { + return null; + }, + getLocalBlobStorage: () => { + return new MemoryBlobStorage( + wrapMemento(this.store, workspace.id + '/blobs/') + ); + }, + getRemoteBlobStorages() { + return []; + }, + }; + } +} diff --git a/packages/common/infra/src/page/context.ts b/packages/common/infra/src/page/context.ts deleted file mode 100644 index 6c705a1628..0000000000 --- a/packages/common/infra/src/page/context.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; - -import type { ServiceCollection } from '../di'; -import { createIdentifier } from '../di'; -import type { PageRecord } from './record'; -import { PageScope } from './service-scope'; - -export const BlockSuitePageContext = createIdentifier( - 'BlockSuitePageContext' -); - -export const PageRecordContext = - createIdentifier('PageRecordContext'); - -export function configurePageContext( - services: ServiceCollection, - blockSuitePage: BlockSuiteDoc, - pageRecord: PageRecord -) { - services - .scope(PageScope) - .addImpl(PageRecordContext, pageRecord) - .addImpl(BlockSuitePageContext, blockSuitePage); -} diff --git a/packages/common/infra/src/page/index.ts b/packages/common/infra/src/page/index.ts deleted file mode 100644 index 0e611f8dde..0000000000 --- a/packages/common/infra/src/page/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export * from './manager'; -export * from './page'; -export * from './record'; -export * from './record-list'; -export * from './service-scope'; - -import type { ServiceCollection } from '../di'; -import { ServiceProvider } from '../di'; -import { CleanupService } from '../lifecycle'; -import { Workspace, WorkspaceLocalState, WorkspaceScope } from '../workspace'; -import { BlockSuitePageContext, PageRecordContext } from './context'; -import { PageManager } from './manager'; -import { Doc } from './page'; -import { PageRecordList } from './record-list'; -import { PageScope } from './service-scope'; - -export function configurePageServices(services: ServiceCollection) { - services - .scope(WorkspaceScope) - .add(PageManager, [Workspace, PageRecordList, ServiceProvider]) - .add(PageRecordList, [Workspace, WorkspaceLocalState]); - - services - .scope(PageScope) - .add(CleanupService) - .add(Doc, [PageRecordContext, BlockSuitePageContext, ServiceProvider]); -} diff --git a/packages/common/infra/src/page/manager.ts b/packages/common/infra/src/page/manager.ts deleted file mode 100644 index 9d8d6c314f..0000000000 --- a/packages/common/infra/src/page/manager.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ServiceProvider } from '../di'; -import { ObjectPool } from '../utils/object-pool'; -import type { Workspace } from '../workspace'; -import type { PageRecordList } from '.'; -import { configurePageContext } from './context'; -import { Doc } from './page'; -import { PageScope } from './service-scope'; - -export class PageManager { - pool = new ObjectPool({}); - - constructor( - private readonly workspace: Workspace, - private readonly pageRecordList: PageRecordList, - private readonly serviceProvider: ServiceProvider - ) {} - - open(pageId: string) { - const pageRecord = this.pageRecordList.record$(pageId).value; - if (!pageRecord) { - throw new Error('Page record not found'); - } - const blockSuitePage = this.workspace.docCollection.getDoc(pageId); - if (!blockSuitePage) { - throw new Error('Page not found'); - } - - const exists = this.pool.get(pageId); - if (exists) { - return { page: exists.obj, release: exists.release }; - } - - const serviceCollection = this.serviceProvider.collection - // avoid to modify the original service collection - .clone(); - - configurePageContext(serviceCollection, blockSuitePage, pageRecord); - - const provider = serviceCollection.provider( - PageScope, - this.serviceProvider - ); - - const page = provider.get(Doc); - - const { obj, release } = this.pool.put(pageId, page); - - return { page: obj, release }; - } -} diff --git a/packages/common/infra/src/page/page.ts b/packages/common/infra/src/page/page.ts deleted file mode 100644 index c76f8fa56a..0000000000 --- a/packages/common/infra/src/page/page.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; - -import type { ServiceProvider } from '../di/core'; -import type { PageMode, PageRecord } from './record'; - -export class Doc { - constructor( - public readonly record: PageRecord, - public readonly blockSuiteDoc: BlockSuiteDoc, - public readonly services: ServiceProvider - ) {} - - get id() { - return this.record.id; - } - - readonly mete$ = this.record.meta$; - readonly mode$ = this.record.mode$; - readonly title$ = this.record.title$; - - setMode(mode: PageMode) { - this.record.setMode(mode); - } - - toggleMode() { - this.record.toggleMode(); - } -} diff --git a/packages/common/infra/src/page/record-list.ts b/packages/common/infra/src/page/record-list.ts deleted file mode 100644 index 9405dfa137..0000000000 --- a/packages/common/infra/src/page/record-list.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { isEqual } from 'lodash-es'; -import { distinctUntilChanged, map, Observable } from 'rxjs'; - -import { LiveData } from '../livedata'; -import type { Workspace, WorkspaceLocalState } from '../workspace'; -import { PageRecord } from './record'; - -export class PageRecordList { - constructor( - private readonly workspace: Workspace, - private readonly localState: WorkspaceLocalState - ) {} - - private readonly recordsPool = new Map(); - - public readonly records$ = LiveData.from( - new Observable(subscriber => { - const emit = () => { - subscriber.next( - this.workspace.docCollection.meta.docMetas.map(v => v.id) - ); - }; - - emit(); - - const dispose = - this.workspace.docCollection.meta.docMetaUpdated.on(emit).dispose; - return () => { - dispose(); - }; - }).pipe( - distinctUntilChanged((p, c) => isEqual(p, c)), - map(ids => - ids.map(id => { - const exists = this.recordsPool.get(id); - if (exists) { - return exists; - } - const record = new PageRecord(id, this.workspace, this.localState); - this.recordsPool.set(id, record); - return record; - }) - ) - ), - [] - ); - - public readonly isReady$ = this.workspace.engine.rootDocState$.map( - state => !state.syncing - ); - - public record$(id: string) { - return this.records$.map(record => record.find(record => record.id === id)); - } -} diff --git a/packages/common/infra/src/page/record.ts b/packages/common/infra/src/page/record.ts deleted file mode 100644 index cea2522b41..0000000000 --- a/packages/common/infra/src/page/record.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { DocMeta } from '@blocksuite/store'; -import { isEqual } from 'lodash-es'; -import { distinctUntilChanged, Observable } from 'rxjs'; - -import { LiveData } from '../livedata'; -import type { Workspace, WorkspaceLocalState } from '../workspace'; - -export type PageMode = 'edgeless' | 'page'; - -export class PageRecord { - meta: Partial | null = null; - constructor( - public readonly id: string, - private readonly workspace: Workspace, - private readonly localState: WorkspaceLocalState - ) {} - - meta$ = LiveData.from>( - new Observable>(subscriber => { - const emit = () => { - if (this.meta === null) { - // getDocMeta is heavy, so we cache the doc meta reference - this.meta = - this.workspace.docCollection.meta.getDocMeta(this.id) || null; - } - subscriber.next({ ...this.meta }); - }; - - emit(); - - const dispose = - this.workspace.docCollection.meta.docMetaUpdated.on(emit).dispose; - return () => { - dispose(); - }; - }).pipe(distinctUntilChanged((p, c) => isEqual(p, c))), - { - id: this.id, - title: '', - tags: [], - createDate: 0, - } - ); - - setMeta(meta: Partial): void { - this.workspace.docCollection.setDocMeta(this.id, meta); - } - - mode$: LiveData = LiveData.from( - this.localState.watch(`page:${this.id}:mode`), - 'page' - ).map(mode => (mode === 'edgeless' ? 'edgeless' : 'page')); - - setMode(mode: PageMode) { - this.localState.set(`page:${this.id}:mode`, mode); - } - - toggleMode() { - this.setMode(this.mode$.value === 'edgeless' ? 'page' : 'edgeless'); - return this.mode$.value; - } - - title$ = this.meta$.map(meta => meta.title ?? ''); -} diff --git a/packages/common/infra/src/page/service-scope.ts b/packages/common/infra/src/page/service-scope.ts deleted file mode 100644 index 53cf99a177..0000000000 --- a/packages/common/infra/src/page/service-scope.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ServiceScope } from '../di'; -import { createScope } from '../di'; -import { WorkspaceScope } from '../workspace'; - -export const PageScope: ServiceScope = createScope('page', WorkspaceScope); diff --git a/packages/common/infra/src/storage/__tests__/memento.spec.ts b/packages/common/infra/src/storage/__tests__/memento.spec.ts index b8cfe96db5..e037ab98f4 100644 --- a/packages/common/infra/src/storage/__tests__/memento.spec.ts +++ b/packages/common/infra/src/storage/__tests__/memento.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { ServiceCollection } from '../../di'; -import { GlobalCache, GlobalState, MemoryMemento } from '..'; +import { MemoryMemento } from '..'; describe('memento', () => { test('memory', () => { @@ -23,18 +22,4 @@ describe('memento', () => { memento.set('foo', 'hello'); expect(subscribed).toEqual('baz'); }); - - test('service', () => { - const services = new ServiceCollection(); - - services - .addImpl(GlobalCache, MemoryMemento) - .addImpl(GlobalState, MemoryMemento); - - const provider = services.provider(); - const cache = provider.get(GlobalCache); - expect(cache).toBeInstanceOf(MemoryMemento); - const state = provider.get(GlobalState); - expect(state).toBeInstanceOf(MemoryMemento); - }); }); diff --git a/packages/common/infra/src/storage/memento.ts b/packages/common/infra/src/storage/memento.ts index c59c9ccbb5..aec1d4d193 100644 --- a/packages/common/infra/src/storage/memento.ts +++ b/packages/common/infra/src/storage/memento.ts @@ -1,6 +1,5 @@ import type { Observable } from 'rxjs'; -import { createIdentifier } from '../di'; import { LiveData } from '../livedata'; /** @@ -15,24 +14,6 @@ export interface Memento { keys(): string[]; } -/** - * A memento object that stores the entire application state. - * - * State is persisted, even the application is closed. - */ -export interface GlobalState extends Memento {} - -export const GlobalState = createIdentifier('GlobalState'); - -/** - * A memento object that stores the entire application cache. - * - * Cache may be deleted from time to time, business logic should not rely on cache. - */ -export interface GlobalCache extends Memento {} - -export const GlobalCache = createIdentifier('GlobalCache'); - /** * A simple implementation of Memento. Used for testing. */ diff --git a/packages/common/infra/src/sync/awareness.ts b/packages/common/infra/src/sync/awareness.ts new file mode 100644 index 0000000000..d2c8243745 --- /dev/null +++ b/packages/common/infra/src/sync/awareness.ts @@ -0,0 +1,16 @@ +export interface AwarenessConnection { + connect(): void; + disconnect(): void; +} + +export class AwarenessEngine { + constructor(public readonly connections: AwarenessConnection[]) {} + + connect() { + this.connections.forEach(connection => connection.connect()); + } + + disconnect() { + this.connections.forEach(connection => connection.disconnect()); + } +} diff --git a/packages/common/infra/src/workspace/engine/blob.ts b/packages/common/infra/src/sync/blob/blob.ts similarity index 82% rename from packages/common/infra/src/workspace/engine/blob.ts rename to packages/common/infra/src/sync/blob/blob.ts index f4edb8457d..bf4b335918 100644 --- a/packages/common/infra/src/workspace/engine/blob.ts +++ b/packages/common/infra/src/sync/blob/blob.ts @@ -2,7 +2,8 @@ import { DebugLogger } from '@affine/debug'; import { Slot } from '@blocksuite/global/utils'; import { difference } from 'lodash-es'; -import { createIdentifier } from '../../di'; +import { LiveData } from '../../livedata'; +import type { Memento } from '../../storage'; import { BlobStorageOverCapacity } from './error'; const logger = new DebugLogger('affine:blob-engine'); @@ -16,12 +17,6 @@ export interface BlobStorage { list: () => Promise; } -export const LocalBlobStorage = - createIdentifier('LocalBlobStorage'); - -export const RemoteBlobStorage = - createIdentifier('RemoteBlobStorage'); - export interface BlobStatus { isStorageOverCapacity: boolean; } @@ -35,27 +30,19 @@ export interface BlobStatus { */ export class BlobEngine { private abort: AbortController | null = null; - private _status: BlobStatus = { isStorageOverCapacity: false }; - onStatusChange = new Slot(); + + readonly isStorageOverCapacity$ = new LiveData(false); + singleBlobSizeLimit: number = 100 * 1024 * 1024; onAbortLargeBlob = new Slot(); - private set status(s: BlobStatus) { - logger.debug('status change', s); - this._status = s; - this.onStatusChange.emit(s); - } - get status() { - return this._status; - } - constructor( private readonly local: BlobStorage, private readonly remotes: BlobStorage[] ) {} start() { - if (this.abort || this._status.isStorageOverCapacity) { + if (this.abort || this.isStorageOverCapacity$.value) { return; } this.abort = new AbortController(); @@ -132,9 +119,7 @@ export class BlobEngine { } } catch (err) { if (err instanceof BlobStorageOverCapacity) { - this.status = { - isStorageOverCapacity: true, - }; + this.isStorageOverCapacity$.value = true; } logger.error( `error when sync ${key} from [${remote.name}] to [${this.local.name}]`, @@ -234,3 +219,36 @@ export const EmptyBlobStorage: BlobStorage = { return []; }, }; + +export class MemoryBlobStorage implements BlobStorage { + name = 'testing'; + readonly = false; + + constructor(private readonly state: Memento) {} + + get(key: string) { + return Promise.resolve(this.state.get(key) ?? null); + } + set(key: string, value: Blob) { + this.state.set(key, value); + + const list = this.state.get>('list') ?? new Set(); + list.add(key); + this.state.set('list', list); + + return Promise.resolve(key); + } + delete(key: string) { + this.state.set(key, null); + + const list = this.state.get>('list') ?? new Set(); + list.delete(key); + this.state.set('list', list); + + return Promise.resolve(); + } + list() { + const list = this.state.get>('list'); + return Promise.resolve(list ? Array.from(list) : []); + } +} diff --git a/packages/common/infra/src/workspace/engine/error.ts b/packages/common/infra/src/sync/blob/error.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/error.ts rename to packages/common/infra/src/sync/blob/error.ts diff --git a/packages/common/infra/src/workspace/engine/doc/README.md b/packages/common/infra/src/sync/doc/README.md similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/README.md rename to packages/common/infra/src/sync/doc/README.md diff --git a/packages/common/infra/src/workspace/engine/doc/__tests__/priority-queue.spec.ts b/packages/common/infra/src/sync/doc/__tests__/priority-queue.spec.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/__tests__/priority-queue.spec.ts rename to packages/common/infra/src/sync/doc/__tests__/priority-queue.spec.ts diff --git a/packages/common/infra/src/workspace/engine/doc/__tests__/sync.spec.ts b/packages/common/infra/src/sync/doc/__tests__/sync.spec.ts similarity index 99% rename from packages/common/infra/src/workspace/engine/doc/__tests__/sync.spec.ts rename to packages/common/infra/src/sync/doc/__tests__/sync.spec.ts index 7a8020acf5..b3d24aed59 100644 --- a/packages/common/infra/src/workspace/engine/doc/__tests__/sync.spec.ts +++ b/packages/common/infra/src/sync/doc/__tests__/sync.spec.ts @@ -8,7 +8,7 @@ import { mergeUpdates, } from 'yjs'; -import { AsyncLock } from '../../../../utils'; +import { AsyncLock } from '../../../utils'; import { DocEngine } from '..'; import type { DocServer } from '../server'; import { MemoryStorage } from '../storage'; diff --git a/packages/common/infra/src/workspace/engine/doc/async-priority-queue.ts b/packages/common/infra/src/sync/doc/async-priority-queue.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/async-priority-queue.ts rename to packages/common/infra/src/sync/doc/async-priority-queue.ts diff --git a/packages/common/infra/src/workspace/engine/doc/clock.ts b/packages/common/infra/src/sync/doc/clock.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/clock.ts rename to packages/common/infra/src/sync/doc/clock.ts diff --git a/packages/common/infra/src/workspace/engine/doc/event.ts b/packages/common/infra/src/sync/doc/event.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/event.ts rename to packages/common/infra/src/sync/doc/event.ts diff --git a/packages/common/infra/src/workspace/engine/doc/index.ts b/packages/common/infra/src/sync/doc/index.ts similarity index 94% rename from packages/common/infra/src/workspace/engine/doc/index.ts rename to packages/common/infra/src/sync/doc/index.ts index 3a8a4581a6..1b52d75a54 100644 --- a/packages/common/infra/src/workspace/engine/doc/index.ts +++ b/packages/common/infra/src/sync/doc/index.ts @@ -3,9 +3,8 @@ import { nanoid } from 'nanoid'; import { map } from 'rxjs'; import type { Doc as YDoc } from 'yjs'; -import { createIdentifier } from '../../../di'; -import { LiveData } from '../../../livedata'; -import { MANUALLY_STOP } from '../../../utils'; +import { LiveData } from '../../livedata'; +import { MANUALLY_STOP } from '../../utils'; import { DocEngineLocalPart } from './local'; import { DocEngineRemotePart } from './remote'; import type { DocServer } from './server'; @@ -23,10 +22,6 @@ export { ReadonlyStorage as ReadonlyDocStorage, } from './storage'; -export const DocServerImpl = createIdentifier('DocServer'); - -export const DocStorageImpl = createIdentifier('DocStorage'); - export class DocEngine { localPart: DocEngineLocalPart; remotePart: DocEngineRemotePart | null; diff --git a/packages/common/infra/src/workspace/engine/doc/local.ts b/packages/common/infra/src/sync/doc/local.ts similarity index 98% rename from packages/common/infra/src/workspace/engine/doc/local.ts rename to packages/common/infra/src/sync/doc/local.ts index d9a658746f..7eea53c7cd 100644 --- a/packages/common/infra/src/workspace/engine/doc/local.ts +++ b/packages/common/infra/src/sync/doc/local.ts @@ -5,8 +5,8 @@ import { Observable, Subject } from 'rxjs'; import type { Doc as YDoc } from 'yjs'; import { applyUpdate, encodeStateAsUpdate, mergeUpdates } from 'yjs'; -import { LiveData } from '../../../livedata'; -import { throwIfAborted } from '../../../utils'; +import { LiveData } from '../../livedata'; +import { throwIfAborted } from '../../utils'; import { AsyncPriorityQueue } from './async-priority-queue'; import type { DocEvent } from './event'; import type { DocStorageInner } from './storage'; diff --git a/packages/common/infra/src/workspace/engine/doc/priority-queue.ts b/packages/common/infra/src/sync/doc/priority-queue.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/priority-queue.ts rename to packages/common/infra/src/sync/doc/priority-queue.ts diff --git a/packages/common/infra/src/workspace/engine/doc/remote.ts b/packages/common/infra/src/sync/doc/remote.ts similarity index 99% rename from packages/common/infra/src/workspace/engine/doc/remote.ts rename to packages/common/infra/src/sync/doc/remote.ts index bc9d4a5c27..e75bdc4709 100644 --- a/packages/common/infra/src/workspace/engine/doc/remote.ts +++ b/packages/common/infra/src/sync/doc/remote.ts @@ -3,8 +3,8 @@ import { remove } from 'lodash-es'; import { Observable, Subject } from 'rxjs'; import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs'; -import { LiveData } from '../../../livedata'; -import { throwIfAborted } from '../../../utils'; +import { LiveData } from '../../livedata'; +import { throwIfAborted } from '../../utils'; import { AsyncPriorityQueue } from './async-priority-queue'; import { ClockMap } from './clock'; import type { DocEvent } from './event'; diff --git a/packages/common/infra/src/workspace/engine/doc/server.ts b/packages/common/infra/src/sync/doc/server.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/server.ts rename to packages/common/infra/src/sync/doc/server.ts diff --git a/packages/common/infra/src/workspace/engine/doc/storage.ts b/packages/common/infra/src/sync/doc/storage.ts similarity index 98% rename from packages/common/infra/src/workspace/engine/doc/storage.ts rename to packages/common/infra/src/sync/doc/storage.ts index b87d7abc87..0260f84fb3 100644 --- a/packages/common/infra/src/workspace/engine/doc/storage.ts +++ b/packages/common/infra/src/sync/doc/storage.ts @@ -1,6 +1,6 @@ -import type { ByteKV, Memento } from '../../../storage'; -import { MemoryMemento, ReadonlyByteKV, wrapMemento } from '../../../storage'; -import { AsyncLock, mergeUpdates, throwIfAborted } from '../../../utils'; +import type { ByteKV, Memento } from '../../storage'; +import { MemoryMemento, ReadonlyByteKV, wrapMemento } from '../../storage'; +import { AsyncLock, mergeUpdates, throwIfAborted } from '../../utils'; import type { DocEventBus } from '.'; import { DocEventBusInner, MemoryDocEventBus } from './event'; import { isEmptyUpdate } from './utils'; diff --git a/packages/common/infra/src/workspace/engine/doc/utils.ts b/packages/common/infra/src/sync/doc/utils.ts similarity index 100% rename from packages/common/infra/src/workspace/engine/doc/utils.ts rename to packages/common/infra/src/sync/doc/utils.ts diff --git a/packages/common/infra/src/sync/index.ts b/packages/common/infra/src/sync/index.ts new file mode 100644 index 0000000000..f55463ee6f --- /dev/null +++ b/packages/common/infra/src/sync/index.ts @@ -0,0 +1,6 @@ +export type { AwarenessConnection } from './awareness'; +export { AwarenessEngine } from './awareness'; +export type { BlobStatus, BlobStorage } from './blob/blob'; +export { BlobEngine, EmptyBlobStorage } from './blob/blob'; +export { BlobStorageOverCapacity } from './blob/error'; +export * from './doc'; diff --git a/packages/common/infra/src/workspace/__tests__/workspace.spec.ts b/packages/common/infra/src/workspace/__tests__/workspace.spec.ts deleted file mode 100644 index d168055fb4..0000000000 --- a/packages/common/infra/src/workspace/__tests__/workspace.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { describe, expect, test } from 'vitest'; - -import { configureInfraServices, configureTestingInfraServices } from '../..'; -import { ServiceCollection } from '../../di'; -import { WorkspaceListService, WorkspaceManager } from '../'; - -describe('Workspace System', () => { - test('create workspace', async () => { - const services = new ServiceCollection(); - configureInfraServices(services); - configureTestingInfraServices(services); - - const provider = services.provider(); - const workspaceManager = provider.get(WorkspaceManager); - const workspaceListService = provider.get(WorkspaceListService); - expect(workspaceListService.workspaceList$.value.length).toBe(0); - - const { workspace } = workspaceManager.open( - await workspaceManager.createWorkspace(WorkspaceFlavour.LOCAL) - ); - - expect(workspaceListService.workspaceList$.value.length).toBe(1); - - const page = workspace.docCollection.createDoc({ - id: 'page0', - }); - page.load(); - page.addBlock('affine:page' as keyof BlockSuite.BlockModels, { - title: new page.Text('test-page'), - }); - - expect(workspace.docCollection.docs.size).toBe(1); - expect( - (page!.getBlockByFlavour('affine:page')[0] as any).title.toString() - ).toBe('test-page'); - }); -}); diff --git a/packages/common/infra/src/workspace/context.ts b/packages/common/infra/src/workspace/context.ts deleted file mode 100644 index a5b127f328..0000000000 --- a/packages/common/infra/src/workspace/context.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * This module contains the context of the workspace scope. - * You can use those context when declare workspace service. - * - * Is helpful when implement workspace low level providers, like `SyncEngine`, - * which need to access workspace low level components. - * - * Normally, business service should depend on `Workspace` service, not workspace context. - * - * @example - * ```ts - * import { declareWorkspaceService } from '@toeverything/infra'; - * declareWorkspaceService(XXXService, { - * factory: declareFactory( - * [BlockSuiteWorkspaceContext, RootYDocContext], // <== inject workspace context - * (bs, rootDoc) => new XXXService(bs.value, rootDoc.value) - * ), - * }) - */ - -import { DocCollection } from '@blocksuite/store'; -import { nanoid } from 'nanoid'; -import type { Awareness } from 'y-protocols/awareness.js'; -import type { Doc as YDoc } from 'yjs'; - -import type { ServiceCollection } from '../di'; -import { createIdentifier } from '../di'; -import { BlobEngine } from './engine/blob'; -import { globalBlockSuiteSchema } from './global-schema'; -import type { WorkspaceMetadata } from './metadata'; -import { WorkspaceScope } from './service-scope'; - -export const BlockSuiteWorkspaceContext = createIdentifier( - 'BlockSuiteWorkspaceContext' -); - -export const RootYDocContext = createIdentifier('RootYDocContext'); - -export const AwarenessContext = createIdentifier('AwarenessContext'); - -export const WorkspaceMetadataContext = createIdentifier( - 'WorkspaceMetadataContext' -); - -export const WorkspaceIdContext = - createIdentifier('WorkspaceIdContext'); - -export function configureWorkspaceContext( - services: ServiceCollection, - workspaceMetadata: WorkspaceMetadata -) { - services - .scope(WorkspaceScope) - .addImpl(WorkspaceMetadataContext, workspaceMetadata) - .addImpl(WorkspaceIdContext, workspaceMetadata.id) - .addImpl(BlockSuiteWorkspaceContext, provider => { - return new DocCollection({ - id: workspaceMetadata.id, - blobStorages: [ - () => ({ - crud: provider.get(BlobEngine), - }), - ], - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - }); - }) - .addImpl( - AwarenessContext, - provider => - provider.get(BlockSuiteWorkspaceContext).awarenessStore.awareness - ) - .addImpl( - RootYDocContext, - provider => provider.get(BlockSuiteWorkspaceContext).doc - ); -} diff --git a/packages/common/infra/src/workspace/engine/awareness.ts b/packages/common/infra/src/workspace/engine/awareness.ts deleted file mode 100644 index fc9b1b41a3..0000000000 --- a/packages/common/infra/src/workspace/engine/awareness.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createIdentifier } from '../../di'; - -export interface AwarenessProvider { - connect(): void; - disconnect(): void; -} - -export const AwarenessProvider = - createIdentifier('AwarenessProvider'); - -export class AwarenessEngine { - constructor(public readonly providers: AwarenessProvider[]) {} - - connect() { - this.providers.forEach(provider => provider.connect()); - } - - disconnect() { - this.providers.forEach(provider => provider.disconnect()); - } -} diff --git a/packages/common/infra/src/workspace/engine/index.ts b/packages/common/infra/src/workspace/engine/index.ts deleted file mode 100644 index 645e16046d..0000000000 --- a/packages/common/infra/src/workspace/engine/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Slot } from '@blocksuite/global/utils'; -import type { Doc as YDoc } from 'yjs'; - -import { throwIfAborted } from '../../utils/throw-if-aborted'; -import type { AwarenessEngine } from './awareness'; -import type { BlobEngine, BlobStatus } from './blob'; -import type { DocEngine } from './doc'; - -export interface WorkspaceEngineStatus { - blob: BlobStatus; -} - -/** - * # WorkspaceEngine - * - * sync ydoc, blob, awareness together - */ -export class WorkspaceEngine { - _status: WorkspaceEngineStatus; - onStatusChange = new Slot(); - - get status() { - return this._status; - } - - set status(status: WorkspaceEngineStatus) { - this._status = status; - this.onStatusChange.emit(status); - } - - constructor( - public blob: BlobEngine, - public doc: DocEngine, - public awareness: AwarenessEngine, - private readonly yDoc: YDoc - ) { - this._status = { - blob: blob.status, - }; - blob.onStatusChange.on(status => { - this.status = { - blob: status, - }; - }); - this.doc.setPriority(yDoc.guid, 100); - this.doc.addDoc(yDoc); - } - - start() { - this.doc.start(); - this.awareness.connect(); - this.blob.start(); - } - - canGracefulStop() { - return this.doc.engineState$.value.saving === 0; - } - - async waitForGracefulStop(abort?: AbortSignal) { - await this.doc.waitForSaved(); - throwIfAborted(abort); - this.forceStop(); - } - - forceStop() { - this.doc.stop(); - this.awareness.disconnect(); - this.blob.stop(); - } - - docEngineState$ = this.doc.engineState$; - - rootDocState$ = this.doc.docState$(this.yDoc.guid); - - waitForSynced() { - return this.doc.waitForSynced(); - } - - waitForRootDocReady() { - return this.doc.waitForReady(this.yDoc.guid); - } -} - -export * from './awareness'; -export * from './blob'; -export * from './doc'; -export * from './error'; diff --git a/packages/common/infra/src/workspace/factory.ts b/packages/common/infra/src/workspace/factory.ts deleted file mode 100644 index de77863208..0000000000 --- a/packages/common/infra/src/workspace/factory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ServiceCollection } from '../di'; -import { createIdentifier } from '../di'; - -export interface WorkspaceFactory { - name: string; - - configureWorkspace(services: ServiceCollection): void; - - /** - * get blob without open workspace - */ - getWorkspaceBlob(id: string, blobKey: string): Promise; -} - -export const WorkspaceFactory = - createIdentifier('WorkspaceFactory'); diff --git a/packages/common/infra/src/workspace/index.ts b/packages/common/infra/src/workspace/index.ts deleted file mode 100644 index cb72630278..0000000000 --- a/packages/common/infra/src/workspace/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -export * from './context'; -export * from './engine'; -export * from './factory'; -export * from './global-schema'; -export * from './list'; -export * from './manager'; -export * from './metadata'; -export * from './service-scope'; -export * from './storage'; -export * from './testing'; -export * from './upgrade'; -export * from './workspace'; - -import type { ServiceCollection } from '../di'; -import { ServiceProvider } from '../di'; -import { CleanupService } from '../lifecycle'; -import { GlobalCache, GlobalState, MemoryMemento } from '../storage'; -import { - BlockSuiteWorkspaceContext, - RootYDocContext, - WorkspaceMetadataContext, -} from './context'; -import { - AwarenessEngine, - AwarenessProvider, - BlobEngine, - DocEngine, - DocServerImpl, - DocStorageImpl, - LocalBlobStorage, - RemoteBlobStorage, - WorkspaceEngine, -} from './engine'; -import { WorkspaceFactory } from './factory'; -import { WorkspaceListProvider, WorkspaceListService } from './list'; -import { WorkspaceManager } from './manager'; -import { WorkspaceScope } from './service-scope'; -import { WorkspaceLocalState } from './storage'; -import { - TestingLocalWorkspaceFactory, - TestingLocalWorkspaceListProvider, -} from './testing'; -import { WorkspaceUpgradeController } from './upgrade'; -import { Workspace } from './workspace'; - -export function configureWorkspaceServices(services: ServiceCollection) { - // global scope - services - .add(WorkspaceManager, [ - WorkspaceListService, - [WorkspaceFactory], - ServiceProvider, - ]) - .add(WorkspaceListService, [[WorkspaceListProvider], GlobalCache]); - - // workspace scope - services - .scope(WorkspaceScope) - .add(CleanupService) - .add(Workspace, [ - WorkspaceMetadataContext, - WorkspaceEngine, - BlockSuiteWorkspaceContext, - WorkspaceUpgradeController, - ServiceProvider, - ]) - .add(WorkspaceEngine, [ - BlobEngine, - DocEngine, - AwarenessEngine, - RootYDocContext, - ]) - .add(AwarenessEngine, [[AwarenessProvider]]) - .add(BlobEngine, [LocalBlobStorage, [RemoteBlobStorage]]) - .addImpl(DocEngine, services => { - return new DocEngine( - services.get(DocStorageImpl), - services.getOptional(DocServerImpl) - ); - }) - .add(WorkspaceUpgradeController, [ - BlockSuiteWorkspaceContext, - DocEngine, - WorkspaceMetadataContext, - ]); -} - -export function configureTestingWorkspaceServices(services: ServiceCollection) { - services - .override(WorkspaceListProvider('affine-cloud'), null) - .override(WorkspaceFactory('affine-cloud'), null) - .override( - WorkspaceListProvider('local'), - TestingLocalWorkspaceListProvider, - [GlobalState] - ) - .override(WorkspaceFactory('local'), TestingLocalWorkspaceFactory, [ - GlobalState, - ]) - .scope(WorkspaceScope) - .override(WorkspaceLocalState, MemoryMemento); -} diff --git a/packages/common/infra/src/workspace/list/cache.ts b/packages/common/infra/src/workspace/list/cache.ts deleted file mode 100644 index a1ea35873d..0000000000 --- a/packages/common/infra/src/workspace/list/cache.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { GlobalCache } from '../../storage'; -import type { WorkspaceMetadata } from '../metadata'; - -const CACHE_STORAGE_KEY = 'jotai-workspaces'; - -export function readWorkspaceListCache(cache: GlobalCache) { - const metadata = cache.get(CACHE_STORAGE_KEY); - if (metadata) { - try { - const items = metadata as WorkspaceMetadata[]; - return [...items]; - } catch (e) { - console.error('cannot parse worksapce', e); - } - return []; - } - return []; -} - -export function writeWorkspaceListCache( - cache: GlobalCache, - metadata: WorkspaceMetadata[] -) { - cache.set(CACHE_STORAGE_KEY, metadata); -} diff --git a/packages/common/infra/src/workspace/list/index.ts b/packages/common/infra/src/workspace/list/index.ts deleted file mode 100644 index 324f20e70a..0000000000 --- a/packages/common/infra/src/workspace/list/index.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { WorkspaceFlavour } from '@affine/env/workspace'; -import type { DocCollection } from '@blocksuite/store'; -import { differenceWith } from 'lodash-es'; - -import { createIdentifier } from '../../di'; -import { LiveData } from '../../livedata'; -import type { GlobalCache } from '../../storage'; -import type { BlobStorage } from '../engine'; -import type { WorkspaceMetadata } from '../metadata'; -import { readWorkspaceListCache, writeWorkspaceListCache } from './cache'; -import type { WorkspaceInfo } from './information'; -import { WorkspaceInformation } from './information'; - -export * from './information'; - -const logger = new DebugLogger('affine:workspace:list'); - -export interface WorkspaceListProvider { - name: WorkspaceFlavour; - - /** - * get workspaces list - */ - getList(): Promise; - - /** - * delete workspace by id - */ - delete(workspaceId: string): Promise; - - /** - * create workspace - * @param initial callback to put initial data to workspace - */ - create( - initial: ( - docCollection: DocCollection, - blobStorage: BlobStorage - ) => Promise - ): Promise; - - /** - * Start subscribe workspaces list - * - * @returns unsubscribe function - */ - subscribe( - callback: (changed: { - added?: WorkspaceMetadata[]; - deleted?: WorkspaceMetadata[]; - }) => void - ): () => void; - - /** - * get workspace avatar and name by id - * - * @param id workspace id - */ - getInformation(id: string): Promise; -} - -export const WorkspaceListProvider = createIdentifier( - 'WorkspaceListProvider' -); - -export interface WorkspaceListStatus { - /** - * is workspace list doing first loading. - * if false, UI can display workspace not found page. - */ - loading: boolean; - workspaceList: WorkspaceMetadata[]; -} - -/** - * # WorkspaceList - * - * manage multiple workspace metadata list providers. - * provide a __cache-first__ and __offline useable__ workspace list. - */ -export class WorkspaceListService { - private readonly abortController = new AbortController(); - - private readonly workspaceInformationList = new Map< - string, - WorkspaceInformation - >(); - - status$ = new LiveData({ - loading: true, - workspaceList: [], - }); - - setStatus(status: WorkspaceListStatus) { - this.status$.next(status); - // update cache - writeWorkspaceListCache(this.cache, status.workspaceList); - } - - workspaceList$ = this.status$.map(x => x.workspaceList); - - constructor( - private readonly providers: WorkspaceListProvider[], - private readonly cache: GlobalCache - ) { - // initialize workspace list from cache - const cached = readWorkspaceListCache(cache); - const workspaceList = cached; - this.status$.next({ - ...this.status$.value, - workspaceList, - }); - - // start first load - this.startLoad(); - } - - /** - * create workspace - * @param flavour workspace flavour - * @param initial callback to put initial data to workspace - * @returns workspace id - */ - async create( - flavour: WorkspaceFlavour, - initial: ( - docCollection: DocCollection, - blobStorage: BlobStorage - ) => Promise = () => Promise.resolve() - ) { - const provider = this.providers.find(x => x.name === flavour); - if (!provider) { - throw new Error(`Unknown workspace flavour: ${flavour}`); - } - const metadata = await provider.create(initial); - // update workspace list - this.setStatus(this.addWorkspace(this.status$.value, metadata)); - return metadata; - } - - /** - * delete workspace - * @param workspaceMetadata - */ - async delete(workspaceMetadata: WorkspaceMetadata) { - logger.info( - `delete workspace [${workspaceMetadata.flavour}] ${workspaceMetadata.id}` - ); - const provider = this.providers.find( - x => x.name === workspaceMetadata.flavour - ); - if (!provider) { - throw new Error( - `Unknown workspace flavour: ${workspaceMetadata.flavour}` - ); - } - await provider.delete(workspaceMetadata.id); - - // delete workspace from list - this.setStatus(this.deleteWorkspace(this.status$.value, workspaceMetadata)); - } - - /** - * add workspace to list - */ - private addWorkspace( - status: WorkspaceListStatus, - workspaceMetadata: WorkspaceMetadata - ) { - if (status.workspaceList.some(x => x.id === workspaceMetadata.id)) { - return status; - } - return { - ...status, - workspaceList: status.workspaceList.concat(workspaceMetadata), - }; - } - - /** - * delete workspace from list - */ - private deleteWorkspace( - status: WorkspaceListStatus, - workspaceMetadata: WorkspaceMetadata - ) { - if (!status.workspaceList.some(x => x.id === workspaceMetadata.id)) { - return status; - } - return { - ...status, - workspaceList: status.workspaceList.filter( - x => x.id !== workspaceMetadata.id - ), - }; - } - - /** - * callback for subscribe workspaces list - */ - private handleWorkspaceChange(changed: { - added?: WorkspaceMetadata[]; - deleted?: WorkspaceMetadata[]; - }) { - let status = this.status$.value; - - for (const added of changed.added ?? []) { - status = this.addWorkspace(status, added); - } - for (const deleted of changed.deleted ?? []) { - status = this.deleteWorkspace(status, deleted); - } - - this.setStatus(status); - } - - /** - * start first load workspace list - */ - private startLoad() { - for (const provider of this.providers) { - // subscribe workspace list change - const unsubscribe = provider.subscribe(changed => { - this.handleWorkspaceChange(changed); - }); - - // unsubscribe when abort - if (this.abortController.signal.aborted) { - unsubscribe(); - return; - } - this.abortController.signal.addEventListener('abort', () => { - unsubscribe(); - }); - } - - this.revalidate() - .catch(error => { - logger.error('load workspace list error: ' + error); - }) - .finally(() => { - this.setStatus({ - ...this.status$.value, - loading: false, - }); - }); - } - - async revalidate() { - await Promise.allSettled( - this.providers.map(async provider => { - try { - const list = await provider.getList(); - const oldList = this.workspaceList$.value.filter( - w => w.flavour === provider.name - ); - this.handleWorkspaceChange({ - added: differenceWith(list, oldList, (a, b) => a.id === b.id), - deleted: differenceWith(oldList, list, (a, b) => a.id === b.id), - }); - } catch (error) { - logger.error('load workspace list error: ' + error); - } - }) - ); - } - - /** - * get workspace information, if not exists, create it. - */ - getInformation(meta: WorkspaceMetadata) { - const exists = this.workspaceInformationList.get(meta.id); - if (exists) { - return exists; - } - - return this.createInformation(meta); - } - - private createInformation(workspaceMetadata: WorkspaceMetadata) { - const provider = this.providers.find( - x => x.name === workspaceMetadata.flavour - ); - if (!provider) { - throw new Error( - `Unknown workspace flavour: ${workspaceMetadata.flavour}` - ); - } - const information = new WorkspaceInformation( - workspaceMetadata, - provider, - this.cache - ); - information.fetch(); - this.workspaceInformationList.set(workspaceMetadata.id, information); - return information; - } - - dispose() { - this.abortController.abort(); - } -} diff --git a/packages/common/infra/src/workspace/list/information.ts b/packages/common/infra/src/workspace/list/information.ts deleted file mode 100644 index 10b35203a3..0000000000 --- a/packages/common/infra/src/workspace/list/information.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; - -import type { Memento } from '../../storage/memento'; -import type { WorkspaceMetadata } from '../metadata'; -import type { Workspace } from '../workspace'; -import type { WorkspaceListProvider } from '.'; - -const logger = new DebugLogger('affine:workspace:list:information'); - -const WORKSPACE_INFORMATION_CACHE_KEY = 'workspace-information:'; - -export interface WorkspaceInfo { - avatar?: string; - name?: string; -} - -/** - * # WorkspaceInformation - * - * This class take care of workspace avatar and name - * - * The class will try to get from 3 places: - * - local cache - * - fetch from `WorkspaceListProvider`, which will fetch from database or server - * - sync with active workspace - */ -export class WorkspaceInformation { - private _info: WorkspaceInfo = {}; - - public set info(info: WorkspaceInfo) { - if (info.avatar !== this._info.avatar || info.name !== this._info.name) { - this.cache.set(WORKSPACE_INFORMATION_CACHE_KEY + this.meta.id, info); - this._info = info; - this.onUpdated.emit(info); - } - } - - public get info() { - return this._info; - } - - public onUpdated = new Slot(); - - constructor( - public meta: WorkspaceMetadata, - public provider: WorkspaceListProvider, - public cache: Memento - ) { - const cached = this.getCachedInformation(); - // init with cached information - this.info = { ...cached }; - } - - /** - * sync information with workspace - */ - syncWithWorkspace(workspace: Workspace) { - this.info = { - avatar: workspace.docCollection.meta.avatar ?? this.info.avatar, - name: workspace.docCollection.meta.name ?? this.info.name, - }; - workspace.docCollection.meta.commonFieldsUpdated.on(() => { - this.info = { - avatar: workspace.docCollection.meta.avatar ?? this.info.avatar, - name: workspace.docCollection.meta.name ?? this.info.name, - }; - }); - } - - getCachedInformation() { - return this.cache.get( - WORKSPACE_INFORMATION_CACHE_KEY + this.meta.id - ); - } - - /** - * fetch information from provider - */ - fetch() { - this.provider - .getInformation(this.meta.id) - .then(info => { - if (info) { - this.info = info; - } - }) - .catch(err => { - logger.warn('get workspace information error: ' + err); - }); - } -} diff --git a/packages/common/infra/src/workspace/manager.ts b/packages/common/infra/src/workspace/manager.ts deleted file mode 100644 index da3ad835cd..0000000000 --- a/packages/common/infra/src/workspace/manager.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { assertEquals } from '@blocksuite/global/utils'; -import type { DocCollection } from '@blocksuite/store'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -import { setupEditorFlags } from '../atom/settings'; -import { fixWorkspaceVersion } from '../blocksuite'; -import type { ServiceCollection, ServiceProvider } from '../di'; -import { ObjectPool } from '../utils/object-pool'; -import { configureWorkspaceContext } from './context'; -import type { BlobStorage } from './engine'; -import type { WorkspaceFactory } from './factory'; -import type { WorkspaceListService } from './list'; -import type { WorkspaceMetadata } from './metadata'; -import { WorkspaceScope } from './service-scope'; -import { Workspace } from './workspace'; - -const logger = new DebugLogger('affine:workspace-manager'); - -/** - * # `WorkspaceManager` - * - * This class acts as the central hub for managing various aspects of workspaces. - * It is structured as follows: - * - * ``` - * ┌───────────┐ - * │ Workspace │ - * │ Manager │ - * └─────┬─────┘ - * ┌─────────────┼─────────────┐ - * ┌───┴───┐ ┌───┴───┐ ┌─────┴─────┐ - * │ List │ │ Pool │ │ Factories │ - * └───────┘ └───────┘ └───────────┘ - * ``` - * - * Manage every about workspace - * - * # List - * - * The `WorkspaceList` component stores metadata for all workspaces, also include workspace avatar and custom name. - * - * # Factories - * - * This class contains a collection of `WorkspaceFactory`, - * We utilize `metadata.flavour` to identify the appropriate factory for opening a workspace. - * Once opened, workspaces are stored in the `WorkspacePool`. - * - * # Pool - * - * The `WorkspacePool` use reference counting to manage active workspaces. - * Calling `use()` to create a reference to the workspace. Calling `release()` to release the reference. - * When the reference count is 0, it will close the workspace. - * - */ -export class WorkspaceManager { - pool = new ObjectPool({ - onDelete(workspace) { - workspace.forceStop(); - }, - onDangling(workspace) { - return workspace.canGracefulStop(); - }, - }); - - constructor( - public readonly list: WorkspaceListService, - public readonly factories: WorkspaceFactory[], - private readonly serviceProvider: ServiceProvider - ) {} - - /** - * get workspace reference by metadata. - * - * You basically don't need to call this function directly, use the react hook `useWorkspace(metadata)` instead. - * - * @returns the workspace reference and a release function, don't forget to call release function when you don't - * need the workspace anymore. - */ - open(metadata: WorkspaceMetadata): { - workspace: Workspace; - release: () => void; - } { - const exist = this.pool.get(metadata.id); - if (exist) { - return { - workspace: exist.obj, - release: exist.release, - }; - } - - const workspace = this.instantiate(metadata); - // sync information with workspace list, when workspace's avatar and name changed, information will be updated - this.list.getInformation(metadata).syncWithWorkspace(workspace); - - const ref = this.pool.put(workspace.meta.id, workspace); - - return { - workspace: ref.obj, - release: ref.release, - }; - } - - createWorkspace( - flavour: WorkspaceFlavour, - initial?: ( - docCollection: DocCollection, - blobStorage: BlobStorage - ) => Promise - ): Promise { - logger.info(`create workspace [${flavour}]`); - return this.list.create(flavour, initial); - } - - /** - * delete workspace by metadata, same as `WorkspaceList.deleteWorkspace` - */ - async deleteWorkspace(metadata: WorkspaceMetadata) { - await this.list.delete(metadata); - } - - /** - * helper function to transform local workspace to cloud workspace - */ - async transformLocalToCloud(local: Workspace): Promise { - assertEquals(local.flavour, WorkspaceFlavour.LOCAL); - - await local.engine.waitForSynced(); - - const newId = await this.list.create( - WorkspaceFlavour.AFFINE_CLOUD, - async (ws, bs) => { - applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc)); - - for (const subdoc of local.docCollection.doc.getSubdocs()) { - for (const newSubdoc of ws.doc.getSubdocs()) { - if (newSubdoc.guid === subdoc.guid) { - applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc)); - } - } - } - - const blobList = await local.engine.blob.list(); - - for (const blobKey of blobList) { - const blob = await local.engine.blob.get(blobKey); - if (blob) { - await bs.set(blobKey, blob); - } - } - } - ); - - await this.list.delete(local.meta); - - return newId; - } - - /** - * helper function to get blob without open workspace, its be used for download workspace avatars. - */ - getWorkspaceBlob(metadata: WorkspaceMetadata, blobKey: string) { - const factory = this.factories.find(x => x.name === metadata.flavour); - if (!factory) { - throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); - } - return factory.getWorkspaceBlob(metadata.id, blobKey); - } - - instantiate( - metadata: WorkspaceMetadata, - configureWorkspace?: (serviceCollection: ServiceCollection) => void - ) { - logger.info(`open workspace [${metadata.flavour}] ${metadata.id} `); - const serviceCollection = this.serviceProvider.collection.clone(); - if (configureWorkspace) { - configureWorkspace(serviceCollection); - } else { - const factory = this.factories.find(x => x.name === metadata.flavour); - if (!factory) { - throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); - } - factory.configureWorkspace(serviceCollection); - } - configureWorkspaceContext(serviceCollection, metadata); - const provider = serviceCollection.provider( - WorkspaceScope, - this.serviceProvider - ); - const workspace = provider.get(Workspace); - - // apply compatibility fix - fixWorkspaceVersion(workspace.docCollection.doc); - - setupEditorFlags(workspace.docCollection); - - return workspace; - } -} diff --git a/packages/common/infra/src/workspace/service-scope.ts b/packages/common/infra/src/workspace/service-scope.ts deleted file mode 100644 index 4212cf9ed7..0000000000 --- a/packages/common/infra/src/workspace/service-scope.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createScope } from '../di'; - -export const WorkspaceScope = createScope('workspace'); diff --git a/packages/common/infra/src/workspace/storage.ts b/packages/common/infra/src/workspace/storage.ts deleted file mode 100644 index b7d2fe41f7..0000000000 --- a/packages/common/infra/src/workspace/storage.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createIdentifier } from '../di'; -import type { Memento } from '../storage'; - -export interface WorkspaceLocalState extends Memento {} - -export const WorkspaceLocalState = createIdentifier( - 'WorkspaceLocalState' -); diff --git a/packages/common/infra/src/workspace/testing.ts b/packages/common/infra/src/workspace/testing.ts deleted file mode 100644 index 7410621773..0000000000 --- a/packages/common/infra/src/workspace/testing.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { DocCollection } from '@blocksuite/store'; -import { differenceBy } from 'lodash-es'; -import { nanoid } from 'nanoid'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -import type { ServiceCollection } from '../di'; -import type { Memento } from '../storage'; -import { GlobalState } from '../storage'; -import { WorkspaceMetadataContext } from './context'; -import type { BlobStorage } from './engine'; -import { - AwarenessProvider, - DocStorageImpl, - LocalBlobStorage, - MemoryDocStorage, -} from './engine'; -import { MemoryStorage } from './engine/doc/storage'; -import type { WorkspaceFactory } from './factory'; -import { globalBlockSuiteSchema } from './global-schema'; -import type { WorkspaceInfo, WorkspaceListProvider } from './list'; -import type { WorkspaceMetadata } from './metadata'; -import { WorkspaceScope } from './service-scope'; - -const LIST_STORE_KEY = 'testing-workspace-list'; - -export class TestingLocalWorkspaceListProvider - implements WorkspaceListProvider -{ - name = WorkspaceFlavour.LOCAL; - docStorage = new MemoryDocStorage(this.state); - - constructor(private readonly state: Memento) {} - - getList(): Promise { - const list = this.state.get(LIST_STORE_KEY); - return Promise.resolve(list ?? []); - } - delete(workspaceId: string): Promise { - const list = this.state.get(LIST_STORE_KEY) ?? []; - const newList = list.filter(meta => meta.id !== workspaceId); - this.state.set(LIST_STORE_KEY, newList); - return Promise.resolve(); - } - async create( - initial: ( - docCollection: DocCollection, - blobStorage: BlobStorage - ) => Promise - ): Promise { - const id = nanoid(); - const meta = { id, flavour: WorkspaceFlavour.LOCAL }; - - const blobStorage = new TestingBlobStorage(meta, this.state); - - const docCollection = new DocCollection({ - id: id, - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - blobStorages: [ - () => { - return { - crud: blobStorage, - }; - }, - ], - }); - - // apply initial state - await initial(docCollection, blobStorage); - - // save workspace to storage - await this.docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc)); - for (const subdocs of docCollection.doc.getSubdocs()) { - await this.docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); - } - - const list = this.state.get(LIST_STORE_KEY) ?? []; - this.state.set(LIST_STORE_KEY, [...list, meta]); - - return { id, flavour: WorkspaceFlavour.LOCAL }; - } - subscribe( - callback: (changed: { - added?: WorkspaceMetadata[] | undefined; - deleted?: WorkspaceMetadata[] | undefined; - }) => void - ): () => void { - let lastWorkspaces: WorkspaceMetadata[] = - this.state.get(LIST_STORE_KEY) ?? []; - - const sub = this.state - .watch(LIST_STORE_KEY) - .subscribe(allWorkspaces => { - if (allWorkspaces) { - const added = differenceBy(allWorkspaces, lastWorkspaces, v => v.id); - const deleted = differenceBy( - lastWorkspaces, - allWorkspaces, - v => v.id - ); - lastWorkspaces = allWorkspaces; - if (added.length || deleted.length) { - callback({ added, deleted }); - } - } - }); - return () => { - sub.unsubscribe(); - }; - } - async getInformation(id: string): Promise { - // get information from root doc - const data = await this.docStorage.doc.get(id); - - if (!data) { - return; - } - - const bs = new DocCollection({ - id, - schema: globalBlockSuiteSchema, - }); - - applyUpdate(bs.doc, data); - - return { - name: bs.meta.name, - avatar: bs.meta.avatar, - }; - } -} - -export class TestingLocalWorkspaceFactory implements WorkspaceFactory { - constructor(private readonly state: Memento) {} - - name = WorkspaceFlavour.LOCAL; - - configureWorkspace(services: ServiceCollection): void { - services - .scope(WorkspaceScope) - .addImpl(LocalBlobStorage, TestingBlobStorage, [ - WorkspaceMetadataContext, - GlobalState, - ]) - .addImpl(DocStorageImpl, MemoryStorage, [GlobalState]) - .addImpl(AwarenessProvider, TestingAwarenessProvider); - } - - getWorkspaceBlob(id: string, blobKey: string): Promise { - return new TestingBlobStorage( - { - flavour: WorkspaceFlavour.LOCAL, - id, - }, - this.state - ).get(blobKey); - } -} - -export class TestingBlobStorage implements BlobStorage { - name = 'testing'; - readonly = false; - - constructor( - private readonly metadata: WorkspaceMetadata, - private readonly state: Memento - ) {} - - get(key: string) { - const storeKey = 'testing-blob/' + this.metadata.id + '/' + key; - return Promise.resolve(this.state.get(storeKey) ?? null); - } - set(key: string, value: Blob) { - const storeKey = 'testing-blob/' + this.metadata.id + '/' + key; - this.state.set(storeKey, value); - - const listKey = 'testing-blob-list/' + this.metadata.id; - const list = this.state.get>(listKey) ?? new Set(); - list.add(key); - this.state.set(listKey, list); - - return Promise.resolve(key); - } - delete(key: string) { - this.state.set(key, null); - - const listKey = 'testing-blob-list/' + this.metadata.id; - const list = this.state.get>(listKey) ?? new Set(); - list.delete(key); - this.state.set(listKey, list); - - return Promise.resolve(); - } - list() { - const listKey = 'testing-blob-list/' + this.metadata.id; - const list = this.state.get>(listKey); - return Promise.resolve(list ? Array.from(list) : []); - } -} - -export class TestingAwarenessProvider implements AwarenessProvider { - connect(): void { - /* do nothing */ - } - disconnect(): void { - /* do nothing */ - } -} diff --git a/packages/common/infra/src/workspace/upgrade.ts b/packages/common/infra/src/workspace/upgrade.ts deleted file mode 100644 index ef6285b034..0000000000 --- a/packages/common/infra/src/workspace/upgrade.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Unreachable } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Slot } from '@blocksuite/global/utils'; -import type { DocCollection } from '@blocksuite/store'; -import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; - -import { - checkWorkspaceCompatibility, - forceUpgradePages, - migrateGuidCompatibility, - MigrationPoint, - upgradeV1ToV2, -} from '../blocksuite'; -import type { DocEngine } from './engine'; -import type { WorkspaceManager } from './manager'; -import type { WorkspaceMetadata } from './metadata'; - -export interface WorkspaceUpgradeStatus { - needUpgrade: boolean; - upgrading: boolean; -} - -export class WorkspaceUpgradeController { - _status: Readonly = { - needUpgrade: false, - upgrading: false, - }; - readonly onStatusChange = new Slot(); - - get status() { - return this._status; - } - - set status(value) { - if ( - value.needUpgrade !== this._status.needUpgrade || - value.upgrading !== this._status.upgrading - ) { - this._status = value; - this.onStatusChange.emit(value); - } - } - - constructor( - private readonly docCollection: DocCollection, - private readonly docEngine: DocEngine, - private readonly workspaceMetadata: WorkspaceMetadata - ) { - docCollection.doc.on('update', () => { - this.checkIfNeedUpgrade(); - }); - } - - checkIfNeedUpgrade() { - const needUpgrade = !!checkWorkspaceCompatibility( - this.docCollection, - this.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD - ); - this.status = { - ...this.status, - needUpgrade, - }; - return needUpgrade; - } - - async upgrade( - workspaceManager: WorkspaceManager - ): Promise { - if (this.status.upgrading) { - return null; - } - - this.status = { ...this.status, upgrading: true }; - - try { - await this.docEngine.waitForSynced(); - - const step = checkWorkspaceCompatibility( - this.docCollection, - this.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD - ); - - if (!step) { - return null; - } - - // Clone a new doc to prevent change events. - const clonedDoc = new YDoc({ - guid: this.docCollection.doc.guid, - }); - applyDoc(clonedDoc, this.docCollection.doc); - - if (step === MigrationPoint.SubDoc) { - const newWorkspace = await workspaceManager.createWorkspace( - WorkspaceFlavour.LOCAL, - async (workspace, blobStorage) => { - await upgradeV1ToV2(clonedDoc, workspace.doc); - migrateGuidCompatibility(clonedDoc); - await forceUpgradePages(workspace.doc, this.docCollection.schema); - const blobList = await this.docCollection.blob.list(); - - for (const blobKey of blobList) { - const blob = await this.docCollection.blob.get(blobKey); - if (blob) { - await blobStorage.set(blobKey, blob); - } - } - } - ); - await workspaceManager.deleteWorkspace(this.workspaceMetadata); - return newWorkspace; - } else if (step === MigrationPoint.GuidFix) { - migrateGuidCompatibility(clonedDoc); - await forceUpgradePages(clonedDoc, this.docCollection.schema); - applyDoc(this.docCollection.doc, clonedDoc); - await this.docEngine.waitForSynced(); - return null; - } else if (step === MigrationPoint.BlockVersion) { - await forceUpgradePages(clonedDoc, this.docCollection.schema); - applyDoc(this.docCollection.doc, clonedDoc); - await this.docEngine.waitForSynced(); - return null; - } else { - throw new Unreachable(); - } - } finally { - this.status = { ...this.status, upgrading: false }; - } - } -} - -function applyDoc(target: YDoc, result: YDoc) { - applyUpdate(target, encodeStateAsUpdate(result)); - for (const targetSubDoc of target.subdocs.values()) { - const resultSubDocs = Array.from(result.subdocs.values()); - const resultSubDoc = resultSubDocs.find( - item => item.guid === targetSubDoc.guid - ); - if (resultSubDoc) { - applyDoc(targetSubDoc, resultSubDoc); - } - } -} diff --git a/packages/common/infra/src/workspace/workspace.ts b/packages/common/infra/src/workspace/workspace.ts deleted file mode 100644 index 0c53b7b4ee..0000000000 --- a/packages/common/infra/src/workspace/workspace.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; -import type { DocCollection } from '@blocksuite/store'; - -import type { ServiceProvider } from '../di'; -import { CleanupService } from '../lifecycle'; -import type { WorkspaceEngine, WorkspaceEngineStatus } from './engine'; -import type { WorkspaceMetadata } from './metadata'; -import type { - WorkspaceUpgradeController, - WorkspaceUpgradeStatus, -} from './upgrade'; - -export type { DocCollection } from '@blocksuite/store'; - -const logger = new DebugLogger('affine:workspace'); - -export type WorkspaceStatus = { - mode: 'ready' | 'closed'; - engine: WorkspaceEngineStatus; - upgrade: WorkspaceUpgradeStatus; -}; - -/** - * # Workspace - * - * ``` - * ┌───────────┐ - * │ Workspace │ - * └─────┬─────┘ - * │ - * │ - * ┌──────────────┼─────────────┐ - * │ │ │ - * ┌───┴─────┐ ┌──────┴─────┐ ┌───┴────┐ - * │ Upgrade │ │ blocksuite │ │ Engine │ - * └─────────┘ └────────────┘ └───┬────┘ - * │ - * ┌──────┼─────────┐ - * │ │ │ - * ┌──┴─┐ ┌──┴─┐ ┌─────┴───┐ - * │sync│ │blob│ │awareness│ - * └────┘ └────┘ └─────────┘ - * ``` - * - * This class contains all the components needed to run a workspace. - */ -export class Workspace { - get id() { - return this.meta.id; - } - get flavour() { - return this.meta.flavour; - } - - private _status: WorkspaceStatus; - - onStatusChange = new Slot(); - get status() { - return this._status; - } - - set status(status: WorkspaceStatus) { - this._status = status; - this.onStatusChange.emit(status); - } - - constructor( - public meta: WorkspaceMetadata, - public engine: WorkspaceEngine, - public docCollection: DocCollection, - public upgrade: WorkspaceUpgradeController, - public services: ServiceProvider - ) { - this._status = { - mode: 'closed', - engine: engine.status, - upgrade: this.upgrade.status, - }; - this.engine.onStatusChange.on(status => { - this.status = { - ...this.status, - engine: status, - }; - }); - this.upgrade.onStatusChange.on(status => { - this.status = { - ...this.status, - upgrade: status, - }; - }); - - this.start(); - } - - /** - * workspace start when create and workspace is one-time use - */ - private start() { - if (this.status.mode === 'ready') { - return; - } - logger.info('start workspace', this.id); - this.engine.start(); - this.status = { - ...this.status, - mode: 'ready', - engine: this.engine.status, - }; - } - - canGracefulStop() { - return this.engine.canGracefulStop() && !this.status.upgrade.upgrading; - } - - forceStop() { - if (this.status.mode === 'closed') { - return; - } - logger.info('stop workspace', this.id); - this.engine.forceStop(); - this.status = { - ...this.status, - mode: 'closed', - engine: this.engine.status, - }; - this.services.get(CleanupService).cleanup(); - } - - setPriorityLoad(docId: string, priority: number) { - this.engine.doc.setPriority(docId, priority); - } -} diff --git a/packages/frontend/component/src/components/auth-components/onboarding-page.tsx b/packages/frontend/component/src/components/auth-components/onboarding-page.tsx index 406cc8cd8b..eb1e04c640 100644 --- a/packages/frontend/component/src/components/auth-components/onboarding-page.tsx +++ b/packages/frontend/component/src/components/auth-components/onboarding-page.tsx @@ -1,5 +1,4 @@ import { apis } from '@affine/electron-api'; -import { fetchWithTraceReport } from '@affine/graphql'; import { ArrowRightSmallIcon } from '@blocksuite/icons'; import clsx from 'clsx'; import { useEffect, useMemo, useState } from 'react'; @@ -112,7 +111,7 @@ export const OnboardingPage = ({ const [questionIdx, setQuestionIdx] = useState(0); const { data: questions } = useSWR( '/api/worker/questionnaire', - url => fetchWithTraceReport(url).then(r => r.json()), + url => fetch(url).then(r => r.json()), { suspense: true, revalidateOnFocus: false } ); const [options, setOptions] = useState(new Set()); @@ -242,7 +241,7 @@ export const OnboardingPage = ({ }; // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchWithTraceReport('/api/worker/questionnaire', { + fetch('/api/worker/questionnaire', { method: 'POST', body: JSON.stringify(answer), }).finally(() => { diff --git a/packages/frontend/component/src/components/auth-components/sign-up-page.tsx b/packages/frontend/component/src/components/auth-components/sign-up-page.tsx index a6a9cf2c5d..24401e431c 100644 --- a/packages/frontend/component/src/components/auth-components/sign-up-page.tsx +++ b/packages/frontend/component/src/components/auth-components/sign-up-page.tsx @@ -7,11 +7,10 @@ import { Button } from '../../ui/button'; import { notify } from '../../ui/notification'; import { AuthPageContainer } from './auth-page-container'; import { SetPassword } from './set-password'; -import type { User } from './type'; export const SignUpPage: FC<{ passwordLimits: PasswordLimitsFragment; - user: User; + user: { email?: string }; onSetPassword: (password: string) => Promise; openButtonText?: string; onOpenAffine: () => void; diff --git a/packages/frontend/component/src/components/auth-components/type.ts b/packages/frontend/component/src/components/auth-components/type.ts index 819bc607c0..02654487e4 100644 --- a/packages/frontend/component/src/components/auth-components/type.ts +++ b/packages/frontend/component/src/components/auth-components/type.ts @@ -1,7 +1,7 @@ export interface User { id: string; - name: string; - email: string; + label: string; + email?: string; image?: string | null; - avatarUrl: string | null; + avatar?: string | null; } diff --git a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx index 6e2fd39b47..08e6434f79 100644 --- a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx +++ b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx @@ -47,7 +47,7 @@ export const NoPermissionOrNotFound = ({
- + {user.email} @@ -91,7 +91,7 @@ export const NotFoundPage = ({ {user ? (
- + {user.email} diff --git a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx index 33599cb100..23622bb7b0 100644 --- a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx +++ b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx @@ -175,7 +175,7 @@ export const ResizePanel = forwardRef( data-handle-position={resizeHandlePos} data-enable-animation={enableAnimation && !resizing} > - {children} + {status !== 'exited' && children} void; onEnableCloudClick?: (meta: WorkspaceMetadata) => void; onDragEnd: (event: DragEndEvent) => void; - useIsWorkspaceOwner: (workspaceMetadata: WorkspaceMetadata) => boolean; + useIsWorkspaceOwner: ( + workspaceMetadata: WorkspaceMetadata + ) => boolean | undefined; useWorkspaceAvatar: ( workspaceMetadata: WorkspaceMetadata ) => string | undefined; diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 6745074e6a..074c2f54c4 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -8,6 +8,7 @@ export * from './ui/date-picker'; export * from './ui/divider'; export * from './ui/editable'; export * from './ui/empty'; +export * from './ui/error-message'; export * from './ui/input'; export * from './ui/layout'; export * from './ui/loading'; diff --git a/packages/frontend/component/src/ui/error-message/error-message.tsx b/packages/frontend/component/src/ui/error-message/error-message.tsx new file mode 100644 index 0000000000..9090f34b7b --- /dev/null +++ b/packages/frontend/component/src/ui/error-message/error-message.tsx @@ -0,0 +1,28 @@ +import clsx from 'clsx'; +import type React from 'react'; + +import { errorMessage } from './style.css'; + +export const ErrorMessage = ({ + children, + inline, + style, + className, +}: React.PropsWithChildren<{ + inline?: boolean; + style?: React.CSSProperties; + className?: string; +}>) => { + if (inline) { + return ( + + {children} + + ); + } + return ( +
+ {children} +
+ ); +}; diff --git a/packages/frontend/component/src/ui/error-message/index.ts b/packages/frontend/component/src/ui/error-message/index.ts new file mode 100644 index 0000000000..72d32ff58f --- /dev/null +++ b/packages/frontend/component/src/ui/error-message/index.ts @@ -0,0 +1 @@ +export { ErrorMessage } from './error-message'; diff --git a/packages/frontend/component/src/ui/error-message/style.css.ts b/packages/frontend/component/src/ui/error-message/style.css.ts new file mode 100644 index 0000000000..69f1b12477 --- /dev/null +++ b/packages/frontend/component/src/ui/error-message/style.css.ts @@ -0,0 +1,8 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const errorMessage = style({ + color: cssVar('--affine-error-color'), + fontSize: '0.6rem', + margin: '4px 8px 2px 2px', +}); diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index b2de8056c0..a9a8318f66 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -18,7 +18,6 @@ "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", "@affine/templates": "workspace:*", - "@affine/workspace-impl": "workspace:*", "@blocksuite/block-std": "0.14.0-canary-202404151235-655ec84", "@blocksuite/blocks": "0.14.0-canary-202404151235-655ec84", "@blocksuite/global": "0.14.0-canary-202404151235-655ec84", @@ -59,7 +58,9 @@ "graphql": "^16.8.1", "history": "^5.3.0", "idb": "^8.0.0", + "idb-keyval": "^6.2.1", "image-blob-reduce": "^4.1.0", + "is-svg": "^5.0.0", "jotai": "^2.8.0", "jotai-devtools": "^0.8.0", "jotai-effect": "^1.0.0", @@ -80,6 +81,7 @@ "react-virtuoso": "^4.7.8", "rxjs": "^7.8.1", "ses": "^1.4.1", + "socket.io-client": "^4.7.5", "swr": "2.2.5", "uuid": "^9.0.1", "valtio": "^1.13.2", diff --git a/packages/frontend/core/src/bootstrap/first-app-data.ts b/packages/frontend/core/src/bootstrap/first-app-data.ts index 8772235867..90f4a6bead 100644 --- a/packages/frontend/core/src/bootstrap/first-app-data.ts +++ b/packages/frontend/core/src/bootstrap/first-app-data.ts @@ -3,38 +3,32 @@ import { DEFAULT_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import onboardingUrl from '@affine/templates/onboarding.zip'; import { ZipTransformer } from '@blocksuite/blocks'; -import { - initEmptyPage, - PageRecordList, - type WorkspaceManager, -} from '@toeverything/infra'; +import type { WorkspacesService } from '@toeverything/infra'; +import { DocsService, initEmptyPage } from '@toeverything/infra'; export async function buildShowcaseWorkspace( - workspaceManager: WorkspaceManager, + workspacesService: WorkspacesService, flavour: WorkspaceFlavour, workspaceName: string ) { - const meta = await workspaceManager.createWorkspace( - flavour, - async docCollection => { - docCollection.meta.setName(workspaceName); - const blob = await (await fetch(onboardingUrl)).blob(); + const meta = await workspacesService.create(flavour, async docCollection => { + docCollection.meta.setName(workspaceName); + const blob = await (await fetch(onboardingUrl)).blob(); - await ZipTransformer.importDocs(docCollection, blob); - } - ); + await ZipTransformer.importDocs(docCollection, blob); + }); - const { workspace, release } = workspaceManager.open(meta); + const { workspace, dispose } = workspacesService.open({ metadata: meta }); await workspace.engine.waitForRootDocReady(); - const pageRecordList = workspace.services.get(PageRecordList); + const docsService = workspace.scope.get(DocsService); // todo: find better way to do the following // perhaps put them into middleware? { // the "Write, Draw, Plan all at Once." page should be set to edgeless mode - const edgelessPage1 = pageRecordList.records$.value.find( + const edgelessPage1 = docsService.list.docs$.value.find( p => p.title$.value === 'Write, Draw, Plan all at Once.' ); @@ -43,7 +37,7 @@ export async function buildShowcaseWorkspace( } // should jump to "Write, Draw, Plan all at Once." by default - const defaultPage = pageRecordList.records$.value.find(p => + const defaultPage = docsService.list.docs$.value.find(p => p.title$.value.startsWith('Write, Draw, Plan all at Once.') ); @@ -53,27 +47,27 @@ export async function buildShowcaseWorkspace( }); } } - release(); + dispose(); return meta; } const logger = new DebugLogger('createFirstAppData'); -export async function createFirstAppData(workspaceManager: WorkspaceManager) { +export async function createFirstAppData(workspacesService: WorkspacesService) { if (localStorage.getItem('is-first-open') !== null) { return; } localStorage.setItem('is-first-open', 'false'); if (runtimeConfig.enablePreloading) { const workspaceMetadata = await buildShowcaseWorkspace( - workspaceManager, + workspacesService, WorkspaceFlavour.LOCAL, DEFAULT_WORKSPACE_NAME ); logger.info('create first workspace', workspaceMetadata); return workspaceMetadata; } else { - const workspaceMetadata = await workspaceManager.createWorkspace( + const workspaceMetadata = await workspacesService.create( WorkspaceFlavour.LOCAL, async workspace => { workspace.meta.setName(DEFAULT_WORKSPACE_NAME); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx index 062deac3d1..5a467e316b 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react'; export interface FallbackProps { error: T; - resetError: () => void; + resetError?: () => void; } export const ERROR_REFLECT_KEY = Symbol('ERROR_REFLECT_KEY'); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx index 2fc161d1c0..93f2461e54 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx @@ -1,22 +1,20 @@ import { + GlobalContextService, useLiveData, - useService, - WorkspaceListService, + useServices, } from '@toeverything/infra'; import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import { CurrentWorkspaceService } from '../../../../modules/workspace/current-workspace'; - export interface DumpInfoProps { error: any; } export const DumpInfo = (_props: DumpInfoProps) => { + const { globalContextService } = useServices({ GlobalContextService }); const location = useLocation(); - const workspaceList = useService(WorkspaceListService); - const currentWorkspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ + const currentWorkspaceId = useLiveData( + globalContextService.globalContext.workspaceId.$ ); const path = location.pathname; const query = useParams(); @@ -24,9 +22,8 @@ export const DumpInfo = (_props: DumpInfoProps) => { console.info('DumpInfo', { path, query, - currentWorkspaceId: currentWorkspace?.id, - workspaceList, + currentWorkspaceId: currentWorkspaceId, }); - }, [path, query, currentWorkspace, workspaceList]); + }, [path, query, currentWorkspaceId]); return null; }; diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx index 588b82538e..2db37e897a 100644 --- a/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx +++ b/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx @@ -1,10 +1,14 @@ import { notify } from '@affine/component'; import { openSettingModalAtom } from '@affine/core/atoms'; -import { CurrentWorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { AiIcon } from '@blocksuite/icons'; -import { Doc, useLiveData, useService } from '@toeverything/infra'; +import { + DocService, + useLiveData, + useServices, + WorkspaceService, +} from '@toeverything/infra'; import { cssVar } from '@toeverything/theme'; import { useAtomValue } from 'jotai'; import Lottie from 'lottie-react'; @@ -39,17 +43,20 @@ const EdgelessOnboardingAnimation = () => { export const AIOnboardingEdgeless = ({ onDismiss, }: BaseAIOnboardingDialogProps) => { + const { workspaceService, docService } = useServices({ + WorkspaceService, + DocService, + }); + const t = useAFFiNEI18N(); const notifyId = useLiveData(edgelessNotifyId$); const generalAIOnboardingOpened = useLiveData(showAIOnboardingGeneral$); const settingModalOpen = useAtomValue(openSettingModalAtom); const timeoutRef = useRef>(); - const currentWorkspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ - ); - const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD; + const isCloud = + workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; - const doc = useService(Doc); + const doc = docService.doc; const mode = useLiveData(doc.mode$); useEffect(() => { diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx index 6693b0b4d1..8ae3324084 100644 --- a/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx +++ b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx @@ -1,11 +1,14 @@ import { Button, Modal } from '@affine/component'; import { openSettingModalAtom } from '@affine/core/atoms'; import { useBlurRoot } from '@affine/core/hooks/use-blur-root'; -import { CurrentWorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useLiveData, useService } from '@toeverything/infra'; +import { + useLiveData, + useServices, + WorkspaceService, +} from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -65,14 +68,13 @@ const getPlayList = (t: Translate): Array => [ export const AIOnboardingGeneral = ({ onDismiss, }: BaseAIOnboardingDialogProps) => { + const { workspaceService } = useServices({ WorkspaceService }); + const videoWrapperRef = useRef(null); const prevVideoRef = useRef(null); - const currentWorkspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ - ); - const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD; + const isCloud = + workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; const t = useAFFiNEI18N(); - // const [open, setOpen] = useState(true); const open = useLiveData(showAIOnboardingGeneral$); const [index, setIndex] = useState(0); const list = useMemo(() => getPlayList(t), [t]); diff --git a/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx b/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx index c4c728122d..6ef04e22da 100644 --- a/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/after-sign-in-send-email.tsx @@ -1,3 +1,4 @@ +import { notify } from '@affine/component'; import { AuthContent, BackButton, @@ -6,37 +7,68 @@ import { } from '@affine/component/auth-components'; import { Button } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { AuthService } from '@affine/core/modules/cloud'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import React, { useCallback } from 'react'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useEffect, useState } from 'react'; -import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; import type { AuthPanelProps } from './index'; import * as style from './style.css'; -import { useAuth } from './use-auth'; import { Captcha, useCaptcha } from './use-captcha'; -import { useSubscriptionSearch } from './use-subscription'; export const AfterSignInSendEmail = ({ setAuthState, email, onSignedIn, }: AuthPanelProps) => { - const t = useAFFiNEI18N(); - const loginStatus = useCurrentLoginStatus(); - const [verifyToken, challenge] = useCaptcha(); - const subscriptionData = useSubscriptionSearch(); + const [resendCountDown, setResendCountDown] = useState(60); + + useEffect(() => { + const timer = setInterval(() => { + setResendCountDown(c => Math.max(c - 1, 0)); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + + const [isSending, setIsSending] = useState(false); + + const t = useAFFiNEI18N(); + const authService = useService(AuthService); + useEffect(() => { + const timer = setInterval(() => { + authService.session.revalidate(); + }, 3000); + + return () => { + clearInterval(timer); + }; + }, [authService]); + const loginStatus = useLiveData(authService.session.status$); + const [verifyToken, challenge] = useCaptcha(); - const { resendCountDown, allowSendEmail, signIn } = useAuth(); if (loginStatus === 'authenticated') { onSignedIn?.(); } const onResendClick = useAsyncCallback(async () => { - if (verifyToken) { - await signIn(email, verifyToken, challenge); + setIsSending(true); + try { + if (verifyToken) { + setResendCountDown(60); + await authService.sendEmailMagicLink(email, verifyToken, challenge); + } + } catch (err) { + console.error(err); + notify.error({ + message: 'Failed to send email, please try again.', + }); } - }, [challenge, email, signIn, verifyToken]); + setIsSending(false); + }, [authService, challenge, email, verifyToken]); const onSignInWithPasswordClick = useCallback(() => { setAuthState('signInWithPassword'); @@ -62,12 +94,12 @@ export const AfterSignInSendEmail = ({
- {allowSendEmail ? ( + {resendCountDown <= 0 ? ( <>
diff --git a/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx b/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx index 6623c613b9..406e4e2b0d 100644 --- a/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/after-sign-up-send-email.tsx @@ -1,3 +1,4 @@ +import { notify } from '@affine/component'; import { AuthContent, BackButton, @@ -8,13 +9,13 @@ import { Button } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useLiveData, useService } from '@toeverything/infra'; import type { FC } from 'react'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; -import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; +import { AuthService } from '../../../modules/cloud'; import type { AuthPanelProps } from './index'; import * as style from './style.css'; -import { useAuth } from './use-auth'; import { Captcha, useCaptcha } from './use-captcha'; export const AfterSignUpSendEmail: FC = ({ @@ -22,21 +23,52 @@ export const AfterSignUpSendEmail: FC = ({ email, onSignedIn, }) => { + const [resendCountDown, setResendCountDown] = useState(60); + + useEffect(() => { + const timer = setInterval(() => { + setResendCountDown(c => Math.max(c - 1, 0)); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + + const [isSending, setIsSending] = useState(false); const t = useAFFiNEI18N(); - const loginStatus = useCurrentLoginStatus(); - const [verifyToken, challenge] = useCaptcha(); - - const { resendCountDown, allowSendEmail, signUp } = useAuth(); - + const authService = useService(AuthService); + const loginStatus = useLiveData(authService.session.status$); + useEffect(() => { + const timeout = setInterval(() => { + // revalidate session to get the latest status + authService.session.revalidate(); + }, 3000); + return () => { + clearInterval(timeout); + }; + }, [authService]); if (loginStatus === 'authenticated') { onSignedIn?.(); } + const [verifyToken, challenge] = useCaptcha(); + const onResendClick = useAsyncCallback(async () => { - if (verifyToken) { - await signUp(email, verifyToken, challenge); + setIsSending(true); + try { + if (verifyToken) { + await authService.sendEmailMagicLink(email, verifyToken, challenge); + } + setResendCountDown(60); + } catch (err) { + console.error(err); + notify.error({ + message: 'Failed to send email, please try again.', + }); } - }, [challenge, email, signUp, verifyToken]); + setIsSending(false); + }, [authService, challenge, email, verifyToken]); return ( <> @@ -54,12 +86,12 @@ export const AfterSignUpSendEmail: FC = ({
- {allowSendEmail ? ( + {resendCountDown <= 0 ? ( <> - { - setAuthState('signIn'); - }, [setAuthState])} - /> + ); }; diff --git a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx index b6765d2cf9..1ef014fc40 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx @@ -1,20 +1,19 @@ -import { Wrapper } from '@affine/component'; +import { notify, Wrapper } from '@affine/component'; import { AuthInput, BackButton, ModalHeader, } from '@affine/component/auth-components'; import { Button } from '@affine/component/ui/button'; -import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { AuthService } from '@affine/core/modules/cloud'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useService } from '@toeverything/infra'; import type { FC } from 'react'; import { useCallback, useState } from 'react'; -import { signInCloud } from '../../../utils/cloud-utils'; import type { AuthPanelProps } from './index'; import * as styles from './style.css'; -import { INTERNAL_BETA_URL, useAuth } from './use-auth'; import { useCaptcha } from './use-captcha'; export const SignInWithPassword: FC = ({ @@ -24,57 +23,49 @@ export const SignInWithPassword: FC = ({ onSignedIn, }) => { const t = useAFFiNEI18N(); - const { reload } = useSession(); + const authService = useService(AuthService); const [password, setPassword] = useState(''); const [passwordError, setPasswordError] = useState(false); - const { - signIn, - allowSendEmail, - resetCountDown, - isMutating: sendingEmail, - } = useAuth(); const [verifyToken, challenge] = useCaptcha(); const [isLoading, setIsLoading] = useState(false); + const [sendingEmail, setSendingEmail] = useState(false); const onSignIn = useAsyncCallback(async () => { if (isLoading) return; setIsLoading(true); - const res = await signInCloud('credentials', { - email, - password, - }).catch(console.error); - - if (res?.ok) { - await reload(); + try { + await authService.signInPassword({ + email, + password, + }); onSignedIn?.(); - } else { + } catch (err) { + console.error(err); setPasswordError(true); + } finally { + setIsLoading(false); } - - setIsLoading(false); - }, [email, password, isLoading, onSignedIn, reload]); + }, [isLoading, authService, email, password, onSignedIn]); const sendMagicLink = useAsyncCallback(async () => { - if (allowSendEmail && verifyToken && !sendingEmail) { - const res = await signIn(email, verifyToken, challenge); - if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) { - resetCountDown(); - return setAuthState('noAccess'); + if (sendingEmail) return; + setSendingEmail(true); + try { + if (verifyToken) { + await authService.sendEmailMagicLink(email, verifyToken, challenge); + setAuthState('afterSignInSendEmail'); } - setAuthState('afterSignInSendEmail'); + } catch (err) { + console.error(err); + notify.error({ + message: 'Failed to send email, please try again.', + }); + // TODO: handle error better } - }, [ - email, - signIn, - allowSendEmail, - sendingEmail, - setAuthState, - verifyToken, - challenge, - resetCountDown, - ]); + setSendingEmail(false); + }, [sendingEmail, verifyToken, authService, email, challenge, setAuthState]); const sendChangePasswordEmail = useCallback(() => { setEmailType('changePassword'); diff --git a/packages/frontend/core/src/components/affine/auth/sign-in.tsx b/packages/frontend/core/src/components/affine/auth/sign-in.tsx index da9ed5da34..926e7bfb9e 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in.tsx @@ -1,28 +1,21 @@ -import { - AuthInput, - CountDownRender, - ModalHeader, -} from '@affine/component/auth-components'; +import { notify } from '@affine/component'; +import { AuthInput, ModalHeader } from '@affine/component/auth-components'; import { Button } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import type { GetUserQuery } from '@affine/graphql'; -import { findGraphQLError, getUserQuery } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowDownBigIcon } from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; import type { FC } from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; -import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; -import { useMutation } from '../../../hooks/use-mutation'; +import { AuthService } from '../../../modules/cloud'; import { mixpanel } from '../../../utils'; import { emailRegex } from '../../../utils/email-regex'; import type { AuthPanelProps } from './index'; import { OAuth } from './oauth'; import * as style from './style.css'; -import { INTERNAL_BETA_URL, useAuth } from './use-auth'; import { Captcha, useCaptcha } from './use-captcha'; -import { useSubscriptionSearch } from './use-subscription'; function validateEmail(email: string) { return emailRegex.test(email); @@ -35,100 +28,74 @@ export const SignIn: FC = ({ onSignedIn, }) => { const t = useAFFiNEI18N(); - const loginStatus = useCurrentLoginStatus(); + const authService = useService(AuthService); + + const [isMutating, setIsMutating] = useState(false); const [verifyToken, challenge] = useCaptcha(); - const subscriptionData = useSubscriptionSearch(); - const { - isMutating: isSigningIn, - resendCountDown, - allowSendEmail, - signIn, - signUp, - } = useAuth(); - - const { trigger: verifyUser, isMutating } = useMutation({ - mutation: getUserQuery, - }); const [isValidEmail, setIsValidEmail] = useState(true); + useEffect(() => { + const timeout = setInterval(() => { + // revalidate session to get the latest status + authService.session.revalidate(); + }, 3000); + return () => { + clearInterval(timeout); + }; + }, [authService]); + const loginStatus = useLiveData(authService.session.status$); if (loginStatus === 'authenticated') { onSignedIn?.(); } const onContinue = useAsyncCallback(async () => { - if (!allowSendEmail) { - return; - } - if (!validateEmail(email)) { setIsValidEmail(false); return; } setIsValidEmail(true); - // 0 for no access for internal beta - const user: GetUserQuery['user'] | null | 0 = await verifyUser({ email }) - .then(({ user }) => user) - .catch(err => { - if (findGraphQLError(err, e => e.extensions.code === 402)) { - setAuthState('noAccess'); - return 0; - } else { - throw err; - } - }); - if (user === 0) { - return; - } + setIsMutating(true); + setAuthEmail(email); + try { + const { hasPassword, isExist: isUserExist } = + await authService.checkUserByEmail(email); - if (verifyToken) { - if (user) { - // provider password sign-in if user has by default - // If with payment, onl support email sign in to avoid redirect to affine app - if (user.hasPassword && !subscriptionData) { - setAuthState('signInWithPassword'); + if (verifyToken) { + if (isUserExist) { + // provider password sign-in if user has by default + // If with payment, onl support email sign in to avoid redirect to affine app + if (hasPassword) { + setAuthState('signInWithPassword'); + } else { + mixpanel.track_forms('SignIn', 'Email', { + email, + }); + await authService.sendEmailMagicLink(email, verifyToken, challenge); + setAuthState('afterSignInSendEmail'); + } } else { - mixpanel.track_forms('SignIn', 'Email', { + await authService.sendEmailMagicLink(email, verifyToken, challenge); + mixpanel.track_forms('SignUp', 'Email', { email, }); - const res = await signIn(email, verifyToken, challenge); - if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) { - return setAuthState('noAccess'); - } - // TODO, should always get id from user - if ('id' in user) { - mixpanel.identify(user.id); - } - setAuthState('afterSignInSendEmail'); + setAuthState('afterSignUpSendEmail'); } - } else { - const res = await signUp(email, verifyToken, challenge); - mixpanel.track_forms('SignUp', 'Email', { - email, - }); - if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) { - return setAuthState('noAccess'); - } else if (!res || res.status >= 400) { - return; - } - setAuthState('afterSignUpSendEmail'); } + } catch (err) { + console.error(err); + + // TODO: better error handling + notify.error({ + message: 'Failed to send email. Please try again.', + }); } - }, [ - allowSendEmail, - subscriptionData, - challenge, - email, - setAuthEmail, - setAuthState, - signIn, - signUp, - verifyToken, - verifyUser, - ]); + + setIsMutating(false); + }, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]); return ( <> @@ -164,24 +131,16 @@ export const SignIn: FC = ({ size="extraLarge" data-testid="continue-login-button" block - loading={isMutating || isSigningIn} - disabled={!allowSendEmail} + loading={isMutating} icon={ - allowSendEmail || isMutating ? ( - - ) : ( - - ) + } iconPosition="end" onClick={onContinue} diff --git a/packages/frontend/core/src/components/affine/auth/subscription-redirect.tsx b/packages/frontend/core/src/components/affine/auth/subscription-redirect.tsx deleted file mode 100644 index aa1ae14b61..0000000000 --- a/packages/frontend/core/src/components/affine/auth/subscription-redirect.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { SignUpPage } from '@affine/component/auth-components'; -import { Button } from '@affine/component/ui/button'; -import { Loading } from '@affine/component/ui/loading'; -import { AffineShapeIcon } from '@affine/core/components/page-list'; -import { useCredentialsRequirement } from '@affine/core/hooks/affine/use-server-config'; -import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { popupWindow } from '@affine/core/utils'; -import { SubscriptionPlan, type SubscriptionRecurring } from '@affine/graphql'; -import { - changePasswordMutation, - createCheckoutSessionMutation, - subscriptionQuery, -} from '@affine/graphql'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { nanoid } from 'nanoid'; -import { Suspense, useCallback, useEffect, useMemo } from 'react'; - -import { useCurrentUser } from '../../../hooks/affine/use-current-user'; -import { useMutation } from '../../../hooks/use-mutation'; -import { - RouteLogic, - useNavigateHelper, -} from '../../../hooks/use-navigate-helper'; -import { useQuery } from '../../../hooks/use-query'; -import * as styles from './subscription-redirect.css'; -import { useSubscriptionSearch } from './use-subscription'; - -const usePaymentRedirect = () => { - const searchData = useSubscriptionSearch(); - if (!searchData?.recurring) { - throw new Error('Invalid recurring data.'); - } - - const recurring = searchData.recurring as SubscriptionRecurring; - const plan = searchData.plan as SubscriptionPlan; - const coupon = searchData.coupon; - const idempotencyKey = useMemo(() => nanoid(), []); - const { trigger: checkoutSubscription } = useMutation({ - mutation: createCheckoutSessionMutation, - }); - - return useAsyncCallback(async () => { - const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({ - input: { - recurring, - plan, - coupon, - idempotencyKey, - successCallbackLink: null, - }, - }); - popupWindow(checkoutUrl); - }, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]); -}; - -const CenterLoading = () => { - return ( -
- -
- ); -}; - -const SubscriptionExisting = () => { - const t = useAFFiNEI18N(); - const { jumpToIndex } = useNavigateHelper(); - - const onButtonClick = useCallback(() => { - jumpToIndex(RouteLogic.REPLACE); - }, [jumpToIndex]); - - return ( -
-
- -

- {t['com.affine.payment.subscription.exist']()} -

- -
-
- ); -}; - -const SubscriptionRedirection = ({ redirect }: { redirect: () => void }) => { - useEffect(() => { - const timeoutId = setTimeout(() => { - redirect(); - }, 100); - - return () => { - clearTimeout(timeoutId); - }; - }, [redirect]); - - return ; -}; - -const SubscriptionRedirectWithData = () => { - const t = useAFFiNEI18N(); - const user = useCurrentUser(); - const searchData = useSubscriptionSearch(); - const openPaymentUrl = usePaymentRedirect(); - const { password: passwordLimits } = useCredentialsRequirement(); - - const { trigger: changePassword } = useMutation({ - mutation: changePasswordMutation, - }); - const { data: subscriptionData } = useQuery({ - query: subscriptionQuery, - }); - - const onSetPassword = useCallback( - async (password: string) => { - await changePassword({ - token: searchData?.passwordToken ?? '', - newPassword: password, - }); - }, - [changePassword, searchData] - ); - - if (searchData?.withSignUp) { - return ( - - ); - } - - if ( - subscriptionData.currentUser?.subscriptions?.some( - sub => sub.plan === SubscriptionPlan.Pro - ) - ) { - return ; - } - - return ; -}; - -export const SubscriptionRedirect = () => { - return ( - }> - - - ); -}; diff --git a/packages/frontend/core/src/components/affine/auth/use-auth.ts b/packages/frontend/core/src/components/affine/auth/use-auth.ts deleted file mode 100644 index 50af0bc6bd..0000000000 --- a/packages/frontend/core/src/components/affine/auth/use-auth.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { notify } from '@affine/component'; -import type { OAuthProviderType } from '@affine/graphql'; -import { atom, useAtom, useSetAtom } from 'jotai'; -import { useCallback } from 'react'; - -import { signInCloud } from '../../../utils/cloud-utils'; -import { useSubscriptionSearch } from './use-subscription'; - -const COUNT_DOWN_TIME = 60; -export const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`; - -type AuthStoreAtom = { - allowSendEmail: boolean; - resendCountDown: number; - isMutating: boolean; -}; - -export const authStoreAtom = atom({ - isMutating: false, - allowSendEmail: true, - resendCountDown: COUNT_DOWN_TIME, -}); - -const countDownAtom = atom( - null, // it's a convention to pass `null` for the first argument - (get, set) => { - const clearId = window.setInterval(() => { - const countDown = get(authStoreAtom).resendCountDown; - if (countDown === 0) { - set(authStoreAtom, { - isMutating: false, - allowSendEmail: true, - resendCountDown: COUNT_DOWN_TIME, - }); - window.clearInterval(clearId); - return; - } - set(authStoreAtom, { - isMutating: false, - resendCountDown: countDown - 1, - allowSendEmail: false, - }); - }, 1000); - } -); - -export const useAuth = () => { - const subscriptionData = useSubscriptionSearch(); - const [authStore, setAuthStore] = useAtom(authStoreAtom); - const startResendCountDown = useSetAtom(countDownAtom); - - const sendEmailMagicLink = useCallback( - async ( - signUp: boolean, - email: string, - verifyToken: string, - challenge?: string - ) => { - setAuthStore(prev => { - return { - ...prev, - isMutating: true, - }; - }); - - const res = await signInCloud( - 'email', - { - email, - }, - { - ...(challenge - ? { - challenge, - token: verifyToken, - } - : { token: verifyToken }), - callbackUrl: subscriptionData - ? subscriptionData.getRedirectUrl(signUp) - : '/auth/signIn', - } - ).catch(console.error); - - if (!res?.ok) { - // TODO: i18n - notify.error({ - title: 'Send email error', - message: 'Please back to home and try again', - }); - } - - setAuthStore({ - isMutating: false, - allowSendEmail: false, - resendCountDown: COUNT_DOWN_TIME, - }); - - // TODO: when errored, should reset the count down - startResendCountDown(); - - return res; - }, - [setAuthStore, startResendCountDown, subscriptionData] - ); - - const signUp = useCallback( - async (email: string, verifyToken: string, challenge?: string) => { - return sendEmailMagicLink(true, email, verifyToken, challenge).catch( - console.error - ); - }, - [sendEmailMagicLink] - ); - - const signIn = useCallback( - async (email: string, verifyToken: string, challenge?: string) => { - return sendEmailMagicLink(false, email, verifyToken, challenge).catch( - console.error - ); - }, - [sendEmailMagicLink] - ); - - const oauthSignIn = useCallback((provider: OAuthProviderType) => { - signInCloud(provider).catch(console.error); - }, []); - - const resetCountDown = useCallback(() => { - setAuthStore({ - isMutating: false, - allowSendEmail: false, - resendCountDown: 0, - }); - }, [setAuthStore]); - - return { - allowSendEmail: authStore.allowSendEmail, - resendCountDown: authStore.resendCountDown, - resetCountDown, - isMutating: authStore.isMutating, - signUp, - signIn, - oauthSignIn, - }; -}; diff --git a/packages/frontend/core/src/components/affine/auth/use-captcha.tsx b/packages/frontend/core/src/components/affine/auth/use-captcha.tsx index 69fac37f90..a00b8923b6 100644 --- a/packages/frontend/core/src/components/affine/auth/use-captcha.tsx +++ b/packages/frontend/core/src/components/affine/auth/use-captcha.tsx @@ -1,5 +1,4 @@ import { apis } from '@affine/electron-api'; -import { fetchWithTraceReport } from '@affine/graphql'; import { Turnstile } from '@marsidev/react-turnstile'; import { atom, useAtom, useSetAtom } from 'jotai'; import { useEffect, useRef } from 'react'; @@ -17,7 +16,7 @@ const challengeFetcher = async (url: string) => { return undefined; } - const res = await fetchWithTraceReport(url); + const res = await fetch(url); if (!res.ok) { throw new Error('Failed to fetch challenge'); } diff --git a/packages/frontend/core/src/components/affine/auth/use-subscription.ts b/packages/frontend/core/src/components/affine/auth/use-subscription.ts deleted file mode 100644 index 70fc5daf8c..0000000000 --- a/packages/frontend/core/src/components/affine/auth/use-subscription.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; - -enum SubscriptionKey { - Recurring = 'subscription_recurring', - Plan = 'subscription_plan', - Coupon = 'coupon', - SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app - Token = 'token', // When signup, there should have a token to set password -} - -export function useSubscriptionSearch() { - const [searchParams] = useSearchParams(); - - return useMemo(() => { - const withPayment = - searchParams.has(SubscriptionKey.Recurring) && - searchParams.has(SubscriptionKey.Plan); - - if (!withPayment) { - return null; - } - - const recurring = searchParams.get(SubscriptionKey.Recurring); - const plan = searchParams.get(SubscriptionKey.Plan); - const coupon = searchParams.get(SubscriptionKey.Coupon); - const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1'; - const passwordToken = searchParams.get(SubscriptionKey.Token); - return { - recurring, - plan, - coupon, - withSignUp, - passwordToken, - getRedirectUrl(signUp?: boolean) { - const paymentParams = new URLSearchParams([ - [SubscriptionKey.Recurring, recurring ?? ''], - [SubscriptionKey.Plan, plan ?? ''], - ]); - - if (coupon) { - paymentParams.set(SubscriptionKey.Coupon, coupon); - } - - if (signUp) { - paymentParams.set(SubscriptionKey.SignUp, '1'); - } - - return `/auth/subscription-redirect?${paymentParams.toString()}`; - }, - }; - }, [searchParams]); -} 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 45fc541d7f..481b362da8 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 @@ -1,17 +1,36 @@ import { Tooltip } from '@affine/component/ui/tooltip'; -import { SubscriptionPlan } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useLiveData, useServices } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; -import { useCallback } from 'react'; -import { withErrorBoundary } from 'react-error-boundary'; +import { useCallback, useEffect } from 'react'; import { openSettingModalAtom } from '../../../atoms'; -import { useUserSubscription } from '../../../hooks/use-subscription'; +import { + ServerConfigService, + SubscriptionService, +} from '../../../modules/cloud'; import * as styles from './style.css'; -const UserPlanButtonWithData = () => { - const [subscription] = useUserSubscription(); - const plan = subscription?.plan ?? SubscriptionPlan.Free; +export const UserPlanButton = () => { + const { serverConfigService, subscriptionService } = useServices({ + ServerConfigService, + SubscriptionService, + }); + + const hasPayment = useLiveData( + serverConfigService.serverConfig.features$.map(r => r?.payment) + ); + const plan = useLiveData( + subscriptionService.subscription.primary$.map(subscription => + subscription !== null ? subscription?.plan : null + ) + ); + const isLoading = plan === null; + + useEffect(() => { + // revalidate subscription to get the latest status + subscriptionService.subscription.revalidate(); + }, [subscriptionService]); const setSettingModalAtom = useSetAtom(openSettingModalAtom); const handleClick = useCallback( @@ -27,9 +46,19 @@ const UserPlanButtonWithData = () => { const t = useAFFiNEI18N(); - if (plan === SubscriptionPlan.SelfHosted) { - // Self hosted version doesn't have a payment apis. - return
{plan}
; + if (!hasPayment) { + // no payment feature + return; + } + + if (isLoading) { + // loading, do nothing + return; + } + + if (!plan) { + // no plan, do nothing + return; } return ( @@ -40,8 +69,3 @@ const UserPlanButtonWithData = () => { ); }; - -// If fetch user data failed, just render empty. -export const UserPlanButton = withErrorBoundary(UserPlanButtonWithData, { - fallbackRender: () => null, -}); diff --git a/packages/frontend/core/src/components/affine/awareness/index.tsx b/packages/frontend/core/src/components/affine/awareness/index.tsx index 32c7b32f1c..5c7be65dc4 100644 --- a/packages/frontend/core/src/components/affine/awareness/index.tsx +++ b/packages/frontend/core/src/components/affine/awareness/index.tsx @@ -1,22 +1,19 @@ -import { useLiveData, useService } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { Suspense, useEffect } from 'react'; -import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; -import { useSession } from '../../../hooks/affine/use-current-user'; -import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace'; +import { AuthService } from '../../../modules/cloud'; const SyncAwarenessInnerLoggedIn = () => { - const { user } = useSession(); - const currentWorkspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ - ); + const authService = useService(AuthService); + const account = useLiveData(authService.session.account$); + const currentWorkspace = useService(WorkspaceService).workspace; useEffect(() => { - if (user && currentWorkspace) { + if (account && currentWorkspace) { currentWorkspace.docCollection.awarenessStore.awareness.setLocalStateField( 'user', { - name: user.name, + name: account.label, // todo: add avatar? } ); @@ -29,13 +26,14 @@ const SyncAwarenessInnerLoggedIn = () => { }; } return; - }, [user, currentWorkspace]); + }, [currentWorkspace, account]); return null; }; const SyncAwarenessInner = () => { - const loginStatus = useCurrentLoginStatus(); + const session = useService(AuthService).session; + const loginStatus = useLiveData(session.status$); if (loginStatus === 'authenticated') { return ; diff --git a/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx b/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx index 755df7d885..4be31e42d4 100644 --- a/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx @@ -2,23 +2,24 @@ import { Avatar, Input, Switch, toast } from '@affine/component'; import type { ConfirmModalProps } from '@affine/component/ui/modal'; import { ConfirmModal, Modal } from '@affine/component/ui/modal'; import { authAtom, openDisableCloudAlertModalAtom } from '@affine/core/atoms'; -import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { DebugLogger } from '@affine/debug'; import { apis } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { _addLocalWorkspace } from '@affine/workspace-impl'; import { initEmptyPage, + useLiveData, useService, - WorkspaceManager, + WorkspacesService, } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import type { KeyboardEvent } from 'react'; import { useCallback, useLayoutEffect, useState } from 'react'; import { buildShowcaseWorkspace } from '../../../bootstrap/first-app-data'; +import { AuthService } from '../../../modules/cloud'; +import { _addLocalWorkspace } from '../../../modules/workspace-engine'; import { mixpanel } from '../../../utils'; import { CloudSvg } from '../share-page-modal/cloud-svg'; import * as styles from './index.css'; @@ -47,8 +48,7 @@ interface NameWorkspaceContentProps extends ConfirmModalProps { ) => void; } -const shouldEnableCloud = - !runtimeConfig.allowLocalWorkspace && !environment.isDesktop; +const shouldEnableCloud = !runtimeConfig.allowLocalWorkspace; const NameWorkspaceContent = ({ loading, @@ -58,7 +58,9 @@ const NameWorkspaceContent = ({ const t = useAFFiNEI18N(); const [workspaceName, setWorkspaceName] = useState(''); const [enable, setEnable] = useState(shouldEnableCloud); - const loginStatus = useCurrentLoginStatus(); + const session = useService(AuthService).session; + const loginStatus = useLiveData(session.status$); + const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); const setOpenSignIn = useSetAtom(authAtom); @@ -181,7 +183,7 @@ export const CreateWorkspaceModal = ({ }: ModalProps) => { const [step, setStep] = useState(); const t = useAFFiNEI18N(); - const workspaceManager = useService(WorkspaceManager); + const workspacesService = useService(WorkspacesService); const [loading, setLoading] = useState(false); // todo: maybe refactor using xstate? @@ -202,9 +204,7 @@ export const CreateWorkspaceModal = ({ const result = await apis.dialog.loadDBFile(); if (result.workspaceId && !canceled) { _addLocalWorkspace(result.workspaceId); - workspaceManager.list.revalidate().catch(err => { - logger.error("can't revalidate workspace list", err); - }); + workspacesService.list.revalidate(); onCreate(result.workspaceId); } else if (result.error || result.canceled) { if (result.error) { @@ -223,7 +223,7 @@ export const CreateWorkspaceModal = ({ return () => { canceled = true; }; - }, [mode, onClose, onCreate, t, workspaceManager]); + }, [mode, onClose, onCreate, t, workspacesService]); const onConfirmName = useAsyncCallback( async (name: string, workspaceFlavour: WorkspaceFlavour) => { @@ -237,13 +237,13 @@ export const CreateWorkspaceModal = ({ // fix me later if (runtimeConfig.enablePreloading) { const { id } = await buildShowcaseWorkspace( - workspaceManager, + workspacesService, workspaceFlavour, name ); onCreate(id); } else { - const { id } = await workspaceManager.createWorkspace( + const { id } = await workspacesService.create( workspaceFlavour, async workspace => { workspace.meta.setName(name); @@ -259,7 +259,7 @@ export const CreateWorkspaceModal = ({ setLoading(false); }, - [loading, onCreate, workspaceManager] + [loading, onCreate, workspacesService] ); const onOpenChange = useCallback( diff --git a/packages/frontend/core/src/components/affine/history-tips-modal/index.tsx b/packages/frontend/core/src/components/affine/history-tips-modal/index.tsx index 697a350b33..3c64bf0e4a 100644 --- a/packages/frontend/core/src/components/affine/history-tips-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/history-tips-modal/index.tsx @@ -5,7 +5,7 @@ import { } from '@affine/core/atoms'; import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useAtom, useSetAtom } from 'jotai'; import { useCallback } from 'react'; @@ -13,7 +13,7 @@ import TopSvg from './top-svg'; export const HistoryTipsModal = () => { const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const [open, setOpen] = useAtom(openHistoryTipsModalAtom); const setTempDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); const confirmEnableCloud = useEnableCloud(); diff --git a/packages/frontend/core/src/components/affine/page-history-modal/data.ts b/packages/frontend/core/src/components/affine/page-history-modal/data.ts index 656daf5ce8..a50f98369a 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/data.ts +++ b/packages/frontend/core/src/components/affine/page-history-modal/data.ts @@ -3,12 +3,7 @@ import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspa import { timestampToLocalDate } from '@affine/core/utils'; import { DebugLogger } from '@affine/debug'; import type { ListHistoryQuery } from '@affine/graphql'; -import { - fetchWithTraceReport, - listHistoryQuery, - recoverDocMutation, -} from '@affine/graphql'; -import { AffineCloudBlobStorage } from '@affine/workspace-impl'; +import { listHistoryQuery, recoverDocMutation } from '@affine/graphql'; import { assertEquals } from '@blocksuite/global/utils'; import { DocCollection } from '@blocksuite/store'; import { globalBlockSuiteSchema } from '@toeverything/infra'; @@ -22,6 +17,7 @@ import { useMutation, } from '../../../hooks/use-mutation'; import { useQueryInfinite } from '../../../hooks/use-query'; +import { CloudBlobStorage } from '../../../modules/workspace-engine/impls/engine/blob-cloud'; const logger = new DebugLogger('page-history'); @@ -76,11 +72,8 @@ const snapshotFetcher = async ( if (!ts) { return null; } - const res = await fetchWithTraceReport( - `/api/workspaces/${workspaceId}/docs/${pageDocId}/histories/${ts}`, - { - priority: 'high', - } + const res = await fetch( + `/api/workspaces/${workspaceId}/docs/${pageDocId}/histories/${ts}` ); if (!res.ok) { @@ -104,7 +97,7 @@ const docCollectionMap = new Map(); const getOrCreateShellWorkspace = (workspaceId: string) => { let docCollection = docCollectionMap.get(workspaceId); if (!docCollection) { - const blobStorage = new AffineCloudBlobStorage(workspaceId); + const blobStorage = new CloudBlobStorage(workspaceId); docCollection = new DocCollection({ id: workspaceId, blobStorages: [ diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index de9afb24a4..10254c24cd 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -3,23 +3,29 @@ import { EditorLoading } from '@affine/component/page-detail-skeleton'; import { Button, IconButton } from '@affine/component/ui/button'; import { Modal, useConfirmModal } from '@affine/component/ui/modal'; import { openSettingModalAtom } from '@affine/core/atoms'; -import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; -import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { WorkspaceQuotaService } from '@affine/core/modules/quota'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons'; import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; import type { DialogContentProps } from '@radix-ui/react-dialog'; -import type { PageMode } from '@toeverything/infra'; -import { Doc, useService, Workspace } from '@toeverything/infra'; +import { + type DocMode, + DocService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; import { atom, useAtom, useSetAtom } from 'jotai'; import type { PropsWithChildren } from 'react'; import { Fragment, Suspense, useCallback, + useEffect, useLayoutEffect, useMemo, useState, @@ -90,8 +96,8 @@ interface HistoryEditorPreviewProps { ts?: string; historyList: HistoryList; snapshotPage?: BlockSuiteDoc; - mode: PageMode; - onModeChange: (mode: PageMode) => void; + mode: DocMode; + onModeChange: (mode: DocMode) => void; title: string; } @@ -190,12 +196,22 @@ const HistoryEditorPreview = ({ const planPromptClosedAtom = atom(false); const PlanPrompt = () => { - const workspace = useService(Workspace); - const workspaceQuota = useWorkspaceQuota(workspace.id); + const workspaceQuotaService = useService(WorkspaceQuotaService); + useEffect(() => { + workspaceQuotaService.quota.revalidate(); + }, [workspaceQuotaService]); + const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$); const isProWorkspace = useMemo(() => { - return workspaceQuota?.humanReadable.name.toLowerCase() !== 'free'; + return workspaceQuota + ? workspaceQuota.humanReadable.name.toLowerCase() !== 'free' + : null; }, [workspaceQuota]); - const isOwner = useIsWorkspaceOwner(workspace.meta); + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + useEffect(() => { + // revalidate permission + permissionService.permission.revalidate(); + }, [permissionService]); const setSettingModalAtom = useSetAtom(openSettingModalAtom); const [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom); @@ -216,11 +232,17 @@ const PlanPrompt = () => { const planTitle = useMemo(() => { return (
- {!isProWorkspace - ? t[ - 'com.affine.history.confirm-restore-modal.plan-prompt.limited-title' - ]() - : t['com.affine.history.confirm-restore-modal.plan-prompt.title']()} + { + isProWorkspace === null + ? !isProWorkspace + ? t[ + 'com.affine.history.confirm-restore-modal.plan-prompt.limited-title' + ]() + : t[ + 'com.affine.history.confirm-restore-modal.plan-prompt.title' + ]() + : '' /* TODO: loading UI */ + } (page.mode$.value); + const doc = useService(DocService).doc; + const [mode, setMode] = useState(doc.mode$.value); const title = useDocCollectionPageTitle(docCollection, pageId); @@ -531,7 +553,7 @@ export const PageHistoryModal = ({ export const GlobalPageHistoryModal = () => { const [{ open, pageId }, setState] = useAtom(pageHistoryModalAtom); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const handleOpenChange = useCallback( (open: boolean) => { mixpanel.track('Button', { diff --git a/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx b/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx index ad0db5a53f..12375599ab 100644 --- a/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx @@ -1,6 +1,6 @@ import { ConfirmModal } from '@affine/component'; -import { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace'; -import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema'; +import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties'; +import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useService } from '@toeverything/infra'; diff --git a/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx b/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx index c245f7ba83..b5b8a72d76 100644 --- a/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx @@ -1,4 +1,4 @@ -import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema'; +import { PagePropertyType } from '@affine/core/modules/properties/services/schema'; import * as icons from '@blocksuite/icons'; import type { SVGProps } from 'react'; diff --git a/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx b/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx index 552f66430b..592e049307 100644 --- a/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx @@ -6,7 +6,7 @@ import { MenuSeparator, Scrollable, } from '@affine/component'; -import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema'; +import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { KeyboardEventHandler, MouseEventHandler } from 'react'; import { cloneElement, isValidElement, useCallback } from 'react'; diff --git a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts index 53791aaf38..42b230281d 100644 --- a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts +++ b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace'; +import type { WorkspacePropertiesAdapter } from '@affine/core/modules/properties'; import type { PageInfoCustomProperty, PageInfoCustomPropertyMeta, -} from '@affine/core/modules/workspace/properties/schema'; -import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema'; +} from '@affine/core/modules/properties/services/schema'; +import { PagePropertyType } from '@affine/core/modules/properties/services/schema'; import { createFractionalIndexingSortableHelper } from '@affine/core/utils'; import { DebugLogger } from '@affine/debug'; import { nanoid } from 'nanoid'; diff --git a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx index 99cfefefe5..fa5d63ce3b 100644 --- a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx @@ -1,14 +1,12 @@ import { Checkbox, DatePicker, Menu } from '@affine/component'; -import { useAllBlockSuiteDocMeta } from '@affine/core/hooks/use-all-block-suite-page-meta'; import type { PageInfoCustomProperty, PageInfoCustomPropertyMeta, PagePropertyType, -} from '@affine/core/modules/workspace/properties/schema'; +} from '@affine/core/modules/properties/services/schema'; import { timestampToLocalDate } from '@affine/core/utils'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; -import { Doc, useService, Workspace } from '@toeverything/infra'; +import { DocService, useService } from '@toeverything/infra'; import { noop } from 'lodash-es'; import type { ChangeEventHandler } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react'; @@ -169,21 +167,16 @@ export const NumberValue = ({ property }: PropertyRowValueProps) => { }; export const TagsValue = () => { - const workspace = useService(Workspace); - const page = useService(Doc); - const docCollection = workspace.docCollection; - const pageMetas = useAllBlockSuiteDocMeta(docCollection); + const doc = useService(DocService).doc; - const pageMeta = pageMetas.find(x => x.id === page.id); - assertExists(pageMeta, 'pageMeta should exist'); const t = useAFFiNEI18N(); return ( ); }; diff --git a/packages/frontend/core/src/components/affine/page-properties/table.tsx b/packages/frontend/core/src/components/affine/page-properties/table.tsx index 068bc5e67f..6436375cc2 100644 --- a/packages/frontend/core/src/components/affine/page-properties/table.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/table.tsx @@ -13,7 +13,7 @@ import type { PageInfoCustomProperty, PageInfoCustomPropertyMeta, PagePropertyType, -} from '@affine/core/modules/workspace/properties/schema'; +} from '@affine/core/modules/properties/services/schema'; import { timestampToLocalDate } from '@affine/core/utils'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; diff --git a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx index 3f83c166b5..f3ff0efd75 100644 --- a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx @@ -1,8 +1,8 @@ import type { MenuProps } from '@affine/component'; import { IconButton, Input, Menu, Scrollable } from '@affine/component'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { WorkspaceLegacyProperties } from '@affine/core/modules/properties'; import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag'; -import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons'; import { useLiveData, useService } from '@toeverything/infra'; @@ -30,9 +30,9 @@ const InlineTagsList = ({ readonly, children, }: PropsWithChildren) => { - const tagService = useService(TagService); - const tags = useLiveData(tagService.tags$); - const tagIds = useLiveData(tagService.tagIdsByPageId$(pageId)); + const tagList = useService(TagService).tagList; + const tags = useLiveData(tagList.tags$); + const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); return (
@@ -71,8 +71,8 @@ export const EditTagMenu = ({ }>) => { const t = useAFFiNEI18N(); const legacyProperties = useService(WorkspaceLegacyProperties); - const tagService = useService(TagService); - const tag = useLiveData(tagService.tagByTagId$(tagId)); + const tagList = useService(TagService).tagList; + const tag = useLiveData(tagList.tagByTagId$(tagId)); const tagColor = useLiveData(tag?.color$); const tagValue = useLiveData(tag?.value$); const navigate = useNavigateHelper(); @@ -169,9 +169,9 @@ export const EditTagMenu = ({ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { const t = useAFFiNEI18N(); - const tagService = useService(TagService); - const tags = useLiveData(tagService.tags$); - const tagIds = useLiveData(tagService.tagIdsByPageId$(pageId)); + const tagList = useService(TagService).tagList; + const tags = useLiveData(tagList.tags$); + const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); const [inputValue, setInputValue] = useState(''); const [open, setOpen] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); @@ -192,10 +192,10 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { [setOpen, setSelectedTagIds] ); - const exactMatch = useLiveData(tagService.tagByTagValue$(inputValue)); + const exactMatch = useLiveData(tagList.tagByTagValue$(inputValue)); const filteredTags = useLiveData( - inputValue ? tagService.filterTagsByName$(inputValue) : tagService.tags$ + inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$ ); const onInputChange = useCallback( @@ -228,10 +228,10 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { return; } rotateNextColor(); - const newTag = tagService.createTag(name.trim(), nextColor); + const newTag = tagList.createTag(name.trim(), nextColor); newTag.tag(pageId); }, - [nextColor, pageId, tagService] + [nextColor, pageId, tagList] ); const onInputKeyDown = useCallback( @@ -335,8 +335,8 @@ export const TagsInlineEditor = ({ placeholder, className, }: TagsInlineEditorProps) => { - const tagService = useService(TagService); - const tagIds = useLiveData(tagService.tagIdsByPageId$(pageId)); + const tagList = useService(TagService).tagList; + const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); const empty = !tagIds || tagIds.length === 0; return ( { const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const [open, setOpen] = useAtom(openQuotaModalAtom); - const workspaceQuota = useWorkspaceQuota(currentWorkspace.id); - const isOwner = useIsWorkspaceOwner(currentWorkspace.meta); - const userQuota = useUserQuota(); + const workspaceQuotaService = useService(WorkspaceQuotaService); + useEffect(() => { + workspaceQuotaService.quota.revalidate(); + }, [workspaceQuotaService]); + const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$); + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + useEffect(() => { + // revalidate permission + permissionService.permission.revalidate(); + }, [permissionService]); + + const quotaService = useService(UserQuotaService); + const userQuota = useLiveData( + quotaService.quota.quota$.map(q => + q + ? { + name: q.humanReadable.name, + blobLimit: q.humanReadable.blobLimit, + } + : null + ) + ); + const isFreePlanOwner = useMemo(() => { - return isOwner && userQuota?.humanReadable.name.toLowerCase() === 'free'; - }, [isOwner, userQuota?.humanReadable.name]); + return isOwner && userQuota?.name === 'free'; + }, [isOwner, userQuota]); const setSettingModalAtom = useSetAtom(openSettingModalAtom); const handleUpgradeConfirm = useCallback(() => { - if (isFreePlanOwner) { - setSettingModalAtom({ - open: true, - activeTab: 'plans', - }); - } + setSettingModalAtom({ + open: true, + activeTab: 'plans', + }); setOpen(false); - }, [isFreePlanOwner, setOpen, setSettingModalAtom]); + }, [setOpen, setSettingModalAtom]); const description = useMemo(() => { if (userQuota && isFreePlanOwner) { return t['com.affine.payment.blob-limit.description.owner.free']({ - planName: userQuota.humanReadable.name, - currentQuota: userQuota.humanReadable.blobLimit, + planName: userQuota.name, + currentQuota: userQuota.blobLimit, upgradeQuota: '100MB', }); } - if (isOwner && userQuota?.humanReadable.name.toLowerCase() === 'pro') { + if (isOwner && userQuota && userQuota.name.toLowerCase() === 'pro') { return t['com.affine.payment.blob-limit.description.owner.pro']({ - planName: userQuota.humanReadable.name, - quota: userQuota.humanReadable.blobLimit, + planName: userQuota.name, + quota: userQuota.blobLimit, }); } - return t['com.affine.payment.blob-limit.description.member']({ - quota: workspaceQuota.humanReadable.blobLimit, - }); - }, [ - isFreePlanOwner, - isOwner, - t, - userQuota, - workspaceQuota.humanReadable.blobLimit, - ]); + if (workspaceQuota) { + return t['com.affine.payment.blob-limit.description.member']({ + quota: workspaceQuota.humanReadable.blobLimit, + }); + } else { + // loading + return null; + } + }, [userQuota, isFreePlanOwner, isOwner, workspaceQuota, t]); useEffect(() => { + if (!workspaceQuota) { + return; + } currentWorkspace.engine.blob.singleBlobSizeLimit = bytes.parse( workspaceQuota.blobLimit.toString() ); @@ -70,15 +91,15 @@ export const CloudQuotaModal = () => { return () => { disposable?.dispose(); }; - }, [currentWorkspace.engine.blob, setOpen, workspaceQuota.blobLimit]); + }, [currentWorkspace.engine.blob, setOpen, workspaceQuota]); useEffect(() => { - if (userQuota?.humanReadable) { + if (userQuota?.name) { mixpanel.people.set({ - plan: userQuota.humanReadable.name, + plan: userQuota.name, }); } - }, [userQuota]); + }, [userQuota?.name]); return ( { const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const [open, setOpen] = useAtom(openQuotaModalAtom); const onConfirm = useCallback(() => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx index 163263b53a..c1c79b5f59 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx @@ -1,28 +1,37 @@ -import { Button } from '@affine/component'; +import { Button, ErrorMessage, Skeleton } from '@affine/component'; import { SettingRow } from '@affine/component/setting-components'; import { openSettingModalAtom } from '@affine/core/atoms'; -import { useQuery } from '@affine/core/hooks/use-query'; -import { useUserSubscription } from '@affine/core/hooks/use-subscription'; import { - getCopilotQuotaQuery, - pricesQuery, - SubscriptionPlan, - SubscriptionRecurring, -} from '@affine/graphql'; + ServerConfigService, + SubscriptionService, + UserQuotaService, +} from '@affine/core/modules/cloud'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; +import { useLiveData, useService } from '@toeverything/infra'; import { cssVar } from '@toeverything/theme'; import { useSetAtom } from 'jotai'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; -import { useAffineAISubscription } from '../general-setting/plans/ai/use-affine-ai-subscription'; +import { AIResume, AISubscribe } from '../general-setting/plans/ai/actions'; import * as styles from './storage-progress.css'; export const AIUsagePanel = () => { const t = useAFFiNEI18N(); const setOpenSettingModal = useSetAtom(openSettingModalAtom); - const [, mutateSubscription] = useUserSubscription(); - const { actionType, Action } = useAffineAISubscription(); + const serverConfigService = useService(ServerConfigService); + const hasPaymentFeature = useLiveData( + serverConfigService.serverConfig.features$.map(f => f?.payment) + ); + const subscriptionService = useService(SubscriptionService); + const aiSubscription = useLiveData(subscriptionService.subscription.ai$); + const quotaService = useService(UserQuotaService); + useEffect(() => { + quotaService.quota.revalidate(); + }, [quotaService]); + const aiActionLimit = useLiveData(quotaService.quota.aiActionLimit$); + const aiActionUsed = useLiveData(quotaService.quota.aiActionUsed$); + const loading = aiActionLimit === null || aiActionUsed === null; + const loadError = useLiveData(quotaService.quota.error$); const openAiPricingPlan = useCallback(() => { setOpenSettingModal({ @@ -32,95 +41,89 @@ export const AIUsagePanel = () => { }); }, [setOpenSettingModal]); - if (actionType === 'cancel') { + if (loading) { + if (loadError) { + return ( + + {/* TODO: i18n */} + Load error + + ); + } return ( - + ); } - if (actionType === 'resume') { - return ( - - - - ); - } - - return ; -}; - -export const AIUsagePanelNotSubscripted = () => { - const t = useAFFiNEI18N(); - const [, mutateSubscription] = useUserSubscription(); - const { actionType, Action } = useAffineAISubscription(); - - const { - data: { prices }, - } = useQuery({ query: pricesQuery }); - const { data: quota } = useQuery({ - query: getCopilotQuotaQuery, - }); - const { limit: nullableLimit, used = 0 } = - quota.currentUser?.copilot.quota || {}; - const limit = nullableLimit || 10; - const percent = Math.min( - 100, - Math.max(0.5, Number(((used / limit) * 100).toFixed(4))) - ); - - const price = prices.find(p => p.plan === SubscriptionPlan.AI); - assertExists(price); + const percent = + aiActionLimit === 'unlimited' + ? 0 + : Math.min( + 100, + Math.max( + 0.5, + Number(((aiActionUsed / aiActionLimit) * 100).toFixed(4)) + ) + ); const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor'); return ( -
-
-
- {t['com.affine.payment.ai.usage.used-caption']()} - - {t['com.affine.payment.ai.usage.used-detail']({ - used: used.toString(), - limit: limit.toString(), - })} - + {aiActionLimit === 'unlimited' ? ( + hasPaymentFeature && aiSubscription?.canceledAt ? ( + + ) : ( + + ) + ) : ( +
+
+
+ {t['com.affine.payment.ai.usage.used-caption']()} + + {t['com.affine.payment.ai.usage.used-detail']({ + used: aiActionUsed.toString(), + limit: aiActionLimit.toString(), + })} + +
+ +
+
+
-
-
-
+ {hasPaymentFeature && ( + + {t['com.affine.payment.ai.usage.purchase-button-label']()} + + )}
- - - {actionType === 'subscribe' - ? t['com.affine.payment.ai.usage.purchase-button-label']() - : null} - -
+ )} ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 8e0ffd4814..0aad1bbfd0 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -5,29 +5,21 @@ import { } from '@affine/component/setting-components'; import { Avatar } from '@affine/component/ui/avatar'; import { Button } from '@affine/component/ui/button'; -import { SWRErrorBoundary } from '@affine/core/components/pure/swr-error-bundary'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { - removeAvatarMutation, - updateUserProfileMutation, - uploadAvatarMutation, -} from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons'; +import { useEnsureLiveData, useService } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import type { FC, MouseEvent } from 'react'; -import { Suspense, useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { authAtom, openSettingModalAtom, openSignOutModalAtom, } from '../../../../atoms'; -import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; -import { useServerFeatures } from '../../../../hooks/affine/use-server-config'; -import { useMutation } from '../../../../hooks/use-mutation'; +import { AuthService } from '../../../../modules/cloud'; import { mixpanel } from '../../../../utils'; -import { validateAndReduceImage } from '../../../../utils/reduce-image'; import { Upload } from '../../../pure/file-upload'; import { AIUsagePanel } from './ai-usage-panel'; import { StorageProgress } from './storage-progress'; @@ -35,27 +27,16 @@ import * as styles from './style.css'; export const UserAvatar = () => { const t = useAFFiNEI18N(); - const user = useCurrentUser(); - - const { trigger: avatarTrigger } = useMutation({ - mutation: uploadAvatarMutation, - }); - const { trigger: removeAvatarTrigger } = useMutation({ - mutation: removeAvatarMutation, - }); + const session = useService(AuthService).session; + const account = useEnsureLiveData(session.account$); const handleUpdateUserAvatar = useAsyncCallback( async (file: File) => { try { mixpanel.track_forms('UpdateProfile', 'UploadAvatar', { - userId: user.id, + userId: account.id, }); - const reducedFile = await validateAndReduceImage(file); - const data = await avatarTrigger({ - avatar: reducedFile, // Pass the reducedFile directly to the avatarTrigger - }); - user.update({ avatarUrl: data.uploadAvatar.avatarUrl }); - // TODO: i18n + await session.uploadAvatar(file); notify.success({ title: 'Update user avatar success' }); } catch (e) { // TODO: i18n @@ -65,19 +46,18 @@ export const UserAvatar = () => { }); } }, - [avatarTrigger, user] + [account, session] ); const handleRemoveUserAvatar = useAsyncCallback( async (e: MouseEvent) => { mixpanel.track('RemoveAvatar', { - userId: user.id, + userId: account.id, }); e.stopPropagation(); - await removeAvatarTrigger(); - user.update({ avatarUrl: null }); + await session.removeAvatar(); }, - [removeAvatarTrigger, user] + [account, session] ); return ( @@ -88,10 +68,10 @@ export const UserAvatar = () => { > } - onRemove={user.avatarUrl ? handleRemoveUserAvatar : undefined} + onRemove={account.avatar ? handleRemoveUserAvatar : undefined} avatarTooltipOptions={{ content: t['Click to replace photo']() }} removeTooltipOptions={{ content: t['Remove photo']() }} data-testid="user-setting-avatar" @@ -105,33 +85,31 @@ export const UserAvatar = () => { export const AvatarAndName = () => { const t = useAFFiNEI18N(); - const user = useCurrentUser(); - const [input, setInput] = useState(user.name); + const session = useService(AuthService).session; + const account = useEnsureLiveData(session.account$); + const [input, setInput] = useState(account.label); - const { trigger: updateProfile } = useMutation({ - mutation: updateUserProfileMutation, - }); - const allowUpdate = !!input && input !== user.name; + const allowUpdate = !!input && input !== account.label; const handleUpdateUserName = useAsyncCallback(async () => { + if (account === null) { + return; + } if (!allowUpdate) { return; } try { mixpanel.track_forms('UpdateProfile', 'UpdateUsername', { - userId: user.id, + userId: account.id, }); - const data = await updateProfile({ - input: { name: input }, - }); - user.update({ name: data.updateProfile.name }); + await session.updateLabel(input); } catch (e) { notify.error({ title: 'Failed to update user name.', message: String(e), }); } - }, [allowUpdate, input, user, updateProfile]); + }, [account, allowUpdate, session, input]); return ( { spreadCol={false} > - - - +
@@ -178,7 +154,6 @@ export const AvatarAndName = () => { const StoragePanel = () => { const t = useAFFiNEI18N(); - const { payment: hasPaymentFeature } = useServerFeatures(); const setSettingModalAtom = useSetAtom(openSettingModalAtom); const onUpgrade = useCallback(() => { @@ -197,14 +172,18 @@ const StoragePanel = () => { desc="" spreadCol={false} > - + ); }; export const AccountSetting: FC = () => { const t = useAFFiNEI18N(); - const user = useCurrentUser(); + const session = useService(AuthService).session; + useEffect(() => { + session.revalidate(); + }, [session]); + const account = useEnsureLiveData(session.account$); const setAuthModal = useSetAtom(authAtom); const setSignOutModal = useSetAtom(openSignOutModalAtom); @@ -212,19 +191,19 @@ export const AccountSetting: FC = () => { setAuthModal({ openModal: true, state: 'sendEmail', - email: user.email, - emailType: user.emailVerified ? 'changeEmail' : 'verifyEmail', + email: account.email, + emailType: account.info?.emailVerified ? 'changeEmail' : 'verifyEmail', }); - }, [setAuthModal, user.email, user.emailVerified]); + }, [account.email, account.info?.emailVerified, setAuthModal]); const onPasswordButtonClick = useCallback(() => { setAuthModal({ openModal: true, state: 'sendEmail', - email: user.email, - emailType: user.hasPassword ? 'changePassword' : 'setPassword', + email: account.email, + emailType: account.info?.hasPassword ? 'changePassword' : 'setPassword', }); - }, [setAuthModal, user.email, user.hasPassword]); + }, [account.email, account.info?.hasPassword, setAuthModal]); const onOpenSignOutModal = useCallback(() => { setSignOutModal(true); @@ -238,9 +217,9 @@ export const AccountSetting: FC = () => { data-testid="account-title" /> - + @@ -250,19 +229,13 @@ export const AccountSetting: FC = () => { desc={t['com.affine.settings.password.message']()} > - - - - }> - - - - + + { +export const StorageProgress = ({ onUpgrade }: StorageProgressProgress) => { const t = useAFFiNEI18N(); - const { plan, usedText, color, percent, maxLimitText } = - useCloudStorageUsage(); + const quota = useService(UserQuotaService).quota; + + 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$); + + const serverConfigService = useService(ServerConfigService); + const hasPaymentFeature = useLiveData( + serverConfigService.serverConfig.features$.map(f => f?.payment) + ); + const subscription = useService(SubscriptionService).subscription; + useEffect(() => { + // revalidate subscription to get the latest status + subscription.revalidate(); + }, [subscription]); + + const primarySubscription = useLiveData(subscription.primary$); + const isFreeUser = + !primarySubscription || primarySubscription?.plan === SubscriptionPlan.Free; + const quotaName = useLiveData( + quota.quota$.map(q => (q !== null ? q?.humanReadable.name : null)) + ); + + const loading = + primarySubscription === null || percent === null || quotaName === null; + const loadError = useLiveData(quota.error$); const buttonType = useMemo(() => { - if (plan === SubscriptionPlan.Free) { + if (isFreeUser) { return ButtonType.Primary; } return ButtonType.Default; - }, [plan]); + }, [isFreeUser]); + + if (loading) { + if (loadError) { + // TODO: i18n + return Load error; + } + // TODO: loading UI + return ; + } return (
@@ -37,24 +78,27 @@ export const StorageProgress = ({
{t['com.affine.storage.used.hint']()} - {usedText}/{maxLimitText} - {` (${plan} ${t['com.affine.storage.plan']()})`} + {usedFormatted}/{maxFormatted} + {` (${quotaName} ${t['com.affine.storage.plan']()})`}
- {upgradable ? ( + {hasPaymentFeature ? ( - {plan === 'Free' + {isFreeUser ? t['com.affine.storage.upgrade']() : t['com.affine.storage.change-plan']()} diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index d75130eea1..4fd17971c3 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -14,29 +14,29 @@ import { getInvoicesCountQuery, invoicesQuery, InvoiceStatus, - pricesQuery, SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; import { ArrowRightSmallIcon } from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; -import { Suspense, useCallback, useMemo, useState } from 'react'; +import { Suspense, useCallback, useEffect, useState } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; -import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; import { useMutation } from '../../../../../hooks/use-mutation'; import { useQuery } from '../../../../../hooks/use-query'; -import type { SubscriptionMutator } from '../../../../../hooks/use-subscription'; -import { useUserSubscription } from '../../../../../hooks/use-subscription'; -import { mixpanel, popupWindow } from '../../../../../utils'; +import { SubscriptionService } from '../../../../../modules/cloud'; +import { + mixpanel, + popupWindow, + timestampToLocalDate, +} from '../../../../../utils'; import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary'; import { CancelAction, ResumeAction } from '../plans/actions'; -import { useAffineAIPrice } from '../plans/ai/use-affine-ai-price'; -import { useAffineAISubscription } from '../plans/ai/use-affine-ai-subscription'; +import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions'; import * as styles from './style.css'; enum DescriptionI18NKey { @@ -58,13 +58,8 @@ const getMessageKey = ( }; export const BillingSettings = () => { - const status = useCurrentLoginStatus(); const t = useAFFiNEI18N(); - if (status !== 'authenticated') { - return null; - } - return ( <> { }; const SubscriptionSettings = () => { - const [subscription, mutateSubscription] = useUserSubscription(); - const [openCancelModal, setOpenCancelModal] = useState(false); - const { - isFree: isFreeAI, - actionType: aiActionType, - Action: AIAction, - billingTip, - } = useAffineAISubscription(); - - const { data: pricesQueryResult } = useQuery({ - query: pricesQuery, - }); - - const plan = subscription?.plan ?? SubscriptionPlan.Free; - const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly; - - const price = pricesQueryResult.prices.find(price => price.plan === plan); - const aiPrice = pricesQueryResult.prices.find( - price => price.plan === SubscriptionPlan.AI - ); - assertExists(aiPrice); - const amount = - plan === SubscriptionPlan.Free - ? '0' - : price - ? recurring === SubscriptionRecurring.Monthly - ? String((price.amount ?? 0) / 100) - : String((price.yearlyAmount ?? 0) / 100) - : '?'; - - const { priceReadable: aiPriceReadable, priceFrequency: aiPriceFrequency } = - useAffineAIPrice(aiPrice); const t = useAFFiNEI18N(); + const subscriptionService = useService(SubscriptionService); + useEffect(() => { + subscriptionService.subscription.revalidate(); + subscriptionService.prices.revalidate(); + }, [subscriptionService]); + + const primarySubscription = useLiveData( + subscriptionService.subscription.primary$ + ); + const proPrice = useLiveData(subscriptionService.prices.proPrice$); + + const currentPlan = + primarySubscription === null + ? null + : primarySubscription?.plan ?? SubscriptionPlan.Free; + + const [openCancelModal, setOpenCancelModal] = useState(false); + + const recurring = + primarySubscription?.recurring ?? SubscriptionRecurring.Monthly; const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); const gotoPlansSetting = useCallback(() => { mixpanel.track('Button', { resolve: 'ChangePlan', - currentPlan: plan, + currentPlan: currentPlan, }); setOpenSettingModalAtom({ open: true, activeTab: 'plans', }); - }, [setOpenSettingModalAtom, plan]); + }, [currentPlan, setOpenSettingModalAtom]); - const currentPlanDesc = useMemo(() => { - const messageKey = getMessageKey(plan, recurring); - return ( - - ), - }} - /> - ); - }, [plan, recurring, gotoPlansSetting]); + const amount = currentPlan + ? currentPlan === SubscriptionPlan.Free + ? '0' + : proPrice + ? recurring === SubscriptionRecurring.Monthly + ? String((proPrice.amount ?? 0) / 100) + : String((proPrice.yearlyAmount ?? 0) / 100) + : '?' + : '?'; return (
-
-
- - -
-

- ${amount} - - / - {recurring === SubscriptionRecurring.Monthly - ? t['com.affine.payment.billing-setting.month']() - : t['com.affine.payment.billing-setting.year']()} - -

-
- -
-
- - {aiPrice?.yearlyAmount ? ( - - {aiActionType === 'subscribe' - ? t['com.affine.payment.billing-setting.ai.purchase']() - : null} - - ) : null} -
-

- {isFreeAI ? '$0' : aiPriceReadable} - /{aiPriceFrequency} -

-
- {subscription?.status === SubscriptionStatus.Active && ( - <> - - - - {subscription.nextBillAt && ( + {currentPlan !== null ? ( +
+
+ ), + }} + /> + } /> - )} - {subscription.canceledAt ? ( - - - - ) : ( - - setOpenCancelModal(true)} - className="dangerous-setting" - name={t[ - 'com.affine.payment.billing-setting.cancel-subscription' - ]()} - desc={t[ - 'com.affine.payment.billing-setting.cancel-subscription.description' - ]()} - > - - - - )} - + +
+

+ ${amount} + + / + {recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.billing-setting.month']() + : t['com.affine.payment.billing-setting.year']()} + +

+
+ ) : ( + )} + + + {primarySubscription !== null ? ( + primarySubscription?.status === SubscriptionStatus.Active && ( + <> + + + + {primarySubscription.nextBillAt && ( + + )} + {primarySubscription.canceledAt ? ( + + + + ) : ( + + setOpenCancelModal(true)} + className="dangerous-setting" + name={t[ + 'com.affine.payment.billing-setting.cancel-subscription' + ]()} + desc={t[ + 'com.affine.payment.billing-setting.cancel-subscription.description' + ]()} + > + + + + )} + + ) + ) : ( + + )} +
+ ); +}; + +const AIPlanCard = () => { + const t = useAFFiNEI18N(); + const subscriptionService = useService(SubscriptionService); + useEffect(() => { + subscriptionService.subscription.revalidate(); + subscriptionService.prices.revalidate(); + }, [subscriptionService]); + const price = useLiveData(subscriptionService.prices.aiPrice$); + const subscription = useLiveData(subscriptionService.subscription.ai$); + + const priceReadable = price?.yearlyAmount + ? `$${(price.yearlyAmount / 100).toFixed(2)}` + : '?'; + const priceFrequency = t['com.affine.payment.billing-setting.year'](); + + if (subscription === null) { + return ; + } + + const billingTip = + subscription === undefined + ? t['com.affine.payment.billing-setting.ai.free-desc']() + : subscription?.nextBillAt + ? t['com.affine.payment.ai.billing-tip.next-bill-at']({ + due: timestampToLocalDate(subscription.nextBillAt), + }) + : subscription?.canceledAt && subscription.end + ? t['com.affine.payment.ai.billing-tip.end-at']({ + end: timestampToLocalDate(subscription.end), + }) + : null; + + return ( +
+
+ + {price?.yearlyAmount ? ( + subscription ? ( + subscription.canceledAt ? ( + + ) : ( + + ) + ) : ( + + {t['com.affine.payment.billing-setting.ai.purchase']()} + + ) + ) : null} +
+

+ {subscription ? priceReadable : '$0'} + /{priceFrequency} +

); }; @@ -322,20 +356,12 @@ const PaymentMethodUpdater = () => { ); }; -const ResumeSubscription = ({ - onSubscriptionUpdate, -}: { - onSubscriptionUpdate: SubscriptionMutator; -}) => { +const ResumeSubscription = () => { const t = useAFFiNEI18N(); const [open, setOpen] = useState(false); return ( - + diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx index 3fedab293c..7a57b3991a 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx @@ -4,10 +4,10 @@ import { InformationIcon, KeyboardIcon, } from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; import type { ReactElement, SVGProps } from 'react'; -import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status'; -import { useServerFeatures } from '../../../../hooks/affine/use-server-config'; +import { AuthService, ServerConfigService } from '../../../../modules/cloud'; import type { GeneralSettingKey } from '../types'; import { AboutAffine } from './about'; import { AppearanceSettings } from './appearance'; @@ -27,8 +27,11 @@ export type GeneralSettingList = GeneralSettingListItem[]; export const useGeneralSettingList = (): GeneralSettingList => { const t = useAFFiNEI18N(); - const status = useCurrentLoginStatus(); - const { payment: hasPaymentFeature } = useServerFeatures(); + const status = useLiveData(useService(AuthService).session.status$); + const serverConfig = useService(ServerConfigService).serverConfig; + const hasPaymentFeature = useLiveData( + serverConfig.features$.map(f => f?.payment) + ); const settings: GeneralSettingListItem[] = [ { diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx index f71032a19b..737b921059 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx @@ -1,14 +1,10 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription'; -import { - cancelSubscriptionMutation, - resumeSubscriptionMutation, -} from '@affine/graphql'; +import { useService } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import type { PropsWithChildren } from 'react'; import { useState } from 'react'; -import { useMutation } from '../../../../../hooks/use-mutation'; +import { SubscriptionService } from '../../../../../modules/cloud'; import { ConfirmLoadingModal, DowngradeModal } from './modals'; /** @@ -20,30 +16,27 @@ export const CancelAction = ({ children, open, onOpenChange, - onSubscriptionUpdate, }: { open: boolean; onOpenChange: (open: boolean) => void; - onSubscriptionUpdate: SubscriptionMutator; } & PropsWithChildren) => { const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); - const { trigger, isMutating } = useMutation({ - mutation: cancelSubscriptionMutation, - }); + const [isMutating, setIsMutating] = useState(false); + const subscription = useService(SubscriptionService).subscription; const downgrade = useAsyncCallback(async () => { - await trigger( - { idempotencyKey }, - { - onSuccess: data => { - // refresh idempotency key - setIdempotencyKey(nanoid()); - onSubscriptionUpdate(data.cancelSubscription); - onOpenChange(false); - }, - } - ); - }, [trigger, idempotencyKey, onSubscriptionUpdate, onOpenChange]); + try { + setIsMutating(true); + await subscription.cancelSubscription(idempotencyKey); + subscription.revalidate(); + await subscription.isRevalidating$.waitFor(v => !v); + // refresh idempotency key + setIdempotencyKey(nanoid()); + onOpenChange(false); + } finally { + setIsMutating(false); + } + }, [subscription, idempotencyKey, onOpenChange]); return ( <> @@ -67,31 +60,28 @@ export const ResumeAction = ({ children, open, onOpenChange, - onSubscriptionUpdate, }: { open: boolean; onOpenChange: (open: boolean) => void; - onSubscriptionUpdate: SubscriptionMutator; } & PropsWithChildren) => { // allow replay request on network error until component unmount or success const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); - const { isMutating, trigger } = useMutation({ - mutation: resumeSubscriptionMutation, - }); + const [isMutating, setIsMutating] = useState(false); + const subscription = useService(SubscriptionService).subscription; const resume = useAsyncCallback(async () => { - await trigger( - { idempotencyKey }, - { - onSuccess: data => { - // refresh idempotency key - setIdempotencyKey(nanoid()); - onSubscriptionUpdate(data.resumeSubscription); - onOpenChange(false); - }, - } - ); - }, [trigger, idempotencyKey, onSubscriptionUpdate, onOpenChange]); + try { + setIsMutating(true); + await subscription.resumeSubscription(idempotencyKey); + subscription.revalidate(); + await subscription.isRevalidating$.waitFor(v => !v); + // refresh idempotency key + setIdempotencyKey(nanoid()); + onOpenChange(false); + } finally { + setIsMutating(false); + } + }, [subscription, idempotencyKey, onOpenChange]); return ( <> diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx index e74c561587..b9637a9859 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx @@ -1,23 +1,19 @@ import { Button, type ButtonProps, useConfirmModal } from '@affine/component'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { useMutation } from '@affine/core/hooks/use-mutation'; -import { cancelSubscriptionMutation, SubscriptionPlan } from '@affine/graphql'; +import { SubscriptionService } from '@affine/core/modules/cloud'; +import { SubscriptionPlan } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useService } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { useState } from 'react'; -import type { BaseActionProps } from '../types'; - -export interface AICancelProps extends BaseActionProps, ButtonProps {} -export const AICancel = ({ - onSubscriptionUpdate, - ...btnProps -}: AICancelProps) => { +export interface AICancelProps extends ButtonProps {} +export const AICancel = ({ ...btnProps }: AICancelProps) => { const t = useAFFiNEI18N(); + const [isMutating, setMutating] = useState(false); const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); - const { trigger, isMutating } = useMutation({ - mutation: cancelSubscriptionMutation, - }); + const subscription = useService(SubscriptionService).subscription; + const { openConfirmModal } = useConfirmModal(); const cancel = useAsyncCallback(async () => { @@ -37,19 +33,19 @@ export const AICancel = ({ type: 'primary', }, onConfirm: async () => { - await trigger( - { idempotencyKey, plan: SubscriptionPlan.AI }, - { - onSuccess: data => { - // refresh idempotency key - setIdempotencyKey(nanoid()); - onSubscriptionUpdate?.(data.cancelSubscription); - }, - } - ); + try { + setMutating(true); + await subscription.cancelSubscription( + idempotencyKey, + SubscriptionPlan.AI + ); + setIdempotencyKey(nanoid()); + } finally { + setMutating(false); + } }, }); - }, [openConfirmModal, t, trigger, idempotencyKey, onSubscriptionUpdate]); + }, [openConfirmModal, t, subscription, idempotencyKey]); return ( - + {isLoggedIn ? ( + subscription ? ( + subscription.canceledAt ? ( + + ) : ( + + ) + ) : ( + <> + + + + + + ) + ) : ( + )}
{billingTip ? ( diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts deleted file mode 100644 index a8eb24b699..0000000000 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription'; -import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql'; - -export interface BaseActionProps { - price?: PricesQuery['prices'][number]; - recurring?: SubscriptionRecurring; - onSubscriptionUpdate?: SubscriptionMutator; -} diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts deleted file mode 100644 index 7d766ac45b..0000000000 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PricesQuery } from '@affine/graphql'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; - -export const useAffineAIPrice = (price: PricesQuery['prices'][number]) => { - const t = useAFFiNEI18N(); - - assertExists(price.yearlyAmount, 'AFFiNE AI yearly price is missing'); - - const priceReadable = `$${(price.yearlyAmount / 100).toFixed(2)}`; - const priceFrequency = t['com.affine.payment.billing-setting.year'](); - - return { priceReadable, priceFrequency }; -}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts deleted file mode 100644 index d2764f8895..0000000000 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status'; -import { useUserSubscription } from '@affine/core/hooks/use-subscription'; -import { timestampToLocalDate } from '@affine/core/utils'; -import { SubscriptionPlan } from '@affine/graphql'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; - -import { AICancel, AILogin, AIResume, AISubscribe } from './actions'; - -const plan = SubscriptionPlan.AI; - -export type ActionType = 'login' | 'subscribe' | 'resume' | 'cancel'; - -export const useAffineAISubscription = () => { - const t = useAFFiNEI18N(); - const loggedIn = useCurrentLoginStatus() === 'authenticated'; - - const [subscription] = useUserSubscription(plan); - - const isCancelled = !!subscription?.canceledAt; - const actionType: ActionType = !loggedIn - ? 'login' - : !subscription - ? 'subscribe' - : isCancelled - ? 'resume' - : 'cancel'; - - const Action = { - login: AILogin, - subscribe: AISubscribe, - resume: AIResume, - cancel: AICancel, - }[actionType]; - - const isFree = !subscription; - - const billingTip = subscription?.nextBillAt - ? t['com.affine.payment.ai.billing-tip.next-bill-at']({ - due: timestampToLocalDate(subscription.nextBillAt), - }) - : subscription?.canceledAt && subscription.end - ? t['com.affine.payment.ai.billing-tip.end-at']({ - end: timestampToLocalDate(subscription.end), - }) - : null; - - return { actionType, Action, billingTip, isFree }; -}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx index 39a9a80b80..77486bcbae 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx @@ -1,20 +1,13 @@ -import { notify, Switch } from '@affine/component'; -import { - pricesQuery, - SubscriptionPlan, - SubscriptionRecurring, -} from '@affine/graphql'; +import { Switch } from '@affine/component'; +import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { SingleSelectSelectSolidIcon } from '@blocksuite/icons'; -import { cssVar } from '@toeverything/theme'; -import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { FallbackProps } from 'react-error-boundary'; import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bundary'; -import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; -import { useQuery } from '../../../../../hooks/use-query'; -import { useUserSubscription } from '../../../../../hooks/use-subscription'; +import { AuthService, SubscriptionService } from '../../../../../modules/cloud'; import { AIPlan } from './ai/ai-plan'; import { type FixedPrice, getPlanDetail } from './cloud-plans'; import { CloudPlanLayout, PlanLayout } from './layout'; @@ -36,19 +29,24 @@ const getRecurringLabel = ({ const Settings = () => { const t = useAFFiNEI18N(); - const [subscription, mutateSubscription] = useUserSubscription(); - const loggedIn = useCurrentLoginStatus() === 'authenticated'; + const loggedIn = + useLiveData(useService(AuthService).session.status$) === 'authenticated'; const planDetail = useMemo(() => getPlanDetail(t), [t]); const scrollWrapper = useRef(null); - const { - data: { prices }, - } = useQuery({ - query: pricesQuery, - }); + const subscriptionService = useService(SubscriptionService); + const primarySubscription = useLiveData( + subscriptionService.subscription.primary$ + ); + const prices = useLiveData(subscriptionService.prices.prices$); - prices.forEach(price => { + useEffect(() => { + subscriptionService.subscription.revalidate(); + subscriptionService.prices.revalidate(); + }, [subscriptionService]); + + prices?.forEach(price => { const detail = planDetail.get(price.plan); if (detail?.type === 'fixed') { @@ -64,13 +62,13 @@ const Settings = () => { }); const [recurring, setRecurring] = useState( - subscription?.recurring ?? SubscriptionRecurring.Yearly + primarySubscription?.recurring ?? SubscriptionRecurring.Yearly ); - const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; - const isCanceled = !!subscription?.canceledAt; + const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free; + const isCanceled = !!primarySubscription?.canceledAt; const currentRecurring = - subscription?.recurring ?? SubscriptionRecurring.Monthly; + primarySubscription?.recurring ?? SubscriptionRecurring.Monthly; const yearlyDiscount = ( planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined @@ -176,33 +174,7 @@ const Settings = () => { const cloudScroll = (
{Array.from(planDetail.values()).map(detail => { - return ( - { - notify({ - style: 'normal', - icon: ( - - ), - title: t['com.affine.payment.updated-notify-title'](), - message: - detail.plan === SubscriptionPlan.Free - ? t[ - 'com.affine.payment.updated-notify-msg.cancel-subscription' - ]() - : t['com.affine.payment.updated-notify-msg']({ - plan: getRecurringLabel({ - recurring: recurring as SubscriptionRecurring, - t, - }), - }), - }); - }} - {...{ detail, subscription, recurring }} - /> - ); + return ; })}
); @@ -214,6 +186,10 @@ const Settings = () => {
); + if (prices === null) { + return ; + } + return ( { scrollRef={scrollWrapper} /> } - ai={ - p.plan === SubscriptionPlan.AI)} - onSubscriptionUpdate={mutateSubscription} - /> - } + ai={} /> ); }; @@ -238,9 +209,7 @@ const Settings = () => { export const AFFiNEPricingPlans = () => { return ( - }> - - + ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx index 2199e387d5..22d488afa5 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -1,30 +1,21 @@ import { Button } from '@affine/component/ui/button'; import { Tooltip } from '@affine/component/ui/tooltip'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import type { - Subscription, - SubscriptionMutator, -} from '@affine/core/hooks/use-subscription'; +import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; import { popupWindow } from '@affine/core/utils'; import type { SubscriptionRecurring } from '@affine/graphql'; -import { - createCheckoutSessionMutation, - SubscriptionPlan, - SubscriptionStatus, - updateSubscriptionMutation, -} from '@affine/graphql'; +import { SubscriptionPlan, SubscriptionStatus } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { DoneIcon } from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; import { useAtom, useSetAtom } from 'jotai'; import { nanoid } from 'nanoid'; import type { PropsWithChildren } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { openPaymentDisableAtom } from '../../../../../atoms'; import { authAtom } from '../../../../../atoms/index'; -import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; -import { useMutation } from '../../../../../hooks/use-mutation'; import { mixpanel } from '../../../../../utils'; import { CancelAction, ResumeAction } from './actions'; import type { DynamicPrice, FixedPrice } from './cloud-plans'; @@ -33,24 +24,23 @@ import * as styles from './style.css'; interface PlanCardProps { detail: FixedPrice | DynamicPrice; - subscription?: Subscription | null; recurring: SubscriptionRecurring; - onSubscriptionUpdate: SubscriptionMutator; - onNotify: (info: { - detail: FixedPrice | DynamicPrice; - recurring: string; - }) => void; } export const PlanCard = (props: PlanCardProps) => { - const { detail, subscription, recurring } = props; - const loggedIn = useCurrentLoginStatus() === 'authenticated'; - const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; + const { detail, recurring } = props; + const loggedIn = + useLiveData(useService(AuthService).session.status$) === 'authenticated'; + const subscriptionService = useService(SubscriptionService); + const primarySubscription = useLiveData( + subscriptionService.subscription.primary$ + ); + const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free; const isCurrent = loggedIn && detail.plan === currentPlan && - recurring === subscription?.recurring; + recurring === primarySubscription?.recurring; const isPro = detail.plan === SubscriptionPlan.Pro; return ( @@ -97,26 +87,16 @@ export const PlanCard = (props: PlanCardProps) => { ); }; -const ActionButton = ({ - detail, - subscription, - recurring, - onSubscriptionUpdate, - onNotify, -}: PlanCardProps) => { +const ActionButton = ({ detail, recurring }: PlanCardProps) => { const t = useAFFiNEI18N(); - const loggedIn = useCurrentLoginStatus() === 'authenticated'; - const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; - const currentRecurring = subscription?.recurring; - - const mutateAndNotify = useCallback( - (sub: Parameters[0]) => { - mixpanel.track_forms('Subscription', detail.plan, sub); - onSubscriptionUpdate?.(sub); - onNotify?.({ detail, recurring }); - }, - [onSubscriptionUpdate, onNotify, detail, recurring] + const loggedIn = + useLiveData(useService(AuthService).session.status$) === 'authenticated'; + const subscriptionService = useService(SubscriptionService); + const primarySubscription = useLiveData( + subscriptionService.subscription.primary$ ); + const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free; + const currentRecurring = primarySubscription?.recurring; // branches: // if contact => 'Contact Sales' @@ -148,43 +128,33 @@ const ActionButton = ({ ); } - const isCanceled = !!subscription?.canceledAt; + const isCanceled = !!primarySubscription?.canceledAt; const isFree = detail.plan === SubscriptionPlan.Free; const isCurrent = detail.plan === currentPlan && (isFree ? true : currentRecurring === recurring && - subscription?.status === SubscriptionStatus.Active); + primarySubscription?.status === SubscriptionStatus.Active); // is current if (isCurrent) { - return isCanceled ? ( - - ) : ( - - ); + return isCanceled ? : ; } if (isFree) { - return ( - - ); + return ; } return currentPlan === detail.plan ? ( ) : ( - + ); }; @@ -197,13 +167,7 @@ const CurrentPlan = () => { ); }; -const Downgrade = ({ - disabled, - onSubscriptionUpdate, -}: { - disabled?: boolean; - onSubscriptionUpdate: SubscriptionMutator; -}) => { +const Downgrade = ({ disabled }: { disabled?: boolean }) => { const t = useAFFiNEI18N(); const [open, setOpen] = useState(false); @@ -212,11 +176,7 @@ const Downgrade = ({ : null; return ( - +
); diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx index a6df8d736b..9bf9bb7250 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx @@ -1,21 +1,34 @@ import { Input, + notify, RadioButton, RadioButtonGroup, + Skeleton, Switch, } from '@affine/component'; import { PublicLinkDisableModal } from '@affine/component/disable-public-link'; import { Button } from '@affine/component/ui/button'; import { Menu, MenuItem, MenuTrigger } from '@affine/component/ui/menu'; -import { useIsSharedPage } from '@affine/core/hooks/affine/use-is-shared-page'; -import { useServerBaseUrl } from '@affine/core/hooks/affine/use-server-config'; +import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { ShareService } from '@affine/core/modules/share-doc'; import { WorkspaceFlavour } from '@affine/env/workspace'; +import { PublicPageMode } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { ArrowRightSmallIcon } from '@blocksuite/icons'; -import type { PageMode } from '@toeverything/infra'; -import { Doc, useLiveData, useService } from '@toeverything/infra'; -import { useCallback, useMemo, useState } from 'react'; +import { + ArrowRightSmallIcon, + SingleSelectSelectSolidIcon, +} from '@blocksuite/icons'; +import { + type DocMode, + DocService, + useLiveData, + useService, +} from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; +import { Suspense, useEffect, useMemo, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ServerConfigService } from '../../../../modules/cloud'; import { CloudSvg } from '../cloud-svg'; import * as styles from './index.css'; import type { ShareMenuProps } from './share-menu'; @@ -50,63 +63,156 @@ export const LocalSharePage = (props: ShareMenuProps) => { export const AffineSharePage = (props: ShareMenuProps) => { const { workspaceMetadata: { id: workspaceId }, - currentPage, } = props; - const pageId = currentPage.id; - const page = useService(Doc); + const doc = useService(DocService).doc; + const shareService = useService(ShareService); + const serverConfig = useService(ServerConfigService).serverConfig; + useEffect(() => { + shareService.share.revalidate(); + }, [shareService]); + const isSharedPage = useLiveData(shareService.share.isShared$); + const sharedMode = useLiveData(shareService.share.sharedMode$); + const baseUrl = useLiveData(serverConfig.config$.map(c => c?.baseUrl)); + const isLoading = + isSharedPage === null || sharedMode === null || baseUrl === null; const [showDisable, setShowDisable] = useState(false); - const { - isSharedPage, - enableShare, - changeShare, - currentShareMode, - disableShare, - } = useIsSharedPage(workspaceId, currentPage.id); - const currentPageMode = useLiveData(page.mode$); + const currentDocMode = useLiveData(doc.mode$); - const defaultMode = useMemo(() => { - if (isSharedPage) { + const mode = useMemo(() => { + if (isSharedPage && sharedMode) { // if it's a shared page, use the share mode - return currentShareMode; + return sharedMode.toLowerCase() as DocMode; } // default to page mode - return currentPageMode; - }, [currentPageMode, currentShareMode, isSharedPage]); - const [mode, setMode] = useState(defaultMode); + return currentDocMode; + }, [currentDocMode, isSharedPage, sharedMode]); const { sharingUrl, onClickCopyLink } = useSharingUrl({ workspaceId, - pageId, + pageId: doc.id, urlType: 'share', }); - const baseUrl = useServerBaseUrl(); + const t = useAFFiNEI18N(); - const onClickCreateLink = useCallback(() => { - enableShare(mode); - if (sharingUrl) { - navigator.clipboard.writeText(sharingUrl).catch(err => { - console.error(err); + const onClickCreateLink = useAsyncCallback(async () => { + try { + await shareService.share.enableShare( + mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page + ); + notify.success({ + title: + t[ + 'com.affine.share-menu.create-public-link.notification.success.title' + ](), + message: + t[ + 'com.affine.share-menu.create-public-link.notification.success.message' + ](), + style: 'normal', + icon: , }); + if (sharingUrl) { + navigator.clipboard.writeText(sharingUrl).catch(err => { + console.error(err); + }); + } + } catch (err) { + notify.error({ + title: + t[ + 'com.affine.share-menu.confirm-modify-mode.notification.fail.title' + ](), + message: + t[ + 'com.affine.share-menu.confirm-modify-mode.notification.fail.message' + ](), + }); + console.error(err); } - }, [enableShare, mode, sharingUrl]); + }, [mode, shareService.share, sharingUrl, t]); - const onDisablePublic = useCallback(() => { - disableShare(); + const onDisablePublic = useAsyncCallback(async () => { + try { + await shareService.share.disableShare(); + notify.error({ + title: + t[ + 'com.affine.share-menu.disable-publish-link.notification.success.title' + ](), + message: + t[ + 'com.affine.share-menu.disable-publish-link.notification.success.message' + ](), + }); + } catch (err) { + notify.error({ + title: + t[ + 'com.affine.share-menu.disable-publish-link.notification.fail.title' + ](), + message: + t[ + 'com.affine.share-menu.disable-publish-link.notification.fail.message' + ](), + }); + console.log(err); + } setShowDisable(false); - }, [disableShare]); + }, [shareService, t]); - const onShareModeChange = useCallback( - (value: PageMode) => { - setMode(value); - if (isSharedPage) { - changeShare(value); + const onShareModeChange = useAsyncCallback( + async (value: DocMode) => { + try { + if (isSharedPage) { + await shareService.share.changeShare( + value === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page + ); + notify.success({ + title: + t[ + 'com.affine.share-menu.confirm-modify-mode.notification.success.title' + ](), + message: t[ + 'com.affine.share-menu.confirm-modify-mode.notification.success.message' + ]({ + preMode: value === 'edgeless' ? t['Page']() : t['Edgeless'](), + currentMode: value === 'edgeless' ? t['Edgeless']() : t['Page'](), + }), + style: 'normal', + icon: ( + + ), + }); + } + } catch (err) { + notify.error({ + title: + t[ + 'com.affine.share-menu.confirm-modify-mode.notification.fail.title' + ](), + message: + t[ + 'com.affine.share-menu.confirm-modify-mode.notification.fail.message' + ](), + }); + console.error(err); } }, - [changeShare, isSharedPage] + [isSharedPage, shareService.share, t] ); + if (isLoading) { + // TODO: loading and error UI + return ( + <> + + + + ); + } + return ( <>
@@ -124,15 +230,7 @@ export const AffineSharePage = (props: ShareMenuProps) => { fontSize: 'var(--affine-font-xs)', lineHeight: '20px', }} - value={ - (isSharedPage && sharingUrl) || - `${ - baseUrl || - `${location.protocol}${ - location.port ? `:${location.port}` : '' - }//${location.hostname}` - }/...` - } + value={(isSharedPage && sharingUrl) || `${baseUrl}/...`} readOnly /> {isSharedPage ? ( @@ -162,7 +260,6 @@ export const AffineSharePage = (props: ShareMenuProps) => {
@@ -236,7 +333,14 @@ export const SharePage = (props: ShareMenuProps) => { } else if ( props.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD ) { - return ; + return ( + // TODO: refactor this part + + + + + + ); } throw new Error('Unreachable'); }; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts index dc5328a74e..2d8b6a1ace 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts @@ -1,5 +1,5 @@ import { toast } from '@affine/component'; -import { useServerBaseUrl } from '@affine/core/hooks/affine/use-server-config'; +import { getAffineCloudBaseUrl } from '@affine/core/modules/cloud/services/fetch'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useCallback, useMemo } from 'react'; @@ -16,7 +16,7 @@ const useGenerateUrl = ({ workspaceId, pageId, urlType }: UseSharingUrl) => { // to generate a public url like https://app.affine.app/share/123/456 // or https://app.affine.app/share/123/456?mode=edgeless - const baseUrl = useServerBaseUrl(); + const baseUrl = getAffineCloudBaseUrl(); const url = useMemo(() => { // baseUrl is null when running in electron and without network diff --git a/packages/frontend/core/src/components/app-sidebar/index.tsx b/packages/frontend/core/src/components/app-sidebar/index.tsx index 2fd4f103f9..3ac82167a1 100644 --- a/packages/frontend/core/src/components/app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/app-sidebar/index.tsx @@ -1,6 +1,6 @@ import { Skeleton } from '@affine/component'; import { ResizePanel } from '@affine/component/resize-panel'; -import { useServiceOptional, Workspace } from '@toeverything/infra'; +import { useServiceOptional, WorkspaceService } from '@toeverything/infra'; import { useAtom, useAtomValue } from 'jotai'; import { debounce } from 'lodash-es'; import type { PropsWithChildren, ReactElement } from 'react'; @@ -121,7 +121,7 @@ export function AppSidebar({ export const AppSidebarFallback = (): ReactElement | null => { const width = useAtomValue(appSidebarWidthAtom); - const currentWorkspace = useServiceOptional(Workspace); + const currentWorkspace = useServiceOptional(WorkspaceService); return (
>>( interface BlocksuiteEditorContainerProps { page: Doc; - mode: PageMode; + mode: DocMode; className?: string; style?: React.CSSProperties; defaultSelectedBlockId?: string; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx index 4464e809c9..1b825dd844 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx @@ -1,9 +1,9 @@ import { FavoriteTag } from '@affine/core/components/page-list'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import { toast } from '@affine/core/utils'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useCallback } from 'react'; export interface FavoriteButtonProps { @@ -12,7 +12,7 @@ export interface FavoriteButtonProps { export const useFavorite = (pageId: string) => { const t = useAFFiNEI18N(); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const docCollection = workspace.docCollection; const currentPage = docCollection.getDoc(pageId); const favAdapter = useService(FavoriteItemsAdapter); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx index 39df3a4b8c..72f1b744d8 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx @@ -11,10 +11,8 @@ import { Export, MoveToTrash } from '@affine/core/components/page-list'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useExportPage } from '@affine/core/hooks/affine/use-export-page'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; -import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; import { DuplicateIcon, EdgelessIcon, @@ -25,7 +23,12 @@ import { ImportIcon, PageIcon, } from '@blocksuite/icons'; -import { Doc, useLiveData, useService, Workspace } from '@toeverything/infra'; +import { + DocService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; @@ -46,16 +49,12 @@ export const PageHeaderMenuButton = ({ }: PageMenuProps) => { const t = useAFFiNEI18N(); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const docCollection = workspace.docCollection; - const currentPage = docCollection.getDoc(pageId); - assertExists(currentPage); - const pageMeta = useBlockSuiteDocMeta(docCollection).find( - meta => meta.id === pageId - ); - const page = useService(Doc); - const currentMode = useLiveData(page.mode$); + const doc = useService(DocService).doc; + const isInTrash = useLiveData(doc.meta$.map(m => m.trash)); + const currentMode = useLiveData(doc.mode$); const { favorite, toggleFavorite } = useFavorite(pageId); @@ -74,30 +73,27 @@ export const PageHeaderMenuButton = ({ }, [setOpenHistoryTipsModal, workspace.flavour]); const handleOpenTrashModal = useCallback(() => { - if (!pageMeta) { - return; - } setTrashModal({ open: true, pageIds: [pageId], - pageTitles: [pageMeta.title], + pageTitles: [doc.meta$.value.title ?? ''], }); - }, [pageId, pageMeta, setTrashModal]); + }, [doc.meta$.value.title, pageId, setTrashModal]); const handleSwitchMode = useCallback(() => { - page.toggleMode(); + doc.toggleMode(); toast( currentMode === 'page' ? t['com.affine.toastMessage.edgelessMode']() : t['com.affine.toastMessage.pageMode']() ); - }, [currentMode, page, t]); + }, [currentMode, doc, t]); const menuItemStyle = { padding: '4px 12px', transition: 'all 0.3s', }; - const exportHandler = useExportPage(currentPage); + const exportHandler = useExportPage(doc.blockSuiteDoc); const handleDuplicate = useCallback(() => { duplicate(pageId); @@ -212,7 +208,7 @@ export const PageHeaderMenuButton = ({ /> ); - if (pageMeta?.trash) { + if (isInTrash) { return null; } return ( diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx index 1e94f7e2c8..4de542be7c 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx @@ -1,8 +1,12 @@ import { Tooltip } from '@affine/component/ui/tooltip'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { PageMode } from '@toeverything/infra'; -import { Doc, useLiveData, useService } from '@toeverything/infra'; +import { + type DocMode, + DocService, + useLiveData, + useService, +} from '@toeverything/infra'; import type { CSSProperties } from 'react'; import { useCallback, useEffect } from 'react'; @@ -17,7 +21,7 @@ export type EditorModeSwitchProps = { pageId: string; style?: CSSProperties; isPublic?: boolean; - publicMode?: PageMode; + publicMode?: DocMode; }; const TooltipContent = () => { const t = useAFFiNEI18N(); @@ -42,9 +46,9 @@ export const EditorModeSwitch = ({ meta => meta.id === pageId ); const trash = pageMeta?.trash ?? false; - const page = useService(Doc); + const doc = useService(DocService).doc; - const currentMode = useLiveData(page.mode$); + const currentMode = useLiveData(doc.mode$); useEffect(() => { if (trash || isPublic) { @@ -53,7 +57,7 @@ export const EditorModeSwitch = ({ const keydown = (e: KeyboardEvent) => { if (e.code === 'KeyS' && e.altKey) { e.preventDefault(); - page.toggleMode(); + doc.toggleMode(); toast( currentMode === 'page' ? t['com.affine.toastMessage.edgelessMode']() @@ -64,7 +68,7 @@ export const EditorModeSwitch = ({ document.addEventListener('keydown', keydown, { capture: true }); return () => document.removeEventListener('keydown', keydown, { capture: true }); - }, [currentMode, isPublic, page, pageId, t, trash]); + }, [currentMode, isPublic, doc, pageId, t, trash]); const onSwitchToPageMode = useCallback(() => { mixpanel.track('Button', { @@ -73,9 +77,9 @@ export const EditorModeSwitch = ({ if (currentMode === 'page' || isPublic) { return; } - page.setMode('page'); + doc.setMode('page'); toast(t['com.affine.toastMessage.pageMode']()); - }, [currentMode, isPublic, page, t]); + }, [currentMode, isPublic, doc, t]); const onSwitchToEdgelessMode = useCallback(() => { mixpanel.track('Button', { @@ -84,18 +88,18 @@ export const EditorModeSwitch = ({ if (currentMode === 'edgeless' || isPublic) { return; } - page.setMode('edgeless'); + doc.setMode('edgeless'); toast(t['com.affine.toastMessage.edgelessMode']()); - }, [currentMode, isPublic, page, t]); + }, [currentMode, isPublic, doc, t]); const shouldHide = useCallback( - (mode: PageMode) => + (mode: DocMode) => (trash && currentMode !== mode) || (isPublic && publicMode !== mode), [currentMode, isPublic, publicMode, trash] ); const shouldActive = useCallback( - (mode: PageMode) => (isPublic ? false : currentMode === mode), + (mode: DocMode) => (isPublic ? false : currentMode === mode), [currentMode, isPublic] ); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx index cfde522ca4..c6e8499a4f 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx @@ -3,7 +3,7 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { useDocCollectionHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; import { WorkspaceSubPath } from '@affine/core/shared'; -import { initEmptyPage, PageRecordList, useService } from '@toeverything/infra'; +import { DocsService, initEmptyPage, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; @@ -13,23 +13,23 @@ export const usePageHelper = (docCollection: DocCollection) => { const { openPage, jumpToSubPath } = useNavigateHelper(); const { createDoc } = useDocCollectionHelper(docCollection); const { setDocMeta } = useDocMetaHelper(docCollection); - const pageRecordList = useService(PageRecordList); + const docRecordList = useService(DocsService).list; const isPreferredEdgeless = useCallback( (pageId: string) => - pageRecordList.record$(pageId).value?.mode$.value === 'edgeless', - [pageRecordList] + docRecordList.doc$(pageId).value?.mode$.value === 'edgeless', + [docRecordList] ); const createPageAndOpen = useCallback( (mode?: 'page' | 'edgeless') => { const page = createDoc(); initEmptyPage(page); - pageRecordList.record$(page.id).value?.setMode(mode || 'page'); + docRecordList.doc$(page.id).value?.setMode(mode || 'page'); openPage(docCollection.id, page.id); return page; }, - [docCollection.id, createDoc, openPage, pageRecordList] + [docCollection.id, createDoc, openPage, docRecordList] ); const createEdgelessAndOpen = useCallback(() => { diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx index ce1f616c09..c17b95eeec 100644 --- a/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx @@ -1,7 +1,11 @@ import { Button } from '@affine/component/ui/button'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra'; +import { + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; import { useCallback, useEffect } from 'react'; import type { ShareHeaderRightItemProps } from './index'; @@ -13,11 +17,9 @@ export const AuthenticatedItem = ({ }: { setIsMember: (value: boolean) => void } & ShareHeaderRightItemProps) => { const { workspaceId, pageId } = props; - const workspaceManager = useService(WorkspaceManager); - const workspaceList = useLiveData(workspaceManager.list.workspaceList$); - const isMember = workspaceList?.some( - workspace => workspace.id === workspaceId - ); + const workspacesService = useService(WorkspacesService); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const isMember = workspaces?.some(workspace => workspace.id === workspaceId); const t = useAFFiNEI18N(); const { jumpToPage } = useNavigateHelper(); diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx index 2950ad1096..f67cf0660f 100644 --- a/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx @@ -1,7 +1,7 @@ -import type { PageMode } from '@toeverything/infra'; +import { AuthService } from '@affine/core/modules/cloud'; +import { type DocMode, useLiveData, useService } from '@toeverything/infra'; import { useState } from 'react'; -import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; import { AuthenticatedItem } from './authenticated-item'; import { PresentButton } from './present'; import * as styles from './styles.css'; @@ -10,11 +10,11 @@ import { PublishPageUserAvatar } from './user-avatar'; export type ShareHeaderRightItemProps = { workspaceId: string; pageId: string; - publishMode: PageMode; + publishMode: DocMode; }; const ShareHeaderRightItem = ({ ...props }: ShareHeaderRightItemProps) => { - const loginStatus = useCurrentLoginStatus(); + const loginStatus = useLiveData(useService(AuthService).session.status$); const { publishMode } = props; const [isMember, setIsMember] = useState(false); diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx index 0b4b313b53..3b19e1e427 100644 --- a/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx @@ -5,37 +5,44 @@ import { MenuItem, MenuSeparator, } from '@affine/component/ui/menu'; -import { useCurrentUser } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { useUserSubscription } from '@affine/core/hooks/use-subscription'; -import { signOutCloud } from '@affine/core/utils/cloud-utils'; -import { SubscriptionPlan } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { SignOutIcon } from '@blocksuite/icons'; -import { useMemo } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useEffect, useMemo } from 'react'; +import { AuthService, SubscriptionService } from '../../../modules/cloud'; import * as styles from './styles.css'; const UserInfo = () => { - const user = useCurrentUser(); - const [subscription] = useUserSubscription(); - const plan = subscription?.plan ?? SubscriptionPlan.Free; + const authService = useService(AuthService); + const user = useLiveData(authService.session.account$); + const subscription = useService(SubscriptionService).subscription; + useEffect(() => { + subscription.revalidate(); + }, [subscription]); + const primary = useLiveData(subscription.primary$); + const plan = primary?.plan; + + if (!user) { + // TODO: loading UI + return null; + } return (
-
- {user.name} +
+ {user.label}
-
{plan}
+ {plan &&
{plan}
}
{user.email} @@ -46,13 +53,13 @@ const UserInfo = () => { }; export const PublishPageUserAvatar = () => { - const user = useCurrentUser(); + const authService = useService(AuthService); + const user = useLiveData(authService.session.account$); const t = useAFFiNEI18N(); - const location = useLocation(); const handleSignOut = useAsyncCallback(async () => { - await signOutCloud(location.pathname); - }, [location.pathname]); + await authService.signOut(); + }, [authService]); const menuItem = useMemo(() => { return ( @@ -74,6 +81,10 @@ export const PublishPageUserAvatar = () => { ); }, [handleSignOut, t]); + if (!user) { + return null; + } + return ( { }} >
- +
); diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 80359408c7..5d100e69d3 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -4,9 +4,9 @@ import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspa import { assertExists, DisposableGroup } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store'; -import type { PageMode } from '@toeverything/infra'; import { - Doc, + type DocMode, + DocService, fontStyleOptions, useLiveData, useService, @@ -32,7 +32,7 @@ export type OnLoadEditor = ( export interface PageDetailEditorProps { isPublic?: boolean; - publishMode?: PageMode; + publishMode?: DocMode; docCollection: DocCollection; pageId: string; onLoad?: OnLoadEditor; @@ -48,7 +48,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ isPublic, publishMode, }: PageDetailEditorProps & { page: BlockSuiteDoc }) { - const currentMode = useLiveData(useService(Doc).mode$); + const currentMode = useLiveData(useService(DocService).doc.mode$); const mode = useMemo(() => { const shareMode = publishMode || currentMode; diff --git a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx index 7c3c6f85ac..e1566cdd9a 100644 --- a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx +++ b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx @@ -1,7 +1,7 @@ import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import type { ReactElement } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; @@ -63,7 +63,7 @@ export const VirtualizedCollectionList = ({ [] ); const collectionService = useService(CollectionService); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const info = useDeleteCollectionInfo(); const collectionOperations = useCollectionOperationsRenderer({ diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx index 3f27129a18..217498a2dd 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx @@ -16,8 +16,8 @@ import { SearchIcon, ViewLayersIcon, } from '@blocksuite/icons'; -import type { Doc } from '@blocksuite/store'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import clsx from 'clsx'; import { nanoid } from 'nanoid'; import { useCallback, useMemo, useState } from 'react'; @@ -37,7 +37,7 @@ import { PageListNewPageButton } from './page-list-new-page-button'; export const PageListHeader = () => { const t = useAFFiNEI18N(); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const { importFile, createEdgeless, createPage } = usePageHelper( workspace.docCollection ); @@ -85,12 +85,12 @@ export const CollectionPageListHeader = ({ collectionService.updateCollection(collection.id, () => ret); }, [collection, collectionService, open]); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const { createEdgeless, createPage } = usePageHelper(workspace.docCollection); const { openConfirmModal } = useConfirmModal(); const createAndAddDocument = useCallback( - (createDocumentFn: () => Doc) => { + (createDocumentFn: () => BlockSuiteDoc) => { const newDoc = createDocumentFn(); collectionService.addPageToCollection(collection.id, newDoc.id); }, @@ -98,7 +98,7 @@ export const CollectionPageListHeader = ({ ); const onConfirmAddDocument = useCallback( - (createDocumentFn: () => Doc) => { + (createDocumentFn: () => BlockSuiteDoc) => { openConfirmModal({ title: t['com.affine.collection.add-doc.confirm.title'](), description: t['com.affine.collection.add-doc.confirm.description'](), @@ -248,9 +248,9 @@ interface SwitchTagProps { export const SwitchTag = ({ onClick }: SwitchTagProps) => { const t = useAFFiNEI18N(); const [inputValue, setInputValue] = useState(''); - const tagService = useService(TagService); + const tagList = useService(TagService).tagList; const filteredTags = useLiveData( - inputValue ? tagService.filterTagsByName$(inputValue) : tagService.tags$ + inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$ ); const onInputChange = useCallback( diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx index 9ae09cbb34..30bca37237 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx @@ -73,8 +73,8 @@ const PageSelectionCell = ({ }; export const PageTagsCell = ({ pageId }: Pick) => { - const tagsService = useService(TagService); - const tags = useLiveData(tagsService.tagsByPageId$(pageId)); + const tagList = useService(TagService).tagList; + const tags = useLiveData(tagList.tagsByPageId$(pageId)); return (
diff --git a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx index 9265e0a37a..05fc2bae48 100644 --- a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx +++ b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx @@ -7,7 +7,7 @@ import type { Collection, Filter } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { DocMeta } from '@blocksuite/store'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useCallback, useMemo, useRef, useState } from 'react'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; @@ -70,13 +70,13 @@ export const VirtualizedPageList = ({ const listRef = useRef(null); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [selectedPageIds, setSelectedPageIds] = useState([]); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); const pageOperations = usePageOperationsRenderer(); const { isPreferredEdgeless } = usePageHelper(currentWorkspace.docCollection); const pageHeaderColsDef = usePageHeaderColsDef(); - const filteredPageMetas = useFilteredPageMetas(currentWorkspace, pageMetas, { + const filteredPageMetas = useFilteredPageMetas(pageMetas, { filters, collection, }); diff --git a/packages/frontend/core/src/components/page-list/group-definitions.tsx b/packages/frontend/core/src/components/page-list/group-definitions.tsx index 40290e759e..c720e73532 100644 --- a/packages/frontend/core/src/components/page-list/group-definitions.tsx +++ b/packages/frontend/core/src/components/page-list/group-definitions.tsx @@ -1,6 +1,6 @@ +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import type { Tag } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons'; import type { DocMeta } from '@blocksuite/store'; @@ -127,8 +127,8 @@ const GroupTagLabel = ({ tag, count }: { tag: Tag; count: number }) => { ); }; export const useTagGroupDefinitions = (): ItemGroupDefinition[] => { - const tagService = useService(TagService); - const tags = useLiveData(tagService.tags$); + const tagList = useService(TagService).tagList; + const tags = useLiveData(tagList.tags$); return useMemo(() => { return tags.map(tag => ({ id: tag.id, diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index e842c473e4..906895c8e2 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -11,8 +11,8 @@ import { import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; -import { Workbench } from '@affine/core/modules/workbench'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { @@ -31,7 +31,7 @@ import { SplitViewIcon, } from '@blocksuite/icons'; import type { DocMeta } from '@blocksuite/store'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useCallback, useState } from 'react'; import { Link } from 'react-router-dom'; @@ -58,13 +58,13 @@ export const PageOperationCell = ({ onRemoveFromAllowList, }: PageOperationCellProps) => { const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const { appSettings } = useAppSettingHelper(); const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection); const [openDisableShared, setOpenDisableShared] = useState(false); const favAdapter = useService(FavoriteItemsAdapter); const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc')); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection); const onDisablePublicSharing = useCallback(() => { diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index 35c56a1c6a..c24128b514 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -11,7 +11,7 @@ import { } from '@blocksuite/icons'; import type { DocCollection, DocMeta } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; -import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; +import { DocsService, useLiveData, useService } from '@toeverything/infra'; import clsx from 'clsx'; import { selectAtom } from 'jotai/utils'; import type { MouseEventHandler } from 'react'; @@ -273,12 +273,8 @@ function tagIdToTagOption( } const PageTitle = ({ id }: { id: string }) => { - const page = useLiveData( - useService(PageRecordList).records$.map(record => { - return record.find(p => p.id === id); - }) - ); - const title = useLiveData(page?.title$); + const doc = useLiveData(useService(DocsService).list.doc$(id)); + const title = useLiveData(doc?.title$); const t = useAFFiNEI18N(); return title || t['Untitled'](); }; diff --git a/packages/frontend/core/src/components/page-list/tags/create-tag.tsx b/packages/frontend/core/src/components/page-list/tags/create-tag.tsx index 457797edad..2ef07f7c99 100644 --- a/packages/frontend/core/src/components/page-list/tags/create-tag.tsx +++ b/packages/frontend/core/src/components/page-list/tags/create-tag.tsx @@ -32,9 +32,9 @@ export const CreateOrEditTag = ({ onOpenChange: (open: boolean) => void; tagMeta?: TagMeta; }) => { - const tagService = useService(TagService); - const tagOptions = useLiveData(tagService.tagMetas$); - const tag = useLiveData(tagService.tagByTagId$(tagMeta?.id)); + const tagList = useService(TagService).tagList; + const tagOptions = useLiveData(tagList.tagMetas$); + const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id)); const t = useAFFiNEI18N(); const [menuOpen, setMenuOpen] = useState(false); @@ -97,7 +97,7 @@ export const CreateOrEditTag = ({ return toast(t['com.affine.tags.create-tag.toast.exist']()); } if (!tagMeta) { - tagService.createTag(tagName.trim(), tagIcon); + tagList.createTag(tagName.trim(), tagIcon); toast(t['com.affine.tags.create-tag.toast.success']()); onClose(); return; @@ -108,7 +108,7 @@ export const CreateOrEditTag = ({ toast(t['com.affine.tags.edit-tag.toast.success']()); onClose(); return; - }, [onClose, t, tag, tagIcon, tagMeta, tagName, tagOptions, tagService]); + }, [onClose, t, tag, tagIcon, tagMeta, tagName, tagOptions, tagList]); useEffect(() => { if (!open) return; diff --git a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx index 5a7a3c18ad..c8a71fa29b 100644 --- a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx +++ b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx @@ -1,6 +1,6 @@ import type { Tag } from '@affine/core/modules/tag'; import { Trans } from '@affine/i18n'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ListFloatingToolbar } from '../components/list-floating-toolbar'; @@ -26,7 +26,7 @@ export const VirtualizedTagList = ({ const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showCreateTagInput, setShowCreateTagInput] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const tagOperations = useCallback( (tag: TagMeta) => { diff --git a/packages/frontend/core/src/components/page-list/use-all-doc-display-properties.ts b/packages/frontend/core/src/components/page-list/use-all-doc-display-properties.ts index 02aeb81dcc..60496c143c 100644 --- a/packages/frontend/core/src/components/page-list/use-all-doc-display-properties.ts +++ b/packages/frontend/core/src/components/page-list/use-all-doc-display-properties.ts @@ -1,4 +1,4 @@ -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useAtom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { useCallback } from 'react'; @@ -30,7 +30,7 @@ export const useAllDocDisplayProperties = (): [ value: PageGroupByType | PageDisplayProperties ) => void, ] => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const [properties, setProperties] = useAtom(displayPropertiesAtom); const workspaceProperties = properties[workspace.id] || defaultProps; diff --git a/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx index c190d857ce..d8fb93f5b5 100644 --- a/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx +++ b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx @@ -1,14 +1,14 @@ -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { ShareDocsService } from '@affine/core/modules/share-doc'; import type { Collection, Filter } from '@affine/env/filter'; +import { PublicPageMode } from '@affine/graphql'; import type { DocMeta } from '@blocksuite/store'; -import { useLiveData, useService, type Workspace } from '@toeverything/infra'; -import { useMemo } from 'react'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useEffect, useMemo } from 'react'; -import { usePublicPages } from '../../hooks/affine/use-is-shared-page'; import { filterPage, filterPageByRules } from './use-collection-manager'; export const useFilteredPageMetas = ( - workspace: Workspace, pageMetas: DocMeta[], options: { trash?: boolean; @@ -16,7 +16,26 @@ export const useFilteredPageMetas = ( collection?: Collection; } = {} ) => { - const { getPublicMode } = usePublicPages(workspace); + const shareDocsService = useService(ShareDocsService); + const shareDocs = useLiveData(shareDocsService.shareDocs.list$); + + const getPublicMode = useCallback( + (id: string) => { + const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode; + return mode + ? mode === PublicPageMode.Edgeless + ? ('edgeless' as const) + : ('page' as const) + : undefined; + }, + [shareDocs] + ); + + useEffect(() => { + // TODO: loading & error UI + shareDocsService.shareDocs.revalidate(); + }, [shareDocsService]); + const favAdapter = useService(FavoriteItemsAdapter); const favoriteItems = useLiveData(favAdapter.favorites$); diff --git a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx index ea14b4ac20..76356e66d9 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx @@ -2,8 +2,8 @@ import type { MenuItemProps } from '@affine/component'; import { Menu, MenuIcon, MenuItem } from '@affine/component'; import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info'; -import { Workbench } from '@affine/core/modules/workbench'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { @@ -42,7 +42,7 @@ export const CollectionOperations = ({ const deleteInfo = useDeleteCollectionInfo(); const { appSettings } = useAppSettingHelper(); const service = useService(CollectionService); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const { open: openEditCollectionModal, node: editModal } = useEditCollection(config); const t = useAFFiNEI18N(); diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx index c26cb6a2c1..406f8e4131 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx @@ -1,5 +1,5 @@ import { Menu } from '@affine/component'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { FilterIcon } from '@blocksuite/icons'; diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx index 73cff4cee4..d7a9cea382 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx @@ -1,5 +1,5 @@ import { Tooltip } from '@affine/component'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import type { Collection } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx index 19ea9bdf62..f95bf3736d 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx @@ -1,5 +1,5 @@ import { Button, Menu } from '@affine/component'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { FilterIcon } from '@blocksuite/icons'; diff --git a/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx b/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx index b709dd13e5..5d2c9b6423 100644 --- a/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx @@ -1,7 +1,4 @@ -import { - useBlockSuiteDocMeta, - useDocMetaHelper, -} from '@affine/core/hooks/use-block-suite-page-meta'; +import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; import { useJournalHelper } from '@affine/core/hooks/use-journal'; import { CollectionService } from '@affine/core/modules/collection'; @@ -14,17 +11,20 @@ import { TodayIcon, ViewLayersIcon, } from '@blocksuite/icons'; -import type { DocMeta } from '@blocksuite/store'; -import type { AffineCommand, CommandCategory } from '@toeverything/infra'; +import type { + AffineCommand, + CommandCategory, + DocRecord, + Workspace, +} from '@toeverything/infra'; import { AffineCommandRegistry, - Doc, - PageRecordList, + DocsService, + GlobalContextService, PreconditionStrategy, useLiveData, useService, - useServiceOptional, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import { atom, useAtomValue } from 'jotai'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -51,13 +51,13 @@ function filterCommandByContext( return true; } if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) { - return context.pageMode === 'edgeless'; + return context.docMode === 'edgeless'; } if (command.preconditionStrategy === PreconditionStrategy.InPaper) { - return context.pageMode === 'page'; + return context.docMode === 'page'; } if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) { - return !!context.pageMode; + return !!context.docMode; } if (command.preconditionStrategy === PreconditionStrategy.Never) { return false; @@ -75,28 +75,22 @@ function getAllCommand(context: CommandContext) { }); } -const useWorkspacePages = () => { - const workspace = useService(Workspace); - const pages = useBlockSuiteDocMeta(workspace.docCollection); - return pages; -}; - -const useRecentPages = () => { - const pages = useWorkspacePages(); +const useRecentDocs = () => { + const docs = useLiveData(useService(DocsService).list.docs$); const recentPageIds = useAtomValue(recentPageIdsBaseAtom); return useMemo(() => { return recentPageIds .map(pageId => { - const page = pages.find(page => page.id === pageId); + const page = docs.find(page => page.id === pageId); return page; }) - .filter((p): p is DocMeta => !!p); - }, [recentPageIds, pages]); + .filter((p): p is DocRecord => !!p); + }, [recentPageIds, docs]); }; -export const pageToCommand = ( +export const docToCommand = ( category: CommandCategory, - page: DocMeta, + doc: DocRecord, navigationHelper: ReturnType, getPageTitle: ReturnType, isPageJournal: (pageId: string) => boolean, @@ -105,10 +99,9 @@ export const pageToCommand = ( subTitle?: string, blockId?: string ): CMDKCommand => { - const pageMode = workspace.services.get(PageRecordList).record$(page.id).value - ?.mode$.value; + const docMode = doc.mode$.value; - const title = getPageTitle(page.id) || t['Untitled'](); + const title = getPageTitle(doc.id) || t['Untitled'](); const commandLabel = { title: title, subTitle: subTitle, @@ -116,11 +109,11 @@ export const pageToCommand = ( // hack: when comparing, the part between >>> and <<< will be ignored // adding this patch so that CMDK will not complain about duplicated commands - const id = category + '.' + page.id; + const id = category + '.' + doc.id; - const icon = isPageJournal(page.id) ? ( + const icon = isPageJournal(doc.id) ? ( - ) : pageMode === 'edgeless' ? ( + ) : docMode === 'edgeless' ? ( ) : ( @@ -136,19 +129,19 @@ export const pageToCommand = ( return; } if (blockId) { - return navigationHelper.jumpToPageBlock(workspace.id, page.id, blockId); + return navigationHelper.jumpToPageBlock(workspace.id, doc.id, blockId); } - return navigationHelper.jumpToPage(workspace.id, page.id); + return navigationHelper.jumpToPage(workspace.id, doc.id); }, icon: icon, - timestamp: page.updatedDate, + timestamp: doc.meta?.updatedDate, }; }; export const usePageCommands = () => { - const recentPages = useRecentPages(); - const pages = useWorkspacePages(); - const workspace = useService(Workspace); + const recentDocs = useRecentDocs(); + const docs = useLiveData(useService(DocsService).list.docs$); + const workspace = useService(WorkspaceService).workspace; const pageHelper = usePageHelper(workspace.docCollection); const pageMetaHelper = useDocMetaHelper(workspace.docCollection); const query = useAtomValue(cmdkQueryAtom); @@ -179,10 +172,10 @@ export const usePageCommands = () => { let results: CMDKCommand[] = []; if (query.trim() === '') { - results = recentPages.map(page => { - return pageToCommand( + results = recentDocs.map(doc => { + return docToCommand( 'affine:recent', - page, + doc, navigationHelper, getPageTitle, isPageJournal, @@ -203,18 +196,18 @@ export const usePageCommands = () => { reverseMapping.set(value.space, key); }); - results = pages.map(page => { + results = docs.map(doc => { const category = 'affine:pages'; const subTitle = resultValues.find( - result => result.space === page.id + result => result.space === doc.id )?.content; - const blockId = reverseMapping.get(page.id); + const blockId = reverseMapping.get(doc.id); - const command = pageToCommand( + const command = docToCommand( category, - page, + doc, navigationHelper, getPageTitle, isPageJournal, @@ -281,13 +274,13 @@ export const usePageCommands = () => { }, [ searchTime, query, - recentPages, + recentDocs, navigationHelper, getPageTitle, isPageJournal, t, workspace, - pages, + docs, journalHelper, pageHelper, pageMetaHelper, @@ -322,7 +315,7 @@ export const useCollectionsCommands = () => { const query = useAtomValue(cmdkQueryAtom); const navigationHelper = useNavigateHelper(); const t = useAFFiNEI18N(); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const selectCollection = useCallback( (id: string) => { navigationHelper.jumpToCollection(workspace.id, id); @@ -353,13 +346,14 @@ export const useCMDKCommandGroups = () => { const pageCommands = usePageCommands(); const collectionCommands = useCollectionsCommands(); - const currentPage = useServiceOptional(Doc); - const currentPageMode = useLiveData(currentPage?.mode$); + const currentDocMode = + useLiveData(useService(GlobalContextService).globalContext.docMode.$) ?? + undefined; const affineCommands = useMemo(() => { return getAllCommand({ - pageMode: currentPageMode, + docMode: currentDocMode, }); - }, [currentPageMode]); + }, [currentDocMode]); const query = useAtomValue(cmdkQueryAtom).trim(); return useMemo(() => { diff --git a/packages/frontend/core/src/components/pure/cmdk/types.ts b/packages/frontend/core/src/components/pure/cmdk/types.ts index cf255b0d8c..b73105b12a 100644 --- a/packages/frontend/core/src/components/pure/cmdk/types.ts +++ b/packages/frontend/core/src/components/pure/cmdk/types.ts @@ -1,7 +1,7 @@ -import type { CommandCategory } from '@toeverything/infra'; +import type { CommandCategory, DocMode } from '@toeverything/infra'; export interface CommandContext { - pageMode: 'page' | 'edgeless' | undefined; + docMode: DocMode | undefined; } // similar to AffineCommand, but for rendering into the UI diff --git a/packages/frontend/core/src/components/pure/footer/index.tsx b/packages/frontend/core/src/components/pure/footer/index.tsx deleted file mode 100644 index e7fe2102b8..0000000000 --- a/packages/frontend/core/src/components/pure/footer/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { CloudWorkspaceIcon } from '@blocksuite/icons'; -import type { CSSProperties, FC } from 'react'; -import { forwardRef, useCallback } from 'react'; - -import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; -import { stringToColour } from '../../../utils'; -import { signInCloud } from '../../../utils/cloud-utils'; -import { StyledFooter, StyledSignInButton } from './styles'; - -export const Footer: FC = () => { - const loginStatus = useCurrentLoginStatus(); - - // const setOpen = useSetAtom(openDisableCloudAlertModalAtom); - return ( - - {loginStatus === 'authenticated' ? null : } - - ); -}; - -const SignInButton = () => { - const t = useAFFiNEI18N(); - - return ( - { - signInCloud('email').catch(console.error); - }, [])} - > -
- -
- - {t['Sign in']()} -
- ); -}; - -interface WorkspaceAvatarProps { - size: number; - name: string | undefined; - avatar: string | undefined; - style?: CSSProperties; -} - -export const WorkspaceAvatar = forwardRef( - function WorkspaceAvatar(props, ref) { - const size = props.size || 20; - const sizeStr = size + 'px'; - - return props.avatar ? ( -
- - - -
- ) : ( -
- {(props.name || 'AFFiNE').substring(0, 1)} -
- ); - } -); diff --git a/packages/frontend/core/src/components/pure/footer/styles.ts b/packages/frontend/core/src/components/pure/footer/styles.ts deleted file mode 100644 index 2361d8a451..0000000000 --- a/packages/frontend/core/src/components/pure/footer/styles.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - displayFlex, - displayInlineFlex, - styled, - textEllipsis, -} from '@affine/component'; - -export const StyledSplitLine = styled('div')(() => { - return { - width: '1px', - height: '20px', - background: 'var(--affine-border-color)', - marginRight: '24px', - }; -}); - -export const StyleWorkspaceInfo = styled('div')(() => { - return { - marginLeft: '15px', - width: '202px', - p: { - height: '20px', - fontSize: 'var(--affine-font-sm)', - ...displayFlex('flex-start', 'center'), - }, - svg: { - marginRight: '10px', - fontSize: '16px', - flexShrink: 0, - }, - span: { - flexGrow: 1, - ...textEllipsis(1), - }, - }; -}); - -export const StyleWorkspaceTitle = styled('div')(() => { - return { - fontSize: 'var(--affine-font-base)', - fontWeight: 600, - lineHeight: '24px', - marginBottom: '10px', - maxWidth: '200px', - ...textEllipsis(1), - }; -}); - -export const StyledFooter = styled('div')({ - padding: '20px 40px', - flexShrink: 0, - ...displayFlex('space-between', 'center'), -}); - -export const StyleUserInfo = styled('div')({ - textAlign: 'left', - marginLeft: '16px', - flex: 1, - p: { - lineHeight: '24px', - color: 'var(--affine-icon-color)', - }, - 'p:first-of-type': { - color: 'var(--affine-text-primary-color)', - fontWeight: 600, - }, -}); - -export const StyledModalHeaderLeft = styled('div')(() => { - return { ...displayFlex('flex-start', 'center') }; -}); -export const StyledModalTitle = styled('div')(() => { - return { - fontWeight: 600, - fontSize: 'var(--affine-font-h6)', - }; -}); - -export const StyledHelperContainer = styled('div')(() => { - return { - color: 'var(--affine-icon-color)', - marginLeft: '15px', - fontWeight: 400, - fontSize: 'var(--affine-font-h6)', - ...displayFlex('center', 'center'), - }; -}); - -export const StyledModalContent = styled('div')({ - height: '534px', - padding: '8px 40px', - marginTop: '72px', - overflow: 'auto', - ...displayFlex('space-between', 'flex-start', 'flex-start'), - flexWrap: 'wrap', -}); -export const StyledOperationWrapper = styled('div')(() => { - return { - ...displayFlex('flex-end', 'center'), - }; -}); - -export const StyleWorkspaceAdd = styled('div')(() => { - return { - width: '58px', - height: '58px', - borderRadius: '100%', - background: '#f4f5fa', - border: '1.5px dashed #f4f5fa', - transition: 'background .2s', - ...displayFlex('center', 'center'), - }; -}); -export const StyledModalHeader = styled('div')({ - width: '100%', - height: '72px', - position: 'absolute', - left: 0, - top: 0, - borderRadius: '24px 24px 0 0', - padding: '0 40px', - ...displayFlex('space-between', 'center'), -}); - -export const StyledSignInButton = styled('button')(() => { - return { - fontWeight: 600, - paddingLeft: 0, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - paddingRight: '15px', - borderRadius: '8px', - '&:hover': { - backgroundColor: 'var(--affine-hover-color)', - }, - '.circle': { - width: '40px', - height: '40px', - borderRadius: '20px', - color: 'var(--affine-primary-color)', - fontSize: '24px', - flexShrink: 0, - marginRight: '16px', - ...displayInlineFlex('center', 'center'), - }, - }; -}); diff --git a/packages/frontend/core/src/components/pure/help-island/index.tsx b/packages/frontend/core/src/components/pure/help-island/index.tsx index 79d089d508..40b3a9a557 100644 --- a/packages/frontend/core/src/components/pure/help-island/index.tsx +++ b/packages/frontend/core/src/components/pure/help-island/index.tsx @@ -2,7 +2,12 @@ import { Tooltip } from '@affine/component/ui/tooltip'; import { popupWindow } from '@affine/core/utils'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloseIcon, NewIcon } from '@blocksuite/icons'; -import { Doc, useLiveData, useServiceOptional } from '@toeverything/infra'; +import { + DocsService, + GlobalContextService, + useLiveData, + useService, +} from '@toeverything/infra'; import { useSetAtom } from 'jotai/react'; import { useCallback, useState } from 'react'; @@ -28,9 +33,12 @@ type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts'; const showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST; export const HelpIsland = () => { - const page = useServiceOptional(Doc); - const pageId = page?.id; - const mode = useLiveData(page?.mode$); + const docId = useLiveData( + useService(GlobalContextService).globalContext.docId.$ + ); + const docRecordList = useService(DocsService).list; + const doc = useLiveData(docId ? docRecordList.doc$(docId) : undefined); + const mode = useLiveData(doc?.mode$); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); const [spread, setShowSpread] = useState(false); const t = useAFFiNEI18N(); @@ -61,7 +69,7 @@ export const HelpIsland = () => { onClick={() => { setShowSpread(!spread); }} - inEdgelessPage={!!pageId && mode === 'edgeless'} + inEdgelessPage={!!docId && mode === 'edgeless'} > { - const workspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ - ); - assertExists(workspace); +export const TrashPageFooter = () => { + const workspace = useService(WorkspaceService).workspace; const docCollection = workspace.docCollection; - const pageMeta = useBlockSuiteDocMeta(docCollection).find( - meta => meta.id === pageId - ); - assertExists(pageMeta); + const doc = useService(DocService).doc; const t = useAFFiNEI18N(); const { appSettings } = useAppSettingHelper(); const { jumpToSubPath } = useNavigateHelper(); @@ -34,19 +25,19 @@ export const TrashPageFooter = ({ pageId }: { pageId: string }) => { const hintText = t['com.affine.cmdk.affine.editor.trash-footer-hint'](); const onRestore = useCallback(() => { - restoreFromTrash(pageId); + restoreFromTrash(doc.id); toast( t['com.affine.toastMessage.restored']({ - title: pageMeta.title || 'Untitled', + title: doc.meta$.value.title || 'Untitled', }) ); - }, [pageId, pageMeta.title, restoreFromTrash, t]); + }, [doc.id, doc.meta$.value.title, restoreFromTrash, t]); const onConfirmDelete = useCallback(() => { jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - docCollection.removeDoc(pageId); + docCollection.removeDoc(doc.id); toast(t['com.affine.toastMessage.permanentlyDeleted']()); - }, [docCollection, jumpToSubPath, pageId, workspace.id, t]); + }, [jumpToSubPath, workspace.id, docCollection, doc.id, t]); const onDelete = useCallback(() => { setOpen(true); diff --git a/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx b/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx index 9b28c22b85..7d0c3d1f89 100644 --- a/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx @@ -4,7 +4,7 @@ import { allPageFilterSelectAtom } from '@affine/core/atoms'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useAtom } from 'jotai'; import { useCallback, useEffect, useState } from 'react'; @@ -15,7 +15,7 @@ export const WorkspaceModeFilterTab = ({ }: { activeFilter: AllPageFilterOption; }) => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const t = useAFFiNEI18N(); const [value, setValue] = useState(activeFilter); const [filterMode, setFilterMode] = useAtom(allPageFilterSelectAtom); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index 595f0ca011..6a18f10999 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -18,7 +18,7 @@ import { resolveDragEndIntent, } from '@affine/core/hooks/affine/use-global-dnd-helper'; import { CollectionService } from '@affine/core/modules/collection'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { @@ -35,13 +35,13 @@ import { useCallback, useMemo, useState } from 'react'; import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config'; import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta'; -import { Workbench } from '../../../../modules/workbench'; +import { WorkbenchService } from '../../../../modules/workbench'; import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link'; import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar'; import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; import * as draggableMenuItemStyles from '../components/draggable-menu-item.css'; import type { CollectionsListProps } from '../index'; -import { Page } from './page'; +import { Doc } from './doc'; import * as styles from './styles.css'; const animateLayoutChanges: AnimateLayoutChanges = ({ @@ -131,8 +131,11 @@ export const CollectionSidebarNavItem = ({ }; return filterPage(collection, pageData); }); - const location = useLiveData(useService(Workbench).location$); - const currentPath = location.pathname; + const currentPath = useLiveData( + useService(WorkbenchService).workbench.location$.map( + location => location.pathname + ) + ); const path = `/collection/${collection.id}`; const onRename = useCallback( @@ -231,12 +234,12 @@ export const CollectionSidebarNavItem = ({
{pagesToRender.map(page => { return ( - diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/doc.tsx similarity index 71% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx rename to packages/frontend/core/src/components/pure/workspace-slider-bar/collections/doc.tsx index 98a9f4a28f..1159735fe0 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/doc.tsx @@ -1,11 +1,11 @@ import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; -import { Workbench } from '@affine/core/modules/workbench'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import type { DocCollection, DocMeta } from '@blocksuite/store'; import { useDraggable } from '@dnd-kit/core'; import * as Collapsible from '@radix-ui/react-collapsible'; -import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; +import { DocsService, useLiveData, useService } from '@toeverything/infra'; import React, { useCallback, useMemo } from 'react'; import { @@ -19,8 +19,8 @@ import { PostfixItem } from '../components/postfix-item'; import { ReferencePage } from '../components/reference-page'; import * as styles from './styles.css'; -export const Page = ({ - page, +export const Doc = ({ + doc, parentId, docCollection, allPageMeta, @@ -28,47 +28,47 @@ export const Page = ({ removeFromAllowList, }: { parentId: DNDIdentifier; - page: DocMeta; + doc: DocMeta; inAllowList: boolean; removeFromAllowList: (id: string) => void; docCollection: DocCollection; allPageMeta: Record; }) => { const [collapsed, setCollapsed] = React.useState(true); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const location = useLiveData(workbench.location$); const t = useAFFiNEI18N(); - const pageId = page.id; - const active = location.pathname === '/' + pageId; - const pageRecord = useLiveData(useService(PageRecordList).record$(pageId)); - const pageMode = useLiveData(pageRecord?.mode$); - const dragItemId = getDNDId('collection-list', 'doc', pageId, parentId); + const docId = doc.id; + const active = location.pathname === '/' + docId; + const docRecord = useLiveData(useService(DocsService).list.doc$(docId)); + const docMode = useLiveData(docRecord?.mode$); + const dragItemId = getDNDId('collection-list', 'doc', docId, parentId); const icon = useMemo(() => { - return pageMode === 'edgeless' ? : ; - }, [pageMode]); + return docMode === 'edgeless' ? : ; + }, [docMode]); const { jumpToPage } = useNavigateHelper(); - const clickPage = useCallback(() => { - jumpToPage(docCollection.id, page.id); - }, [jumpToPage, page.id, docCollection.id]); + const clickDoc = useCallback(() => { + jumpToPage(docCollection.id, doc.id); + }, [jumpToPage, doc.id, docCollection.id]); - const references = useBlockSuitePageReferences(docCollection, pageId); + const references = useBlockSuitePageReferences(docCollection, docId); const referencesToRender = references.filter( id => allPageMeta[id] && !allPageMeta[id]?.trash ); - const pageTitle = page.title || t['Untitled'](); - const pageTitleElement = useMemo(() => { - return ; - }, [icon, pageTitle]); + const docTitle = doc.title || t['Untitled'](); + const docTitleElement = useMemo(() => { + return ; + }, [icon, docTitle]); const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: dragItemId, data: { - preview: pageTitleElement, + preview: docTitleElement, }, }); @@ -82,7 +82,7 @@ export const Page = ({ data-testid="collection-page" data-type="collection-list-item" icon={icon} - onClick={clickPage} + onClick={clickDoc} className={styles.title} active={active} collapsed={referencesToRender.length > 0 ? collapsed : undefined} @@ -90,8 +90,8 @@ export const Page = ({ postfix={ @@ -100,7 +100,7 @@ export const Page = ({ {...attributes} {...listeners} > - {page.title || t['Untitled']()} + {doc.title || t['Untitled']()} {referencesToRender.map(id => { @@ -110,7 +110,7 @@ export const Page = ({ docCollection={docCollection} pageId={id} metaMapping={allPageMeta} - parentIds={new Set([pageId])} + parentIds={new Set([docId])} /> ); })} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx index f54cf4a32d..0d1440e8ac 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx @@ -1,2 +1,2 @@ export * from './collections-list'; -export { Page } from './page'; +export { Doc } from './doc'; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx index 2e6b0613dd..a0dae1826d 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx @@ -1,8 +1,8 @@ import { toast } from '@affine/component'; import { IconButton } from '@affine/component/ui/button'; import { Menu } from '@affine/component/ui/menu'; -import { Workbench } from '@affine/core/modules/workbench'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon } from '@blocksuite/icons'; import type { DocCollection } from '@blocksuite/store'; @@ -40,7 +40,7 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { const { setTrashModal } = useTrashModalHelper(docCollection); const favAdapter = useService(FavoriteItemsAdapter); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const handleRename = useCallback(() => { setRenameModalOpen?.(); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx index 56737f02f8..106409e119 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx @@ -1,10 +1,10 @@ import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; -import { Workbench } from '@affine/core/modules/workbench'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import type { DocCollection, DocMeta } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; -import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; +import { DocsService, useLiveData, useService } from '@toeverything/infra'; import { useMemo, useState } from 'react'; import { MenuLinkItem } from '../../../app-sidebar'; @@ -24,11 +24,11 @@ export const ReferencePage = ({ parentIds, }: ReferencePageProps) => { const t = useAFFiNEI18N(); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const location = useLiveData(workbench.location$); const active = location.pathname === '/' + pageId; - const pageRecord = useLiveData(useService(PageRecordList).record$(pageId)); + const pageRecord = useLiveData(useService(DocsService).list.doc$(pageId)); const pageMode = useLiveData(pageRecord?.mode$); const icon = useMemo(() => { return pageMode === 'edgeless' ? : ; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx index a9e8f35d3a..7c99e1f4ab 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx @@ -1,6 +1,6 @@ import { IconButton } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import { PlusIcon } from '@blocksuite/icons'; import type { DocCollection } from '@blocksuite/store'; import { useService } from '@toeverything/infra'; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx index 430a5474f6..59d0e2af67 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx @@ -5,8 +5,8 @@ import { } from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { CollectionService } from '@affine/core/modules/collection'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; -import type { WorkspaceFavoriteItem } from '@affine/core/modules/workspace/properties/schema'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { DocMeta } from '@blocksuite/store'; import { useDndContext, useDroppable } from '@dnd-kit/core'; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx index 6e1c0d7958..9202179258 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx @@ -3,13 +3,13 @@ import { parseDNDId, } from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; -import { Workbench } from '@affine/core/modules/workbench'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import * as Collapsible from '@radix-ui/react-collapsible'; -import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; +import { DocsService, useLiveData, useService } from '@toeverything/infra'; import { useMemo, useState } from 'react'; import { MenuLinkItem } from '../../../app-sidebar'; @@ -33,15 +33,15 @@ export const FavouriteDocSidebarNavItem = ({ sortable?: boolean; }) => { const t = useAFFiNEI18N(); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const location = useLiveData(workbench.location$); const linkActive = location.pathname === '/' + pageId; - const pageRecord = useLiveData(useService(PageRecordList).record$(pageId)); - const pageMode = useLiveData(pageRecord?.mode$); + const docRecord = useLiveData(useService(DocsService).list.doc$(pageId)); + const docMode = useLiveData(docRecord?.mode$); const icon = useMemo(() => { - return pageMode === 'edgeless' ? : ; - }, [pageMode]); + return docMode === 'edgeless' ? : ; + }, [docMode]); const references = useBlockSuitePageReferences(workspace, pageId); const referencesToShow = useMemo(() => { diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.tsx index b5ff952583..3d363840a9 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.tsx @@ -15,7 +15,7 @@ export const AddWorkspace = ({ return (
- {runtimeConfig.enableSQLiteProvider && environment.isDesktop ? ( + {environment.isDesktop ? ( } @@ -36,7 +36,7 @@ export const AddWorkspace = ({ className={styles.ItemContainer} >
- {runtimeConfig.enableSQLiteProvider && environment.isDesktop + {runtimeConfig.allowLocalWorkspace ? t['com.affine.workspaceList.addWorkspace.create']() : t['com.affine.workspaceList.addWorkspace.create-cloud']()}
diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx index aef119b753..c075db0b1a 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx @@ -1,11 +1,14 @@ import { Loading } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { MenuItem } from '@affine/component/ui/menu'; -import { useSession } from '@affine/core/hooks/affine/use-current-user'; -import { Unreachable } from '@affine/env/constant'; +import { AuthService } from '@affine/core/modules/cloud'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Logo1Icon } from '@blocksuite/icons'; -import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra'; +import { + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { Suspense, useCallback, useEffect } from 'react'; @@ -80,9 +83,9 @@ interface UserWithWorkspaceListProps { const UserWithWorkspaceListInner = ({ onEventEnd, }: UserWithWorkspaceListProps) => { - const { user, status } = useSession(); + const session = useLiveData(useService(AuthService).session.session$); - const isAuthenticated = status === 'authenticated'; + const isAuthenticated = session.status === 'authenticated'; const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); @@ -101,11 +104,7 @@ const UserWithWorkspaceListInner = ({ }, [setDisableCloudOpen, setOpenSignIn]); const onNewWorkspace = useCallback(() => { - if ( - !isAuthenticated && - !environment.isDesktop && - !runtimeConfig.allowLocalWorkspace - ) { + if (!isAuthenticated && !runtimeConfig.allowLocalWorkspace) { return openSignInModal(); } mixpanel.track('Button', { @@ -128,21 +127,19 @@ const UserWithWorkspaceListInner = ({ onEventEnd?.(); }, [onEventEnd, setOpenCreateWorkspaceModal]); - const workspaceManager = useService(WorkspaceManager); - const workspaces = useLiveData(workspaceManager.list.workspaceList$); + const workspaceManager = useService(WorkspacesService); + const workspaces = useLiveData(workspaceManager.list.workspaces$); // revalidate workspace list when mounted useEffect(() => { - workspaceManager.list.revalidate().catch(err => { - throw new Unreachable('revlidate should never throw, ' + err); - }); + workspaceManager.list.revalidate(); }, [workspaceManager]); return (
{isAuthenticated ? ( ) : ( diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx index afd8e23f16..871b3965ff 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx @@ -1,30 +1,41 @@ import { ScrollableContainer } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { WorkspaceList } from '@affine/component/workspace-list'; -import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud'; import { useWorkspaceAvatar, + useWorkspaceInfo, useWorkspaceName, } from '@affine/core/hooks/use-workspace-info'; +import { AuthService } from '@affine/core/modules/cloud'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons'; import type { DragEndEvent } from '@dnd-kit/core'; import type { WorkspaceMetadata } from '@toeverything/infra'; -import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra'; +import { + GlobalContextService, + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; import { useSetAtom } from 'jotai'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { openCreateWorkspaceModalAtom, openSettingModalAtom, } from '../../../../../atoms'; -import { CurrentWorkspaceService } from '../../../../../modules/workspace/current-workspace'; import { WorkspaceSubPath } from '../../../../../shared'; -import { useIsWorkspaceOwner } from '../.././../../../hooks/affine/use-is-workspace-owner'; import { useNavigateHelper } from '../.././../../../hooks/use-navigate-helper'; import * as styles from './index.css'; + +function useIsWorkspaceOwner(meta: WorkspaceMetadata) { + const info = useWorkspaceInfo(meta); + + return info?.isOwner; +} + interface WorkspaceModalProps { disabled?: boolean; workspaces: WorkspaceMetadata[]; @@ -121,23 +132,21 @@ export const AFFiNEWorkspaceList = ({ }: { onEventEnd?: () => void; }) => { - const openWsRef = useRef>(); - const workspaceManager = useService(WorkspaceManager); - const workspaces = useLiveData(workspaceManager.list.workspaceList$); + const workspacesService = useService(WorkspacesService); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const currentWorkspaceId = useLiveData( + useService(GlobalContextService).globalContext.workspaceId.$ + ); const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); - const [openingId, setOpeningId] = useState(null); const confirmEnableCloud = useEnableCloud(); const { jumpToSubPath } = useNavigateHelper(); - const currentWorkspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ - ); - const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); - const { status } = useSession(); + const session = useService(AuthService).session; + const status = useLiveData(session.status$); const isAuthenticated = status === 'authenticated'; @@ -171,26 +180,16 @@ export const AFFiNEWorkspaceList = ({ const onClickEnableCloud = useCallback( (meta: WorkspaceMetadata) => { - openWsRef.current?.release(); - openWsRef.current = workspaceManager.open(meta); - confirmEnableCloud(openWsRef.current.workspace, { + const { workspace, dispose } = workspacesService.open({ metadata: meta }); + confirmEnableCloud(workspace, { onFinished: () => { - openWsRef.current?.release(); - openWsRef.current = undefined; - setOpeningId(null); + dispose(); }, }); - setOpeningId(meta.id); }, - [confirmEnableCloud, workspaceManager] + [confirmEnableCloud, workspacesService] ); - useEffect(() => { - return () => { - openWsRef.current?.release(); - }; - }, []); - const onMoveWorkspace = useCallback((_activeId: string, _overId: string) => { // TODO: order // const oldIndex = workspaces.findIndex(w => w.id === activeId); @@ -241,7 +240,7 @@ export const AFFiNEWorkspaceList = ({ onClickWorkspaceSetting={onClickWorkspaceSetting} onNewWorkspace={onNewWorkspace} onAddWorkspace={onAddWorkspace} - currentWorkspaceId={currentWorkspace?.id} + currentWorkspaceId={currentWorkspaceId} onDragEnd={onDragEnd} /> {localWorkspaces.length > 0 && cloudWorkspaces.length > 0 ? ( @@ -250,14 +249,13 @@ export const AFFiNEWorkspaceList = ({
) : null} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx index 5b231b03e5..4ef3fad14b 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx @@ -3,9 +3,9 @@ import { Avatar, type AvatarProps } from '@affine/component/ui/avatar'; import { Loading } from '@affine/component/ui/loading'; import { openSettingModalAtom } from '@affine/core/atoms'; import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status'; -import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -16,7 +16,7 @@ import { NoNetworkIcon, UnsyncIcon, } from '@blocksuite/icons'; -import { useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { cssVar } from '@toeverything/theme'; import { useSetAtom } from 'jotai'; import { debounce } from 'lodash-es'; @@ -83,8 +83,13 @@ const useSyncEngineSyncProgress = () => { const { syncing, progress, retrying, errorMessage } = useDocEngineStatus(); const [isOverCapacity, setIsOverCapacity] = useState(false); - const currentWorkspace = useService(Workspace); - const isOwner = useIsWorkspaceOwner(currentWorkspace.meta); + const currentWorkspace = useService(WorkspaceService).workspace; + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + useEffect(() => { + // revalidate permission + permissionService.permission.revalidate(); + }, [permissionService]); const setSettingModalAtom = useSetAtom(openSettingModalAtom); const jumpToPricePlan = useCallback(() => { @@ -97,9 +102,9 @@ const useSyncEngineSyncProgress = () => { // debounce sync engine status useEffect(() => { const disposableOverCapacity = - currentWorkspace.engine.blob.onStatusChange.on( - debounce(status => { - const isOver = status?.isStorageOverCapacity; + currentWorkspace.engine.blob.isStorageOverCapacity$.subscribe( + debounce((isStorageOverCapacity: boolean) => { + const isOver = isStorageOverCapacity; if (!isOver) { setIsOverCapacity(false); return; @@ -125,7 +130,7 @@ const useSyncEngineSyncProgress = () => { }) ); return () => { - disposableOverCapacity?.dispose(); + disposableOverCapacity?.unsubscribe(); }; }, [currentWorkspace, isOwner, jumpToPricePlan, t]); @@ -213,7 +218,7 @@ const usePauseAnimation = (timeToResume = 5000) => { const WorkspaceInfo = ({ name }: { name: string }) => { const { message, active } = useSyncEngineSyncProgress(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const isCloud = currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; const { progress } = useDocEngineStatus(); const { paused, pause } = usePauseAnimation(); @@ -275,7 +280,7 @@ export const WorkspaceCard = forwardRef< HTMLDivElement, HTMLAttributes >(({ ...props }, ref) => { - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const information = useWorkspaceInfo(currentWorkspace.meta); 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 602adb0960..a5afdd0a57 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -17,7 +17,7 @@ import { forwardRef, useCallback, useEffect } from 'react'; import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper'; import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { Workbench } from '../../modules/workbench'; +import { WorkbenchService } from '../../modules/workbench'; import { AddPageButton, AppDownloadButton, @@ -100,7 +100,11 @@ export const RootAppSidebar = ({ const { appSettings } = useAppSettingHelper(); const docCollection = currentWorkspace.docCollection; const t = useAFFiNEI18N(); - const currentPath = useLiveData(useService(Workbench).location$).pathname; + const currentPath = useLiveData( + useService(WorkbenchService).workbench.location$.map( + location => location.pathname + ) + ); const onClickNewPage = useAsyncCallback(async () => { const page = createPage(); diff --git a/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx b/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx index 3ec7d7fe70..b0fbc2fe67 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx @@ -2,7 +2,7 @@ import { useJournalInfoHelper, useJournalRouteHelper, } from '@affine/core/hooks/use-journal'; -import { Workbench } from '@affine/core/modules/workbench'; +import { WorkbenchService } from '@affine/core/modules/workbench'; import type { DocCollection } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons'; @@ -18,7 +18,7 @@ export const AppSidebarJournalButton = ({ docCollection, }: AppSidebarJournalButtonProps) => { const t = useAFFiNEI18N(); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const location = useLiveData(workbench.location$); const { openToday } = useJournalRouteHelper(docCollection); const { journalDate, isJournal } = useJournalInfoHelper( 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 index ec1b9b7765..ce84d3f905 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/user-info.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/user-info.tsx @@ -2,6 +2,7 @@ import { Avatar, Button, Divider, + ErrorMessage, Menu, MenuIcon, MenuItem, @@ -13,32 +14,36 @@ import { openSettingModalAtom, openSignOutModalAtom, } from '@affine/core/atoms'; -import { useCloudStorageUsage } from '@affine/core/hooks/affine/use-cloud-storage-usage'; -import { - useCurrentUser, - useSession, -} from '@affine/core/hooks/affine/use-current-user'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { AccountIcon, ArrowRightSmallIcon, SignOutIcon, } from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import { useSetAtom } from 'jotai'; -import { Suspense, useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; +import { + type AuthAccountInfo, + AuthService, + UserQuotaService, +} from '../../modules/cloud'; import * as styles from './index.css'; import { UnknownUserIcon } from './unknow-user'; export const UserInfo = () => { - const { status } = useSession(); - const isAuthenticated = status === 'authenticated'; - return isAuthenticated ? : ; + const session = useService(AuthService).session; + const account = useLiveData(session.account$); + return account ? ( + + ) : ( + + ); }; -const AuthorizedUserInfo = () => { - const user = useCurrentUser(); +const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => { return ( }> ); @@ -131,7 +136,29 @@ const AccountMenu = () => { }; const CloudUsage = () => { - const { color, usedText, maxLimitText, percent } = useCloudStorageUsage(); + const quota = useService(UserQuotaService).quota; + const quotaError = useLiveData(quota.error$); + + 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 (
{ })} >
- {usedText} + {usedFormatted}  /  - {maxLimitText} + {maxFormatted}
@@ -156,27 +183,12 @@ const CloudUsage = () => { ); }; -const MenuFallback = () => { - return ( - <> -
- - -
- - - - - - ); -}; - const OperationMenu = () => { return ( - }> + <> - + ); }; diff --git a/packages/frontend/core/src/components/top-tip.tsx b/packages/frontend/core/src/components/top-tip.tsx index 9016685818..4f4f20fd79 100644 --- a/packages/frontend/core/src/components/top-tip.tsx +++ b/packages/frontend/core/src/components/top-tip.tsx @@ -2,13 +2,13 @@ import { BrowserWarning, LocalDemoTips } from '@affine/component/affine-banner'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@toeverything/infra'; +import { useLiveData, useService, type Workspace } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; import { authAtom } from '../atoms'; -import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; import { useEnableCloud } from '../hooks/affine/use-enable-cloud'; +import { AuthService } from '../modules/cloud'; const minimumChromeVersion = 106; @@ -59,7 +59,7 @@ export const TopTip = ({ pageId?: string; workspace: Workspace; }) => { - const loginStatus = useCurrentLoginStatus(); + const loginStatus = useLiveData(useService(AuthService).session.status$); const isLoggedIn = loginStatus === 'authenticated'; const [showWarning, setShowWarning] = useState(shouldShowWarning); diff --git a/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx b/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx index 1a884beb9d..2bac01997c 100644 --- a/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx +++ b/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx @@ -1,9 +1,10 @@ import { Button } from '@affine/component/ui/button'; import { AffineShapeIcon } from '@affine/core/components/page-list'; // TODO: import from page-list temporarily, need to defined common svg icon/images management. import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useService, Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useState } from 'react'; import { mixpanel } from '../../utils'; @@ -15,13 +16,13 @@ import { ArrowCircleIcon, HeartBreakIcon } from './upgrade-icon'; */ export const WorkspaceUpgrade = function WorkspaceUpgrade() { const [error, setError] = useState(null); - const currentWorkspace = useService(Workspace); - const workspaceManager = useService(WorkspaceManager); - const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade); + const currentWorkspace = useService(WorkspaceService).workspace; + const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$); const t = useAFFiNEI18N(); + const { openPage } = useNavigateHelper(); const onButtonClick = useAsyncCallback(async () => { - if (upgradeStatus?.upgrading) { + if (upgrading) { return; } @@ -30,13 +31,9 @@ export const WorkspaceUpgrade = function WorkspaceUpgrade() { }); try { - const newWorkspace = - await currentWorkspace.upgrade.upgrade(workspaceManager); + const newWorkspace = await currentWorkspace.upgrade.upgrade(); if (newWorkspace) { - location.pathname = `/workspace/${newWorkspace.id}/all`; - //FIXME: use openPage will cause a bug, which will cause the 'v1 to v4' test fail. - // params.workspaceId will not be updated, so the page will not be re-rendered and still show the 404 page. - // openPage(newWorkspace.id, WorkspaceSubPath.ALL); + openPage(newWorkspace.id, WorkspaceSubPath.ALL); } else { // blocksuite may enter an incorrect state, reload to reset it. location.reload(); @@ -44,7 +41,7 @@ export const WorkspaceUpgrade = function WorkspaceUpgrade() { } catch (error) { setError(error instanceof Error ? error.message : '' + error); } - }, [upgradeStatus?.upgrading, currentWorkspace.upgrade, workspaceManager]); + }, [upgrading, currentWorkspace.upgrade, openPage]); return (
@@ -62,9 +59,7 @@ export const WorkspaceUpgrade = function WorkspaceUpgrade() { ) : ( ) } @@ -72,7 +67,7 @@ export const WorkspaceUpgrade = function WorkspaceUpgrade() { > {error ? t['com.affine.upgrade.button-text.error']() - : upgradeStatus?.upgrading + : upgrading ? t['com.affine.upgrade.button-text.upgrading']() : t['com.affine.upgrade.button-text.pending']()} diff --git a/packages/frontend/core/src/hooks/__tests__/gql.spec.tsx b/packages/frontend/core/src/hooks/__tests__/gql.spec.tsx deleted file mode 100644 index 3f5b9df57b..0000000000 --- a/packages/frontend/core/src/hooks/__tests__/gql.spec.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { uploadAvatarMutation } from '@affine/graphql'; -import { render } from '@testing-library/react'; -import type { Mock } from 'vitest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useMutation } from '../use-mutation'; -import { useQuery } from '../use-query'; - -let fetch: Mock; -describe('GraphQL wrapper for SWR', () => { - beforeEach(() => { - fetch = vi.fn(() => - Promise.resolve( - new Response(JSON.stringify({ data: { hello: 1 } }), { - headers: { - 'content-type': 'application/json', - }, - }) - ) - ); - vi.stubGlobal('fetch', fetch); - }); - - afterEach(() => { - fetch.mockReset(); - }); - - describe('useQuery', () => { - const Component = ({ id }: { id: number }) => { - const { data, isLoading, error } = useQuery({ - query: { - id: 'query', - query: ` - query { - hello - } - `, - operationName: 'query', - definitionName: 'query', - }, - // @ts-expect-error forgive the fake variables - variables: { id }, - }); - - if (isLoading) { - return
loading
; - } - - if (error) { - return
error
; - } - - // @ts-expect-error - return
number: {data!.hello}
; - }; - - it('should send query correctly', async () => { - const component = ; - const renderer = render(component); - const el = await renderer.findByText('number: 1'); - expect(el).toMatchInlineSnapshot(` -
- number:${' '} - 1 -
- `); - }); - - it('should not send request if cache hit', async () => { - const component = ; - const renderer = render(component); - expect(fetch).toBeCalledTimes(1); - - renderer.rerender(component); - expect(fetch).toBeCalledTimes(1); - - render(); - - expect(fetch).toBeCalledTimes(2); - }); - }); - - describe('useMutation', () => { - const Component = () => { - const { trigger, error, isMutating } = useMutation({ - mutation: { - id: 'mutation', - query: ` - mutation { - hello - } - `, - operationName: 'mutation', - definitionName: 'mutation', - }, - }); - - if (isMutating) { - return
mutating
; - } - - if (error) { - return
error
; - } - - return ( -
- -
- ); - }; - - it('should trigger mutation', async () => { - const component = ; - const renderer = render(component); - const button = await renderer.findByText('click'); - - button.click(); - expect(fetch).toBeCalledTimes(1); - - renderer.rerender(component); - expect(renderer.asFragment()).toMatchInlineSnapshot(` - -
- mutating -
-
- `); - }); - - it('should get rid of generated types', async () => { - function _NotActuallyRunDefinedForTypeTesting() { - const { trigger } = useMutation({ - mutation: uploadAvatarMutation, - }); - trigger({ - avatar: new File([''], 'avatar.png'), - }); - } - expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function'); - }); - }); -}); diff --git a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx index 44e906aba7..8459e6806c 100644 --- a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx +++ b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx @@ -5,8 +5,12 @@ import 'fake-indexeddb/auto'; import { configureTestingEnvironment } from '@affine/core/testing'; import { renderHook } from '@testing-library/react'; -import type { Workspace } from '@toeverything/infra'; -import { initEmptyPage, ServiceProviderContext } from '@toeverything/infra'; +import type { FrameworkProvider, Workspace } from '@toeverything/infra'; +import { + FrameworkRoot, + FrameworkScope, + initEmptyPage, +} from '@toeverything/infra'; import type { PropsWithChildren } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -14,33 +18,33 @@ import { useBlockSuiteDocMeta } from '../use-block-suite-page-meta'; import { useDocCollectionHelper } from '../use-block-suite-workspace-helper'; const configureTestingWorkspace = async () => { - const { workspace } = await configureTestingEnvironment(); + const { framework, workspace } = await configureTestingEnvironment(); const docCollection = workspace.docCollection; initEmptyPage(docCollection.createDoc({ id: 'page1' })); initEmptyPage(docCollection.createDoc({ id: 'page2' })); - return workspace; + return { framework, workspace }; }; beforeEach(async () => { vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); }); -const getWrapper = (workspace: Workspace) => +const getWrapper = (framework: FrameworkProvider, workspace: Workspace) => function Provider({ children }: PropsWithChildren) { return ( - - {children} - + + {children} + ); }; describe('useDocCollectionHelper', () => { test('should create page', async () => { - const workspace = await configureTestingWorkspace(); + const { framework, workspace } = await configureTestingWorkspace(); const docCollection = workspace.docCollection; - const Wrapper = getWrapper(workspace); + const Wrapper = getWrapper(framework, workspace); expect(docCollection.meta.docMetas.length).toBe(3); const helperHook = renderHook(() => useDocCollectionHelper(docCollection), { diff --git a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx index 8cce3b379a..a1a0ec8248 100644 --- a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx +++ b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx @@ -3,12 +3,13 @@ */ import 'fake-indexeddb/auto'; -import { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace'; +import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties'; import { render } from '@testing-library/react'; import { - ServiceProviderContext, + FrameworkRoot, + FrameworkScope, useService, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import { createStore, Provider } from 'jotai'; import { Suspense } from 'react'; @@ -20,8 +21,11 @@ import { useDocCollectionPageTitle } from '../use-block-suite-workspace-page-tit const store = createStore(); const Component = () => { - const workspace = useService(Workspace); - const title = useDocCollectionPageTitle(workspace.docCollection, 'page0'); + const workspaceService = useService(WorkspaceService); + const title = useDocCollectionPageTitle( + workspaceService.workspace.docCollection, + 'page0' + ); return
title: {title}
; }; @@ -31,42 +35,54 @@ beforeEach(async () => { describe('useDocCollectionPageTitle', () => { test('basic', async () => { - const { workspace, page } = await configureTestingEnvironment(); + const { framework, workspace, doc } = await configureTestingEnvironment(); const { findByText, rerender } = render( - - - - - - - + + + + + + + + + + + ); expect(await findByText('title: Untitled')).toBeDefined(); - workspace.docCollection.setDocMeta(page.id, { title: '1' }); + workspace.docCollection.setDocMeta(doc.id, { title: '1' }); rerender( - - - - - - - + + + + + + + + + + + ); expect(await findByText('title: 1')).toBeDefined(); }); test('journal', async () => { - const { workspace, page } = await configureTestingEnvironment(); - const adapter = workspace.services.get(WorkspacePropertiesAdapter); - adapter.setJournalPageDateString(page.id, '2021-01-01'); + const { framework, workspace, doc } = await configureTestingEnvironment(); + const adapter = workspace.scope.get(WorkspacePropertiesAdapter); + adapter.setJournalPageDateString(doc.id, '2021-01-01'); const { findByText } = render( - - - - - - - + + + + + + + + + + + ); expect(await findByText('title: Jan 1, 2021')).toBeDefined(); }); diff --git a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx index 99d8643437..62449a7bab 100644 --- a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx +++ b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx @@ -2,18 +2,26 @@ import { toast } from '@affine/component'; import type { AllPageListConfig } from '@affine/core/components/page-list'; import { FavoriteTag } from '@affine/core/components/page-list'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { ShareDocsService } from '@affine/core/modules/share-doc'; +import { PublicPageMode } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { DocMeta } from '@blocksuite/store'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; -import { useCallback, useMemo } from 'react'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { useCallback, useEffect, useMemo } from 'react'; import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; -import { usePublicPages } from './use-is-shared-page'; export const useAllPageListConfig = () => { - const currentWorkspace = useService(Workspace); - const { getPublicMode } = usePublicPages(currentWorkspace); + const currentWorkspace = useService(WorkspaceService).workspace; + const shareDocService = useService(ShareDocsService); + const shareDocs = useLiveData(shareDocService.shareDocs.list$); + + useEffect(() => { + // TODO: loading & error UI + shareDocService.shareDocs.revalidate(); + }, [shareDocService]); + const workspace = currentWorkspace.docCollection; const pageMetas = useBlockSuiteDocMeta(workspace); const { isPreferredEdgeless } = usePageHelper(workspace); @@ -48,7 +56,16 @@ export const useAllPageListConfig = () => { return { allPages: pageMetas, isEdgeless: isPreferredEdgeless, - getPublicMode, + getPublicMode(id) { + const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode; + if (mode === PublicPageMode.Edgeless) { + return 'edgeless'; + } else if (mode === PublicPageMode.Page) { + return 'page'; + } else { + return undefined; + } + }, docCollection: currentWorkspace.docCollection, getPage: id => pageMap[id], favoriteRender: page => { @@ -64,8 +81,8 @@ export const useAllPageListConfig = () => { }, [ pageMetas, isPreferredEdgeless, - getPublicMode, currentWorkspace.docCollection, + shareDocs, pageMap, isActive, onToggleFavoritePage, diff --git a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts index 273249acc0..6a82267b6c 100644 --- a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts @@ -2,7 +2,7 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { useDocCollectionHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; import { CollectionService } from '@affine/core/modules/collection'; -import { PageRecordList, useService } from '@toeverything/infra'; +import { DocsService, useService } from '@toeverything/infra'; import { useCallback } from 'react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; @@ -17,7 +17,7 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) { const { createDoc } = useDocCollectionHelper(docCollection); const { openPage } = useNavigateHelper(); const collectionService = useService(CollectionService); - const pageRecordList = useService(PageRecordList); + const pageRecordList = useService(DocsService).list; // TODO-Doma // "Remove" may cause ambiguity here. Consider renaming as "moveToTrash". @@ -85,7 +85,7 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) { const duplicate = useAsyncCallback( async (pageId: string, openPageAfterDuplication: boolean = true) => { - const currentPageMode = pageRecordList.record$(pageId).value?.mode$.value; + const currentPageMode = pageRecordList.doc$(pageId).value?.mode$.value; const currentPageMeta = getDocMeta(pageId); const newPage = createDoc(); const currentPage = docCollection.getDoc(pageId); @@ -109,9 +109,7 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) { const newPageTitle = currentPageMeta.title.replace(lastDigitRegex, '') + `(${newNumber})`; - pageRecordList - .record$(newPage.id) - .value?.setMode(currentPageMode || 'page'); + pageRecordList.doc$(newPage.id).value?.setMode(currentPageMode || 'page'); setDocTitle(newPage.id, newPageTitle); openPageAfterDuplication && openPage(docCollection.id, newPage.id); }, diff --git a/packages/frontend/core/src/hooks/affine/use-cloud-storage-usage.ts b/packages/frontend/core/src/hooks/affine/use-cloud-storage-usage.ts deleted file mode 100644 index f0439c0830..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-cloud-storage-usage.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { allBlobSizesQuery, SubscriptionPlan } from '@affine/graphql'; -import { cssVar } from '@toeverything/theme'; -import bytes from 'bytes'; -import { useMemo } from 'react'; - -import { useQuery } from '../use-query'; -import { useUserQuota } from '../use-quota'; -import { useUserSubscription } from '../use-subscription'; - -/** - * Hook to get currentUser's cloud storage usage information. - */ -export const useCloudStorageUsage = () => { - const { data } = useQuery({ - query: allBlobSizesQuery, - }); - - const quota = useUserQuota(); - const [subscription] = useUserSubscription(); - - const plan = subscription?.plan ?? SubscriptionPlan.Free; - - const maxLimit = useMemo(() => { - if (quota) { - return quota.storageQuota; - } - return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB'); - }, [plan, quota]); - - const used = data?.collectAllBlobSizes?.size ?? 0; - const percent = Math.min( - 100, - Math.max(0.5, Number(((used / maxLimit) * 100).toFixed(4))) - ); - const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor'); - - const usedText = bytes.format(used); - const maxLimitText = bytes.format(maxLimit); - - return { - /** Current subscription plan of logged in user */ - plan, - /** Used storage in bytes */ - used, - /** Formatted used storage */ - usedText, - /** CSS variable name for progress bar color */ - color, - /** Percentage of storage used */ - percent, - /** Maximum storage limit in bytes */ - maxLimit, - /** Formatted maximum storage limit */ - maxLimitText, - }; -}; diff --git a/packages/frontend/core/src/hooks/affine/use-current-login-status.ts b/packages/frontend/core/src/hooks/affine/use-current-login-status.ts deleted file mode 100644 index 14ff11afa7..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-current-login-status.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useSession } from './use-current-user'; - -export function useCurrentLoginStatus() { - const session = useSession(); - return session.status; -} diff --git a/packages/frontend/core/src/hooks/affine/use-current-user.ts b/packages/frontend/core/src/hooks/affine/use-current-user.ts deleted file mode 100644 index b74414b2c9..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-current-user.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { getBaseUrl } from '@affine/graphql'; -import { AIProvider } from '@blocksuite/presets'; -import { useEffect, useMemo, useReducer } from 'react'; -import useSWR from 'swr'; - -import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors'; -import { useAsyncCallback } from '../affine-async-hooks'; - -const logger = new DebugLogger('auth'); - -interface User { - id: string; - email: string; - name: string; - hasPassword: boolean; - avatarUrl: string | null; - emailVerified: string | null; -} - -export interface Session { - user?: User | null; - status: 'authenticated' | 'unauthenticated' | 'loading'; - reload: () => Promise; -} - -export type CheckedUser = Session['user'] & { - update: (changes?: Partial) => void; -}; - -export async function getSession( - url: string = getBaseUrl() + '/api/auth/session' -) { - try { - const res = await fetch(url); - - if (res.ok) { - return (await res.json()) as { user?: User | null }; - } - - logger.error('Failed to fetch session', res.statusText); - throw new Error('Failed to fetch session'); - } catch (e) { - logger.error('Failed to fetch session', e); - throw new Error('Failed to fetch session'); - } -} - -export function useSession(): Session { - const { - data, - mutate, - isLoading, - error: _error, // use error here to avoid uncaught error in the console - } = useSWR('session', () => getSession(), { - errorRetryCount: 3, - errorRetryInterval: 500, - shouldRetryOnError: true, - suspense: false, - }); - - return { - user: data?.user, - status: isLoading - ? 'loading' - : data?.user - ? 'authenticated' - : 'unauthenticated', - reload: async () => { - return mutate().then(e => { - console.error(e); - }); - }, - }; -} - -type UpdateSessionAction = - | { - type: 'update'; - payload?: Partial; - } - | { - type: 'fetchError'; - payload: null; - }; - -function updateSessionReducer(prevState: User, action: UpdateSessionAction) { - const { type, payload } = action; - switch (type) { - case 'update': - return { ...prevState, ...payload }; - case 'fetchError': - return prevState; - } -} - -/** - * This hook checks if the user is logged in. - * If so, the user object will be cached and returned. - * If not, and there is no cache, it will throw an error. - * If network error or API response error, it will use the cached value. - */ -export function useCurrentUser(): CheckedUser { - const session = useSession(); - - const [user, dispatcher] = useReducer( - updateSessionReducer, - session.user, - firstSession => { - if (!firstSession) { - // barely possible. - // login succeed but the session request failed then. - // also need a error boundary to handle this error. - throw new SessionFetchErrorRightAfterLoginOrSignUp( - 'Fetching session failed', - () => { - getSession() - .then(session => { - if (session.user) { - dispatcher({ - type: 'update', - payload: session.user, - }); - } - }) - .catch(err => { - console.error(err); - }); - } - ); - } - - return firstSession; - } - ); - - const update = useAsyncCallback( - async (changes?: Partial) => { - dispatcher({ - type: 'update', - payload: changes, - }); - - await session.reload(); - }, - [dispatcher, session] - ); - - // update user when session reloaded - // maybe lift user state up to global state? - useEffect(() => { - if (session.user) { - const user = session.user; - dispatcher({ type: 'update', payload: user }); - // todo: move this to a better place! - AIProvider.provide('userInfo', () => { - return user; - }); - } else { - dispatcher({ type: 'fetchError', payload: null }); - } - }, [ - session.user, - session.user?.id, - session.user?.name, - session.user?.avatarUrl, - ]); - - return useMemo( - () => ({ - ...user, - update, - }), - // only list the things will change as deps - // eslint-disable-next-line react-hooks/exhaustive-deps - [user.id, user.avatarUrl, user.name, update] - ); -} diff --git a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts index 2d151d9abc..dc8fea8e1c 100644 --- a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts +++ b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts @@ -1,13 +1,15 @@ +import { AuthService } from '@affine/core/modules/cloud'; import type { DeleteCollectionInfo } from '@affine/env/filter'; +import { useLiveData, useService } from '@toeverything/infra'; import { useMemo } from 'react'; -import { useSession } from './use-current-user'; - export const useDeleteCollectionInfo = () => { - const { user } = useSession(); + const authService = useService(AuthService); + + const user = useLiveData(authService.session.account$); return useMemo( - () => (user ? { userName: user.name, userId: user.id } : null), + () => (user ? { userName: user.label, userId: user.id } : null), [user] ); }; diff --git a/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx b/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx index a4990bb991..9b90c532ca 100644 --- a/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx +++ b/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx @@ -1,8 +1,8 @@ -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useMemo } from 'react'; export function useDocEngineStatus() { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const engineState = useLiveData( workspace.engine.docEngineState$.throttleTime(100) diff --git a/packages/frontend/core/src/hooks/affine/use-enable-cloud.tsx b/packages/frontend/core/src/hooks/affine/use-enable-cloud.tsx index 23f832ffb9..1a9bb3c425 100644 --- a/packages/frontend/core/src/hooks/affine/use-enable-cloud.tsx +++ b/packages/frontend/core/src/hooks/affine/use-enable-cloud.tsx @@ -1,15 +1,19 @@ import { useConfirmModal } from '@affine/component'; import { authAtom } from '@affine/core/atoms'; import { setOnceSignedInEventAtom } from '@affine/core/atoms/event'; +import { AuthService } from '@affine/core/modules/cloud'; import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { Workspace } from '@toeverything/infra'; -import { useService, WorkspaceManager } from '@toeverything/infra'; +import { + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { useCallback } from 'react'; import { useNavigateHelper } from '../use-navigate-helper'; -import { useCurrentLoginStatus } from './use-current-login-status'; interface ConfirmEnableCloudOptions { /** @@ -26,21 +30,21 @@ type ConfirmEnableArgs = [Workspace, ConfirmEnableCloudOptions | undefined]; export const useEnableCloud = () => { const t = useAFFiNEI18N(); - const loginStatus = useCurrentLoginStatus(); + const loginStatus = useLiveData(useService(AuthService).session.status$); const setAuthAtom = useSetAtom(authAtom); const setOnceSignedInEvent = useSetAtom(setOnceSignedInEventAtom); const { openConfirmModal, closeConfirmModal } = useConfirmModal(); - const workspaceManager = useService(WorkspaceManager); + const workspacesService = useService(WorkspacesService); const { openPage } = useNavigateHelper(); const enableCloud = useCallback( async (ws: Workspace | null, options?: ConfirmEnableCloudOptions) => { if (!ws) return; - const { id: newId } = await workspaceManager.transformLocalToCloud(ws); + const { id: newId } = await workspacesService.transformLocalToCloud(ws); openPage(newId, options?.openPageId || WorkspaceSubPath.ALL); options?.onSuccess?.(); }, - [openPage, workspaceManager] + [openPage, workspacesService] ); const openSignIn = useCallback( diff --git a/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts b/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts index e904a21e81..b30506362a 100644 --- a/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts @@ -1,7 +1,7 @@ import { toast } from '@affine/component'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { CollectionService } from '@affine/core/modules/collection'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { Active, @@ -9,7 +9,7 @@ import type { Over, UniqueIdentifier, } from '@dnd-kit/core'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useMemo } from 'react'; import { useDeleteCollectionInfo } from './use-delete-collection-info'; @@ -153,7 +153,7 @@ export type GlobalDragEndIntent = ReturnType; export const useGlobalDNDHelper = () => { const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const favAdapter = useService(FavoriteItemsAdapter); const workspace = currentWorkspace.docCollection; const { setTrashModal } = useTrashModalHelper(workspace); diff --git a/packages/frontend/core/src/hooks/affine/use-is-shared-page.tsx b/packages/frontend/core/src/hooks/affine/use-is-shared-page.tsx index 06bc8891e0..73ebd4d687 100644 --- a/packages/frontend/core/src/hooks/affine/use-is-shared-page.tsx +++ b/packages/frontend/core/src/hooks/affine/use-is-shared-page.tsx @@ -8,7 +8,7 @@ import { } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { SingleSelectSelectSolidIcon } from '@blocksuite/icons'; -import type { PageMode, Workspace } from '@toeverything/infra'; +import type { DocMode, Workspace } from '@toeverything/infra'; import { cssVar } from '@toeverything/theme'; import { useCallback, useMemo } from 'react'; @@ -64,10 +64,10 @@ export function useIsSharedPage( pageId: string ): { isSharedPage: boolean; - changeShare: (mode: PageMode) => void; + changeShare: (mode: DocMode) => void; disableShare: () => void; - currentShareMode: PageMode; - enableShare: (mode: PageMode) => void; + currentShareMode: DocMode; + enableShare: (mode: DocMode) => void; } { const t = useAFFiNEI18N(); const { data, mutate } = useQuery({ @@ -90,14 +90,14 @@ export function useIsSharedPage( ); const isPageShared = !!publicPage; - const currentShareMode: PageMode = + const currentShareMode: DocMode = publicPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page'; return [isPageShared, currentShareMode]; }, [data?.workspace.publicPages, pageId]); const enableShare = useCallback( - (mode: PageMode) => { + (mode: DocMode) => { const publishMode = mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page; @@ -125,7 +125,7 @@ export function useIsSharedPage( ); const changeShare = useCallback( - (mode: PageMode) => { + (mode: DocMode) => { const publishMode = mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page; @@ -211,7 +211,7 @@ export function usePublicPages(workspace: Workspace) { const publicPages: { id: string; - mode: PageMode; + mode: DocMode; }[] = useMemo( () => maybeData?.workspace.publicPages.map(i => ({ diff --git a/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts b/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts deleted file mode 100644 index 21ac3b4828..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { getIsOwnerQuery } from '@affine/graphql'; -import type { WorkspaceMetadata } from '@toeverything/infra'; - -import { useQueryImmutable } from '../use-query'; - -export function useIsWorkspaceOwner(workspaceMetadata: WorkspaceMetadata) { - const { data } = useQueryImmutable( - workspaceMetadata.flavour !== WorkspaceFlavour.LOCAL - ? { - query: getIsOwnerQuery, - variables: { - workspaceId: workspaceMetadata.id, - }, - } - : undefined - ); - - if (workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) { - return true; - } - - return data.isOwner; -} diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx index d3220ba1de..d1fbfba86c 100644 --- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx +++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx @@ -1,17 +1,17 @@ import { toast } from '@affine/component'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons'; import { - Doc, + DocService, PreconditionStrategy, registerAffineCommand, useLiveData, useService, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; @@ -22,30 +22,30 @@ import { useExportPage } from './use-export-page'; import { useTrashModalHelper } from './use-trash-modal-helper'; export function useRegisterBlocksuiteEditorCommands() { - const page = useService(Doc); - const pageId = page.id; - const mode = useLiveData(page.mode$); + const doc = useService(DocService).doc; + const docId = doc.id; + const mode = useLiveData(doc.mode$); const t = useAFFiNEI18N(); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const docCollection = workspace.docCollection; const { getDocMeta } = useDocMetaHelper(docCollection); - const currentPage = docCollection.getDoc(pageId); + const currentPage = docCollection.getDoc(docId); assertExists(currentPage); - const pageMeta = getDocMeta(pageId); + const pageMeta = getDocMeta(docId); assertExists(pageMeta); const favAdapter = useService(FavoriteItemsAdapter); - const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc')); + const favorite = useLiveData(favAdapter.isFavorite$(docId, 'doc')); const trash = pageMeta.trash ?? false; const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom); const openHistoryModal = useCallback(() => { setPageHistoryModalState(() => ({ - pageId, + pageId: docId, open: true, })); - }, [pageId, setPageHistoryModalState]); + }, [docId, setPageHistoryModalState]); const { restoreFromTrash, duplicate } = useBlockSuiteMetaHelper(docCollection); @@ -54,10 +54,10 @@ export function useRegisterBlocksuiteEditorCommands() { const onClickDelete = useCallback(() => { setTrashModal({ open: true, - pageIds: [pageId], + pageIds: [docId], pageTitles: [pageMeta.title], }); - }, [pageId, pageMeta.title, setTrashModal]); + }, [docId, pageMeta.title, setTrashModal]); const isCloudWorkspace = workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; @@ -97,7 +97,7 @@ export function useRegisterBlocksuiteEditorCommands() { ? t['com.affine.favoritePageOperation.remove']() : t['com.affine.favoritePageOperation.add'](), run() { - favAdapter.toggle(pageId, 'doc'); + favAdapter.toggle(docId, 'doc'); toast( favorite ? t['com.affine.cmdk.affine.editor.remove-from-favourites']() @@ -121,7 +121,7 @@ export function useRegisterBlocksuiteEditorCommands() { : t['com.affine.pageMode.page']() }`, run() { - page.toggleMode(); + doc.toggleMode(); toast( mode === 'page' ? t['com.affine.toastMessage.edgelessMode']() @@ -140,7 +140,7 @@ export function useRegisterBlocksuiteEditorCommands() { icon: mode === 'page' ? : , label: t['com.affine.header.option.duplicate'](), run() { - duplicate(pageId); + duplicate(docId); }, }) ); @@ -219,7 +219,7 @@ export function useRegisterBlocksuiteEditorCommands() { icon: mode === 'page' ? : , label: t['com.affine.cmdk.affine.editor.restore-from-trash'](), run() { - restoreFromTrash(pageId); + restoreFromTrash(docId); }, }) ); @@ -246,14 +246,14 @@ export function useRegisterBlocksuiteEditorCommands() { mode, onClickDelete, exportHandler, - pageId, restoreFromTrash, t, trash, isCloudWorkspace, openHistoryModal, duplicate, - page, favAdapter, + docId, + doc, ]); } diff --git a/packages/frontend/core/src/hooks/affine/use-server-config.ts b/packages/frontend/core/src/hooks/affine/use-server-config.ts deleted file mode 100644 index 1418894145..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-server-config.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ServerConfigQuery, ServerFeature } from '@affine/graphql'; -import { - getBaseUrl, - oauthProvidersQuery, - serverConfigQuery, -} from '@affine/graphql'; -import type { BareFetcher, Middleware } from 'swr'; - -import { useQueryImmutable } from '../use-query'; - -const wrappedFetcher = (fetcher: BareFetcher | null, ...args: any[]) => - fetcher?.(...args).catch(() => null); - -const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => { - return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config); -}; - -const useServerConfig = () => { - const { data: config, error } = useQueryImmutable( - { query: serverConfigQuery }, - { - use: [errorHandler], - } - ); - - if (error || !config) { - return null; - } - - return config.serverConfig; -}; - -type LowercaseServerFeature = Lowercase; -type ServerFeatureRecord = { - [key in LowercaseServerFeature]: boolean; -}; - -export const useServerFeatures = (): ServerFeatureRecord => { - const config = useServerConfig(); - - if (!config) { - return {} as ServerFeatureRecord; - } - - return Array.from(new Set(config.features)).reduce((acc, cur) => { - acc[cur.toLowerCase() as LowercaseServerFeature] = true; - return acc; - }, {} as ServerFeatureRecord); -}; - -export const useOAuthProviders = () => { - const { data, error } = useQueryImmutable( - { query: oauthProvidersQuery }, - { - use: [errorHandler], - } - ); - - if (error || !data) { - return []; - } - - return data.serverConfig.oauthProviders; -}; - -export const useServerBaseUrl = () => { - const baseUrl = getBaseUrl(); - - if (!baseUrl) { - if (environment.isDesktop) { - // don't use window.location in electron - return null; - } - const { protocol, hostname, port } = window.location; - return `${protocol}//${hostname}${port ? `:${port}` : ''}`; - } - - return baseUrl; -}; - -export const useCredentialsRequirement = () => { - const config = useServerConfig(); - - if (!config) { - return {} as ServerConfigQuery['serverConfig']['credentialsRequirement']; - } - - return config.credentialsRequirement; -}; diff --git a/packages/frontend/core/src/hooks/affine/use-user-features.ts b/packages/frontend/core/src/hooks/affine/use-user-features.ts deleted file mode 100644 index 505e04a2ff..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-user-features.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { FeatureType, getUserFeaturesQuery } from '@affine/graphql'; -import type { BareFetcher, Middleware } from 'swr'; - -import { useQueryImmutable } from '../use-query'; - -const wrappedFetcher = (fetcher: BareFetcher | null, ...args: any[]) => - fetcher?.(...args).catch(() => null); - -const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => { - return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config); -}; - -export function useIsEarlyAccess() { - const { data } = useQueryImmutable( - { - query: getUserFeaturesQuery, - }, - { - use: [errorHandler], - } - ); - - return data?.currentUser?.features.includes(FeatureType.EarlyAccess) ?? false; -} diff --git a/packages/frontend/core/src/hooks/use-affine-adapter.ts b/packages/frontend/core/src/hooks/use-affine-adapter.ts index 2dd09ca47a..726dc8e34b 100644 --- a/packages/frontend/core/src/hooks/use-affine-adapter.ts +++ b/packages/frontend/core/src/hooks/use-affine-adapter.ts @@ -1,9 +1,8 @@ -import type { Workspace } from '@toeverything/infra'; import { useService } from '@toeverything/infra'; import { useDebouncedState } from 'foxact/use-debounced-state'; -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; -import { WorkspacePropertiesAdapter } from '../modules/workspace/properties'; +import { WorkspacePropertiesAdapter } from '../modules/properties'; function getProxy(obj: T) { return new Proxy(obj, {}); @@ -38,11 +37,3 @@ export function useCurrentWorkspacePropertiesAdapter() { const adapter = useService(WorkspacePropertiesAdapter); return useReactiveAdapter(adapter); } - -export function useWorkspacePropertiesAdapter(workspace: Workspace) { - const adapter = useMemo( - () => new WorkspacePropertiesAdapter(workspace), - [workspace] - ); - return useReactiveAdapter(adapter); -} diff --git a/packages/frontend/core/src/hooks/use-navigate-helper.ts b/packages/frontend/core/src/hooks/use-navigate-helper.ts index ae6ed54c9a..c2b24be04b 100644 --- a/packages/frontend/core/src/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/hooks/use-navigate-helper.ts @@ -1,9 +1,8 @@ import type { WorkspaceSubPath } from '@affine/core/shared'; -import { createContext, useCallback, useContext, useMemo } from 'react'; -import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'; -import { useLocation } from 'react-router-dom'; +import { useCallback, useContext, useMemo } from 'react'; +import type { NavigateOptions, To } from 'react-router-dom'; -import { router } from '../router'; +import { NavigateContext, router } from '../router'; export enum RouteLogic { REPLACE = 'replace', @@ -11,17 +10,16 @@ export enum RouteLogic { } function defaultNavigate(to: To, option?: { replace?: boolean }) { - router.navigate(to, option).catch(err => { - console.error('Failed to navigate', err); - }); + console.log(to, option); + setTimeout(() => { + router.navigate(to, option).catch(err => { + console.error('Failed to navigate', err); + }); + }, 100); } -export const NavigateContext = createContext(null); - // todo: add a name -> path helper in the results export function useNavigateHelper() { - const location = useLocation(); - const navigate = useContext(NavigateContext) ?? defaultNavigate; const jumpToPage = useCallback( @@ -89,18 +87,6 @@ export function useNavigateHelper() { }, [navigate] ); - const jumpToPublicWorkspacePage = useCallback( - ( - workspaceId: string, - pageId: string, - logic: RouteLogic = RouteLogic.PUSH - ) => { - return navigate(`/public-workspace/${workspaceId}/${pageId}`, { - replace: logic === RouteLogic.REPLACE, - }); - }, - [navigate] - ); const jumpToSubPath = useCallback( ( workspaceId: string, @@ -114,19 +100,11 @@ export function useNavigateHelper() { [navigate] ); - const isPublicWorkspace = useMemo(() => { - return location.pathname.indexOf('/public-workspace') === 0; - }, [location.pathname]); - const openPage = useCallback( (workspaceId: string, pageId: string) => { - if (isPublicWorkspace) { - return jumpToPublicWorkspacePage(workspaceId, pageId); - } else { - return jumpToPage(workspaceId, pageId); - } + return jumpToPage(workspaceId, pageId); }, - [jumpToPage, jumpToPublicWorkspacePage, isPublicWorkspace] + [jumpToPage] ); const jumpToIndex = useCallback( @@ -174,7 +152,6 @@ export function useNavigateHelper() { () => ({ jumpToPage, jumpToPageBlock, - jumpToPublicWorkspacePage, jumpToSubPath, jumpToIndex, jumpTo404, @@ -189,7 +166,6 @@ export function useNavigateHelper() { [ jumpToPage, jumpToPageBlock, - jumpToPublicWorkspacePage, jumpToSubPath, jumpToIndex, jumpTo404, diff --git a/packages/frontend/core/src/hooks/use-quota.ts b/packages/frontend/core/src/hooks/use-quota.ts deleted file mode 100644 index 9ea58f6994..0000000000 --- a/packages/frontend/core/src/hooks/use-quota.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { quotaQuery, workspaceQuotaQuery } from '@affine/graphql'; - -import { useQuery } from './use-query'; - -export const useUserQuota = () => { - const { data } = useQuery({ - query: quotaQuery, - }); - - return data.currentUser?.quota || null; -}; - -export const useWorkspaceQuota = (id: string) => { - const { data } = useQuery({ - query: workspaceQuotaQuery, - variables: { - id, - }, - }); - - return data.workspace?.quota || null; -}; diff --git a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts index 77d7271d0d..46d379fd7e 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -1,5 +1,5 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useStore } from 'jotai'; import { useTheme } from 'next-themes'; import { useEffect } from 'react'; @@ -21,7 +21,7 @@ export function useRegisterWorkspaceCommands() { const store = useStore(); const t = useAFFiNEI18N(); const theme = useTheme(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const languageHelper = useLanguageHelper(); const pageHelper = usePageHelper(currentWorkspace.docCollection); const navigationHelper = useNavigateHelper(); diff --git a/packages/frontend/core/src/hooks/use-subscription.ts b/packages/frontend/core/src/hooks/use-subscription.ts deleted file mode 100644 index 8191b2a9fa..0000000000 --- a/packages/frontend/core/src/hooks/use-subscription.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import type { SubscriptionQuery } from '@affine/graphql'; -import { SubscriptionPlan, subscriptionQuery } from '@affine/graphql'; - -import { useServerFeatures } from './affine/use-server-config'; -import { useQuery } from './use-query'; - -export type Subscription = NonNullable< - NonNullable['subscriptions'][number] ->; - -export type SubscriptionMutator = (update?: Partial) => void; - -const selector = (data: SubscriptionQuery, plan: SubscriptionPlan) => - (data.currentUser?.subscriptions ?? []).find(p => p.plan === plan); - -export const useUserSubscription = ( - plan: SubscriptionPlan = SubscriptionPlan.Pro -) => { - const { payment: hasPaymentFeature } = useServerFeatures(); - const { data, mutate } = useQuery( - hasPaymentFeature ? { query: subscriptionQuery } : undefined - ); - - const set: SubscriptionMutator = useAsyncCallback( - async (update?: Partial) => { - await mutate(prev => { - if (!update || !prev?.currentUser?.subscriptions?.length) { - return; - } - - return { - currentUser: { - subscriptions: (prev.currentUser?.subscriptions ?? []).map(sub => - sub.plan !== plan ? sub : { ...sub, ...update } - ), - }, - }; - }); - }, - [mutate, plan] - ); - - if (!hasPaymentFeature) { - return [null, () => {}] as const; - } - - return [selector(data, plan), set] as const; -}; diff --git a/packages/frontend/core/src/hooks/use-workspace-blob.ts b/packages/frontend/core/src/hooks/use-workspace-blob.ts index 3b234c76f8..35b94bce0b 100644 --- a/packages/frontend/core/src/hooks/use-workspace-blob.ts +++ b/packages/frontend/core/src/hooks/use-workspace-blob.ts @@ -1,12 +1,12 @@ import type { WorkspaceMetadata } from '@toeverything/infra'; -import { useService, WorkspaceManager } from '@toeverything/infra'; +import { useService, WorkspacesService } from '@toeverything/infra'; import { useEffect, useState } from 'react'; export function useWorkspaceBlobObjectUrl( meta?: WorkspaceMetadata, blobKey?: string | null ) { - const workspaceManager = useService(WorkspaceManager); + const workspacesService = useService(WorkspacesService); const [blob, setBlob] = useState(undefined); @@ -17,7 +17,7 @@ export function useWorkspaceBlobObjectUrl( } let canceled = false; let objectUrl: string = ''; - workspaceManager + workspacesService .getWorkspaceBlob(meta, blobKey) .then(blob => { if (blob && !canceled) { @@ -33,7 +33,7 @@ export function useWorkspaceBlobObjectUrl( canceled = true; URL.revokeObjectURL(objectUrl); }; - }, [meta, blobKey, workspaceManager]); + }, [meta, blobKey, workspacesService]); return blob; } diff --git a/packages/frontend/core/src/hooks/use-workspace-info.ts b/packages/frontend/core/src/hooks/use-workspace-info.ts index 4fb04fda73..bf487a296e 100644 --- a/packages/frontend/core/src/hooks/use-workspace-info.ts +++ b/packages/frontend/core/src/hooks/use-workspace-info.ts @@ -1,26 +1,29 @@ import type { WorkspaceMetadata } from '@toeverything/infra'; -import { useService, WorkspaceManager } from '@toeverything/infra'; +import { + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; import { useEffect, useState } from 'react'; import { useWorkspaceBlobObjectUrl } from './use-workspace-blob'; export function useWorkspaceInfo(meta: WorkspaceMetadata) { - const workspaceManager = useService(WorkspaceManager); + const workspacesService = useService(WorkspacesService); - const [information, setInformation] = useState( - () => workspaceManager.list.getInformation(meta).info + const [profile, setProfile] = useState(() => + workspacesService.getProfile(meta) ); useEffect(() => { - const information = workspaceManager.list.getInformation(meta); + const profile = workspacesService.getProfile(meta); - setInformation(information.info); - return information.onUpdated.on(info => { - setInformation(info); - }).dispose; - }, [meta, workspaceManager]); + profile.revalidate(); - return information; + setProfile(profile); + }, [meta, workspacesService]); + + return useLiveData(profile.profile$); } export function useWorkspaceName(meta: WorkspaceMetadata) { diff --git a/packages/frontend/core/src/hooks/use-workspace-quota.ts b/packages/frontend/core/src/hooks/use-workspace-quota.ts deleted file mode 100644 index aea5670ab1..0000000000 --- a/packages/frontend/core/src/hooks/use-workspace-quota.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { workspaceQuotaQuery } from '@affine/graphql'; -import bytes from 'bytes'; -import { useCallback } from 'react'; - -import { useQuery } from './use-query'; - -export const useWorkspaceQuota = (workspaceId: string) => { - const { data } = useQuery({ - query: workspaceQuotaQuery, - variables: { - id: workspaceId, - }, - }); - - const changeToHumanReadable = useCallback((value: string | number) => { - return bytes.format(bytes.parse(value)); - }, []); - - const quotaData = data.workspace.quota; - const humanReadableUsedSize = changeToHumanReadable( - quotaData.usedSize.toString() - ); - - return { - blobLimit: quotaData.blobLimit, - storageQuota: quotaData.storageQuota, - usedSize: quotaData.usedSize, - humanReadable: { - name: quotaData.humanReadable.name, - blobLimit: quotaData.humanReadable.blobLimit, - storageQuota: quotaData.humanReadable.storageQuota, - usedSize: humanReadableUsedSize, - }, - }; -}; diff --git a/packages/frontend/core/src/hooks/use-workspace-status.ts b/packages/frontend/core/src/hooks/use-workspace-status.ts deleted file mode 100644 index 5a885afa49..0000000000 --- a/packages/frontend/core/src/hooks/use-workspace-status.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Workspace, WorkspaceStatus } from '@toeverything/infra'; -import { useEffect, useState } from 'react'; - -export function useWorkspaceStatus< - Selector extends ((status: WorkspaceStatus) => any) | undefined | null, - Status = Selector extends (status: WorkspaceStatus) => any - ? ReturnType - : WorkspaceStatus, ->(workspace?: Workspace | null, selector?: Selector): Status | null { - // avoid re-render when selector is changed - const [cachedSelector] = useState(() => selector); - - const [status, setStatus] = useState(() => { - if (!workspace) { - return null; - } - return cachedSelector ? cachedSelector(workspace.status) : workspace.status; - }); - - useEffect(() => { - if (!workspace) { - setStatus(null); - return; - } - setStatus( - cachedSelector ? cachedSelector(workspace.status) : workspace.status - ); - return workspace.onStatusChange.on(status => { - requestAnimationFrame(() => { - setStatus(cachedSelector ? cachedSelector(status) : status); - }); - }).dispose; - }, [cachedSelector, workspace]); - - return status; -} diff --git a/packages/frontend/core/src/hooks/use-workspace.ts b/packages/frontend/core/src/hooks/use-workspace.ts index 3257f30208..0a69933303 100644 --- a/packages/frontend/core/src/hooks/use-workspace.ts +++ b/packages/frontend/core/src/hooks/use-workspace.ts @@ -1,12 +1,12 @@ import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; -import { useService, WorkspaceManager } from '@toeverything/infra'; +import { useService, WorkspacesService } from '@toeverything/infra'; import { useEffect, useState } from 'react'; /** * definitely be careful when using this hook, open workspace is a heavy operation */ export function useWorkspace(meta?: WorkspaceMetadata | null) { - const workspaceManager = useService(WorkspaceManager); + const workspaceManager = useService(WorkspacesService); const [workspace, setWorkspace] = useState(null); @@ -15,10 +15,10 @@ export function useWorkspace(meta?: WorkspaceMetadata | null) { setWorkspace(null); // set to null if meta is null or undefined return; } - const ref = workspaceManager.open(meta); + const ref = workspaceManager.open({ metadata: meta }); setWorkspace(ref.workspace); return () => { - ref.release(); + ref.dispose(); }; }, [meta, workspaceManager]); diff --git a/packages/frontend/core/src/index.tsx b/packages/frontend/core/src/index.tsx deleted file mode 100644 index b80c83af34..0000000000 --- a/packages/frontend/core/src/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './web'; diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 23f8a4d65c..b73d63447e 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -1,4 +1,3 @@ -import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status'; import { assertExists } from '@blocksuite/global/utils'; import { DndContext, @@ -9,16 +8,16 @@ import { useSensors, } from '@dnd-kit/core'; import { - PageRecordList, + DocsService, + GlobalContextService, useLiveData, useService, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { PropsWithChildren, ReactNode } from 'react'; import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { matchPath } from 'react-router-dom'; import { Map as YMap } from 'yjs'; import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms'; @@ -41,7 +40,6 @@ import { } from '../hooks/affine/use-global-dnd-helper'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands'; -import { Workbench } from '../modules/workbench'; import { AllWorkspaceModals, CurrentWorkspaceModals, @@ -70,13 +68,12 @@ export const QuickSearch = () => { [setOpenQuickSearchModalAtom] ); - const workbench = useService(Workbench); - const currentPath = useLiveData(workbench.location$.map(l => l.pathname)); - const pageRecordList = useService(PageRecordList); - const currentPathId = matchPath('/:pageId', currentPath)?.params.pageId; - // TODO: getting pageid from route is fragile, get current page from context + const docRecordList = useService(DocsService).list; + const currentDocId = useLiveData( + useService(GlobalContextService).globalContext.docId.$ + ); const currentPage = useLiveData( - currentPathId ? pageRecordList.record$(currentPathId) : null + currentDocId ? docRecordList.doc$(currentDocId) : null ); const pageMeta = useLiveData(currentPage?.meta$); @@ -109,10 +106,13 @@ export const WorkspaceLayout = function WorkspaceLayout({ }; export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const { openPage } = useNavigateHelper(); const pageHelper = usePageHelper(currentWorkspace.docCollection); + const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$); + const needUpgrade = useLiveData(currentWorkspace.upgrade.needUpgrade$); + useRegisterWorkspaceCommands(); useEffect(() => { @@ -166,7 +166,6 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { const { handleDragEnd } = useGlobalDNDHelper(); const { appSettings } = useAppSettingHelper(); - const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade); return ( <> @@ -192,11 +191,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { - {upgradeStatus?.needUpgrade || upgradeStatus?.upgrading ? ( - - ) : ( - children - )} + {needUpgrade || upgrading ? : children} diff --git a/packages/frontend/core/src/modules/cloud/entities/server-config.ts b/packages/frontend/core/src/modules/cloud/entities/server-config.ts new file mode 100644 index 0000000000..eb6f6f419f --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/server-config.ts @@ -0,0 +1,70 @@ +import type { + OauthProvidersQuery, + ServerConfigQuery, + ServerFeature, +} from '@affine/graphql'; +import { + backoffRetry, + effect, + Entity, + fromPromise, + LiveData, +} from '@toeverything/infra'; +import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; + +import type { ServerConfigStore } from '../stores/server-config'; + +type LowercaseServerFeature = Lowercase; +type ServerFeatureRecord = { + [key in LowercaseServerFeature]: boolean; +}; + +export type ServerConfigType = ServerConfigQuery['serverConfig'] & + OauthProvidersQuery['serverConfig']; + +export class ServerConfig extends Entity { + readonly config$ = new LiveData(null); + + readonly features$ = this.config$.map(config => { + return config + ? Array.from(new Set(config.features)).reduce((acc, cur) => { + acc[cur.toLowerCase() as LowercaseServerFeature] = true; + return acc; + }, {} as ServerFeatureRecord) + : null; + }); + + readonly credentialsRequirement$ = this.config$.map(config => { + return config ? config.credentialsRequirement : null; + }); + + constructor(private readonly store: ServerConfigStore) { + super(); + } + + revalidate = effect( + exhaustMap(() => { + return fromPromise(signal => + this.store.fetchServerConfig(signal) + ).pipe( + backoffRetry({ + count: Infinity, + }), + mergeMap(config => { + this.config$.next(config); + return EMPTY; + }) + ); + }) + ); + + revalidateIfNeeded = () => { + if (!this.config$.value) { + this.revalidate(); + } + }; + + override dispose(): void { + this.revalidate.unsubscribe(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/entities/session.ts b/packages/frontend/core/src/modules/cloud/entities/session.ts new file mode 100644 index 0000000000..83c3112364 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/session.ts @@ -0,0 +1,134 @@ +import { + backoffRetry, + effect, + Entity, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; + +import { validateAndReduceImage } from '../../../utils/reduce-image'; +import type { AccountProfile, AuthStore } from '../stores/auth'; + +export interface AuthSessionInfo { + account: AuthAccountInfo; +} + +export interface AuthAccountInfo { + id: string; + label: string; + email?: string; + info?: AccountProfile | null; + avatar?: string | null; +} + +export interface AuthSessionUnauthenticated { + status: 'unauthenticated'; +} + +export interface AuthSessionAuthenticated { + status: 'authenticated'; + session: AuthSessionInfo; +} + +export class AuthSession extends Entity { + id = 'affine-cloud' as const; + + session$: LiveData = + LiveData.from(this.store.watchCachedAuthSession(), null).map(session => + session + ? { + status: 'authenticated', + session: session as AuthSessionInfo, + } + : { + status: 'unauthenticated', + } + ); + + status$ = this.session$.map(session => session.status); + + account$ = this.session$.map(session => + session.status === 'authenticated' ? session.session.account : null + ); + + waitForAuthenticated = (signal?: AbortSignal) => + this.session$.waitFor( + session => session.status === 'authenticated', + signal + ) as Promise; + + isRevalidating$ = new LiveData(false); + + constructor(private readonly store: AuthStore) { + super(); + } + + revalidate = effect( + exhaustMap(() => + fromPromise(this.getSession()).pipe( + backoffRetry({ + count: Infinity, + }), + mergeMap(sessionInfo => { + this.store.setCachedAuthSession(sessionInfo); + return EMPTY; + }), + onStart(() => { + this.isRevalidating$.next(true); + }), + onComplete(() => { + this.isRevalidating$.next(false); + }) + ) + ) + ); + + private async getSession(): Promise { + const session = await this.store.fetchSession(); + + if (session?.user) { + const account = { + id: session.user.id, + email: session.user.email, + label: session.user.name, + avatar: session.user.avatarUrl, + info: session.user, + }; + const result = { + account, + }; + return result; + } else { + return null; + } + } + + async waitForRevalidation() { + this.revalidate(); + await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating); + } + + async removeAvatar() { + await this.store.removeAvatar(); + await this.waitForRevalidation(); + } + + async uploadAvatar(file: File) { + const reducedFile = await validateAndReduceImage(file); + await this.store.uploadAvatar(reducedFile); + await this.waitForRevalidation(); + } + + async updateLabel(label: string) { + await this.store.updateLabel(label); + console.log('updateLabel'); + await this.waitForRevalidation(); + } + + override dispose(): void { + this.revalidate.unsubscribe(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts new file mode 100644 index 0000000000..aafccf65a8 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts @@ -0,0 +1,69 @@ +import type { PricesQuery } from '@affine/graphql'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + fromPromise, + LiveData, + mapInto, + onComplete, + onStart, +} from '@toeverything/infra'; +import { exhaustMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../error'; +import type { ServerConfigService } from '../services/server-config'; +import type { SubscriptionStore } from '../stores/subscription'; + +export class SubscriptionPrices extends Entity { + prices$ = new LiveData(null); + isRevalidating$ = new LiveData(false); + error$ = new LiveData(null); + + proPrice$ = this.prices$.map(prices => + prices ? prices.find(price => price.plan === 'Pro') : null + ); + aiPrice$ = this.prices$.map(prices => + prices ? prices.find(price => price.plan === 'AI') : null + ); + + constructor( + private readonly serverConfigService: ServerConfigService, + private readonly store: SubscriptionStore + ) { + super(); + } + + revalidate = effect( + exhaustMap(() => { + return fromPromise(async signal => { + // ensure server config is loaded + this.serverConfigService.serverConfig.revalidateIfNeeded(); + + const serverConfig = + await this.serverConfigService.serverConfig.features$.waitForNonNull( + signal + ); + + if (!serverConfig.payment) { + // No payment feature, no subscription + return []; + } + return this.store.fetchSubscriptionPrices(signal); + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mapInto(this.prices$), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ); + }) + ); +} diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts new file mode 100644 index 0000000000..8b3eb61e08 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts @@ -0,0 +1,176 @@ +import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql'; +import { SubscriptionPlan } from '@affine/graphql'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + exhaustMapSwitchUntilChanged, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { EMPTY, map, mergeMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../error'; +import type { AuthService } from '../services/auth'; +import type { ServerConfigService } from '../services/server-config'; +import type { SubscriptionStore } from '../stores/subscription'; + +export type SubscriptionType = NonNullable< + SubscriptionQuery['currentUser'] +>['subscriptions'][number]; + +export class Subscription extends Entity { + // undefined means no user, null means loading + subscription$ = new LiveData(null); + isRevalidating$ = new LiveData(false); + error$ = new LiveData(null); + + /** + * Primary subscription is the subscription that is not AI. + */ + primary$ = this.subscription$.map(subscriptions => + subscriptions + ? subscriptions.find(sub => sub.plan !== SubscriptionPlan.AI) + : null + ); + isFree$ = this.subscription$.map(subscriptions => + subscriptions + ? subscriptions.some(sub => sub.plan === SubscriptionPlan.Free) + : null + ); + isPro$ = this.subscription$.map(subscriptions => + subscriptions + ? subscriptions.some(sub => sub.plan === SubscriptionPlan.Pro) + : null + ); + isSelfHosted$ = this.subscription$.map(subscriptions => + subscriptions + ? subscriptions.some(sub => sub.plan === SubscriptionPlan.SelfHosted) + : null + ); + ai$ = this.subscription$.map(subscriptions => + subscriptions + ? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI) + : null + ); + + constructor( + private readonly authService: AuthService, + private readonly serverConfigService: ServerConfigService, + private readonly store: SubscriptionStore + ) { + super(); + } + + async resumeSubscription(idempotencyKey: string, plan?: SubscriptionPlan) { + await this.store.mutateResumeSubscription(idempotencyKey, plan); + await this.waitForRevalidation(); + } + + async cancelSubscription(idempotencyKey: string, plan?: SubscriptionPlan) { + await this.store.mutateCancelSubscription(idempotencyKey, plan); + await this.waitForRevalidation(); + } + + async setSubscriptionRecurring( + idempotencyKey: string, + recurring: SubscriptionRecurring, + plan?: SubscriptionPlan + ) { + await this.store.setSubscriptionRecurring(idempotencyKey, recurring, plan); + await this.waitForRevalidation(); + } + + async waitForRevalidation() { + this.revalidate(); + await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating); + } + + revalidate = effect( + map(() => ({ + accountId: this.authService.session.account$.value?.id, + })), + exhaustMapSwitchUntilChanged( + (a, b) => a.accountId === b.accountId, + ({ accountId }) => { + return fromPromise(async signal => { + if (!accountId) { + return undefined; // no subscription if no user + } + + // ensure server config is loaded + this.serverConfigService.serverConfig.revalidateIfNeeded(); + + const serverConfig = + await this.serverConfigService.serverConfig.features$.waitForNonNull( + signal + ); + + if (!serverConfig.payment) { + // No payment feature, no subscription + return { + userId: accountId, + subscriptions: [], + }; + } + const { userId, subscriptions } = + await this.store.fetchSubscriptions(signal); + if (userId !== accountId) { + // The user has changed, ignore the result + this.authService.session.revalidate(); + await this.authService.session.waitForRevalidation(); + return null; + } + return { + userId: userId, + subscriptions: subscriptions, + }; + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mergeMap(data => { + if (data) { + this.store.setCachedSubscriptions( + data.userId, + data.subscriptions + ); + this.subscription$.next(data.subscriptions); + } else { + this.subscription$.next(undefined); + } + return EMPTY; + }), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ); + }, + ({ accountId }) => { + this.reset(); + if (!accountId) { + this.subscription$.next(null); + } else { + this.subscription$.next(this.store.getCachedSubscriptions(accountId)); + } + } + ) + ); + + reset() { + this.subscription$.next(null); + this.isRevalidating$.next(false); + this.error$.next(null); + } + + override dispose(): void { + this.revalidate.unsubscribe(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/entities/user-feature.ts b/packages/frontend/core/src/modules/cloud/entities/user-feature.ts new file mode 100644 index 0000000000..e3fdf30f8a --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/user-feature.ts @@ -0,0 +1,94 @@ +import { FeatureType } from '@affine/graphql'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + exhaustMapSwitchUntilChanged, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { EMPTY, map, mergeMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../error'; +import type { AuthService } from '../services/auth'; +import type { UserFeatureStore } from '../stores/user-feature'; + +export class UserFeature extends Entity { + // undefined means no user, null means loading + features$ = new LiveData(null); + isEarlyAccess$ = this.features$.map(features => + features === null + ? null + : features?.some(f => f === FeatureType.EarlyAccess) + ); + + isRevalidating$ = new LiveData(false); + error$ = new LiveData(null); + + constructor( + private readonly authService: AuthService, + private readonly store: UserFeatureStore + ) { + super(); + } + + revalidate = effect( + map(() => ({ + accountId: this.authService.session.account$.value?.id, + })), + exhaustMapSwitchUntilChanged( + (a, b) => a.accountId === b.accountId, + ({ accountId }) => { + return fromPromise(async signal => { + if (!accountId) { + return; // no feature if no user + } + + const { userId, features } = await this.store.getUserFeatures(signal); + if (userId !== accountId) { + // The user has changed, ignore the result + this.authService.session.revalidate(); + await this.authService.session.waitForRevalidation(); + return; + } + return { + userId: userId, + features: features, + }; + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mergeMap(data => { + if (data) { + this.features$.next(data.features); + } else { + this.features$.next(null); + } + return EMPTY; + }), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ); + }, + () => { + // Reset the state when the user is changed + this.reset(); + } + ) + ); + + reset() { + this.features$.next(null); + this.error$.next(null); + this.isRevalidating$.next(false); + } +} diff --git a/packages/frontend/core/src/modules/cloud/entities/user-quota.ts b/packages/frontend/core/src/modules/cloud/entities/user-quota.ts new file mode 100644 index 0000000000..3294d9abf2 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/user-quota.ts @@ -0,0 +1,131 @@ +import type { QuotaQuery } from '@affine/graphql'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + exhaustMapSwitchUntilChanged, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; +import bytes from 'bytes'; +import { EMPTY, map, mergeMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../error'; +import type { AuthService } from '../services/auth'; +import type { UserQuotaStore } from '../stores/user-quota'; + +export class UserQuota extends Entity { + quota$ = new LiveData['quota']>(null); + /** Used storage in bytes */ + used$ = new LiveData(null); + /** Formatted used storage */ + usedFormatted$ = this.used$.map(used => + used !== null ? bytes.format(used) : null + ); + /** Maximum storage limit in bytes */ + max$ = this.quota$.map(quota => (quota ? quota.storageQuota : null)); + /** Maximum storage limit formatted */ + maxFormatted$ = this.max$.map(max => (max ? bytes.format(max) : null)); + + aiActionLimit$ = new LiveData(null); + aiActionUsed$ = new LiveData(null); + + /** Percentage of storage used */ + percent$ = LiveData.computed(get => { + const max = get(this.max$); + const used = get(this.used$); + if (max === null || used === null) { + return null; + } + return Math.min( + 100, + Math.max(0.5, Number(((used / max) * 100).toFixed(4))) + ); + }); + + color$ = this.percent$.map(percent => + percent !== null + ? percent > 80 + ? cssVar('errorColor') + : cssVar('processingColor') + : null + ); + + isRevalidating$ = new LiveData(false); + error$ = new LiveData(null); + + constructor( + private readonly authService: AuthService, + private readonly store: UserQuotaStore + ) { + super(); + } + + revalidate = effect( + map(() => ({ + accountId: this.authService.session.account$.value?.id, + })), + exhaustMapSwitchUntilChanged( + (a, b) => a.accountId === b.accountId, + ({ accountId }) => + fromPromise(async signal => { + if (!accountId) { + return; // no quota if no user + } + const { quota, aiQuota, used } = + await this.store.fetchUserQuota(signal); + + return { quota, aiQuota, used }; + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mergeMap(data => { + if (data) { + const { aiQuota, quota, used } = data; + this.quota$.next(quota); + this.used$.next(used); + this.aiActionUsed$.next(aiQuota.used); + this.aiActionLimit$.next( + aiQuota.limit === null ? 'unlimited' : aiQuota.limit + ); // fix me: unlimited status + } else { + this.quota$.next(null); + this.used$.next(null); + this.aiActionUsed$.next(null); + this.aiActionLimit$.next(null); + } + return EMPTY; + }), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ), + () => { + // Reset the state when the user is changed + this.reset(); + } + ) + ); + + reset() { + this.quota$.next(null); + this.used$.next(null); + this.aiActionUsed$.next(null); + this.aiActionLimit$.next(null); + this.error$.next(null); + this.isRevalidating$.next(false); + } + + override dispose(): void { + this.revalidate.unsubscribe(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/error.ts b/packages/frontend/core/src/modules/cloud/error.ts new file mode 100644 index 0000000000..b344a85144 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/error.ts @@ -0,0 +1,21 @@ +export class NetworkError extends Error { + constructor(public readonly originError: Error) { + super(`Network error: ${originError.message}`); + this.stack = originError.stack; + } +} + +export function isNetworkError(error: Error): error is NetworkError { + return error instanceof NetworkError; +} + +export class BackendError extends Error { + constructor(public readonly originError: Error) { + super(`Server error: ${originError.message}`); + this.stack = originError.stack; + } +} + +export function isBackendError(error: Error): error is BackendError { + return error instanceof BackendError; +} diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts new file mode 100644 index 0000000000..0bb6e27668 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -0,0 +1,64 @@ +export type { AuthAccountInfo } from './entities/session'; +export { + BackendError, + isBackendError, + isNetworkError, + NetworkError, +} from './error'; +export { AccountChanged, AuthService } from './services/auth'; +export { FetchService } from './services/fetch'; +export { GraphQLService } from './services/graphql'; +export { ServerConfigService } from './services/server-config'; +export { SubscriptionService } from './services/subscription'; +export { UserFeatureService } from './services/user-feature'; +export { UserQuotaService } from './services/user-quota'; +export { WebSocketService } from './services/websocket'; + +import { + type Framework, + GlobalCacheService, + GlobalStateService, +} from '@toeverything/infra'; + +import { ServerConfig } from './entities/server-config'; +import { AuthSession } from './entities/session'; +import { Subscription } from './entities/subscription'; +import { SubscriptionPrices } from './entities/subscription-prices'; +import { UserFeature } from './entities/user-feature'; +import { UserQuota } from './entities/user-quota'; +import { AuthService } from './services/auth'; +import { FetchService } from './services/fetch'; +import { GraphQLService } from './services/graphql'; +import { ServerConfigService } from './services/server-config'; +import { SubscriptionService } from './services/subscription'; +import { UserFeatureService } from './services/user-feature'; +import { UserQuotaService } from './services/user-quota'; +import { WebSocketService } from './services/websocket'; +import { AuthStore } from './stores/auth'; +import { ServerConfigStore } from './stores/server-config'; +import { SubscriptionStore } from './stores/subscription'; +import { UserFeatureStore } from './stores/user-feature'; +import { UserQuotaStore } from './stores/user-quota'; + +export function configureCloudModule(framework: Framework) { + framework + .service(FetchService) + .service(GraphQLService, [FetchService]) + .service(WebSocketService) + .service(ServerConfigService) + .entity(ServerConfig, [ServerConfigStore]) + .store(ServerConfigStore, [GraphQLService]) + .service(AuthService, [FetchService, AuthStore]) + .store(AuthStore, [FetchService, GraphQLService, GlobalStateService]) + .entity(AuthSession, [AuthStore]) + .service(SubscriptionService, [SubscriptionStore]) + .store(SubscriptionStore, [GraphQLService, GlobalCacheService]) + .entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore]) + .entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore]) + .service(UserQuotaService) + .store(UserQuotaStore, [GraphQLService]) + .entity(UserQuota, [AuthService, UserQuotaStore]) + .service(UserFeatureService) + .entity(UserFeature, [AuthService, UserFeatureStore]) + .store(UserFeatureStore, [GraphQLService]); +} diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts new file mode 100644 index 0000000000..49248abc17 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -0,0 +1,161 @@ +import { apis } from '@affine/electron-api'; +import type { OAuthProviderType } from '@affine/graphql'; +import { + ApplicationFocused, + ApplicationStarted, + createEvent, + OnEvent, + Service, +} from '@toeverything/infra'; +import { distinctUntilChanged, map, skip } from 'rxjs'; + +import { type AuthAccountInfo, AuthSession } from '../entities/session'; +import type { AuthStore } from '../stores/auth'; +import type { FetchService } from './fetch'; + +// Emit when account changed +export const AccountChanged = createEvent( + 'AccountChanged' +); + +export const AccountLoggedIn = createEvent('AccountLoggedIn'); + +export const AccountLoggedOut = + createEvent('AccountLoggedOut'); + +@OnEvent(ApplicationStarted, e => e.onApplicationStart) +@OnEvent(ApplicationFocused, e => e.onApplicationFocused) +export class AuthService extends Service { + session = this.framework.createEntity(AuthSession); + + constructor( + private readonly fetchService: FetchService, + private readonly store: AuthStore + ) { + super(); + + this.session.account$ + .pipe( + map(a => ({ + id: a?.id, + account: a, + })), + distinctUntilChanged((a, b) => a.id === b.id), // only emit when the value changes + skip(1) // skip the initial value + ) + .subscribe(({ account }) => { + if (account === null) { + this.eventBus.emit(AccountLoggedOut, account); + } else { + this.eventBus.emit(AccountLoggedIn, account); + } + this.eventBus.emit(AccountChanged, account); + }); + } + + private onApplicationStart() { + this.session.revalidate(); + } + + private onApplicationFocused() { + this.session.revalidate(); + } + + async sendEmailMagicLink( + email: string, + verifyToken: string, + challenge?: string + ) { + const searchParams = new URLSearchParams(); + if (challenge) { + searchParams.set('challenge', challenge); + } + searchParams.set('token', verifyToken); + const redirectUri = new URL(location.href); + if (environment.isDesktop) { + redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect'); + } + searchParams.set('redirect_uri', redirectUri.toString()); + + const res = await this.fetchService.fetch( + '/api/auth/sign-in?' + searchParams.toString(), + { + method: 'POST', + body: JSON.stringify({ email }), + headers: { + 'content-type': 'application/json', + }, + } + ); + if (!res?.ok) { + throw new Error('Failed to send email'); + } + } + + async signInOauth(provider: OAuthProviderType) { + if (environment.isDesktop) { + await apis?.ui.openExternal( + `${ + runtimeConfig.serverUrlPrefix + }/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri( + '/open-app/signin-redirect' + )}` + ); + } else { + location.href = `${ + runtimeConfig.serverUrlPrefix + }/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent( + location.pathname + )}`; + } + + return; + } + + async signInPassword(credential: { email: string; password: string }) { + const searchParams = new URLSearchParams(); + const redirectUri = new URL(location.href); + if (environment.isDesktop) { + redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect'); + } + searchParams.set('redirect_uri', redirectUri.toString()); + + const res = await this.fetchService.fetch( + '/api/auth/sign-in?' + searchParams.toString(), + { + method: 'POST', + body: JSON.stringify(credential), + headers: { + 'content-type': 'application/json', + }, + } + ); + if (!res.ok) { + throw new Error('Failed to sign in'); + } + this.session.revalidate(); + } + + async signOut() { + await this.fetchService.fetch('/api/auth/sign-out'); + this.store.setCachedAuthSession(null); + this.session.revalidate(); + } + + private buildRedirectUri(callbackUrl: string) { + const params: string[][] = []; + if (environment.isDesktop && window.appInfo.schema) { + params.push(['schema', window.appInfo.schema]); + } + const query = + params.length > 0 + ? '?' + + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') + : ''; + return callbackUrl + query; + } + + checkUserByEmail(email: string) { + return this.store.checkUserByEmail(email); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/fetch.ts b/packages/frontend/core/src/modules/cloud/services/fetch.ts new file mode 100644 index 0000000000..2ae70358ad --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/fetch.ts @@ -0,0 +1,84 @@ +import { DebugLogger } from '@affine/debug'; +import { fromPromise, Service } from '@toeverything/infra'; + +import { BackendError, NetworkError } from '../error'; + +export function getAffineCloudBaseUrl(): string { + if (environment.isDesktop) { + return runtimeConfig.serverUrlPrefix; + } + const { protocol, hostname, port } = window.location; + return `${protocol}//${hostname}${port ? `:${port}` : ''}`; +} + +const logger = new DebugLogger('affine:fetch'); + +export type FetchInit = RequestInit & { timeout?: number }; + +export class FetchService extends Service { + rxFetch = ( + input: string, + init?: RequestInit & { + // https://github.com/microsoft/TypeScript/issues/54472 + priority?: 'auto' | 'low' | 'high'; + } & { + traceEvent?: string; + } + ) => { + return fromPromise(signal => { + return this.fetch(input, { signal, ...init }); + }); + }; + + /** + * fetch with custom custom timeout and error handling. + */ + fetch = async (input: string, init?: FetchInit): Promise => { + logger.debug('fetch', input); + const externalSignal = init?.signal; + if (externalSignal?.aborted) { + throw externalSignal.reason; + } + const abortController = new AbortController(); + externalSignal?.addEventListener('abort', () => { + abortController.abort(); + }); + + const timeout = init?.timeout ?? 15000; + const timeoutId = setTimeout(() => { + abortController.abort('timeout'); + }, timeout); + + const res = await fetch(new URL(input, getAffineCloudBaseUrl()), { + ...init, + signal: abortController.signal, + }).catch(err => { + logger.debug('network error', err); + throw new NetworkError(err); + }); + clearTimeout(timeoutId); + if (res.status === 504) { + const error = new Error('Gateway Timeout'); + logger.debug('network error', error); + throw new NetworkError(error); + } + if (!res.ok) { + logger.warn( + 'backend error', + new Error(`${res.status} ${res.statusText}`) + ); + let reason: string | any = ''; + if (res.headers.get('Content-Type')?.includes('application/json')) { + try { + reason = await res.json(); + } catch (err) { + // ignore + } + } + throw new BackendError( + new Error(`${res.status} ${res.statusText}`, reason) + ); + } + return res; + }; +} diff --git a/packages/frontend/core/src/modules/cloud/services/graphql.ts b/packages/frontend/core/src/modules/cloud/services/graphql.ts new file mode 100644 index 0000000000..e2789a7746 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/graphql.ts @@ -0,0 +1,53 @@ +import { + gqlFetcherFactory, + GraphQLError, + type GraphQLQuery, + type QueryOptions, + type QueryResponse, +} from '@affine/graphql'; +import { fromPromise, Service } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; + +import { BackendError } from '../error'; +import { AuthService } from './auth'; +import type { FetchService } from './fetch'; + +export class GraphQLService extends Service { + constructor(private readonly fetcher: FetchService) { + super(); + } + + private readonly rawGql = gqlFetcherFactory('/graphql', this.fetcher.fetch); + + rxGql = ( + options: QueryOptions + ): Observable> => { + return fromPromise(signal => { + return this.gql({ + ...options, + context: { + signal, + ...options.context, + }, + } as any); + }); + }; + + gql = async ( + options: QueryOptions + ): Promise> => { + try { + return await this.rawGql(options); + } catch (err) { + if (err instanceof Array) { + for (const error of err) { + if (error instanceof GraphQLError && error.extensions?.code === 403) { + this.framework.get(AuthService).session.revalidate(); + } + } + throw new BackendError(new Error('Graphql Error')); + } + throw err; + } + }; +} diff --git a/packages/frontend/core/src/modules/cloud/services/server-config.ts b/packages/frontend/core/src/modules/cloud/services/server-config.ts new file mode 100644 index 0000000000..5555fdbc26 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/server-config.ts @@ -0,0 +1,12 @@ +import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra'; + +import { ServerConfig } from '../entities/server-config'; + +@OnEvent(ApplicationStarted, e => e.onApplicationStart) +export class ServerConfigService extends Service { + serverConfig = this.framework.createEntity(ServerConfig); + + private onApplicationStart() { + this.serverConfig.revalidate(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/subscription.ts b/packages/frontend/core/src/modules/cloud/services/subscription.ts new file mode 100644 index 0000000000..2066b0d72e --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/subscription.ts @@ -0,0 +1,25 @@ +import { type CreateCheckoutSessionInput } from '@affine/graphql'; +import { OnEvent, Service } from '@toeverything/infra'; + +import { Subscription } from '../entities/subscription'; +import { SubscriptionPrices } from '../entities/subscription-prices'; +import type { SubscriptionStore } from '../stores/subscription'; +import { AccountChanged } from './auth'; + +@OnEvent(AccountChanged, e => e.onAccountChanged) +export class SubscriptionService extends Service { + subscription = this.framework.createEntity(Subscription); + prices = this.framework.createEntity(SubscriptionPrices); + + constructor(private readonly store: SubscriptionStore) { + super(); + } + + async createCheckoutSession(input: CreateCheckoutSessionInput) { + return await this.store.createCheckoutSession(input); + } + + private onAccountChanged() { + this.subscription.revalidate(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/user-feature.ts b/packages/frontend/core/src/modules/cloud/services/user-feature.ts new file mode 100644 index 0000000000..5abbdbbde9 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/user-feature.ts @@ -0,0 +1,13 @@ +import { OnEvent, Service } from '@toeverything/infra'; + +import { UserFeature } from '../entities/user-feature'; +import { AccountChanged } from './auth'; + +@OnEvent(AccountChanged, e => e.onAccountChanged) +export class UserFeatureService extends Service { + userFeature = this.framework.createEntity(UserFeature); + + private onAccountChanged() { + this.userFeature.revalidate(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/user-quota.ts b/packages/frontend/core/src/modules/cloud/services/user-quota.ts new file mode 100644 index 0000000000..585b7c6f87 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/user-quota.ts @@ -0,0 +1,13 @@ +import { OnEvent, Service } from '@toeverything/infra'; + +import { UserQuota } from '../entities/user-quota'; +import { AccountChanged } from './auth'; + +@OnEvent(AccountChanged, e => e.onAccountChanged) +export class UserQuotaService extends Service { + quota = this.framework.createEntity(UserQuota); + + private onAccountChanged() { + this.quota.revalidate(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/websocket.ts b/packages/frontend/core/src/modules/cloud/services/websocket.ts new file mode 100644 index 0000000000..7c9bb1515e --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/websocket.ts @@ -0,0 +1,37 @@ +import { OnEvent, Service } from '@toeverything/infra'; +import type { Socket } from 'socket.io-client'; +import { Manager } from 'socket.io-client'; + +import { getAffineCloudBaseUrl } from '../services/fetch'; +import { AccountChanged } from './auth'; + +@OnEvent(AccountChanged, e => e.reconnect) +export class WebSocketService extends Service { + ioManager: Manager = new Manager(`${getAffineCloudBaseUrl()}/`, { + autoConnect: false, + transports: ['websocket'], + secure: location.protocol === 'https:', + }); + sockets: Set = new Set(); + + constructor() { + super(); + } + + newSocket(): Socket { + const socket = this.ioManager.socket('/'); + this.sockets.add(socket); + + return socket; + } + + reconnect(): void { + for (const socket of this.sockets) { + socket.disconnect(); + } + + for (const socket of this.sockets) { + socket.connect(); + } + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts new file mode 100644 index 0000000000..c85ce2fcb9 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -0,0 +1,97 @@ +import { + getUserQuery, + removeAvatarMutation, + updateUserProfileMutation, + uploadAvatarMutation, +} from '@affine/graphql'; +import type { GlobalStateService } from '@toeverything/infra'; +import { Store } from '@toeverything/infra'; + +import type { AuthSessionInfo } from '../entities/session'; +import type { FetchService } from '../services/fetch'; +import type { GraphQLService } from '../services/graphql'; + +export interface AccountProfile { + id: string; + email: string; + name: string; + hasPassword: boolean; + avatarUrl: string | null; + emailVerified: string | null; +} + +export class AuthStore extends Store { + constructor( + private readonly fetchService: FetchService, + private readonly gqlService: GraphQLService, + private readonly globalStateService: GlobalStateService + ) { + super(); + } + + watchCachedAuthSession() { + return this.globalStateService.globalState.watch( + 'affine-cloud-auth' + ); + } + + setCachedAuthSession(session: AuthSessionInfo | null) { + this.globalStateService.globalState.set('affine-cloud-auth', session); + } + + async fetchSession() { + const url = `/api/auth/session`; + const options: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const res = await this.fetchService.fetch(url, options); + const data = (await res.json()) as { + user?: AccountProfile | null; + }; + if (!res.ok) + throw new Error('Get session fetch error: ' + JSON.stringify(data)); + return data; // Return null if data empty + } + + async uploadAvatar(file: File) { + await this.gqlService.gql({ + query: uploadAvatarMutation, + variables: { + avatar: file, + }, + }); + } + + async removeAvatar() { + await this.gqlService.gql({ + query: removeAvatarMutation, + }); + } + + async updateLabel(label: string) { + await this.gqlService.gql({ + query: updateUserProfileMutation, + variables: { + input: { + name: label, + }, + }, + }); + } + + async checkUserByEmail(email: string) { + const data = await this.gqlService.gql({ + query: getUserQuery, + variables: { + email, + }, + }); + return { + isExist: !!data.user, + hasPassword: !!data.user?.hasPassword, + }; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/server-config.ts b/packages/frontend/core/src/modules/cloud/stores/server-config.ts new file mode 100644 index 0000000000..508b4ca397 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/server-config.ts @@ -0,0 +1,39 @@ +import { + oauthProvidersQuery, + serverConfigQuery, + ServerFeature, +} from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { ServerConfigType } from '../entities/server-config'; +import type { GraphQLService } from '../services/graphql'; + +export class ServerConfigStore extends Store { + constructor(private readonly gqlService: GraphQLService) { + super(); + } + + async fetchServerConfig( + abortSignal?: AbortSignal + ): Promise { + const serverConfigData = await this.gqlService.gql({ + query: serverConfigQuery, + context: { + signal: abortSignal, + }, + }); + if (serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)) { + const oauthProvidersData = await this.gqlService.gql({ + query: oauthProvidersQuery, + context: { + signal: abortSignal, + }, + }); + return { + ...serverConfigData.serverConfig, + ...oauthProvidersData.serverConfig, + }; + } + return { ...serverConfigData.serverConfig, oauthProviders: [] }; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/subscription.ts b/packages/frontend/core/src/modules/cloud/stores/subscription.ts new file mode 100644 index 0000000000..8524ee0e9d --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/subscription.ts @@ -0,0 +1,130 @@ +import type { + CreateCheckoutSessionInput, + SubscriptionPlan, + SubscriptionRecurring, +} from '@affine/graphql'; +import { + cancelSubscriptionMutation, + createCheckoutSessionMutation, + pricesQuery, + resumeSubscriptionMutation, + subscriptionQuery, + updateSubscriptionMutation, +} from '@affine/graphql'; +import type { GlobalCacheService } from '@toeverything/infra'; +import { Store } from '@toeverything/infra'; + +import type { SubscriptionType } from '../entities/subscription'; +import type { GraphQLService } from '../services/graphql'; + +const SUBSCRIPTION_CACHE_KEY = 'subscription:'; + +export class SubscriptionStore extends Store { + constructor( + private readonly gqlService: GraphQLService, + private readonly globalCacheService: GlobalCacheService + ) { + super(); + } + + async fetchSubscriptions(abortSignal?: AbortSignal) { + const data = await this.gqlService.gql({ + query: subscriptionQuery, + context: { + signal: abortSignal, + }, + }); + + if (!data.currentUser) { + throw new Error('No logged in'); + } + + return { + userId: data.currentUser?.id, + subscriptions: data.currentUser?.subscriptions, + }; + } + + async mutateResumeSubscription( + idempotencyKey: string, + plan?: SubscriptionPlan, + abortSignal?: AbortSignal + ) { + const data = await this.gqlService.gql({ + query: resumeSubscriptionMutation, + variables: { + idempotencyKey, + plan, + }, + context: { + signal: abortSignal, + }, + }); + return data.resumeSubscription; + } + + async mutateCancelSubscription( + idempotencyKey: string, + plan?: SubscriptionPlan, + abortSignal?: AbortSignal + ) { + const data = await this.gqlService.gql({ + query: cancelSubscriptionMutation, + variables: { + idempotencyKey, + plan, + }, + context: { + signal: abortSignal, + }, + }); + return data.cancelSubscription; + } + + getCachedSubscriptions(userId: string) { + return this.globalCacheService.globalCache.get( + SUBSCRIPTION_CACHE_KEY + userId + ); + } + + setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) { + return this.globalCacheService.globalCache.set( + SUBSCRIPTION_CACHE_KEY + userId, + subscriptions + ); + } + + setSubscriptionRecurring( + idempotencyKey: string, + recurring: SubscriptionRecurring, + plan?: SubscriptionPlan + ) { + return this.gqlService.gql({ + query: updateSubscriptionMutation, + variables: { + idempotencyKey, + plan, + recurring, + }, + }); + } + + async createCheckoutSession(input: CreateCheckoutSessionInput) { + const data = await this.gqlService.gql({ + query: createCheckoutSessionMutation, + variables: { input }, + }); + return data.createCheckoutSession; + } + + async fetchSubscriptionPrices(abortSignal?: AbortSignal) { + const data = await this.gqlService.gql({ + query: pricesQuery, + context: { + signal: abortSignal, + }, + }); + + return data.prices; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/user-feature.ts b/packages/frontend/core/src/modules/cloud/stores/user-feature.ts new file mode 100644 index 0000000000..6176c30ce9 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/user-feature.ts @@ -0,0 +1,23 @@ +import { getUserFeaturesQuery } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { GraphQLService } from '../services/graphql'; + +export class UserFeatureStore extends Store { + constructor(private readonly gqlService: GraphQLService) { + super(); + } + + async getUserFeatures(signal: AbortSignal) { + const data = await this.gqlService.gql({ + query: getUserFeaturesQuery, + context: { + signal, + }, + }); + return { + userId: data.currentUser?.id, + features: data.currentUser?.features, + }; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/user-quota.ts b/packages/frontend/core/src/modules/cloud/stores/user-quota.ts new file mode 100644 index 0000000000..84819127b6 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/user-quota.ts @@ -0,0 +1,30 @@ +import { quotaQuery } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { GraphQLService } from '../services/graphql'; + +export class UserQuotaStore extends Store { + constructor(private readonly graphqlService: GraphQLService) { + super(); + } + + async fetchUserQuota(abortSignal?: AbortSignal) { + const data = await this.graphqlService.gql({ + query: quotaQuery, + context: { + signal: abortSignal, + }, + }); + + if (!data.currentUser) { + throw new Error('No logged in'); + } + + return { + userId: data.currentUser.id, + aiQuota: data.currentUser.copilot.quota, + quota: data.currentUser.quota, + used: data.collectAllBlobSizes.size, + }; + } +} diff --git a/packages/frontend/core/src/modules/collection/index.ts b/packages/frontend/core/src/modules/collection/index.ts index f78beabc33..d33921f375 100644 --- a/packages/frontend/core/src/modules/collection/index.ts +++ b/packages/frontend/core/src/modules/collection/index.ts @@ -1 +1,15 @@ -export * from './service'; +export { CollectionService } from './services/collection'; + +import { + type Framework, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { CollectionService } from './services/collection'; + +export function configureCollectionModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(CollectionService, [WorkspaceService]); +} diff --git a/packages/frontend/core/src/modules/collection/service.ts b/packages/frontend/core/src/modules/collection/services/collection.ts similarity index 90% rename from packages/frontend/core/src/modules/collection/service.ts rename to packages/frontend/core/src/modules/collection/services/collection.ts index 0302f1026e..bdc9aaa588 100644 --- a/packages/frontend/core/src/modules/collection/service.ts +++ b/packages/frontend/core/src/modules/collection/services/collection.ts @@ -3,8 +3,8 @@ import type { DeleteCollectionInfo, DeletedCollection, } from '@affine/env/filter'; -import type { Workspace } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra'; +import type { WorkspaceService } from '@toeverything/infra'; +import { LiveData, Service } from '@toeverything/infra'; import { Observable } from 'rxjs'; import { Array as YArray } from 'yjs'; @@ -13,15 +13,19 @@ const SETTING_KEY = 'setting'; const COLLECTIONS_KEY = 'collections'; const COLLECTIONS_TRASH_KEY = 'collections_trash'; -export class CollectionService { - constructor(private readonly workspace: Workspace) {} +export class CollectionService extends Service { + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } private get doc() { - return this.workspace.docCollection.doc; + return this.workspaceService.workspace.docCollection.doc; } private get setting() { - return this.workspace.docCollection.doc.getMap(SETTING_KEY); + return this.workspaceService.workspace.docCollection.doc.getMap( + SETTING_KEY + ); } private get collectionsYArray(): YArray | undefined { @@ -105,7 +109,7 @@ export class CollectionService { return; } const set = new Set(ids); - this.workspace.docCollection.doc.transact(() => { + this.workspaceService.workspace.docCollection.doc.transact(() => { const indexList: number[] = []; const list: Collection[] = []; collectionsYArray.forEach((collection, i) => { diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts new file mode 100644 index 0000000000..0379b7d71f --- /dev/null +++ b/packages/frontend/core/src/modules/index.ts @@ -0,0 +1,33 @@ +import { configureQuotaModule } from '@affine/core/modules/quota'; +import { configureInfraModules, type Framework } from '@toeverything/infra'; + +import { configureCloudModule } from './cloud'; +import { configureCollectionModule } from './collection'; +import { configureNavigationModule } from './navigation'; +import { configurePermissionsModule } from './permissions'; +import { configureWorkspacePropertiesModule } from './properties'; +import { configureRightSidebarModule } from './right-sidebar'; +import { configureShareDocsModule } from './share-doc'; +import { configureStorageImpls } from './storage'; +import { configureTagModule } from './tag'; +import { configureTelemetryModule } from './telemetry'; +import { configureWorkbenchModule } from './workbench'; + +export function configureCommonModules(framework: Framework) { + configureInfraModules(framework); + configureCollectionModule(framework); + configureNavigationModule(framework); + configureRightSidebarModule(framework); + configureTagModule(framework); + configureWorkbenchModule(framework); + configureWorkspacePropertiesModule(framework); + configureCloudModule(framework); + configureQuotaModule(framework); + configurePermissionsModule(framework); + configureShareDocsModule(framework); + configureTelemetryModule(framework); +} + +export function configureImpls(framework: Framework) { + configureStorageImpls(framework); +} diff --git a/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx b/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx deleted file mode 100644 index efac0f4110..0000000000 --- a/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { ServiceProvider } from '@toeverything/infra'; -import { - ServiceProviderContext, - useLiveData, - useService, -} from '@toeverything/infra'; -import type React from 'react'; - -import { CurrentWorkspaceService } from '../../workspace'; - -export const GlobalScopeProvider: React.FC< - React.PropsWithChildren<{ provider: ServiceProvider }> -> = ({ provider: rootProvider, children }) => { - const currentWorkspaceService = useService(CurrentWorkspaceService, { - provider: rootProvider, - }); - - const workspaceProvider = useLiveData( - currentWorkspaceService.currentWorkspace$ - )?.services; - - return ( - - {children} - - ); -}; diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/index.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/index.ts index fcdc4c8355..8d9530cd5c 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/index.ts +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/index.ts @@ -1,4 +1,4 @@ -export type { SidebarTabName } from './entities/sidebar-tab'; -export { sidebarTabs } from './entities/sidebar-tabs'; +export type { SidebarTabName } from './multi-tabs/sidebar-tab'; +export { sidebarTabs } from './multi-tabs/sidebar-tabs'; export { MultiTabSidebarBody } from './view/body'; export { MultiTabSidebarHeaderSwitcher } from './view/header-switcher'; diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/sidebar-tab.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/sidebar-tab.ts similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/sidebar-tab.ts rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/sidebar-tab.ts diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/sidebar-tabs.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/sidebar-tabs.ts similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/sidebar-tabs.ts rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/sidebar-tabs.ts diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/chat.css.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.css.ts similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/chat.css.ts rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.css.ts diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/chat.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.tsx similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/chat.tsx rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.tsx diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/frame.css.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/frame.css.ts similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/frame.css.ts rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/frame.css.ts diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/frame.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/frame.tsx similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/frame.tsx rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/frame.tsx diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.css.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/journal.css.ts similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.css.ts rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/journal.css.ts diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/journal.tsx similarity index 84% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/journal.tsx index d90a3ab0c0..5b7c4c175d 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/journal.tsx @@ -15,13 +15,13 @@ import { PageIcon, TodayIcon, } from '@blocksuite/icons'; -import type { PageRecord } from '@toeverything/infra'; +import type { DocRecord } from '@toeverything/infra'; import { - Doc, - PageRecordList, + DocService, + DocsService, useLiveData, useService, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; @@ -43,21 +43,16 @@ const CountDisplay = ({ return {count > max ? `${max}+` : count}; }; interface PageItemProps extends HTMLAttributes { - pageRecord: PageRecord; + docRecord: DocRecord; right?: ReactNode; } -const PageItem = ({ - pageRecord, - right, - className, - ...attrs -}: PageItemProps) => { - const title = useLiveData(pageRecord.title$); - const mode = useLiveData(pageRecord.mode$); - const workspace = useService(Workspace); +const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => { + const title = useLiveData(docRecord.title$); + const mode = useLiveData(docRecord.mode$); + const workspace = useService(WorkspaceService).workspace; const { isJournal } = useJournalInfoHelper( workspace.docCollection, - pageRecord.id + docRecord.id ); const Icon = isJournal @@ -92,8 +87,8 @@ interface JournalBlockProps { const EditorJournalPanel = () => { const t = useAFFiNEI18N(); - const doc = useService(Doc); - const workspace = useService(Workspace); + const doc = useService(DocService).doc; + const workspace = useService(WorkspaceService).workspace; const { journalDate, isJournal } = useJournalInfoHelper( workspace.docCollection, doc.id @@ -159,11 +154,11 @@ const EditorJournalPanel = () => { }; const sortPagesByDate = ( - pages: PageRecord[], + docs: DocRecord[], field: 'updatedDate' | 'createDate', order: 'asc' | 'desc' = 'desc' ) => { - return [...pages].sort((a, b) => { + return [...docs].sort((a, b) => { return ( (order === 'asc' ? 1 : -1) * dayjs(b.meta$.value[field]).diff(dayjs(a.meta$.value[field])) @@ -183,27 +178,26 @@ const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => { ); }; const JournalDailyCountBlock = ({ date }: JournalBlockProps) => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const nodeRef = useRef(null); const t = useAFFiNEI18N(); const [activeItem, setActiveItem] = useState('createdToday'); - const pageRecordList = useService(PageRecordList); - const pageRecords = useLiveData(pageRecordList.records$); + const docRecords = useLiveData(useService(DocsService).list.docs$); const navigateHelper = useNavigateHelper(); const getTodaysPages = useCallback( (field: 'createDate' | 'updatedDate') => { return sortPagesByDate( - pageRecords.filter(pageRecord => { - const meta = pageRecord.meta$.value; + docRecords.filter(docRecord => { + const meta = docRecord.meta$.value; if (meta.trash) return false; return meta[field] && dayjs(meta[field]).isSame(date, 'day'); }), field ); }, - [date, pageRecords] + [date, docRecords] ); const createdToday = useMemo( @@ -279,7 +273,7 @@ const JournalDailyCountBlock = ({ date }: JournalBlockProps) => { } tabIndex={name === activeItem ? 0 : -1} key={index} - pageRecord={pageRecord} + docRecord={pageRecord} /> ))}
@@ -296,25 +290,25 @@ const MAX_CONFLICT_COUNT = 5; interface ConflictListProps extends PropsWithChildren, HTMLAttributes { - pageRecords: PageRecord[]; + docRecords: DocRecord[]; } const ConflictList = ({ - pageRecords, + docRecords, children, className, ...attrs }: ConflictListProps) => { const navigateHelper = useNavigateHelper(); - const workspace = useService(Workspace); - const currentDoc = useService(Doc); + const workspace = useService(WorkspaceService).workspace; + const currentDoc = useService(DocService).doc; const { setTrashModal } = useTrashModalHelper(workspace.docCollection); const handleOpenTrashModal = useCallback( - (pageRecord: PageRecord) => { + (docRecord: DocRecord) => { setTrashModal({ open: true, - pageIds: [pageRecord.id], - pageTitles: [pageRecord.title$.value], + pageIds: [docRecord.id], + pageTitles: [docRecord.title$.value], }); }, [setTrashModal] @@ -322,18 +316,18 @@ const ConflictList = ({ return (
- {pageRecords.map(pageRecord => { - const isCurrent = pageRecord.id === currentDoc.id; + {docRecords.map(docRecord => { + const isCurrent = docRecord.id === currentDoc.id; return ( handleOpenTrashModal(pageRecord)} + onSelect={() => handleOpenTrashModal(docRecord)} /> } > @@ -342,7 +336,7 @@ const ConflictList = ({ } - onClick={() => navigateHelper.openPage(workspace.id, pageRecord.id)} + onClick={() => navigateHelper.openPage(workspace.id, docRecord.id)} /> ); })} @@ -352,30 +346,34 @@ const ConflictList = ({ }; const JournalConflictBlock = ({ date }: JournalBlockProps) => { const t = useAFFiNEI18N(); - const workspace = useService(Workspace); - const pageRecordList = useService(PageRecordList); + const workspace = useService(WorkspaceService).workspace; + const docRecordList = useService(DocsService).list; const journalHelper = useJournalHelper(workspace.docCollection); const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD')); - const pageRecords = useLiveData(pageRecordList.records$).filter(v => { - return docs.some(doc => doc.id === v.id); - }); + const docRecords = useLiveData( + docRecordList.docs$.map(records => + records.filter(v => { + return docs.some(doc => doc.id === v.id); + }) + ) + ); if (docs.length <= 1) return null; return ( {docs.length > MAX_CONFLICT_COUNT ? ( + } >
{t['com.affine.journal.conflict-show-more']({ - count: (pageRecords.length - MAX_CONFLICT_COUNT).toFixed(0), + count: (docRecords.length - MAX_CONFLICT_COUNT).toFixed(0), })}
diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/outline.css.ts b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/outline.css.ts similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/outline.css.ts rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/outline.css.ts diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/outline.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/outline.tsx similarity index 100% rename from packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/outline.tsx rename to packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/outline.tsx diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/view/body.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/view/body.tsx index 48cc43513f..d1f7072b10 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/view/body.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/view/body.tsx @@ -1,6 +1,6 @@ import type { PropsWithChildren } from 'react'; -import type { SidebarTab, SidebarTabProps } from '../entities/sidebar-tab'; +import type { SidebarTab, SidebarTabProps } from '../multi-tabs/sidebar-tab'; import * as styles from './body.css'; export const MultiTabSidebarBody = ( diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx index ddfd21701a..9026c6002d 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx @@ -1,10 +1,10 @@ import { IconButton } from '@affine/component'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; -import { Doc, useService, Workspace } from '@toeverything/infra'; +import { DocService, useService, WorkspaceService } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import { useEffect } from 'react'; -import type { SidebarTab, SidebarTabName } from '../entities/sidebar-tab'; +import type { SidebarTab, SidebarTabName } from '../multi-tabs/sidebar-tab'; import * as styles from './header-switcher.css'; export interface MultiTabSidebarHeaderSwitcherProps { @@ -20,8 +20,8 @@ export const MultiTabSidebarHeaderSwitcher = ({ activeTabName, setActiveTabName, }: MultiTabSidebarHeaderSwitcherProps) => { - const workspace = useService(Workspace); - const doc = useService(Doc); + const workspace = useService(WorkspaceService).workspace; + const doc = useService(DocService).doc; const { isJournal } = useJournalInfoHelper(workspace.docCollection, doc.id); diff --git a/packages/frontend/core/src/modules/navigation/README.md b/packages/frontend/core/src/modules/navigation/README.md new file mode 100644 index 0000000000..50afca6d7c --- /dev/null +++ b/packages/frontend/core/src/modules/navigation/README.md @@ -0,0 +1,3 @@ +# navigation + +Provide support for forward and back buttons. diff --git a/packages/frontend/core/src/modules/navigation/entities/navigator.ts b/packages/frontend/core/src/modules/navigation/entities/navigator.ts index 32e8f8c68f..f213a1160b 100644 --- a/packages/frontend/core/src/modules/navigation/entities/navigator.ts +++ b/packages/frontend/core/src/modules/navigation/entities/navigator.ts @@ -1,13 +1,15 @@ -import { LiveData } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; import type { Location } from 'history'; import { Observable, switchMap } from 'rxjs'; -import type { Workbench } from '../../workbench'; +import type { WorkbenchService } from '../../workbench'; -export class Navigator { - constructor(private readonly workbench: Workbench) {} +export class Navigator extends Entity { + constructor(private readonly workbenchService: WorkbenchService) { + super(); + } - private readonly history$ = this.workbench.activeView$.map( + private readonly history$ = this.workbenchService.workbench.activeView$.map( view => view.history ); diff --git a/packages/frontend/core/src/modules/navigation/index.ts b/packages/frontend/core/src/modules/navigation/index.ts index 9672b435a7..380a3139ac 100644 --- a/packages/frontend/core/src/modules/navigation/index.ts +++ b/packages/frontend/core/src/modules/navigation/index.ts @@ -1,2 +1,15 @@ export { Navigator } from './entities/navigator'; export { NavigationButtons } from './view/navigation-buttons'; + +import { type Framework, WorkspaceScope } from '@toeverything/infra'; + +import { WorkbenchService } from '../workbench'; +import { Navigator } from './entities/navigator'; +import { NavigatorService } from './services/navigator'; + +export function configureNavigationModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(NavigatorService) + .entity(Navigator, [WorkbenchService]); +} diff --git a/packages/frontend/core/src/modules/navigation/services/navigator.ts b/packages/frontend/core/src/modules/navigation/services/navigator.ts new file mode 100644 index 0000000000..a5fec44d6e --- /dev/null +++ b/packages/frontend/core/src/modules/navigation/services/navigator.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { Navigator } from '../entities/navigator'; + +export class NavigatorService extends Service { + public readonly navigator = this.framework.createEntity(Navigator); +} diff --git a/packages/frontend/core/src/modules/navigation/view/navigation-buttons.tsx b/packages/frontend/core/src/modules/navigation/view/navigation-buttons.tsx index 376079ad56..d703915ed1 100644 --- a/packages/frontend/core/src/modules/navigation/view/navigation-buttons.tsx +++ b/packages/frontend/core/src/modules/navigation/view/navigation-buttons.tsx @@ -5,7 +5,7 @@ import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useEffect, useMemo } from 'react'; import { useGeneralShortcuts } from '../../../hooks/affine/use-shortcuts'; -import { Navigator } from '../entities/navigator'; +import { NavigatorService } from '../services/navigator'; import * as styles from './navigation-buttons.css'; import { useRegisterNavigationCommands } from './use-register-navigation-commands'; @@ -30,7 +30,7 @@ export const NavigationButtons = () => { }; }, [shortcuts, t]); - const navigator = useService(Navigator); + const navigator = useService(NavigatorService).navigator; const backable = useLiveData(navigator.backable$); const forwardable = useLiveData(navigator.forwardable$); diff --git a/packages/frontend/core/src/modules/navigation/view/use-register-navigation-commands.ts b/packages/frontend/core/src/modules/navigation/view/use-register-navigation-commands.ts index 102f05ee47..400ff95efa 100644 --- a/packages/frontend/core/src/modules/navigation/view/use-register-navigation-commands.ts +++ b/packages/frontend/core/src/modules/navigation/view/use-register-navigation-commands.ts @@ -5,10 +5,10 @@ import { } from '@toeverything/infra'; import { useEffect } from 'react'; -import { Navigator } from '../entities/navigator'; +import { NavigatorService } from '../services/navigator'; export function useRegisterNavigationCommands() { - const navigator = useService(Navigator); + const navigator = useService(NavigatorService).navigator; useEffect(() => { const unsubs: Array<() => void> = []; diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts new file mode 100644 index 0000000000..ca7afa8163 --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts @@ -0,0 +1,65 @@ +import { DebugLogger } from '@affine/debug'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import type { WorkspaceService } from '@toeverything/infra'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + fromPromise, + LiveData, + mapInto, + onComplete, + onStart, +} from '@toeverything/infra'; +import { exhaustMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../../cloud'; +import type { WorkspacePermissionStore } from '../stores/permission'; + +const logger = new DebugLogger('affine:workspace-permission'); + +export class WorkspacePermission extends Entity { + isOwner$ = new LiveData(null); + isLoading$ = new LiveData(false); + error$ = new LiveData(null); + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly store: WorkspacePermissionStore + ) { + super(); + } + + revalidate = effect( + exhaustMap(() => { + return fromPromise(async signal => { + if ( + this.workspaceService.workspace.flavour === + WorkspaceFlavour.AFFINE_CLOUD + ) { + return await this.store.fetchIsOwner( + this.workspaceService.workspace.id, + signal + ); + } else { + return true; + } + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mapInto(this.isOwner$), + catchErrorInto(this.error$, error => { + logger.error('Failed to fetch isOwner', error); + }), + onStart(() => this.isLoading$.setValue(true)), + onComplete(() => this.isLoading$.setValue(false)) + ); + }) + ); +} diff --git a/packages/frontend/core/src/modules/permissions/index.ts b/packages/frontend/core/src/modules/permissions/index.ts new file mode 100644 index 0000000000..495f0bc805 --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/index.ts @@ -0,0 +1,20 @@ +export { WorkspacePermissionService } from './services/permission'; + +import { GraphQLService } from '@affine/core/modules/cloud'; +import { + type Framework, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { WorkspacePermission } from './entities/permission'; +import { WorkspacePermissionService } from './services/permission'; +import { WorkspacePermissionStore } from './stores/permission'; + +export function configurePermissionsModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(WorkspacePermissionService) + .store(WorkspacePermissionStore, [GraphQLService]) + .entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore]); +} diff --git a/packages/frontend/core/src/modules/permissions/services/permission.ts b/packages/frontend/core/src/modules/permissions/services/permission.ts new file mode 100644 index 0000000000..ca9c5ef6a5 --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/services/permission.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { WorkspacePermission } from '../entities/permission'; + +export class WorkspacePermissionService extends Service { + permission = this.framework.createEntity(WorkspacePermission); +} diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts new file mode 100644 index 0000000000..dfe15a9502 --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -0,0 +1,21 @@ +import type { GraphQLService } from '@affine/core/modules/cloud'; +import { getIsOwnerQuery } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +export class WorkspacePermissionStore extends Store { + constructor(private readonly graphqlService: GraphQLService) { + super(); + } + + async fetchIsOwner(workspaceId: string, signal?: AbortSignal) { + const isOwner = await this.graphqlService.gql({ + query: getIsOwnerQuery, + variables: { + workspaceId, + }, + context: { signal }, + }); + + return isOwner.isOwner; + } +} diff --git a/packages/frontend/core/src/modules/properties/index.ts b/packages/frontend/core/src/modules/properties/index.ts new file mode 100644 index 0000000000..ba052ccd86 --- /dev/null +++ b/packages/frontend/core/src/modules/properties/index.ts @@ -0,0 +1,25 @@ +export { + FavoriteItemsAdapter, + WorkspacePropertiesAdapter, +} from './services/adapter'; +export { WorkspaceLegacyProperties } from './services/legacy-properties'; + +import { + type Framework, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { + FavoriteItemsAdapter, + WorkspacePropertiesAdapter, +} from './services/adapter'; +import { WorkspaceLegacyProperties } from './services/legacy-properties'; + +export function configureWorkspacePropertiesModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(WorkspaceLegacyProperties, [WorkspaceService]) + .service(WorkspacePropertiesAdapter, [WorkspaceService]) + .service(FavoriteItemsAdapter, [WorkspacePropertiesAdapter]); +} diff --git a/packages/frontend/core/src/modules/workspace/properties/adapter.ts b/packages/frontend/core/src/modules/properties/services/adapter.ts similarity index 90% rename from packages/frontend/core/src/modules/workspace/properties/adapter.ts rename to packages/frontend/core/src/modules/properties/services/adapter.ts index 844b795a8c..aa91b59afd 100644 --- a/packages/frontend/core/src/modules/workspace/properties/adapter.ts +++ b/packages/frontend/core/src/modules/properties/services/adapter.ts @@ -2,7 +2,8 @@ // the adapter is to bridge the workspace rootdoc & native js bindings import { createFractionalIndexingSortableHelper } from '@affine/core/utils'; import { createYProxy, type Y } from '@blocksuite/store'; -import { LiveData, type Workspace } from '@toeverything/infra'; +import type { WorkspaceService } from '@toeverything/infra'; +import { LiveData, Service } from '@toeverything/infra'; import { defaultsDeep } from 'lodash-es'; import { Observable } from 'rxjs'; @@ -24,7 +25,7 @@ const AFFINE_PROPERTIES_ID = 'affine:workspace-properties'; * So that the adapter could be more focused and easier to maintain (like assigning default values) * However the properties for an abstraction may not be limited to a single yjs map. */ -export class WorkspacePropertiesAdapter { +export class WorkspacePropertiesAdapter extends Service { // provides a easy-to-use interface for workspace properties public readonly proxy: WorkspaceAffineProperties; public readonly properties: Y.Map; @@ -33,9 +34,14 @@ export class WorkspacePropertiesAdapter { private ensuredRoot = false; private ensuredPages = {} as Record; - constructor(public readonly workspace: Workspace) { + get workspace() { + return this.workspaceService.workspace; + } + + constructor(public readonly workspaceService: WorkspaceService) { + super(); // check if properties exists, if not, create one - const rootDoc = workspace.docCollection.doc; + const rootDoc = workspaceService.workspace.docCollection.doc; this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID); this.proxy = createYProxy(this.properties); @@ -80,8 +86,8 @@ export class WorkspacePropertiesAdapter { source: 'system', type: PagePropertyType.Tags, options: - this.workspace.docCollection.meta.properties.tags?.options ?? - [], // better use a one time migration + this.workspaceService.workspace.docCollection.meta.properties + .tags?.options ?? [], // better use a one time migration }, }, }, @@ -116,8 +122,8 @@ export class WorkspacePropertiesAdapter { } // leak some yjs abstraction to modify multiple properties at once - transact = this.workspace.docCollection.doc.transact.bind( - this.workspace.docCollection.doc + transact = this.workspaceService.workspace.docCollection.doc.transact.bind( + this.workspaceService.workspace.docCollection.doc ); get schema() { @@ -150,8 +156,9 @@ export class WorkspacePropertiesAdapter { } } -export class FavoriteItemsAdapter { +export class FavoriteItemsAdapter extends Service { constructor(private readonly adapter: WorkspacePropertiesAdapter) { + super(); this.migrateFavorites(); } @@ -191,7 +198,7 @@ export class FavoriteItemsAdapter { } get workspace() { - return this.adapter.workspace; + return this.adapter.workspaceService.workspace; } getItemId(item: WorkspaceFavoriteItem) { diff --git a/packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts b/packages/frontend/core/src/modules/properties/services/legacy-properties.ts similarity index 66% rename from packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts rename to packages/frontend/core/src/modules/properties/services/legacy-properties.ts index e2b66cd480..a65e8a25b7 100644 --- a/packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts +++ b/packages/frontend/core/src/modules/properties/services/legacy-properties.ts @@ -1,32 +1,37 @@ import type { Tag } from '@affine/env/filter'; import type { DocsPropertiesMeta } from '@blocksuite/store'; -import type { Workspace } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra'; +import type { WorkspaceService } from '@toeverything/infra'; +import { LiveData, Service } from '@toeverything/infra'; import { Observable } from 'rxjs'; /** * @deprecated use WorkspacePropertiesAdapter instead (later) */ -export class WorkspaceLegacyProperties { - constructor(private readonly workspace: Workspace) {} +export class WorkspaceLegacyProperties extends Service { + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } get workspaceId() { - return this.workspace.id; + return this.workspaceService.workspace.id; } get properties() { - return this.workspace.docCollection.meta.properties; + return this.workspaceService.workspace.docCollection.meta.properties; } get tagOptions() { return this.properties.tags?.options ?? []; } updateProperties = (properties: DocsPropertiesMeta) => { - this.workspace.docCollection.meta.setProperties(properties); + this.workspaceService.workspace.docCollection.meta.setProperties( + properties + ); }; subscribe(cb: () => void) { - const disposable = this.workspace.docCollection.meta.docMetaUpdated.on(cb); + const disposable = + this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(cb); return disposable.dispose; } @@ -58,10 +63,10 @@ export class WorkspaceLegacyProperties { }; removeTagOption = (id: string) => { - this.workspace.docCollection.doc.transact(() => { + this.workspaceService.workspace.docCollection.doc.transact(() => { this.updateTagOptions(this.tagOptions.filter(o => o.id !== id)); // need to remove tag from all pages - this.workspace.docCollection.docs.forEach(doc => { + this.workspaceService.workspace.docCollection.docs.forEach(doc => { const tags = doc.meta?.tags ?? []; if (tags.includes(id)) { this.updatePageTags( @@ -74,7 +79,7 @@ export class WorkspaceLegacyProperties { }; updatePageTags = (pageId: string, tags: string[]) => { - this.workspace.docCollection.setDocMeta(pageId, { + this.workspaceService.workspace.docCollection.setDocMeta(pageId, { tags, }); }; diff --git a/packages/frontend/core/src/modules/workspace/properties/schema.ts b/packages/frontend/core/src/modules/properties/services/schema.ts similarity index 100% rename from packages/frontend/core/src/modules/workspace/properties/schema.ts rename to packages/frontend/core/src/modules/properties/services/schema.ts diff --git a/packages/frontend/core/src/modules/quota/entities/quota.ts b/packages/frontend/core/src/modules/quota/entities/quota.ts new file mode 100644 index 0000000000..561f64022d --- /dev/null +++ b/packages/frontend/core/src/modules/quota/entities/quota.ts @@ -0,0 +1,61 @@ +import { DebugLogger } from '@affine/debug'; +import type { WorkspaceQuotaQuery } from '@affine/graphql'; +import type { WorkspaceService } from '@toeverything/infra'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + fromPromise, + LiveData, + mapInto, + onComplete, + onStart, +} from '@toeverything/infra'; +import { exhaustMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../../cloud'; +import type { WorkspaceQuotaStore } from '../stores/quota'; + +type QuotaType = WorkspaceQuotaQuery['workspace']['quota']; + +const logger = new DebugLogger('affine:workspace-permission'); + +export class WorkspaceQuota extends Entity { + quota$ = new LiveData(null); + isLoading$ = new LiveData(false); + error$ = new LiveData(null); + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly store: WorkspaceQuotaStore + ) { + super(); + } + + revalidate = effect( + exhaustMap(() => { + return fromPromise(signal => + this.store.fetchWorkspaceQuota( + this.workspaceService.workspace.id, + signal + ) + ).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + count: 3, + }), + mapInto(this.quota$), + catchErrorInto(this.error$, error => { + logger.error('Failed to fetch isOwner', error); + }), + onStart(() => this.isLoading$.setValue(true)), + onComplete(() => this.isLoading$.setValue(false)) + ); + }) + ); +} diff --git a/packages/frontend/core/src/modules/quota/index.ts b/packages/frontend/core/src/modules/quota/index.ts new file mode 100644 index 0000000000..b0891bed56 --- /dev/null +++ b/packages/frontend/core/src/modules/quota/index.ts @@ -0,0 +1,20 @@ +export { WorkspaceQuotaService } from './services/quota'; + +import { GraphQLService } from '@affine/core/modules/cloud'; +import { + type Framework, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { WorkspaceQuota } from './entities/quota'; +import { WorkspaceQuotaService } from './services/quota'; +import { WorkspaceQuotaStore } from './stores/quota'; + +export function configureQuotaModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(WorkspaceQuotaService) + .store(WorkspaceQuotaStore, [GraphQLService]) + .entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]); +} diff --git a/packages/frontend/core/src/modules/quota/services/quota.ts b/packages/frontend/core/src/modules/quota/services/quota.ts new file mode 100644 index 0000000000..a92997e04d --- /dev/null +++ b/packages/frontend/core/src/modules/quota/services/quota.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { WorkspaceQuota } from '../entities/quota'; + +export class WorkspaceQuotaService extends Service { + quota = this.framework.createEntity(WorkspaceQuota); +} diff --git a/packages/frontend/core/src/modules/quota/stores/quota.ts b/packages/frontend/core/src/modules/quota/stores/quota.ts new file mode 100644 index 0000000000..1db66f5bc2 --- /dev/null +++ b/packages/frontend/core/src/modules/quota/stores/quota.ts @@ -0,0 +1,22 @@ +import type { GraphQLService } from '@affine/core/modules/cloud'; +import { workspaceQuotaQuery } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +export class WorkspaceQuotaStore extends Store { + constructor(private readonly graphqlService: GraphQLService) { + super(); + } + + async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) { + const data = await this.graphqlService.gql({ + query: workspaceQuotaQuery, + variables: { + id: workspaceId, + }, + context: { + signal, + }, + }); + return data.workspace.quota; + } +} diff --git a/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar-view.ts b/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar-view.ts index 322d727be3..507fd62f97 100644 --- a/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar-view.ts +++ b/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar-view.ts @@ -1,6 +1,8 @@ +import { Entity } from '@toeverything/infra'; + import { createIsland } from '../../../utils/island'; -export class RightSidebarView { +export class RightSidebarView extends Entity { readonly body = createIsland(); readonly header = createIsland(); } diff --git a/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts b/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts index f236de0c65..e40802876f 100644 --- a/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts +++ b/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts @@ -1,12 +1,15 @@ import type { GlobalState } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; -import type { RightSidebarView } from './right-sidebar-view'; +import { RightSidebarView } from './right-sidebar-view'; const RIGHT_SIDEBAR_KEY = 'app:settings:rightsidebar'; -export class RightSidebar { - constructor(private readonly globalState: GlobalState) {} +export class RightSidebar extends Entity { + constructor(private readonly globalState: GlobalState) { + super(); + } + readonly isOpen$ = LiveData.from( this.globalState.watch(RIGHT_SIDEBAR_KEY), false @@ -36,8 +39,10 @@ export class RightSidebar { /** * @private use `RightSidebarViewIsland` instead */ - _append(view: RightSidebarView) { + _append() { + const view = this.framework.createEntity(RightSidebarView); this.views$.next([...this.views$.value, view]); + return view; } /** diff --git a/packages/frontend/core/src/modules/right-sidebar/index.ts b/packages/frontend/core/src/modules/right-sidebar/index.ts index 11dd63935b..dbd8e120c7 100644 --- a/packages/frontend/core/src/modules/right-sidebar/index.ts +++ b/packages/frontend/core/src/modules/right-sidebar/index.ts @@ -1,3 +1,22 @@ export { RightSidebar } from './entities/right-sidebar'; +export { RightSidebarService } from './services/right-sidebar'; export { RightSidebarContainer } from './view/container'; export { RightSidebarViewIsland } from './view/view-island'; + +import { + type Framework, + GlobalState, + WorkspaceScope, +} from '@toeverything/infra'; + +import { RightSidebar } from './entities/right-sidebar'; +import { RightSidebarView } from './entities/right-sidebar-view'; +import { RightSidebarService } from './services/right-sidebar'; + +export function configureRightSidebarModule(services: Framework) { + services + .scope(WorkspaceScope) + .service(RightSidebarService) + .entity(RightSidebar, [GlobalState]) + .entity(RightSidebarView); +} diff --git a/packages/frontend/core/src/modules/right-sidebar/services/right-sidebar.ts b/packages/frontend/core/src/modules/right-sidebar/services/right-sidebar.ts new file mode 100644 index 0000000000..454aaba612 --- /dev/null +++ b/packages/frontend/core/src/modules/right-sidebar/services/right-sidebar.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { RightSidebar } from '../entities/right-sidebar'; + +export class RightSidebarService extends Service { + rightSidebar = this.framework.createEntity(RightSidebar); +} diff --git a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx index 46f993fd3e..4e50e636d5 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx +++ b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx @@ -1,10 +1,10 @@ import { ResizePanel } from '@affine/component/resize-panel'; -import { appSidebarOpenAtom } from '@affine/core/components/app-sidebar'; import { appSettingAtom, useLiveData, useService } from '@toeverything/infra'; import { useAtomValue } from 'jotai'; import { useCallback, useEffect, useState } from 'react'; -import { RightSidebar } from '../entities/right-sidebar'; +import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai'; +import { RightSidebarService } from '../services/right-sidebar'; import * as styles from './container.css'; import { Header } from './header'; @@ -15,7 +15,7 @@ export const RightSidebarContainer = () => { const { clientBorder } = useAtomValue(appSettingAtom); const [width, setWidth] = useState(300); const [resizing, setResizing] = useState(false); - const rightSidebar = useService(RightSidebar); + const rightSidebar = useService(RightSidebarService).rightSidebar; const frontView = useLiveData(rightSidebar.front$); const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined; diff --git a/packages/frontend/core/src/modules/right-sidebar/view/view-island.tsx b/packages/frontend/core/src/modules/right-sidebar/view/view-island.tsx index dfd067de53..9e8ab527b5 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/view-island.tsx +++ b/packages/frontend/core/src/modules/right-sidebar/view/view-island.tsx @@ -1,8 +1,8 @@ import { useService } from '@toeverything/infra'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useState } from 'react'; -import { RightSidebar } from '../entities/right-sidebar'; -import { RightSidebarView } from '../entities/right-sidebar-view'; +import type { RightSidebarView } from '../entities/right-sidebar-view'; +import { RightSidebarService } from '../services/right-sidebar'; export interface RightSidebarViewProps { body: JSX.Element; @@ -16,23 +16,29 @@ export const RightSidebarViewIsland = ({ header, active, }: RightSidebarViewProps) => { - const rightSidebar = useService(RightSidebar); + const rightSidebar = useService(RightSidebarService).rightSidebar; - const view = useMemo(() => new RightSidebarView(), []); + const [view, setView] = useState(null); useEffect(() => { - rightSidebar._append(view); + const view = rightSidebar._append(); + setView(view); return () => { rightSidebar._remove(view); + setView(null); }; - }, [rightSidebar, view]); + }, [rightSidebar]); useEffect(() => { - if (active) { + if (active && view) { rightSidebar._moveToFront(view); } }, [active, rightSidebar, view]); + if (!view) { + return null; + } + return ( <> {header} diff --git a/packages/frontend/core/src/modules/services.ts b/packages/frontend/core/src/modules/services.ts deleted file mode 100644 index 849f19c04f..0000000000 --- a/packages/frontend/core/src/modules/services.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ServiceCollection } from '@toeverything/infra'; -import { - GlobalCache, - GlobalState, - PageRecordList, - Workspace, - WorkspaceScope, -} from '@toeverything/infra'; - -import { CollectionService } from './collection'; -import { - LocalStorageGlobalCache, - LocalStorageGlobalState, -} from './infra-web/storage'; -import { Navigator } from './navigation'; -import { RightSidebar } from './right-sidebar/entities/right-sidebar'; -import { TagService } from './tag'; -import { Workbench } from './workbench'; -import { - CurrentWorkspaceService, - FavoriteItemsAdapter, - WorkspaceLegacyProperties, - WorkspacePropertiesAdapter, -} from './workspace'; - -export function configureBusinessServices(services: ServiceCollection) { - services.add(CurrentWorkspaceService); - services - .scope(WorkspaceScope) - .add(Workbench) - .add(Navigator, [Workbench]) - .add(RightSidebar, [GlobalState]) - .add(WorkspacePropertiesAdapter, [Workspace]) - .add(FavoriteItemsAdapter, [WorkspacePropertiesAdapter]) - .add(CollectionService, [Workspace]) - .add(WorkspaceLegacyProperties, [Workspace]) - .add(TagService, [WorkspaceLegacyProperties, PageRecordList]); -} - -export function configureWebInfraServices(services: ServiceCollection) { - services - .addImpl(GlobalCache, LocalStorageGlobalCache) - .addImpl(GlobalState, LocalStorageGlobalState); -} diff --git a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts new file mode 100644 index 0000000000..05f375ce76 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts @@ -0,0 +1,68 @@ +import { DebugLogger } from '@affine/debug'; +import type { GetWorkspacePublicPagesQuery } from '@affine/graphql'; +import type { GlobalCache, WorkspaceService } from '@toeverything/infra'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { EMPTY, mergeMap, switchMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../../cloud'; +import type { ShareDocsStore } from '../stores/share-docs'; + +type ShareDocListType = + GetWorkspacePublicPagesQuery['workspace']['publicPages']; + +export const logger = new DebugLogger('affine:share-doc-list'); + +export class ShareDocsList extends Entity { + list$ = LiveData.from(this.cache.watch('share-docs'), []); + isLoading$ = new LiveData(false); + error$ = new LiveData(null); + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly store: ShareDocsStore, + private readonly cache: GlobalCache + ) { + super(); + } + + revalidate = effect( + switchMap(() => + fromPromise(signal => + this.store.getWorkspacesShareDocs( + this.workspaceService.workspace.id, + signal + ) + ).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mergeMap(list => { + this.cache.set('share-docs', list); + return EMPTY; + }), + catchErrorInto(this.error$, err => + logger.error('revalidate share docs error', err) + ), + onStart(() => { + this.isLoading$.next(true); + }), + onComplete(() => { + this.isLoading$.next(false); + }) + ) + ) + ); +} diff --git a/packages/frontend/core/src/modules/share-doc/entities/share-info.ts b/packages/frontend/core/src/modules/share-doc/entities/share-info.ts new file mode 100644 index 0000000000..c1f59d6270 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/entities/share-info.ts @@ -0,0 +1,92 @@ +import type { + GetWorkspacePublicPageByIdQuery, + PublicPageMode, +} from '@affine/graphql'; +import type { DocService, WorkspaceService } from '@toeverything/infra'; +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + fromPromise, + LiveData, + mapInto, + onComplete, + onStart, +} from '@toeverything/infra'; +import { switchMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../../cloud'; +import type { ShareStore } from '../stores/share'; + +type ShareInfoType = GetWorkspacePublicPageByIdQuery['workspace']['publicPage']; + +export class Share extends Entity { + info$ = new LiveData(null); + isShared$ = this.info$.map(info => + // null means not loaded yet, undefined means not shared + info !== null ? info !== undefined : null + ); + sharedMode$ = this.info$.map(info => (info !== null ? info?.mode : null)); + + error$ = new LiveData(null); + isRevalidating$ = new LiveData(false); + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly docService: DocService, + private readonly store: ShareStore + ) { + super(); + } + + revalidate = effect( + switchMap(() => { + return fromPromise(signal => + this.store.getShareInfoByDocId( + this.workspaceService.workspace.id, + this.docService.doc.id, + signal + ) + ).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mapInto(this.info$), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ); + }) + ); + + waitForRevalidation(signal?: AbortSignal) { + this.revalidate(); + return this.isRevalidating$.waitFor(v => v === false, signal); + } + + async enableShare(mode: PublicPageMode) { + await this.store.enableSharePage( + this.workspaceService.workspace.id, + this.docService.doc.id, + mode + ); + await this.waitForRevalidation(); + } + + async changeShare(mode: PublicPageMode) { + await this.enableShare(mode); + } + + async disableShare() { + await this.store.disableSharePage( + this.workspaceService.workspace.id, + this.docService.doc.id + ); + await this.waitForRevalidation(); + } +} diff --git a/packages/frontend/core/src/modules/share-doc/index.ts b/packages/frontend/core/src/modules/share-doc/index.ts new file mode 100644 index 0000000000..074dcedd90 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/index.ts @@ -0,0 +1,35 @@ +export { ShareService } from './services/share'; +export { ShareDocsService } from './services/share-docs'; + +import { + DocScope, + DocService, + type Framework, + WorkspaceLocalCache, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { GraphQLService } from '../cloud'; +import { ShareDocsList } from './entities/share-docs-list'; +import { Share } from './entities/share-info'; +import { ShareService } from './services/share'; +import { ShareDocsService } from './services/share-docs'; +import { ShareStore } from './stores/share'; +import { ShareDocsStore } from './stores/share-docs'; + +export function configureShareDocsModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(ShareDocsService) + .store(ShareDocsStore, [GraphQLService]) + .entity(ShareDocsList, [ + WorkspaceService, + ShareDocsStore, + WorkspaceLocalCache, + ]) + .scope(DocScope) + .service(ShareService) + .entity(Share, [WorkspaceService, DocService, ShareStore]) + .store(ShareStore, [GraphQLService]); +} diff --git a/packages/frontend/core/src/modules/share-doc/services/share-docs.ts b/packages/frontend/core/src/modules/share-doc/services/share-docs.ts new file mode 100644 index 0000000000..e550b5fe95 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/services/share-docs.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { ShareDocsList } from '../entities/share-docs-list'; + +export class ShareDocsService extends Service { + shareDocs = this.framework.createEntity(ShareDocsList); +} diff --git a/packages/frontend/core/src/modules/share-doc/services/share.ts b/packages/frontend/core/src/modules/share-doc/services/share.ts new file mode 100644 index 0000000000..119ed79ac3 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/services/share.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { Share } from '../entities/share-info'; + +export class ShareService extends Service { + share = this.framework.createEntity(Share); +} diff --git a/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts b/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts new file mode 100644 index 0000000000..2f67867ec5 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts @@ -0,0 +1,22 @@ +import type { GraphQLService } from '@affine/core/modules/cloud'; +import { getWorkspacePublicPagesQuery } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +export class ShareDocsStore extends Store { + constructor(private readonly graphqlService: GraphQLService) { + super(); + } + + async getWorkspacesShareDocs(workspaceId: string, signal?: AbortSignal) { + const data = await this.graphqlService.gql({ + query: getWorkspacePublicPagesQuery, + variables: { + workspaceId: workspaceId, + }, + context: { + signal, + }, + }); + return data.workspace.publicPages; + } +} diff --git a/packages/frontend/core/src/modules/share-doc/stores/share.ts b/packages/frontend/core/src/modules/share-doc/stores/share.ts new file mode 100644 index 0000000000..f6abe0bf86 --- /dev/null +++ b/packages/frontend/core/src/modules/share-doc/stores/share.ts @@ -0,0 +1,69 @@ +import type { PublicPageMode } from '@affine/graphql'; +import { + getWorkspacePublicPageByIdQuery, + publishPageMutation, + revokePublicPageMutation, +} from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { GraphQLService } from '../../cloud'; + +export class ShareStore extends Store { + constructor(private readonly gqlService: GraphQLService) { + super(); + } + + async getShareInfoByDocId( + workspaceId: string, + docId: string, + signal?: AbortSignal + ) { + const data = await this.gqlService.gql({ + query: getWorkspacePublicPageByIdQuery, + variables: { + pageId: docId, + workspaceId, + }, + context: { + signal, + }, + }); + return data.workspace.publicPage ?? undefined; + } + + async enableSharePage( + workspaceId: string, + pageId: string, + docMode?: PublicPageMode, + signal?: AbortSignal + ) { + await this.gqlService.gql({ + query: publishPageMutation, + variables: { + pageId, + workspaceId, + mode: docMode, + }, + context: { + signal, + }, + }); + } + + async disableSharePage( + workspaceId: string, + pageId: string, + signal?: AbortSignal + ) { + await this.gqlService.gql({ + query: revokePublicPageMutation, + variables: { + pageId, + workspaceId, + }, + context: { + signal, + }, + }); + } +} diff --git a/packages/frontend/core/src/modules/infra-web/storage/index.ts b/packages/frontend/core/src/modules/storage/impls/storage.ts similarity index 100% rename from packages/frontend/core/src/modules/infra-web/storage/index.ts rename to packages/frontend/core/src/modules/storage/impls/storage.ts diff --git a/packages/frontend/core/src/modules/storage/index.ts b/packages/frontend/core/src/modules/storage/index.ts new file mode 100644 index 0000000000..3e60428e9e --- /dev/null +++ b/packages/frontend/core/src/modules/storage/index.ts @@ -0,0 +1,11 @@ +import { type Framework, GlobalCache, GlobalState } from '@toeverything/infra'; + +import { + LocalStorageGlobalCache, + LocalStorageGlobalState, +} from './impls/storage'; + +export function configureStorageImpls(framework: Framework) { + framework.impl(GlobalCache, LocalStorageGlobalCache); + framework.impl(GlobalState, LocalStorageGlobalState); +} diff --git a/packages/frontend/core/src/modules/tag/entities/tag-list.ts b/packages/frontend/core/src/modules/tag/entities/tag-list.ts new file mode 100644 index 0000000000..2e50b994f6 --- /dev/null +++ b/packages/frontend/core/src/modules/tag/entities/tag-list.ts @@ -0,0 +1,79 @@ +import type { DocsService } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; + +import { Tag } from '../entities/tag'; +import type { TagStore } from '../stores/tag'; + +export class TagList extends Entity { + constructor( + private readonly store: TagStore, + private readonly docs: DocsService + ) { + super(); + } + + readonly tags$ = LiveData.from(this.store.watchTagIds(), []).map(ids => { + return ids.map(id => this.framework.createEntity(Tag, { id })); + }); + + createTag(value: string, color: string) { + const newId = this.store.createNewTag(value, color); + const newTag = this.framework.createEntity(Tag, { id: newId }); + return newTag; + } + + deleteTag(tagId: string) { + this.store.deleteTag(tagId); + } + + tagsByPageId$(pageId: string) { + return LiveData.computed(get => { + const docRecord = get(this.docs.list.doc$(pageId)); + if (!docRecord) return []; + const tagIds = get(docRecord.meta$).tags; + + return get(this.tags$).filter(tag => (tagIds ?? []).includes(tag.id)); + }); + } + + tagIdsByPageId$(pageId: string) { + return this.tagsByPageId$(pageId).map(tags => tags.map(tag => tag.id)); + } + + tagByTagId$(tagId?: string) { + return this.tags$.map(tags => tags.find(tag => tag.id === tagId)); + } + + tagMetas$ = LiveData.computed(get => { + return get(this.tags$).map(tag => { + return { + id: tag.id, + title: get(tag.value$), + color: get(tag.color$), + pageCount: get(tag.pageIds$).length, + createDate: get(tag.createDate$), + updatedDate: get(tag.updateDate$), + }; + }); + }); + + private filterFn(value: string, query?: string) { + const trimmedQuery = query?.trim().toLowerCase() ?? ''; + const trimmedValue = value.trim().toLowerCase(); + return trimmedValue.includes(trimmedQuery); + } + + filterTagsByName$(name: string) { + return LiveData.computed(get => { + return get(this.tags$).filter(tag => + this.filterFn(get(tag.value$), name) + ); + }); + } + + tagByTagValue$(value: string) { + return LiveData.computed(get => { + return get(this.tags$).find(tag => this.filterFn(get(tag.value$), value)); + }); + } +} diff --git a/packages/frontend/core/src/modules/tag/entities/tag.ts b/packages/frontend/core/src/modules/tag/entities/tag.ts index 4550021946..0d5018a516 100644 --- a/packages/frontend/core/src/modules/tag/entities/tag.ts +++ b/packages/frontend/core/src/modules/tag/entities/tag.ts @@ -1,31 +1,33 @@ -import type { Tag as TagSchema } from '@affine/env/filter'; -import type { PageRecordList } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra'; +import type { DocsService } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; -import type { WorkspaceLegacyProperties } from '../../workspace'; +import type { TagStore } from '../stores/tag'; import { tagColorMap } from './utils'; -export class Tag { +export class Tag extends Entity<{ id: string }> { + id = this.props.id; constructor( - readonly id: string, - private readonly properties: WorkspaceLegacyProperties, - private readonly pageRecordList: PageRecordList - ) {} + private readonly store: TagStore, + private readonly docs: DocsService + ) { + super(); + } - private readonly tagOption$ = this.properties.tagOptions$.map( - tags => tags.find(tag => tag.id === this.id) as TagSchema - ); + private readonly tagOption$ = LiveData.from( + this.store.watchTagInfo(this.id), + undefined + ).map(tagInfo => tagInfo); value$ = this.tagOption$.map(tag => tag?.value || ''); - color$ = this.tagOption$.map(tag => tagColorMap(tag?.color) || ''); + color$ = this.tagOption$.map(tag => tagColorMap(tag?.color ?? '') || ''); createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now()); updateDate$ = this.tagOption$.map(tag => tag?.updateDate || Date.now()); rename(value: string) { - this.properties.updateTagOption(this.id, { + this.store.updateTagInfo(this.id, { id: this.id, value, color: this.color$.value, @@ -35,17 +37,13 @@ export class Tag { } changeColor(color: string) { - this.properties.updateTagOption(this.id, { - id: this.id, - value: this.value$.value, + this.store.updateTagInfo(this.id, { color, - createDate: this.createDate$.value, - updateDate: Date.now(), }); } tag(pageId: string) { - const pageRecord = this.pageRecordList.record$(pageId).value; + const pageRecord = this.docs.list.doc$(pageId).value; if (!pageRecord) { return; } @@ -55,7 +53,7 @@ export class Tag { } untag(pageId: string) { - const pageRecord = this.pageRecordList.record$(pageId).value; + const pageRecord = this.docs.list.doc$(pageId).value; if (!pageRecord) { return; } @@ -65,7 +63,7 @@ export class Tag { } readonly pageIds$ = LiveData.computed(get => { - const pages = get(this.pageRecordList.records$); + const pages = get(this.docs.list.docs$); return pages .filter(page => get(page.meta$).tags?.includes(this.id)) .map(page => page.id); diff --git a/packages/frontend/core/src/modules/tag/index.ts b/packages/frontend/core/src/modules/tag/index.ts index c485f457b9..69c0f1f750 100644 --- a/packages/frontend/core/src/modules/tag/index.ts +++ b/packages/frontend/core/src/modules/tag/index.ts @@ -2,3 +2,24 @@ export { Tag } from './entities/tag'; export { tagColorMap } from './entities/utils'; export { TagService } from './service/tag'; export { DeleteTagConfirmModal } from './view/delete-tag-modal'; + +import { + DocsService, + type Framework, + WorkspaceScope, +} from '@toeverything/infra'; + +import { WorkspaceLegacyProperties } from '../properties'; +import { Tag } from './entities/tag'; +import { TagList } from './entities/tag-list'; +import { TagService } from './service/tag'; +import { TagStore } from './stores/tag'; + +export function configureTagModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(TagService) + .store(TagStore, [WorkspaceLegacyProperties]) + .entity(TagList, [TagStore, DocsService]) + .entity(Tag, [TagStore, DocsService]); +} diff --git a/packages/frontend/core/src/modules/tag/service/tag.ts b/packages/frontend/core/src/modules/tag/service/tag.ts index e1d6a25b89..b589caf358 100644 --- a/packages/frontend/core/src/modules/tag/service/tag.ts +++ b/packages/frontend/core/src/modules/tag/service/tag.ts @@ -1,88 +1,7 @@ -import type { PageRecordList } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra'; -import { nanoid } from 'nanoid'; +import { Service } from '@toeverything/infra'; -import type { WorkspaceLegacyProperties } from '../../workspace'; -import { Tag } from '../entities/tag'; +import { TagList } from '../entities/tag-list'; -export class TagService { - constructor( - private readonly properties: WorkspaceLegacyProperties, - private readonly pageRecordList: PageRecordList - ) {} - - readonly tags$ = this.properties.tagOptions$.map(tags => - tags.map(tag => new Tag(tag.id, this.properties, this.pageRecordList)) - ); - - createTag(value: string, color: string) { - const newId = nanoid(); - this.properties.updateTagOptions([ - ...this.properties.tagOptions$.value, - { - id: newId, - value, - color, - createDate: Date.now(), - updateDate: Date.now(), - }, - ]); - const newTag = new Tag(newId, this.properties, this.pageRecordList); - return newTag; - } - - deleteTag(tagId: string) { - this.properties.removeTagOption(tagId); - } - - tagsByPageId$(pageId: string) { - return LiveData.computed(get => { - const pageRecord = get(this.pageRecordList.record$(pageId)); - if (!pageRecord) return []; - const tagIds = get(pageRecord.meta$).tags; - - return get(this.tags$).filter(tag => (tagIds ?? []).includes(tag.id)); - }); - } - - tagIdsByPageId$(pageId: string) { - return this.tagsByPageId$(pageId).map(tags => tags.map(tag => tag.id)); - } - - tagByTagId$(tagId?: string) { - return this.tags$.map(tags => tags.find(tag => tag.id === tagId)); - } - - tagMetas$ = LiveData.computed(get => { - return get(this.tags$).map(tag => { - return { - id: tag.id, - title: get(tag.value$), - color: get(tag.color$), - pageCount: get(tag.pageIds$).length, - createDate: get(tag.createDate$), - updatedDate: get(tag.updateDate$), - }; - }); - }); - - private filterFn(value: string, query?: string) { - const trimmedQuery = query?.trim().toLowerCase() ?? ''; - const trimmedValue = value.trim().toLowerCase(); - return trimmedValue.includes(trimmedQuery); - } - - filterTagsByName$(name: string) { - return LiveData.computed(get => { - return get(this.tags$).filter(tag => - this.filterFn(get(tag.value$), name) - ); - }); - } - - tagByTagValue$(value: string) { - return LiveData.computed(get => { - return get(this.tags$).find(tag => this.filterFn(get(tag.value$), value)); - }); - } +export class TagService extends Service { + tagList = this.framework.createEntity(TagList); } diff --git a/packages/frontend/core/src/modules/tag/stores/tag.ts b/packages/frontend/core/src/modules/tag/stores/tag.ts new file mode 100644 index 0000000000..28ce9e760d --- /dev/null +++ b/packages/frontend/core/src/modules/tag/stores/tag.ts @@ -0,0 +1,59 @@ +import type { Tag as TagSchema } from '@affine/env/filter'; +import { Store } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; + +import type { WorkspaceLegacyProperties } from '../../properties'; + +export class TagStore extends Store { + constructor(private readonly properties: WorkspaceLegacyProperties) { + super(); + } + + watchTagIds() { + return this.properties.tagOptions$ + .map(tags => tags.map(tag => tag.id)) + .asObservable(); + } + + createNewTag(value: string, color: string) { + const newId = nanoid(); + this.properties.updateTagOptions([ + ...this.properties.tagOptions$.value, + { + id: newId, + value, + color, + createDate: Date.now(), + updateDate: Date.now(), + }, + ]); + return newId; + } + + deleteTag(id: string) { + this.properties.removeTagOption(id); + } + + watchTagInfo(id: string) { + return this.properties.tagOptions$.map( + tags => tags.find(tag => tag.id === id) as TagSchema | undefined + ); + } + + updateTagInfo(id: string, tagInfo: Partial) { + const tag = this.properties.tagOptions$.value.find(tag => tag.id === id) as + | TagSchema + | undefined; + if (!tag) { + return; + } + this.properties.updateTagOption(id, { + id: id, + value: tag.value, + color: tag.color, + createDate: tag.createDate, + updateDate: Date.now(), + ...tagInfo, + }); + } +} diff --git a/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx b/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx index ed41f68be2..230983c792 100644 --- a/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx +++ b/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx @@ -17,7 +17,7 @@ export const DeleteTagConfirmModal = ({ }) => { const t = useAFFiNEI18N(); const tagService = useService(TagService); - const tags = useLiveData(tagService.tags$); + const tags = useLiveData(tagService.tagList.tags$); const selectedTags = useMemo(() => { return tags.filter(tag => selectedTagIds.includes(tag.id)); }, [selectedTagIds, tags]); @@ -25,7 +25,7 @@ export const DeleteTagConfirmModal = ({ const handleDelete = useCallback(() => { selectedTagIds.forEach(tagId => { - tagService.deleteTag(tagId); + tagService.tagList.deleteTag(tagId); }); toast( diff --git a/packages/frontend/core/src/modules/telemetry/index.ts b/packages/frontend/core/src/modules/telemetry/index.ts new file mode 100644 index 0000000000..9a1b05362b --- /dev/null +++ b/packages/frontend/core/src/modules/telemetry/index.ts @@ -0,0 +1,8 @@ +import type { Framework } from '@toeverything/infra'; + +import { AuthService } from '../cloud'; +import { TelemetryService } from './services/telemetry'; + +export function configureTelemetryModule(framework: Framework) { + framework.service(TelemetryService, [AuthService]); +} diff --git a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts new file mode 100644 index 0000000000..d73482d541 --- /dev/null +++ b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts @@ -0,0 +1,32 @@ +import { mixpanel } from '@affine/core/utils'; +import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra'; + +import { + AccountChanged, + type AuthAccountInfo, + type AuthService, +} from '../../cloud'; + +@OnEvent(ApplicationStarted, e => e.onApplicationStart) +@OnEvent(AccountChanged, e => e.onAccountChanged) +export class TelemetryService extends Service { + constructor(private readonly auth: AuthService) { + super(); + } + + onApplicationStart() { + const account = this.auth.session.account$.value; + if (account) { + mixpanel.identify(account.id); + } + } + + onAccountChanged(account: AuthAccountInfo | null) { + if (account === null) { + mixpanel.reset(); + } else { + mixpanel.reset(); + mixpanel.identify(account.id); + } + } +} diff --git a/packages/frontend/core/src/modules/workbench/entities/view.ts b/packages/frontend/core/src/modules/workbench/entities/view.ts index dc3ce94e85..dd87d159fe 100644 --- a/packages/frontend/core/src/modules/workbench/entities/view.ts +++ b/packages/frontend/core/src/modules/workbench/entities/view.ts @@ -1,21 +1,24 @@ -import { LiveData } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; import type { Location, To } from 'history'; -import { nanoid } from 'nanoid'; import { Observable } from 'rxjs'; import { createIsland } from '../../../utils/island'; import { createNavigableHistory } from '../../../utils/navigable-history'; +import type { ViewScope } from '../scopes/view'; -export class View { - constructor(defaultPath: To = { pathname: '/all' }) { +export class View extends Entity { + id = this.scope.props.id; + + constructor(public readonly scope: ViewScope) { + super(); this.history = createNavigableHistory({ - initialEntries: [defaultPath], + initialEntries: [ + this.scope.props.defaultLocation ?? { pathname: '/all' }, + ], initialIndex: 0, }); } - id = nanoid(); - history = createNavigableHistory({ initialEntries: ['/all'], initialIndex: 0, diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index bd943c01a2..65b5649bb8 100644 --- a/packages/frontend/core/src/modules/workbench/entities/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/entities/workbench.ts @@ -1,9 +1,12 @@ import { Unreachable } from '@affine/env/constant'; -import { LiveData } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; import type { To } from 'history'; +import { nanoid } from 'nanoid'; import { combineLatest, map, switchMap } from 'rxjs'; -import { View } from './view'; +import { ViewScope } from '../scopes/view'; +import { ViewService } from '../services/view'; +import type { View } from './view'; export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number; @@ -12,8 +15,11 @@ interface WorkbenchOpenOptions { replaceHistory?: boolean; } -export class Workbench { - readonly views$ = new LiveData([new View()]); +export class Workbench extends Entity { + readonly views$ = new LiveData([ + this.framework.createScope(ViewScope, { id: nanoid() }).get(ViewService) + .view, + ]); activeViewIndex$ = new LiveData(0); activeView$ = LiveData.from( @@ -35,7 +41,9 @@ export class Workbench { } createView(at: WorkbenchPosition = 'beside', defaultLocation: To) { - const view = new View(defaultLocation); + const view = this.framework + .createScope(ViewScope, { id: nanoid(), defaultLocation }) + .get(ViewService).view; const newViews = [...this.views$.value]; newViews.splice(this.indexAt(at), 0, view); this.views$.next(newViews); diff --git a/packages/frontend/core/src/modules/workbench/index.ts b/packages/frontend/core/src/modules/workbench/index.ts index 9cefeba9f7..52555be4cf 100644 --- a/packages/frontend/core/src/modules/workbench/index.ts +++ b/packages/frontend/core/src/modules/workbench/index.ts @@ -1,7 +1,26 @@ -export { View } from './entities/view'; export { Workbench } from './entities/workbench'; +export { ViewScope as View } from './scopes/view'; +export { WorkbenchService } from './services/workbench'; export { useIsActiveView } from './view/use-is-active-view'; export { ViewBodyIsland } from './view/view-body-island'; export { ViewHeaderIsland } from './view/view-header-island'; export { WorkbenchLink } from './view/workbench-link'; export { WorkbenchRoot } from './view/workbench-root'; + +import { type Framework, WorkspaceScope } from '@toeverything/infra'; + +import { View } from './entities/view'; +import { Workbench } from './entities/workbench'; +import { ViewScope } from './scopes/view'; +import { ViewService } from './services/view'; +import { WorkbenchService } from './services/workbench'; + +export function configureWorkbenchModule(services: Framework) { + services + .scope(WorkspaceScope) + .service(WorkbenchService) + .entity(Workbench) + .scope(ViewScope) + .entity(View, [ViewScope]) + .service(ViewService); +} diff --git a/packages/frontend/core/src/modules/workbench/scopes/view.ts b/packages/frontend/core/src/modules/workbench/scopes/view.ts new file mode 100644 index 0000000000..8a286577e5 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/scopes/view.ts @@ -0,0 +1,7 @@ +import { Scope } from '@toeverything/infra'; +import type { To } from 'history'; + +export class ViewScope extends Scope<{ + id: string; + defaultLocation?: To | undefined; +}> {} diff --git a/packages/frontend/core/src/modules/workbench/services/view.ts b/packages/frontend/core/src/modules/workbench/services/view.ts new file mode 100644 index 0000000000..028e57d41a --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/services/view.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { View } from '../entities/view'; + +export class ViewService extends Service { + view = this.framework.createEntity(View); +} diff --git a/packages/frontend/core/src/modules/workbench/services/workbench.ts b/packages/frontend/core/src/modules/workbench/services/workbench.ts new file mode 100644 index 0000000000..69474ae075 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/services/workbench.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { Workbench } from '../entities/workbench'; + +export class WorkbenchService extends Service { + workbench = this.framework.createEntity(Workbench); +} diff --git a/packages/frontend/core/src/modules/workbench/view/route-container.tsx b/packages/frontend/core/src/modules/workbench/view/route-container.tsx index 8db2b9299d..d93cb82dd9 100644 --- a/packages/frontend/core/src/modules/workbench/view/route-container.tsx +++ b/packages/frontend/core/src/modules/workbench/view/route-container.tsx @@ -6,13 +6,11 @@ import { useAtomValue } from 'jotai'; import { Suspense, useCallback } from 'react'; import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary'; -import { - appSidebarOpenAtom, - SidebarSwitch, -} from '../../../components/app-sidebar'; -import { RightSidebar } from '../../right-sidebar'; +import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai'; +import { SidebarSwitch } from '../../../components/app-sidebar/sidebar-header/sidebar-switch'; +import { RightSidebarService } from '../../right-sidebar'; +import { ViewService } from '../services/view'; import * as styles from './route-container.css'; -import { useView } from './use-view'; import { useViewPosition } from './use-view-position'; export interface Props { @@ -43,10 +41,10 @@ const ToggleButton = ({ }; export const RouteContainer = ({ route }: Props) => { - const view = useView(); + const view = useService(ViewService).view; const viewPosition = useViewPosition(); const leftSidebarOpen = useAtomValue(appSidebarOpenAtom); - const rightSidebar = useService(RightSidebar); + const rightSidebar = useService(RightSidebarService).rightSidebar; const rightSidebarOpen = useLiveData(rightSidebar.isOpen$); const rightSidebarHasViews = useLiveData(rightSidebar.hasViews$); const handleToggleRightSidebar = useCallback(() => { diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx index 2efec77412..4edd813b50 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx @@ -19,7 +19,7 @@ import type { import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { View } from '../../entities/view'; -import { Workbench } from '../../entities/workbench'; +import { WorkbenchService } from '../../services/workbench'; import { SplitViewIndicator } from './indicator'; import * as styles from './split-view.css'; @@ -40,7 +40,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({ const [indicatorPressed, setIndicatorPressed] = useState(false); const ref = useRef(null); const size = useLiveData(view.size$); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const activeView = useLiveData(workbench.activeView$); const views = useLiveData(workbench.views$); const isLast = views[views.length - 1] === view; @@ -109,7 +109,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({ const SplitViewMenu = ({ view }: { view: View }) => { const t = useAFFiNEI18N(); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const views = useLiveData(workbench.views$); const viewIndex = views.findIndex(v => v === view); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx index f9a6cba126..375c8fb55a 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx @@ -19,7 +19,7 @@ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { View } from '../../entities/view'; -import { Workbench } from '../../entities/workbench'; +import { WorkbenchService } from '../../services/workbench'; import { SplitViewPanel } from './panel'; import { ResizeHandle } from './resize-handle'; import * as styles from './split-view.css'; @@ -49,7 +49,7 @@ export const SplitView = ({ const [slots, setSlots] = useState({}); const [resizingViewId, setResizingViewId] = useState(null); const { appSettings } = useAppSettingHelper(); - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const sensors = useSensors( useSensor(PointerSensor, { diff --git a/packages/frontend/core/src/modules/workbench/view/use-is-active-view.tsx b/packages/frontend/core/src/modules/workbench/view/use-is-active-view.tsx index 09429b52ac..26f97191ca 100644 --- a/packages/frontend/core/src/modules/workbench/view/use-is-active-view.tsx +++ b/packages/frontend/core/src/modules/workbench/view/use-is-active-view.tsx @@ -1,11 +1,12 @@ import { useLiveData, useService } from '@toeverything/infra'; -import { Workbench } from '../entities/workbench'; -import { useView } from './use-view'; +import { ViewService } from '../services/view'; +import { WorkbenchService } from '../services/workbench'; export function useIsActiveView() { - const workbench = useService(Workbench); - const currentView = useView(); + const workbench = useService(WorkbenchService).workbench; + const view = useService(ViewService).view; + const activeView = useLiveData(workbench.activeView$); - return currentView === activeView; + return view === activeView; } diff --git a/packages/frontend/core/src/modules/workbench/view/use-view-position.tsx b/packages/frontend/core/src/modules/workbench/view/use-view-position.tsx index d728e963ca..9b9eb6888f 100644 --- a/packages/frontend/core/src/modules/workbench/view/use-view-position.tsx +++ b/packages/frontend/core/src/modules/workbench/view/use-view-position.tsx @@ -2,12 +2,12 @@ import { useService } from '@toeverything/infra'; import { useEffect, useState } from 'react'; import type { View } from '../entities/view'; -import { Workbench } from '../entities/workbench'; -import { useView } from './use-view'; +import { ViewService } from '../services/view'; +import { WorkbenchService } from '../services/workbench'; export const useViewPosition = () => { - const workbench = useService(Workbench); - const view = useView(); + const workbench = useService(WorkbenchService).workbench; + const view = useService(ViewService).view; const [position, setPosition] = useState(() => calcPosition(view, workbench.views$.value) diff --git a/packages/frontend/core/src/modules/workbench/view/use-view.tsx b/packages/frontend/core/src/modules/workbench/view/use-view.tsx deleted file mode 100644 index 588d26e01a..0000000000 --- a/packages/frontend/core/src/modules/workbench/view/use-view.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from 'react'; - -import type { View } from '../entities/view'; - -export const ViewContext = createContext(null); - -export const useView = () => { - const view = useContext(ViewContext); - if (!view) { - throw new Error( - 'No view found in context. Make sure you are rendering inside a ViewRoot.' - ); - } - return view; -}; diff --git a/packages/frontend/core/src/modules/workbench/view/view-body-island.tsx b/packages/frontend/core/src/modules/workbench/view/view-body-island.tsx index 6ca691433a..642da4b943 100644 --- a/packages/frontend/core/src/modules/workbench/view/view-body-island.tsx +++ b/packages/frontend/core/src/modules/workbench/view/view-body-island.tsx @@ -1,6 +1,8 @@ -import { useView } from './use-view'; +import { useService } from '@toeverything/infra'; + +import { ViewService } from '../services/view'; export const ViewBodyIsland = ({ children }: React.PropsWithChildren) => { - const view = useView(); + const view = useService(ViewService).view; return {children}; }; diff --git a/packages/frontend/core/src/modules/workbench/view/view-header-island.tsx b/packages/frontend/core/src/modules/workbench/view/view-header-island.tsx index ca875eb88d..4cd6a4d626 100644 --- a/packages/frontend/core/src/modules/workbench/view/view-header-island.tsx +++ b/packages/frontend/core/src/modules/workbench/view/view-header-island.tsx @@ -1,6 +1,8 @@ -import { useView } from './use-view'; +import { useService } from '@toeverything/infra'; + +import { ViewService } from '../services/view'; export const ViewHeaderIsland = ({ children }: React.PropsWithChildren) => { - const view = useView(); + const view = useService(ViewService).view; return {children}; }; diff --git a/packages/frontend/core/src/modules/workbench/view/view-root.tsx b/packages/frontend/core/src/modules/workbench/view/view-root.tsx index 37c61c8b7a..a9b0f9e8b2 100644 --- a/packages/frontend/core/src/modules/workbench/view/view-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/view-root.tsx @@ -1,4 +1,4 @@ -import { useLiveData } from '@toeverything/infra'; +import { FrameworkScope, useLiveData } from '@toeverything/infra'; import { lazy as reactLazy, useEffect, useMemo } from 'react'; import { createMemoryRouter, @@ -10,7 +10,6 @@ import { import { viewRoutes } from '../../../router'; import type { View } from '../entities/view'; import { RouteContainer } from './route-container'; -import { ViewContext } from './use-view'; const warpedRoutes = viewRoutes.map(({ path, lazy }) => { const Component = reactLazy(() => @@ -43,7 +42,7 @@ export const ViewRoot = ({ view }: { view: View }) => { // https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736 return ( - + { - + ); }; diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 3cf9adaf29..0c33500ab3 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -4,7 +4,7 @@ import { useLiveData, useService } from '@toeverything/infra'; import type { To } from 'history'; import { useCallback } from 'react'; -import { Workbench } from '../entities/workbench'; +import { WorkbenchService } from '../services/workbench'; export const WorkbenchLink = ({ to, @@ -13,7 +13,7 @@ export const WorkbenchLink = ({ }: React.PropsWithChildren< { to: To } & React.HTMLProps >) => { - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const { appSettings } = useAppSettingHelper(); const basename = useLiveData(workbench.basename$); const link = diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx index bebf038274..46b99481ff 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import type { View } from '../entities/view'; -import { Workbench } from '../entities/workbench'; +import { WorkbenchService } from '../services/workbench'; import { useBindWorkbenchToBrowserRouter } from './browser-adapter'; import { useBindWorkbenchToDesktopRouter } from './desktop-adapter'; import { SplitView } from './split-view/split-view'; @@ -15,7 +15,7 @@ const useAdapter = environment.isDesktop : useBindWorkbenchToBrowserRouter; export const WorkbenchRoot = () => { - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; // for debugging (window as any).workbench = workbench; @@ -53,7 +53,7 @@ export const WorkbenchRoot = () => { }; const WorkbenchView = ({ view, index }: { view: View; index: number }) => { - const workbench = useService(Workbench); + const workbench = useService(WorkbenchService).workbench; const handleOnFocus = useCallback(() => { workbench.active(index); diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts new file mode 100644 index 0000000000..4fc4080237 --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -0,0 +1,276 @@ +import { DebugLogger } from '@affine/debug'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { + createWorkspaceMutation, + deleteWorkspaceMutation, + getIsOwnerQuery, + getWorkspacesQuery, +} from '@affine/graphql'; +import { DocCollection } from '@blocksuite/store'; +import { + ApplicationStarted, + type BlobStorage, + catchErrorInto, + exhaustMapSwitchUntilChanged, + fromPromise, + type GlobalState, + LiveData, + onComplete, + OnEvent, + onStart, + type Workspace, + type WorkspaceEngineProvider, + type WorkspaceFlavourProvider, + type WorkspaceMetadata, + type WorkspaceProfileInfo, +} from '@toeverything/infra'; +import { effect, globalBlockSuiteSchema, Service } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; +import { EMPTY, lastValueFrom, map, mergeMap, timeout } from 'rxjs'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import type { + AuthService, + GraphQLService, + WebSocketService, +} from '../../cloud'; +import { AccountChanged } from '../../cloud'; +import type { WorkspaceEngineStorageProvider } from '../providers/engine'; +import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel'; +import { CloudAwarenessConnection } from './engine/awareness-cloud'; +import { CloudBlobStorage } from './engine/blob-cloud'; +import { CloudDocEngineServer } from './engine/doc-cloud'; +import { CloudStaticDocStorage } from './engine/doc-cloud-static'; + +const CLOUD_WORKSPACES_CACHE_KEY = 'cloud-workspace:'; + +const logger = new DebugLogger('affine:cloud-workspace-flavour-provider'); + +@OnEvent(ApplicationStarted, e => e.revalidate) +@OnEvent(AccountChanged, e => e.revalidate) +export class CloudWorkspaceFlavourProviderService + extends Service + implements WorkspaceFlavourProvider +{ + constructor( + private readonly globalState: GlobalState, + private readonly authService: AuthService, + private readonly storageProvider: WorkspaceEngineStorageProvider, + private readonly graphqlService: GraphQLService, + private readonly webSocketService: WebSocketService + ) { + super(); + } + flavour: WorkspaceFlavour = WorkspaceFlavour.AFFINE_CLOUD; + + async deleteWorkspace(id: string): Promise { + await this.graphqlService.gql({ + query: deleteWorkspaceMutation, + variables: { + id: id, + }, + }); + this.revalidate(); + await this.waitForLoaded(); + } + async createWorkspace( + initial: ( + docCollection: DocCollection, + blobStorage: BlobStorage + ) => Promise + ): Promise { + const tempId = nanoid(); + + // create workspace on cloud, get workspace id + const { + createWorkspace: { id: workspaceId }, + } = await this.graphqlService.gql({ + query: createWorkspaceMutation, + }); + + // save the initial state to local storage, then sync to cloud + const blobStorage = this.storageProvider.getBlobStorage(workspaceId); + const docStorage = this.storageProvider.getDocStorage(workspaceId); + + const docCollection = new DocCollection({ + id: tempId, + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + blobStorages: [() => ({ crud: blobStorage })], + }); + + // apply initial state + await initial(docCollection, blobStorage); + + // save workspace to local storage, should be vary fast + await docStorage.doc.set( + workspaceId, + encodeStateAsUpdate(docCollection.doc) + ); + for (const subdocs of docCollection.doc.getSubdocs()) { + await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + } + + this.revalidate(); + await this.waitForLoaded(); + + return { id: workspaceId, flavour: WorkspaceFlavour.AFFINE_CLOUD }; + } + revalidate = effect( + map(() => { + return { accountId: this.authService.session.account$.value?.id }; + }), + exhaustMapSwitchUntilChanged( + (a, b) => a.accountId === b.accountId, + ({ accountId }) => { + return fromPromise(async signal => { + if (!accountId) { + return null; // no cloud workspace if no account + } + + const { workspaces } = await this.graphqlService.gql({ + query: getWorkspacesQuery, + context: { + signal, + }, + }); + + const ids = workspaces.map(({ id }) => id); + return { + accountId, + workspaces: ids.map(id => ({ + id, + flavour: WorkspaceFlavour.AFFINE_CLOUD, + })), + }; + }).pipe( + mergeMap(data => { + if (data) { + const { accountId, workspaces } = data; + this.globalState.set( + CLOUD_WORKSPACES_CACHE_KEY + accountId, + workspaces + ); + this.workspaces$.next(workspaces); + } else { + this.workspaces$.next([]); + } + return EMPTY; + }), + catchErrorInto(this.error$, err => { + logger.error('error to revalidate cloud workspaces', err); + }), + onStart(() => this.isLoading$.next(true)), + onComplete(() => this.isLoading$.next(false)) + ); + }, + ({ accountId }) => { + if (accountId) { + this.workspaces$.next( + this.globalState.get(CLOUD_WORKSPACES_CACHE_KEY + accountId) ?? [] + ); + } else { + this.workspaces$.next([]); + } + } + ) + ); + error$ = new LiveData(null); + isLoading$ = new LiveData(false); + workspaces$ = new LiveData([]); + async getWorkspaceProfile( + id: string + ): Promise { + // get information from both cloud and local storage + + // we use affine 'static' storage here, which use http protocol, no need to websocket. + const cloudStorage = new CloudStaticDocStorage(id); + const docStorage = this.storageProvider.getDocStorage(id); + // download root doc + const localData = await docStorage.doc.get(id); + const cloudData = await cloudStorage.pull(id); + + const isOwner = await this.getIsOwner(id); + + if (!cloudData && !localData) { + return { + isOwner, + }; + } + + const bs = new DocCollection({ + id, + schema: globalBlockSuiteSchema, + }); + + if (localData) applyUpdate(bs.doc, localData); + if (cloudData) applyUpdate(bs.doc, cloudData.data); + + return { + name: bs.meta.name, + avatar: bs.meta.avatar, + isOwner, + }; + } + async getWorkspaceBlob(id: string, blob: string): Promise { + const localBlob = await this.storageProvider.getBlobStorage(id).get(blob); + + if (localBlob) { + return localBlob; + } + + const cloudBlob = new CloudBlobStorage(id); + return await cloudBlob.get(blob); + } + getEngineProvider(workspace: Workspace): WorkspaceEngineProvider { + return { + getAwarenessConnections: () => { + return [ + new BroadcastChannelAwarenessConnection( + workspace.id, + workspace.awareness + ), + new CloudAwarenessConnection( + workspace.id, + workspace.awareness, + this.webSocketService.newSocket() + ), + ]; + }, + getDocServer: () => { + return new CloudDocEngineServer( + workspace.id, + this.webSocketService.newSocket() + ); + }, + getDocStorage: () => { + return this.storageProvider.getDocStorage(workspace.id); + }, + getLocalBlobStorage: () => { + return this.storageProvider.getBlobStorage(workspace.id); + }, + getRemoteBlobStorages() { + return [new CloudBlobStorage(workspace.id)]; + }, + }; + } + + private async getIsOwner(workspaceId: string) { + return ( + await lastValueFrom( + this.graphqlService + .rxGql({ + query: getIsOwnerQuery, + variables: { + workspaceId, + }, + }) + .pipe(timeout(3000)) + ) + ).isOwner; + } + + private waitForLoaded() { + return this.isLoading$.waitFor(loading => !loading); + } +} diff --git a/packages/frontend/workspace-impl/src/local/awareness.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/awareness-broadcast-channel.ts similarity index 92% rename from packages/frontend/workspace-impl/src/local/awareness.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/awareness-broadcast-channel.ts index cfbcbedb46..36ceff042c 100644 --- a/packages/frontend/workspace-impl/src/local/awareness.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/awareness-broadcast-channel.ts @@ -1,4 +1,4 @@ -import type { AwarenessProvider } from '@toeverything/infra'; +import type { AwarenessConnection } from '@toeverything/infra'; import type { Awareness } from 'y-protocols/awareness.js'; import { applyAwarenessUpdate, @@ -11,7 +11,9 @@ type ChannelMessage = | { type: 'connect' } | { type: 'update'; update: Uint8Array }; -export class BroadcastChannelAwarenessProvider implements AwarenessProvider { +export class BroadcastChannelAwarenessConnection + implements AwarenessConnection +{ channel: BroadcastChannel | null = null; constructor( @@ -34,6 +36,7 @@ export class BroadcastChannelAwarenessProvider implements AwarenessProvider { } ); } + disconnect(): void { this.channel?.close(); this.channel = null; diff --git a/packages/frontend/workspace-impl/src/cloud/awareness.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/awareness-cloud.ts similarity index 91% rename from packages/frontend/workspace-impl/src/cloud/awareness.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/awareness-cloud.ts index fc932c9da9..02d43d2635 100644 --- a/packages/frontend/workspace-impl/src/cloud/awareness.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/awareness-cloud.ts @@ -1,5 +1,6 @@ import { DebugLogger } from '@affine/debug'; -import type { AwarenessProvider } from '@toeverything/infra'; +import type { AwarenessConnection } from '@toeverything/infra'; +import type { Socket } from 'socket.io-client'; import type { Awareness } from 'y-protocols/awareness'; import { applyAwarenessUpdate, @@ -7,19 +8,17 @@ import { removeAwarenessStates, } from 'y-protocols/awareness'; -import { getIoManager } from '../utils/affine-io'; -import { base64ToUint8Array, uint8ArrayToBase64 } from '../utils/base64'; +import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64'; const logger = new DebugLogger('affine:awareness:socketio'); type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>; -export class AffineCloudAwarenessProvider implements AwarenessProvider { - socket = getIoManager().socket('/'); - +export class CloudAwarenessConnection implements AwarenessConnection { constructor( private readonly workspaceId: string, - private readonly awareness: Awareness + private readonly awareness: Awareness, + private readonly socket: Socket ) {} connect(): void { diff --git a/packages/frontend/workspace-impl/src/cloud/blob.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts similarity index 86% rename from packages/frontend/workspace-impl/src/cloud/blob.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts index b8bb4b1081..1206d0a802 100644 --- a/packages/frontend/workspace-impl/src/cloud/blob.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts @@ -1,7 +1,6 @@ import { deleteBlobMutation, fetcher, - fetchWithTraceReport, findGraphQLError, getBaseUrl, listBlobsQuery, @@ -10,12 +9,12 @@ import { import type { BlobStorage } from '@toeverything/infra'; import { BlobStorageOverCapacity } from '@toeverything/infra'; -import { bufferToBlob } from '../utils/buffer-to-blob'; +import { bufferToBlob } from '../../utils/buffer-to-blob'; -export class AffineCloudBlobStorage implements BlobStorage { +export class CloudBlobStorage implements BlobStorage { constructor(private readonly workspaceId: string) {} - name = 'affine-cloud'; + name = 'cloud'; readonly = false; async get(key: string) { @@ -23,7 +22,7 @@ export class AffineCloudBlobStorage implements BlobStorage { ? key : `/api/workspaces/${this.workspaceId}/blobs/${key}`; - return fetchWithTraceReport(getBaseUrl() + suffix).then(async res => { + return fetch(getBaseUrl() + suffix).then(async res => { if (!res.ok) { // status not in the range 200-299 return null; diff --git a/packages/frontend/workspace-impl/src/local/blob-indexeddb.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-indexeddb.ts similarity index 93% rename from packages/frontend/workspace-impl/src/local/blob-indexeddb.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-indexeddb.ts index 0df29ac957..d7b81db223 100644 --- a/packages/frontend/workspace-impl/src/local/blob-indexeddb.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-indexeddb.ts @@ -1,7 +1,7 @@ import type { BlobStorage } from '@toeverything/infra'; import { createStore, del, get, keys, set } from 'idb-keyval'; -import { bufferToBlob } from '../utils/buffer-to-blob'; +import { bufferToBlob } from '../../utils/buffer-to-blob'; export class IndexedDBBlobStorage implements BlobStorage { constructor(private readonly workspaceId: string) {} diff --git a/packages/frontend/workspace-impl/src/local/blob-sqlite.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts similarity index 88% rename from packages/frontend/workspace-impl/src/local/blob-sqlite.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts index 257ea3f2e4..e3506dd762 100644 --- a/packages/frontend/workspace-impl/src/local/blob-sqlite.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts @@ -2,9 +2,9 @@ import { apis } from '@affine/electron-api'; import { assertExists } from '@blocksuite/global/utils'; import type { BlobStorage } from '@toeverything/infra'; -import { bufferToBlob } from '../utils/buffer-to-blob'; +import { bufferToBlob } from '../../utils/buffer-to-blob'; -export class SQLiteBlobStorage implements BlobStorage { +export class SqliteBlobStorage implements BlobStorage { constructor(private readonly workspaceId: string) {} name = 'sqlite'; readonly = false; diff --git a/packages/frontend/workspace-impl/src/local/blob-static.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-static.ts similarity index 100% rename from packages/frontend/workspace-impl/src/local/blob-static.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-static.ts diff --git a/packages/frontend/workspace-impl/src/local/doc-broadcast-channel.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-broadcast-channel.ts similarity index 100% rename from packages/frontend/workspace-impl/src/local/doc-broadcast-channel.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-broadcast-channel.ts diff --git a/packages/frontend/workspace-impl/src/cloud/doc-static.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud-static.ts similarity index 69% rename from packages/frontend/workspace-impl/src/cloud/doc-static.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud-static.ts index fbfd97f8a7..b71a3d5eff 100644 --- a/packages/frontend/workspace-impl/src/cloud/doc-static.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud-static.ts @@ -1,17 +1,15 @@ -import { fetchWithTraceReport } from '@affine/graphql'; - -export class AffineStaticDocStorage { - name = 'affine-cloud-static'; +export class CloudStaticDocStorage { + name = 'cloud-static'; constructor(private readonly workspaceId: string) {} async pull( docId: string ): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> { - const response = await fetchWithTraceReport( + const response = await fetch( `/api/workspaces/${this.workspaceId}/docs/${docId}`, { priority: 'high', - } + } as any ); if (response.ok) { const arrayBuffer = await response.arrayBuffer(); diff --git a/packages/frontend/workspace-impl/src/cloud/doc.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts similarity index 92% rename from packages/frontend/workspace-impl/src/cloud/doc.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts index 4e2a285106..4f2009e610 100644 --- a/packages/frontend/workspace-impl/src/cloud/doc.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts @@ -3,19 +3,20 @@ import type { DocServer } from '@toeverything/infra'; import { throwIfAborted } from '@toeverything/infra'; import type { Socket } from 'socket.io-client'; -import { getIoManager } from '../utils/affine-io'; -import { base64ToUint8Array, uint8ArrayToBase64 } from '../utils/base64'; +import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64'; (window as any)._TEST_SIMULATE_SYNC_LAG = Promise.resolve(); const logger = new DebugLogger('affine-cloud-doc-engine-server'); -export class AffineCloudDocEngineServer implements DocServer { - socket = null as unknown as Socket; +export class CloudDocEngineServer implements DocServer { interruptCb: ((reason: string) => void) | null = null; SEND_TIMEOUT = 30000; - constructor(private readonly workspaceId: string) {} + constructor( + private readonly workspaceId: string, + private readonly socket: Socket + ) {} private async clientHandShake() { await this.socket.emitWithAck('client-handshake-sync', { @@ -137,8 +138,6 @@ export class AffineCloudDocEngineServer implements DocServer { }; } async waitForConnectingServer(signal: AbortSignal): Promise { - const socket = getIoManager().socket('/'); - this.socket = socket; this.socket.on('server-version-rejected', this.handleVersionRejected); this.socket.on('disconnect', this.handleDisconnect); @@ -167,7 +166,7 @@ export class AffineCloudDocEngineServer implements DocServer { this.socket.emit('client-leave-sync', this.workspaceId); this.socket.off('server-version-rejected', this.handleVersionRejected); this.socket.off('disconnect', this.handleDisconnect); - this.socket = null as unknown as Socket; + this.socket.disconnect(); } onInterrupted = (cb: (reason: string) => void) => { this.interruptCb = cb; diff --git a/packages/frontend/workspace-impl/src/local/doc-indexeddb.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-indexeddb.ts similarity index 100% rename from packages/frontend/workspace-impl/src/local/doc-indexeddb.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-indexeddb.ts diff --git a/packages/frontend/workspace-impl/src/local/doc-sqlite.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-sqlite.ts similarity index 100% rename from packages/frontend/workspace-impl/src/local/doc-sqlite.ts rename to packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-sqlite.ts diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts new file mode 100644 index 0000000000..6a3121b36d --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts @@ -0,0 +1,180 @@ +import { apis } from '@affine/electron-api'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { DocCollection } from '@blocksuite/store'; +import type { + BlobStorage, + Workspace, + WorkspaceEngineProvider, + WorkspaceFlavourProvider, + WorkspaceMetadata, + WorkspaceProfileInfo, +} from '@toeverything/infra'; +import { globalBlockSuiteSchema, LiveData, Service } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; +import { Observable } from 'rxjs'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import type { WorkspaceEngineStorageProvider } from '../providers/engine'; +import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel'; + +export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace'; +const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY = + 'affine-local-workspace-changed'; + +export class LocalWorkspaceFlavourProvider + extends Service + implements WorkspaceFlavourProvider +{ + constructor( + private readonly storageProvider: WorkspaceEngineStorageProvider + ) { + super(); + } + + flavour: WorkspaceFlavour = WorkspaceFlavour.LOCAL; + notifyChannel = new BroadcastChannel( + LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY + ); + + async deleteWorkspace(id: string): Promise { + const allWorkspaceIDs: string[] = JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ); + localStorage.setItem( + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + JSON.stringify(allWorkspaceIDs.filter(x => x !== id)) + ); + + if (apis && environment.isDesktop) { + await apis.workspace.delete(id); + } + + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(id); + } + async createWorkspace( + initial: ( + docCollection: DocCollection, + blobStorage: BlobStorage + ) => Promise + ): Promise { + const id = nanoid(); + + // save the initial state to local storage, then sync to cloud + const blobStorage = this.storageProvider.getBlobStorage(id); + const docStorage = this.storageProvider.getDocStorage(id); + + const docCollection = new DocCollection({ + id: id, + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + blobStorages: [() => ({ crud: blobStorage })], + }); + + // apply initial state + await initial(docCollection, blobStorage); + + // save workspace to local storage, should be vary fast + await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc)); + for (const subdocs of docCollection.doc.getSubdocs()) { + await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + } + + // save workspace id to local storage + const allWorkspaceIDs: string[] = JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ); + allWorkspaceIDs.push(id); + localStorage.setItem( + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + JSON.stringify(allWorkspaceIDs) + ); + + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(id); + + return { id, flavour: WorkspaceFlavour.LOCAL }; + } + workspaces$ = LiveData.from( + new Observable(subscriber => { + const emit = () => { + subscriber.next( + JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL })) + ); + }; + + emit(); + const channel = new BroadcastChannel( + LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY + ); + channel.addEventListener('message', emit); + + return () => { + channel.removeEventListener('message', emit); + channel.close(); + }; + }), + [] + ); + isLoading$ = new LiveData(false); + revalidate(): void { + // notify livedata to re-scan workspaces + this.notifyChannel.postMessage(null); + } + + async getWorkspaceProfile( + id: string + ): Promise { + const docStorage = this.storageProvider.getDocStorage(id); + const localData = await docStorage.doc.get(id); + + if (!localData) { + return { + isOwner: true, + }; + } + + const bs = new DocCollection({ + id, + schema: globalBlockSuiteSchema, + }); + + if (localData) applyUpdate(bs.doc, localData); + + return { + name: bs.meta.name, + avatar: bs.meta.avatar, + isOwner: true, + }; + } + getWorkspaceBlob(id: string, blob: string): Promise { + return this.storageProvider.getBlobStorage(id).get(blob); + } + + getEngineProvider(workspace: Workspace): WorkspaceEngineProvider { + return { + getAwarenessConnections() { + return [ + new BroadcastChannelAwarenessConnection( + workspace.id, + workspace.awareness + ), + ]; + }, + getDocServer() { + return null; + }, + getDocStorage: () => { + return this.storageProvider.getDocStorage(workspace.id); + }, + getLocalBlobStorage: () => { + return this.storageProvider.getBlobStorage(workspace.id); + }, + getRemoteBlobStorages() { + return []; + }, + }; + } +} diff --git a/packages/frontend/core/src/modules/workspace-engine/index.ts b/packages/frontend/core/src/modules/workspace-engine/index.ts new file mode 100644 index 0000000000..27e253941f --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/index.ts @@ -0,0 +1,79 @@ +import { + AuthService, + GraphQLService, + WebSocketService, +} from '@affine/core/modules/cloud'; +import { + type Framework, + GlobalState, + WorkspaceFlavourProvider, +} from '@toeverything/infra'; + +import { CloudWorkspaceFlavourProviderService } from './impls/cloud'; +import { IndexedDBBlobStorage } from './impls/engine/blob-indexeddb'; +import { SqliteBlobStorage } from './impls/engine/blob-sqlite'; +import { IndexedDBDocStorage } from './impls/engine/doc-indexeddb'; +import { SqliteDocStorage } from './impls/engine/doc-sqlite'; +import { + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + LocalWorkspaceFlavourProvider, +} from './impls/local'; +import { WorkspaceEngineStorageProvider } from './providers/engine'; + +export function configureBrowserWorkspaceFlavours(framework: Framework) { + framework + .impl(WorkspaceFlavourProvider('LOCAL'), LocalWorkspaceFlavourProvider, [ + WorkspaceEngineStorageProvider, + ]) + .service(CloudWorkspaceFlavourProviderService, [ + GlobalState, + AuthService, + WorkspaceEngineStorageProvider, + GraphQLService, + WebSocketService, + ]) + .impl(WorkspaceFlavourProvider('CLOUD'), p => + p.get(CloudWorkspaceFlavourProviderService) + ); +} + +export function configureIndexedDBWorkspaceEngineStorageProvider( + framework: Framework +) { + framework.impl(WorkspaceEngineStorageProvider, { + getDocStorage(workspaceId: string) { + return new IndexedDBDocStorage(workspaceId); + }, + getBlobStorage(workspaceId: string) { + return new IndexedDBBlobStorage(workspaceId); + }, + }); +} + +export function configureSqliteWorkspaceEngineStorageProvider( + framework: Framework +) { + framework.impl(WorkspaceEngineStorageProvider, { + getDocStorage(workspaceId: string) { + return new SqliteDocStorage(workspaceId); + }, + getBlobStorage(workspaceId: string) { + return new SqliteBlobStorage(workspaceId); + }, + }); +} + +/** + * a hack for directly add local workspace to workspace list + * Used after copying sqlite database file to appdata folder + */ +export function _addLocalWorkspace(id: string) { + const allWorkspaceIDs: string[] = JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ); + allWorkspaceIDs.push(id); + localStorage.setItem( + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + JSON.stringify(allWorkspaceIDs) + ); +} diff --git a/packages/frontend/core/src/modules/workspace-engine/providers/engine.ts b/packages/frontend/core/src/modules/workspace-engine/providers/engine.ts new file mode 100644 index 0000000000..c85ad1bf0b --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/providers/engine.ts @@ -0,0 +1,15 @@ +import { + type BlobStorage, + createIdentifier, + type DocStorage, +} from '@toeverything/infra'; + +export interface WorkspaceEngineStorageProvider { + getDocStorage(workspaceId: string): DocStorage; + getBlobStorage(workspaceId: string): BlobStorage; +} + +export const WorkspaceEngineStorageProvider = + createIdentifier( + 'WorkspaceEngineStorageProvider' + ); diff --git a/packages/frontend/workspace-impl/src/utils/__tests__/buffer-to-blob.spec.ts b/packages/frontend/core/src/modules/workspace-engine/utils/__tests__/buffer-to-blob.spec.ts similarity index 100% rename from packages/frontend/workspace-impl/src/utils/__tests__/buffer-to-blob.spec.ts rename to packages/frontend/core/src/modules/workspace-engine/utils/__tests__/buffer-to-blob.spec.ts diff --git a/packages/frontend/workspace-impl/src/utils/base64.ts b/packages/frontend/core/src/modules/workspace-engine/utils/base64.ts similarity index 100% rename from packages/frontend/workspace-impl/src/utils/base64.ts rename to packages/frontend/core/src/modules/workspace-engine/utils/base64.ts diff --git a/packages/frontend/workspace-impl/src/utils/buffer-to-blob.ts b/packages/frontend/core/src/modules/workspace-engine/utils/buffer-to-blob.ts similarity index 100% rename from packages/frontend/workspace-impl/src/utils/buffer-to-blob.ts rename to packages/frontend/core/src/modules/workspace-engine/utils/buffer-to-blob.ts diff --git a/packages/frontend/core/src/modules/workspace/current-workspace.ts b/packages/frontend/core/src/modules/workspace/current-workspace.ts deleted file mode 100644 index 9c5b40a2dd..0000000000 --- a/packages/frontend/core/src/modules/workspace/current-workspace.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Workspace } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra'; - -/** - * service to manage current workspace - */ -export class CurrentWorkspaceService { - currentWorkspace$ = new LiveData(null); - - /** - * open workspace, current workspace will be set to the workspace - * @param workspace - */ - openWorkspace(workspace: Workspace) { - this.currentWorkspace$.next(workspace); - } - - /** - * close current workspace, current workspace will be null - */ - closeWorkspace() { - this.currentWorkspace$.next(null); - } -} diff --git a/packages/frontend/core/src/modules/workspace/index.ts b/packages/frontend/core/src/modules/workspace/index.ts deleted file mode 100644 index d633b8d2ea..0000000000 --- a/packages/frontend/core/src/modules/workspace/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './current-workspace'; -export * from './properties'; diff --git a/packages/frontend/core/src/modules/workspace/properties/index.ts b/packages/frontend/core/src/modules/workspace/properties/index.ts deleted file mode 100644 index 5a9bbe0f43..0000000000 --- a/packages/frontend/core/src/modules/workspace/properties/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './adapter'; -export * from './legacy-properties'; diff --git a/packages/frontend/core/src/pages/404.tsx b/packages/frontend/core/src/pages/404.tsx index de1fa48338..9321d1d962 100644 --- a/packages/frontend/core/src/pages/404.tsx +++ b/packages/frontend/core/src/pages/404.tsx @@ -2,14 +2,14 @@ import { NoPermissionOrNotFound, NotFoundPage, } from '@affine/component/not-found-page'; -import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { useLiveData, useService } from '@toeverything/infra'; import type { ReactElement } from 'react'; import { useCallback, useState } from 'react'; import { SignOutModal } from '../components/affine/sign-out-modal'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; -import { signOutCloud } from '../utils/cloud-utils'; +import { AuthService } from '../modules/cloud'; import { SignIn } from './sign-in'; export const PageNotFound = ({ @@ -17,9 +17,9 @@ export const PageNotFound = ({ }: { noPermission?: boolean; }): ReactElement => { - const { user } = useSession(); + const authService = useService(AuthService); + const account = useLiveData(authService.session.account$); const { jumpToIndex } = useNavigateHelper(); - const { reload } = useSession(); const [open, setOpen] = useState(false); const handleBackButtonClick = useCallback( @@ -33,21 +33,21 @@ export const PageNotFound = ({ const onConfirmSignOut = useAsyncCallback(async () => { setOpen(false); - await signOutCloud(); - await reload(); - }, [reload]); + await authService.signOut(); + }, [authService]); + return ( <> {noPermission ? ( } /> ) : ( diff --git a/packages/frontend/core/src/pages/auth.tsx b/packages/frontend/core/src/pages/auth.tsx index 0a3a400e9d..a15b007e81 100644 --- a/packages/frontend/core/src/pages/auth.tsx +++ b/packages/frontend/core/src/pages/auth.tsx @@ -8,7 +8,6 @@ import { SignInSuccessPage, SignUpPage, } from '@affine/component/auth-components'; -import { useCredentialsRequirement } from '@affine/core/hooks/affine/use-server-config'; import { changeEmailMutation, changePasswordMutation, @@ -17,18 +16,17 @@ import { verifyEmailMutation, } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useLiveData, useService } from '@toeverything/infra'; import type { ReactElement } from 'react'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import type { LoaderFunction } from 'react-router-dom'; import { redirect, useParams, useSearchParams } from 'react-router-dom'; import { z } from 'zod'; -import { SubscriptionRedirect } from '../components/affine/auth/subscription-redirect'; import { WindowsAppControls } from '../components/pure/header/windows-app-controls'; -import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; -import { useCurrentUser } from '../hooks/affine/use-current-user'; import { useMutation } from '../hooks/use-mutation'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; +import { AuthService, ServerConfigService } from '../modules/cloud'; const authTypeSchema = z.enum([ 'onboarding', @@ -43,9 +41,13 @@ const authTypeSchema = z.enum([ ]); export const AuthPage = (): ReactElement | null => { - const user = useCurrentUser(); + const authService = useService(AuthService); + const account = useLiveData(authService.session.account$); const t = useAFFiNEI18N(); - const { password: passwordLimits } = useCredentialsRequirement(); + const serverConfig = useService(ServerConfigService).serverConfig; + const passwordLimits = useLiveData( + serverConfig.credentialsRequirement$.map(r => r?.password) + ); const { authType } = useParams(); const [searchParams] = useSearchParams(); @@ -97,11 +99,16 @@ export const AuthPage = (): ReactElement | null => { jumpToIndex(RouteLogic.REPLACE); }, [jumpToIndex]); + if (!passwordLimits || !account) { + // TODO: loading UI + return null; + } + switch (authType) { case 'onboarding': return ( } /> @@ -109,7 +116,7 @@ export const AuthPage = (): ReactElement | null => { case 'signUp': { return ( { case 'changePassword': { return ( { case 'setPassword': { return ( { case 'confirm-change-email': { return ; } - case 'subscription-redirect': { - return ; - } case 'verify-email': { return ; } @@ -203,10 +207,16 @@ export const loader: LoaderFunction = async args => { }; export const Component = () => { - const loginStatus = useCurrentLoginStatus(); + const authService = useService(AuthService); + const isRevalidating = useLiveData(authService.session.isRevalidating$); + const loginStatus = useLiveData(authService.session.status$); const { jumpToExpired } = useNavigateHelper(); - if (loginStatus === 'unauthenticated') { + useEffect(() => { + authService.session.revalidate(); + }, [authService]); + + if (loginStatus === 'unauthenticated' && !isRevalidating) { jumpToExpired(RouteLogic.REPLACE); } @@ -214,5 +224,6 @@ export const Component = () => { return ; } + // TODO: loading UI return null; }; diff --git a/packages/frontend/core/src/pages/desktop-signin.tsx b/packages/frontend/core/src/pages/desktop-signin.tsx index 7d03c916e8..1d5a06ecf1 100644 --- a/packages/frontend/core/src/pages/desktop-signin.tsx +++ b/packages/frontend/core/src/pages/desktop-signin.tsx @@ -2,9 +2,6 @@ import { OAuthProviderType } from '@affine/graphql'; import type { LoaderFunction } from 'react-router-dom'; import { z } from 'zod'; -import { getSession } from '../hooks/affine/use-current-user'; -import { signInCloud, signOutCloud } from '../utils/cloud-utils'; - const supportedProvider = z.enum([ 'google', ...Object.values(OAuthProviderType), @@ -22,12 +19,8 @@ export const loader: LoaderFunction = async ({ request }) => { return null; } - const session = await getSession(); - - if (session.user) { - // already signed in, need to sign out first - await signOutCloud(request.url); - } + // sign out first + await fetch('/api/auth/sign-out'); const maybeProvider = supportedProvider.safeParse(provider); if (maybeProvider.success) { @@ -36,9 +29,11 @@ export const loader: LoaderFunction = async ({ request }) => { if (provider === 'google') { provider = OAuthProviderType.Google; } - await signInCloud(provider, undefined, { - redirectUri, - }); + location.href = `${ + runtimeConfig.serverUrlPrefix + }/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent( + redirectUri + )}`; } return null; }; diff --git a/packages/frontend/core/src/pages/index.tsx b/packages/frontend/core/src/pages/index.tsx index 00bdf6bd70..3cd69f5b03 100644 --- a/packages/frontend/core/src/pages/index.tsx +++ b/packages/frontend/core/src/pages/index.tsx @@ -4,8 +4,7 @@ import { initEmptyPage, useLiveData, useService, - WorkspaceListService, - WorkspaceManager, + WorkspacesService, } from '@toeverything/infra'; import { lazy, @@ -20,8 +19,8 @@ import { type LoaderFunction, useSearchParams } from 'react-router-dom'; import { createFirstAppData } from '../bootstrap/first-app-data'; import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list'; import { WorkspaceFallback } from '../components/workspace'; -import { useSession } from '../hooks/affine/use-current-user'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { AuthService } from '../modules/cloud'; import { WorkspaceSubPath } from '../shared'; const AllWorkspaceModals = lazy(() => @@ -36,14 +35,16 @@ export const loader: LoaderFunction = async () => { export const Component = () => { // navigating and creating may be slow, to avoid flickering, we show workspace fallback - const [navigating, setNavigating] = useState(false); + const [navigating, setNavigating] = useState(true); const [creating, setCreating] = useState(false); - const { status } = useSession(); - const workspaceManager = useService(WorkspaceManager); + const authService = useService(AuthService); + const loggedIn = useLiveData( + authService.session.status$.map(s => s === 'authenticated') + ); - const workspaceListService = useService(WorkspaceListService); - const list = useLiveData(workspaceListService.workspaceList$); - const workspaceListStatus = useLiveData(workspaceListService.status$); + const workspacesService = useService(WorkspacesService); + const list = useLiveData(workspacesService.list.workspaces$); + const listIsLoading = useLiveData(workspacesService.list.isLoading$); const { openPage } = useNavigateHelper(); const [searchParams] = useSearchParams(); @@ -53,26 +54,23 @@ export const Component = () => { const createCloudWorkspace = useCallback(() => { if (createOnceRef.current) return; createOnceRef.current = true; - workspaceManager - .createWorkspace(WorkspaceFlavour.AFFINE_CLOUD, async workspace => { + workspacesService + .create(WorkspaceFlavour.AFFINE_CLOUD, async workspace => { workspace.meta.setName('AFFiNE Cloud'); const page = workspace.createDoc(); initEmptyPage(page); }) .then(workspace => openPage(workspace.id, WorkspaceSubPath.ALL)) .catch(err => console.error('Failed to create cloud workspace', err)); - }, [openPage, workspaceManager]); + }, [openPage, workspacesService]); useLayoutEffect(() => { - if (workspaceListStatus.loading) { + if (listIsLoading) { return; } // check is user logged in && has cloud workspace - if ( - searchParams.get('initCloud') === 'true' && - status === 'authenticated' - ) { + if (searchParams.get('initCloud') === 'true' && loggedIn) { searchParams.delete('initCloud'); if (list.every(w => w.flavour !== WorkspaceFlavour.AFFINE_CLOUD)) { createCloudWorkspace(); @@ -81,6 +79,7 @@ export const Component = () => { } if (list.length === 0) { + setNavigating(false); return; } @@ -89,26 +88,31 @@ export const Component = () => { const openWorkspace = list.find(w => w.id === lastId) ?? list[0]; openPage(openWorkspace.id, WorkspaceSubPath.ALL); - setNavigating(true); }, [ createCloudWorkspace, list, openPage, searchParams, - status, - workspaceListStatus.loading, + listIsLoading, + loggedIn, + navigating, ]); useEffect(() => { setCreating(true); - createFirstAppData(workspaceManager) + createFirstAppData(workspacesService) + .then(workspaceMeta => { + if (workspaceMeta) { + openPage(workspaceMeta.id, WorkspaceSubPath.ALL); + } + }) .catch(err => { console.error('Failed to create first app data', err); }) .finally(() => { setCreating(false); }); - }, [workspaceManager]); + }, [openPage, workspacesService]); if (navigating || creating) { return ; diff --git a/packages/frontend/core/src/pages/invite.tsx b/packages/frontend/core/src/pages/invite.tsx index 3f07ffd834..69ae5bc671 100644 --- a/packages/frontend/core/src/pages/invite.tsx +++ b/packages/frontend/core/src/pages/invite.tsx @@ -6,6 +6,7 @@ import { fetcher, getInviteInfoQuery, } from '@affine/graphql'; +import { useLiveData, useService } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; import type { LoaderFunction } from 'react-router-dom'; @@ -13,8 +14,8 @@ import { redirect, useLoaderData } from 'react-router-dom'; import { authAtom } from '../atoms'; import { setOnceSignedInEventAtom } from '../atoms/event'; -import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; +import { AuthService } from '../modules/cloud'; export const loader: LoaderFunction = async args => { const inviteId = args.params.inviteId || ''; @@ -47,7 +48,14 @@ export const loader: LoaderFunction = async args => { }; export const Component = () => { - const loginStatus = useCurrentLoginStatus(); + const authService = useService(AuthService); + const isRevalidating = useLiveData(authService.session.isRevalidating$); + const loginStatus = useLiveData(authService.session.status$); + + useEffect(() => { + authService.session.revalidate(); + }, [authService]); + const { jumpToSignIn } = useNavigateHelper(); const { jumpToSubPath } = useNavigateHelper(); @@ -68,7 +76,7 @@ export const Component = () => { }, [inviteInfo.workspace.id, jumpToSubPath]); useEffect(() => { - if (loginStatus === 'unauthenticated') { + if (loginStatus === 'unauthenticated' && !isRevalidating) { // We can not pass function to navigate state, so we need to save it in atom setOnceSignedInEvent(openWorkspace); jumpToSignIn(RouteLogic.REPLACE, { @@ -79,6 +87,7 @@ export const Component = () => { } }, [ inviteInfo.workspace.id, + isRevalidating, jumpToSignIn, loginStatus, openWorkspace, diff --git a/packages/frontend/core/src/pages/share/share-detail-page.tsx b/packages/frontend/core/src/pages/share/share-detail-page.tsx index 11347a9400..783a5542ae 100644 --- a/packages/frontend/core/src/pages/share/share-detail-page.tsx +++ b/packages/frontend/core/src/pages/share/share-detail-page.tsx @@ -1,32 +1,23 @@ import { Scrollable } from '@affine/component'; -import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status'; import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor'; import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state'; +import { AuthService } from '@affine/core/modules/cloud'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { fetchWithTraceReport } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - AffineCloudBlobStorage, - StaticBlobStorage, -} from '@affine/workspace-impl'; import { noop } from '@blocksuite/global/utils'; import { Logo1Icon } from '@blocksuite/icons'; import type { AffineEditorContainer } from '@blocksuite/presets'; import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; -import type { Doc, PageMode } from '@toeverything/infra'; +import type { Doc, DocMode, Workspace } from '@toeverything/infra'; import { - DocStorageImpl, + DocsService, EmptyBlobStorage, - LocalBlobStorage, - PageManager, + FrameworkScope, ReadonlyDocStorage, - RemoteBlobStorage, - ServiceProviderContext, useLiveData, useService, - WorkspaceIdContext, - WorkspaceManager, - WorkspaceScope, + WorkspaceFlavourProvider, + WorkspacesService, } from '@toeverything/infra'; import clsx from 'clsx'; import { useCallback, useEffect, useState } from 'react'; @@ -42,7 +33,7 @@ import { AppContainer } from '../../components/affine/app-container'; import { PageDetailEditor } from '../../components/page-detail-editor'; import { SharePageNotFoundError } from '../../components/share-page-not-found-error'; import { MainContainer } from '../../components/workspace'; -import { CurrentWorkspaceService } from '../../modules/workspace'; +import { CloudBlobStorage } from '../../modules/workspace-engine/impls/engine/blob-cloud'; import * as styles from './share-detail-page.css'; import { ShareFooter } from './share-footer'; import { ShareHeader } from './share-header'; @@ -58,12 +49,7 @@ export async function downloadBinaryFromCloud( rootGuid: string, pageGuid: string ): Promise { - const response = await fetchWithTraceReport( - `/api/workspaces/${rootGuid}/docs/${pageGuid}`, - { - priority: 'high', - } - ); + const response = await fetch(`/api/workspaces/${rootGuid}/docs/${pageGuid}`); if (response.ok) { const publishMode = (response.headers.get('publish-mode') || 'page') as DocPublishMode; @@ -79,7 +65,7 @@ export async function downloadBinaryFromCloud( type LoaderData = { pageId: string; workspaceId: string; - publishMode: PageMode; + publishMode: DocMode; pageArrayBuffer: ArrayBuffer; workspaceArrayBuffer: ArrayBuffer; }; @@ -124,72 +110,90 @@ export const loader: LoaderFunction = async ({ params }) => { export const Component = () => { const { workspaceId, - pageId, + pageId: docId, publishMode, workspaceArrayBuffer, pageArrayBuffer, } = useLoaderData() as LoaderData; - const workspaceManager = useService(WorkspaceManager); + const workspacesService = useService(WorkspacesService); - const currentWorkspace = useService(CurrentWorkspaceService); const t = useAFFiNEI18N(); + const [workspace, setWorkspace] = useState(null); const [page, setPage] = useState(null); const [_, setActiveBlocksuiteEditor] = useActiveBlocksuiteEditor(); + const defaultCloudProvider = workspacesService.framework.get( + WorkspaceFlavourProvider('CLOUD') + ); + useEffect(() => { // create a workspace for share page - const workspace = workspaceManager.instantiate( + const { workspace } = workspacesService.open( { - id: workspaceId, - flavour: WorkspaceFlavour.AFFINE_CLOUD, + metadata: { + id: workspaceId, + flavour: WorkspaceFlavour.AFFINE_CLOUD, + }, + isSharedMode: true, }, - services => { - services - .scope(WorkspaceScope) - .addImpl(LocalBlobStorage, EmptyBlobStorage) - .addImpl(RemoteBlobStorage('affine'), AffineCloudBlobStorage, [ - WorkspaceIdContext, - ]) - .addImpl(RemoteBlobStorage('static'), StaticBlobStorage) - .addImpl( - DocStorageImpl, - new ReadonlyDocStorage({ - [workspaceId]: new Uint8Array(workspaceArrayBuffer), - [pageId]: new Uint8Array(pageArrayBuffer), - }) - ); + { + ...defaultCloudProvider, + getEngineProvider(workspace) { + return { + getDocStorage() { + return new ReadonlyDocStorage({ + [workspace.id]: new Uint8Array(workspaceArrayBuffer), + [docId]: new Uint8Array(pageArrayBuffer), + }); + }, + getAwarenessConnections() { + return []; + }, + getDocServer() { + return null; + }, + getLocalBlobStorage() { + return EmptyBlobStorage; + }, + getRemoteBlobStorages() { + return [new CloudBlobStorage(workspace.id)]; + }, + }; + }, } ); + setWorkspace(workspace); + workspace.engine .waitForRootDocReady() .then(() => { - const { page } = workspace.services.get(PageManager).open(pageId); + const { doc } = workspace.scope.get(DocsService).open(docId); workspace.docCollection.awarenessStore.setReadonly( - page.blockSuiteDoc.blockCollection, + doc.blockSuiteDoc.blockCollection, true ); - currentWorkspace.openWorkspace(workspace); - setPage(page); + setPage(doc); }) .catch(err => { console.error(err); }); }, [ - currentWorkspace, + defaultCloudProvider, pageArrayBuffer, - pageId, + docId, workspaceArrayBuffer, workspaceId, - workspaceManager, + workspacesService, ]); const pageTitle = useLiveData(page?.title$); usePageDocumentTitle(pageTitle); - const loginStatus = useCurrentLoginStatus(); + const authService = useService(AuthService); + const loginStatus = useLiveData(authService.session.status$); const onEditorLoad = useCallback( (_: BlockSuiteDoc, editor: AffineEditorContainer) => { @@ -199,57 +203,59 @@ export const Component = () => { [setActiveBlocksuiteEditor] ); - if (!page) { + if (!workspace || !page) { return; } return ( - - - -
-
- - - - - {publishMode === 'page' ? : null} - - - - {loginStatus !== 'authenticated' ? ( - - - {t['com.affine.share-page.footer.built-with']()} - - - - ) : null} + + + + +
+
+ + + + + {publishMode === 'page' ? : null} + + + + {loginStatus !== 'authenticated' ? ( + + + {t['com.affine.share-page.footer.built-with']()} + + + + ) : null} +
-
- - - + + + + ); }; diff --git a/packages/frontend/core/src/pages/share/share-header.tsx b/packages/frontend/core/src/pages/share/share-header.tsx index 1e6e8069b2..8a09bb5d56 100644 --- a/packages/frontend/core/src/pages/share/share-header.tsx +++ b/packages/frontend/core/src/pages/share/share-header.tsx @@ -1,7 +1,7 @@ import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch'; import ShareHeaderRightItem from '@affine/core/components/cloud/share-header-right-item'; import type { DocCollection } from '@blocksuite/store'; -import type { PageMode } from '@toeverything/infra'; +import type { DocMode } from '@toeverything/infra'; import { BlocksuiteHeaderTitle } from '../../components/blocksuite/block-suite-header/title/index'; import * as styles from './share-header.css'; @@ -12,7 +12,7 @@ export function ShareHeader({ docCollection, }: { pageId: string; - publishMode: PageMode; + publishMode: DocMode; docCollection: DocCollection; }) { return ( diff --git a/packages/frontend/core/src/pages/sign-in.tsx b/packages/frontend/core/src/pages/sign-in.tsx index 405c9f2831..7bbf5e2fa1 100644 --- a/packages/frontend/core/src/pages/sign-in.tsx +++ b/packages/frontend/core/src/pages/sign-in.tsx @@ -1,16 +1,15 @@ import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout'; import { SignInPageContainer } from '@affine/component/auth-components'; +import { AuthService } from '@affine/core/modules/cloud'; +import { useLiveData, useService } from '@toeverything/infra'; import { useAtom } from 'jotai'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { authAtom } from '../atoms'; import type { AuthProps } from '../components/affine/auth'; import { AuthPanel } from '../components/affine/auth'; -import { SubscriptionRedirect } from '../components/affine/auth/subscription-redirect'; -import { useSubscriptionSearch } from '../components/affine/auth/use-subscription'; -import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; interface LocationState { @@ -19,31 +18,18 @@ interface LocationState { }; } export const SignIn = () => { - const paymentRedirectRef = useRef<'redirect' | 'ignore' | null>(null); const [{ state, email = '', emailType = 'changePassword' }, setAuthAtom] = useAtom(authAtom); - const loginStatus = useCurrentLoginStatus(); + const session = useService(AuthService).session; + const status = useLiveData(session.status$); + const isRevalidating = useLiveData(session.isRevalidating$); const location = useLocation() as LocationState; const navigate = useNavigate(); const { jumpToIndex } = useNavigateHelper(); - const subscriptionData = useSubscriptionSearch(); const [searchParams] = useSearchParams(); - - const isLoggedIn = loginStatus === 'authenticated'; - - // Check payment redirect once after session loaded, to avoid unnecessary page rendering. - if (loginStatus !== 'loading' && !paymentRedirectRef.current) { - // If user is logged in and visit sign in page with subscription query, redirect to stripe payment page immediately. - // Otherwise, user will login through email, and then redirect to payment page. - paymentRedirectRef.current = - subscriptionData && isLoggedIn ? 'redirect' : 'ignore'; - } + const isLoggedIn = status === 'authenticated' && !isRevalidating; useEffect(() => { - if (paymentRedirectRef.current === 'redirect') { - return; - } - if (isLoggedIn) { if (location.state?.callbackURL) { navigate(location.state.callbackURL, { @@ -57,10 +43,9 @@ export const SignIn = () => { } }, [ jumpToIndex, - location.state?.callbackURL, + location.state, navigate, setAuthAtom, - subscriptionData, isLoggedIn, searchParams, ]); @@ -86,10 +71,6 @@ export const SignIn = () => { [setAuthAtom] ); - if (paymentRedirectRef.current === 'redirect') { - return ; - } - return (
diff --git a/packages/frontend/core/src/pages/workspace/all-collection/index.tsx b/packages/frontend/core/src/pages/workspace/all-collection/index.tsx index 656cabd106..b1d35148bb 100644 --- a/packages/frontend/core/src/pages/workspace/all-collection/index.tsx +++ b/packages/frontend/core/src/pages/workspace/all-collection/index.tsx @@ -8,7 +8,7 @@ import { import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { useCallback, useMemo, useState } from 'react'; @@ -20,7 +20,7 @@ import * as styles from './index.css'; export const AllCollection = () => { const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true); const collectionService = useService(CollectionService); diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx index ee082fb2b6..33a0f9eac5 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx @@ -1,6 +1,6 @@ import { CollectionService } from '@affine/core/modules/collection'; import type { Collection, Filter } from '@affine/env/filter'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useCallback } from 'react'; import { filterContainerStyle } from '../../../components/filter-container.css'; @@ -17,7 +17,7 @@ export const FilterContainer = ({ filters: Filter[]; onChangeFilters: (filters: Filter[]) => void; }) => { - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const navigateHelper = useNavigateHelper(); const collectionService = useService(CollectionService); const saveToCollection = useCallback( diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx index de65cb6070..fe01a92662 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx @@ -8,7 +8,7 @@ import { Header } from '@affine/core/components/pure/header'; import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; import type { Filter } from '@affine/env/filter'; import { PlusIcon } from '@blocksuite/icons'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import clsx from 'clsx'; import * as styles from './all-page.css'; @@ -22,10 +22,11 @@ export const AllPageHeader = ({ filters: Filter[]; onChangeFilters: (filters: Filter[]) => void; }) => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const { importFile, createEdgeless, createPage } = usePageHelper( workspace.docCollection ); + return (
{ - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true); const [filters, setFilters] = useState([]); - const filteredPageMetas = useFilteredPageMetas(currentWorkspace, pageMetas, { + const filteredPageMetas = useFilteredPageMetas(pageMetas, { filters: filters, }); @@ -59,7 +59,7 @@ export const AllPage = () => { export const Component = () => { performanceRenderLogger.info('AllPage'); - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const navigateHelper = useNavigateHelper(); useEffect(() => { diff --git a/packages/frontend/core/src/pages/workspace/all-tag/index.tsx b/packages/frontend/core/src/pages/workspace/all-tag/index.tsx index 1bdd872cc2..be7268843a 100644 --- a/packages/frontend/core/src/pages/workspace/all-tag/index.tsx +++ b/packages/frontend/core/src/pages/workspace/all-tag/index.tsx @@ -31,12 +31,12 @@ const EmptyTagListHeader = () => { }; export const AllTag = () => { - const tagService = useService(TagService); - const tags = useLiveData(tagService.tags$); + const tagList = useService(TagService).tagList; + const tags = useLiveData(tagList.tags$); const [open, setOpen] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); - const tagMetas: TagMeta[] = useLiveData(tagService.tagMetas$); + const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$); const handleCloseModal = useCallback( (open: boolean) => { diff --git a/packages/frontend/core/src/pages/workspace/collection/index.tsx b/packages/frontend/core/src/pages/workspace/collection/index.tsx index 05e3519a51..d5cdfcf8f6 100644 --- a/packages/frontend/core/src/pages/workspace/collection/index.tsx +++ b/packages/frontend/core/src/pages/workspace/collection/index.tsx @@ -16,7 +16,7 @@ import { PageIcon, ViewLayersIcon, } from '@blocksuite/icons'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -67,7 +67,7 @@ export const Component = function CollectionPage() { const collections = useLiveData(collectionService.collections$); const navigate = useNavigateHelper(); const params = useParams(); - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const collection = collections.find(v => v.id === params.collectionId); const notifyCollectionDeleted = useCallback(() => { @@ -103,7 +103,7 @@ export const Component = function CollectionPage() { }; const Placeholder = ({ collection }: { collection: Collection }) => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const collectionService = useService(CollectionService); const { node, open } = useEditCollection(useAllPageListConfig()); const { jumpToCollections } = useNavigateHelper(); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 745b855dd2..65a9abe2ad 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -1,7 +1,6 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding'; -import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import type { PageRootService } from '@blocksuite/blocks'; import { BookmarkService, @@ -14,29 +13,23 @@ import { import { DisposableGroup } from '@blocksuite/global/utils'; import { type AffineEditorContainer, AIProvider } from '@blocksuite/presets'; import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; +import type { Doc } from '@toeverything/infra'; import { - Doc, + DocService, + DocsService, + FrameworkScope, globalBlockSuiteSchema, - GlobalState, + GlobalContextService, + GlobalStateService, LiveData, - PageManager, - PageRecordList, - ServiceProviderContext, useLiveData, useService, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import clsx from 'clsx'; import { useSetAtom } from 'jotai'; import type { ReactElement } from 'react'; -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useState, -} from 'react'; +import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import type { Map as YMap } from 'yjs'; @@ -58,7 +51,7 @@ import { sidebarTabs, } from '../../../modules/multi-tab-sidebar'; import { - RightSidebar, + RightSidebarService, RightSidebarViewIsland, } from '../../../modules/right-sidebar'; import { @@ -74,8 +67,7 @@ import { DetailPageHeader } from './detail-page-header'; const RIGHT_SIDEBAR_TABS_ACTIVE_KEY = 'app:settings:rightsidebar:tabs:active'; const DetailPageImpl = memo(function DetailPageImpl() { - const globalState = useService(GlobalState); - const rightSidebar = useService(RightSidebar); + const globalState = useService(GlobalStateService).globalState; const activeTabName = useLiveData( LiveData.from( globalState.watch(RIGHT_SIDEBAR_TABS_ACTIVE_KEY), @@ -89,13 +81,15 @@ const DetailPageImpl = memo(function DetailPageImpl() { [globalState] ); - const page = useService(Doc); - const pageRecordList = useService(PageRecordList); - const currentPageId = page.id; + const doc = useService(DocService).doc; + const docRecordList = useService(DocsService).list; const { openPage, jumpToTag } = useNavigateHelper(); const [editor, setEditor] = useState(null); - const currentWorkspace = useService(Workspace); - const docCollection = currentWorkspace.docCollection; + const workspace = useService(WorkspaceService).workspace; + const globalContext = useService(GlobalContextService).globalContext; + const rightSidebar = useService(RightSidebarService).rightSidebar; + const docCollection = workspace.docCollection; + const mode = useLiveData(doc.mode$); const isActiveView = useIsActiveView(); // TODO: remove jotai here @@ -116,15 +110,32 @@ const DetailPageImpl = memo(function DetailPageImpl() { }); }, [activeTabName, rightSidebar, setActiveTabName]); - const pageMeta = useBlockSuiteDocMeta(docCollection).find( - meta => meta.id === page.id - ); + useEffect(() => { + if (isActiveView) { + globalContext.docId.set(doc.id); - const isInTrash = pageMeta?.trash; + return () => { + globalContext.docId.set(null); + }; + } + return; + }, [doc, globalContext, isActiveView]); + + useEffect(() => { + if (isActiveView) { + globalContext.docMode.set(mode); + + return () => { + globalContext.docId.set(null); + }; + } + return; + }, [doc, globalContext, isActiveView, mode]); + + const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash)); - const mode = useLiveData(page.mode$); useRegisterBlocksuiteEditorCommands(); - const title = useLiveData(page.title$); + const title = useLiveData(doc.title$); usePageDocumentTitle(title); const onLoad = useCallback( @@ -170,9 +181,9 @@ const DetailPageImpl = memo(function DetailPageImpl() { const disposable = new DisposableGroup(); pageService.getEditorMode = (pageId: string) => - pageRecordList.record$(pageId).value?.mode$.value ?? 'page'; + docRecordList.doc$(pageId).value?.mode$.value ?? 'page'; pageService.getDocUpdatedAt = (pageId: string) => { - const linkedPage = pageRecordList.record$(pageId).value; + const linkedPage = docRecordList.doc$(pageId).value; if (!linkedPage) return new Date(); const updatedDate = linkedPage.meta$.value.updatedDate; @@ -180,7 +191,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { return new Date(updatedDate || createDate || Date.now()); }; - page.setMode(mode); + doc.setMode(mode); // fixme: it seems pageLinkClicked is not triggered sometimes? disposable.add( pageService.slots.docLinkClicked.on(({ docId }) => { @@ -189,12 +200,12 @@ const DetailPageImpl = memo(function DetailPageImpl() { ); disposable.add( pageService.slots.tagClicked.on(({ tagId }) => { - jumpToTag(currentWorkspace.id, tagId); + jumpToTag(workspace.id, tagId); }) ); disposable.add( pageService.slots.editorModeSwitch.on(mode => { - page.setMode(mode); + doc.setMode(mode); }) ); @@ -205,13 +216,13 @@ const DetailPageImpl = memo(function DetailPageImpl() { }; }, [ - docCollection.id, - currentWorkspace.id, - jumpToTag, + doc, mode, + docRecordList, openPage, - page, - pageRecordList, + docCollection.id, + jumpToTag, + workspace.id, ] ); @@ -220,16 +231,13 @@ const DetailPageImpl = memo(function DetailPageImpl() { return ( <> - +
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */} - - + + @@ -247,7 +255,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { - {isInTrash ? : null} + {isInTrash ? : null}
@@ -282,7 +290,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { } /> - + @@ -290,73 +298,65 @@ const DetailPageImpl = memo(function DetailPageImpl() { }); export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { - const currentWorkspace = useService(Workspace); - const pageRecordList = useService(PageRecordList); - const pageListReady = useLiveData(pageRecordList.isReady$); + const currentWorkspace = useService(WorkspaceService).workspace; + const docsService = useService(DocsService); + const docRecordList = docsService.list; + const docListReady = useLiveData(docRecordList.isReady$); + const docRecord = docRecordList.doc$(pageId).value; - const pageRecords = useLiveData(pageRecordList.records$); - - const pageRecord = useMemo( - () => pageRecords.find(page => page.id === pageId), - [pageRecords, pageId] - ); - const pageManager = useService(PageManager); - - const [page, setPage] = useState(null); + const [doc, setDoc] = useState(null); useLayoutEffect(() => { - if (!pageRecord) { + if (!docRecord) { return; } - const { page, release } = pageManager.open(pageRecord.id); - setPage(page); + const { doc: opened, release } = docsService.open(pageId); + setDoc(opened); return () => { release(); }; - }, [pageManager, pageRecord]); + }, [docRecord, docsService, pageId]); // set sync engine priority target useEffect(() => { - currentWorkspace.setPriorityLoad(pageId, 10); + currentWorkspace.engine.doc.setPriority(pageId, 10); return () => { - currentWorkspace.setPriorityLoad(pageId, 5); + currentWorkspace.engine.doc.setPriority(pageId, 5); }; }, [currentWorkspace, pageId]); - const jumpOnce = useLiveData(pageRecord?.meta$.map(meta => meta.jumpOnce)); + const jumpOnce = useLiveData(doc?.meta$.map(meta => meta.jumpOnce)); useEffect(() => { if (jumpOnce) { - pageRecord?.setMeta({ jumpOnce: false }); + doc?.record.setMeta({ jumpOnce: false }); } - }, [jumpOnce, pageRecord]); + }, [doc?.record, jumpOnce]); + + const isInTrash = useLiveData(doc?.meta$.map(meta => meta.trash)); useEffect(() => { - if (page && pageRecord?.meta?.trash) { + if (doc && isInTrash) { currentWorkspace.docCollection.awarenessStore.setReadonly( - page.blockSuiteDoc.blockCollection, + doc.blockSuiteDoc.blockCollection, true ); } - }, [ - currentWorkspace.docCollection.awarenessStore, - page, - pageRecord?.meta?.trash, - ]); + }, [currentWorkspace.docCollection.awarenessStore, doc, isInTrash]); // if sync engine has been synced and the page is null, show 404 page. - if (pageListReady && !page) { + if (docListReady && !doc) { return ; } - if (!page) { + if (!doc) { return ; } return ( - + - + ); }; diff --git a/packages/frontend/core/src/pages/workspace/index.tsx b/packages/frontend/core/src/pages/workspace/index.tsx index 4414ac5e22..716858b84e 100644 --- a/packages/frontend/core/src/pages/workspace/index.tsx +++ b/packages/frontend/core/src/pages/workspace/index.tsx @@ -2,14 +2,14 @@ import { useWorkspace } from '@affine/core/hooks/use-workspace'; import { ZipTransformer } from '@blocksuite/blocks'; import type { Workspace } from '@toeverything/infra'; import { - ServiceProviderContext, + FrameworkScope, + GlobalContextService, useLiveData, useService, - WorkspaceListService, - WorkspaceManager, + WorkspacesService, } from '@toeverything/infra'; import type { ReactElement } from 'react'; -import { Suspense, useEffect, useMemo } from 'react'; +import { Suspense, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; @@ -17,7 +17,6 @@ import { WorkspaceFallback } from '../../components/workspace'; import { WorkspaceLayout } from '../../layouts/workspace-layout'; import { RightSidebarContainer } from '../../modules/right-sidebar'; import { WorkbenchRoot } from '../../modules/workbench'; -import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace'; import { AllWorkspaceModals } from '../../providers/modal-provider'; import { performanceRenderLogger } from '../../shared'; import { PageNotFound } from '../404'; @@ -38,62 +37,87 @@ declare global { export const Component = (): ReactElement => { performanceRenderLogger.info('WorkspaceLayout'); - const currentWorkspaceService = useService(CurrentWorkspaceService); - const params = useParams(); - const { workspaceList, loading: listLoading } = useLiveData( - useService(WorkspaceListService).status$ - ); - const workspaceManager = useService(WorkspaceManager); + const [showNotFound, setShowNotFound] = useState(false); + const workspacesService = useService(WorkspacesService); + const listLoading = useLiveData(workspacesService.list.isLoading$); + const workspaces = useLiveData(workspacesService.list.workspaces$); const meta = useMemo(() => { - return workspaceList.find(({ id }) => id === params.workspaceId); - }, [workspaceList, params.workspaceId]); + return workspaces.find(({ id }) => id === params.workspaceId); + }, [workspaces, params.workspaceId]); const workspace = useWorkspace(meta); + const globalContext = useService(GlobalContextService).globalContext; useEffect(() => { - if (!workspace) { - currentWorkspaceService.closeWorkspace(); - return undefined; - } - currentWorkspaceService.openWorkspace(workspace ?? null); + workspacesService.list.revalidate(); + }, [workspacesService]); - // for debug purpose - window.currentWorkspace = workspace; - window.exportWorkspaceSnapshot = async () => { - const zip = await ZipTransformer.exportDocs( - workspace.docCollection, - Array.from(workspace.docCollection.docs.values()).map(collection => - collection.getDoc() - ) + useEffect(() => { + if (workspace) { + // for debug purpose + window.currentWorkspace = workspace ?? undefined; + window.dispatchEvent( + new CustomEvent('affine:workspace:change', { + detail: { + id: workspace.id, + }, + }) ); - const url = URL.createObjectURL(zip); - // download url - const a = document.createElement('a'); - a.href = url; - a.download = `${workspace.docCollection.meta.name}.zip`; - a.click(); - URL.revokeObjectURL(url); - }; - window.dispatchEvent( - new CustomEvent('affine:workspace:change', { - detail: { - id: workspace.id, - }, - }) - ); - - localStorage.setItem('last_workspace_id', workspace.id); - }, [meta, workspaceManager, workspace, currentWorkspaceService]); + window.exportWorkspaceSnapshot = async () => { + const zip = await ZipTransformer.exportDocs( + workspace.docCollection, + Array.from(workspace.docCollection.docs.values()).map(collection => + collection.getDoc() + ) + ); + const url = URL.createObjectURL(zip); + // download url + const a = document.createElement('a'); + a.href = url; + a.download = `${workspace.docCollection.meta.name}.zip`; + a.click(); + URL.revokeObjectURL(url); + }; + localStorage.setItem('last_workspace_id', workspace.id); + globalContext.workspaceId.set(workspace.id); + return () => { + window.currentWorkspace = undefined; + globalContext.workspaceId.set(null); + }; + } + return; + }, [globalContext, meta, workspace]); // avoid doing operation, before workspace is loaded const isRootDocReady = useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false; // if listLoading is false, we can show 404 page, otherwise we should show loading page. - if (listLoading === false && meta === undefined) { + useEffect(() => { + if (listLoading === false && meta === undefined) { + setShowNotFound(true); + } + if (meta) { + setShowNotFound(false); + } + }, [listLoading, meta, workspacesService]); + + useEffect(() => { + if (showNotFound) { + const timer = setInterval(() => { + workspacesService.list.revalidate(); + }, 3000); + return () => { + clearInterval(timer); + }; + } + return; + }, [showNotFound, workspacesService]); + + if (showNotFound) { return ; } if (!workspace) { @@ -102,15 +126,15 @@ export const Component = (): ReactElement => { if (!isRootDocReady) { return ( - + - + ); } return ( - + }> @@ -119,6 +143,6 @@ export const Component = (): ReactElement => { - + ); }; diff --git a/packages/frontend/core/src/pages/workspace/tag/index.tsx b/packages/frontend/core/src/pages/workspace/tag/index.tsx index 200e2d99b0..910da8c69f 100644 --- a/packages/frontend/core/src/pages/workspace/tag/index.tsx +++ b/packages/frontend/core/src/pages/workspace/tag/index.tsx @@ -8,7 +8,7 @@ import { ViewBodyIsland, ViewHeaderIsland, } from '@affine/core/modules/workbench'; -import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; @@ -18,11 +18,11 @@ import { TagDetailHeader } from './header'; import * as styles from './index.css'; export const TagDetail = ({ tagId }: { tagId?: string }) => { - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); - const tagService = useService(TagService); - const currentTag = useLiveData(tagService.tagByTagId$(tagId)); + const tagList = useService(TagService).tagList; + const currentTag = useLiveData(tagList.tagByTagId$(tagId)); const pageIds = useLiveData(currentTag?.pageIds$); diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index a7a0ab9666..fdb1679c0a 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -16,7 +16,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons'; import type { DocMeta } from '@blocksuite/store'; -import { useService, Workspace } from '@toeverything/infra'; +import { useService, WorkspaceService } from '@toeverything/infra'; import { useCallback } from 'react'; import { ViewBodyIsland, ViewHeaderIsland } from '../../modules/workbench'; @@ -38,12 +38,12 @@ const TrashHeader = () => { }; export const TrashPage = () => { - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const docCollection = currentWorkspace.docCollection; assertExists(docCollection); const pageMetas = useBlockSuiteDocMeta(docCollection); - const filteredPageMetas = useFilteredPageMetas(currentWorkspace, pageMetas, { + const filteredPageMetas = useFilteredPageMetas(pageMetas, { trash: true, }); diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 9f413d17bc..47168ef91d 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -1,10 +1,12 @@ +import { notify } from '@affine/component'; import { events } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useLiveData, useService, - Workspace, - WorkspaceManager, + useServiceOptional, + WorkspaceService, + WorkspacesService, } from '@toeverything/infra'; import { useAtom } from 'jotai'; import type { ReactElement } from 'react'; @@ -19,13 +21,10 @@ import { openSignOutModalAtom, } from '../atoms'; import { PaymentDisableModal } from '../components/affine/payment-disable'; -import { useSession } from '../hooks/affine/use-current-user'; import { useAsyncCallback } from '../hooks/affine-async-hooks'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; -import { CurrentWorkspaceService } from '../modules/workspace/current-workspace'; +import { AuthService } from '../modules/cloud/services/auth'; import { WorkspaceSubPath } from '../shared'; -import { mixpanel } from '../utils'; -import { signOutCloud } from '../utils/cloud-utils'; const SettingModal = lazy(() => import('../components/affine/setting-modal').then(module => ({ @@ -182,7 +181,7 @@ export const AuthModal = (): ReactElement => { }; export function CurrentWorkspaceModals() { - const currentWorkspace = useService(Workspace); + const currentWorkspace = useService(WorkspaceService).workspace; const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom( openDisableCloudAlertModalAtom ); @@ -213,21 +212,25 @@ export function CurrentWorkspaceModals() { export const SignOutConfirmModal = () => { const { openPage } = useNavigateHelper(); - const { reload } = useSession(); + const authService = useService(AuthService); const [open, setOpen] = useAtom(openSignOutModalAtom); - const currentWorkspace = useLiveData( - useService(CurrentWorkspaceService).currentWorkspace$ - ); + const currentWorkspace = useServiceOptional(WorkspaceService)?.workspace; const workspaces = useLiveData( - useService(WorkspaceManager).list.workspaceList$ + useService(WorkspacesService).list.workspaces$ ); const onConfirm = useAsyncCallback(async () => { setOpen(false); - await signOutCloud(); - await reload(); - - mixpanel.reset(); + try { + await authService.signOut(); + } catch (err) { + console.error(err); + // TODO: i18n + notify({ + style: 'alert', + message: 'Failed to sign out', + }); + } // if current workspace is affine cloud, switch to local workspace if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { @@ -238,7 +241,7 @@ export const SignOutConfirmModal = () => { openPage(localWorkspace.id, WorkspaceSubPath.ALL); } } - }, [currentWorkspace?.flavour, openPage, reload, setOpen, workspaces]); + }, [authService, currentWorkspace, openPage, setOpen, workspaces]); return ( diff --git a/packages/frontend/core/src/providers/session-provider.tsx b/packages/frontend/core/src/providers/session-provider.tsx deleted file mode 100644 index 7f5b332034..0000000000 --- a/packages/frontend/core/src/providers/session-provider.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { notify } from '@affine/component'; -import { useSession } from '@affine/core/hooks/affine/use-current-user'; -import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { affine } from '@affine/electron-api'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl'; -import type { PropsWithChildren } from 'react'; -import { startTransition, useEffect, useRef } from 'react'; - -import { useOnceSignedInEvents } from '../atoms/event'; -import { mixpanel } from '../utils'; - -export const CloudSessionProvider = (props: PropsWithChildren) => { - const session = useSession(); - const prevSession = useRef>(); - const onceSignedInEvents = useOnceSignedInEvents(); - const t = useAFFiNEI18N(); - - const refreshAfterSignedInEvents = useAsyncCallback(async () => { - await onceSignedInEvents(); - new BroadcastChannel( - CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY - ).postMessage(1); - }, [onceSignedInEvents]); - - useEffect(() => { - if (session.user?.id) { - mixpanel.identify(session.user.id); - } - }, [session]); - - useEffect(() => { - if (prevSession.current !== session && session.status !== 'loading') { - // unauthenticated -> authenticated - if ( - prevSession.current?.status === 'unauthenticated' && - session.status === 'authenticated' - ) { - startTransition(() => refreshAfterSignedInEvents()); - notify.success({ - title: t['com.affine.auth.has.signed'](), - message: t['com.affine.auth.has.signed.message'](), - }); - - if (environment.isDesktop) { - affine?.ipcRenderer.send('affine:login'); - } - } - prevSession.current = session; - } - }, [session, prevSession, refreshAfterSignedInEvents, t]); - - return props.children; -}; diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index 08dbb6546e..c39197a2f3 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -1,16 +1,21 @@ import { wrapCreateBrowserRouter } from '@sentry/react'; -import { useEffect } from 'react'; -import type { RouteObject } from 'react-router-dom'; +import { createContext, useEffect } from 'react'; +import type { NavigateFunction, RouteObject } from 'react-router-dom'; import { createBrowserRouter as reactRouterCreateBrowserRouter, Outlet, useLocation, + // eslint-disable-next-line @typescript-eslint/no-restricted-imports + useNavigate, } from 'react-router-dom'; import { mixpanel } from './utils'; +export const NavigateContext = createContext(null); + function RootRouter() { const location = useLocation(); + const navigate = useNavigate(); useEffect(() => { mixpanel.track_pageview({ page: location.pathname, @@ -20,7 +25,11 @@ function RootRouter() { isSelfHosted: Boolean(runtimeConfig.isSelfHosted), }); }, [location]); - return ; + return ( + + + + ); } export const topLevelRoutes = [ diff --git a/packages/frontend/core/src/testing.ts b/packages/frontend/core/src/testing.ts index 577068a353..478ee12e58 100644 --- a/packages/frontend/core/src/testing.ts +++ b/packages/frontend/core/src/testing.ts @@ -1,44 +1,44 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; import { - configureTestingInfraServices, - PageManager, - ServiceCollection, - WorkspaceManager, + configureTestingInfraModules, + DocsService, + Framework, + WorkspacesService, } from '@toeverything/infra'; -import { CurrentWorkspaceService } from './modules/workspace'; -import { configureWebServices } from './web'; +import { configureCommonModules } from './modules'; export async function configureTestingEnvironment() { - const serviceCollection = new ServiceCollection(); + const framework = new Framework(); - configureWebServices(serviceCollection); - configureTestingInfraServices(serviceCollection); + configureCommonModules(framework); + configureTestingInfraModules(framework); - const rootServices = serviceCollection.provider(); + const frameworkProvider = framework.provider(); - const workspaceManager = rootServices.get(WorkspaceManager); + const workspaceManager = frameworkProvider.get(WorkspacesService); - const { workspace } = workspaceManager.open( - await workspaceManager.createWorkspace(WorkspaceFlavour.LOCAL, async ws => { - const initPage = async (page: BlockSuiteDoc) => { - page.load(); - const pageBlockId = page.addBlock('affine:page', { - title: new page.Text(''), - }); - const frameId = page.addBlock('affine:note', {}, pageBlockId); - page.addBlock('affine:paragraph', {}, frameId); - }; - await initPage(ws.createDoc({ id: 'page0' })); - }) - ); + const { workspace } = workspaceManager.open({ + metadata: await workspaceManager.create( + WorkspaceFlavour.LOCAL, + async ws => { + const initDoc = async (page: BlockSuiteDoc) => { + page.load(); + const pageBlockId = page.addBlock('affine:page', { + title: new page.Text(''), + }); + const frameId = page.addBlock('affine:note', {}, pageBlockId); + page.addBlock('affine:paragraph', {}, frameId); + }; + await initDoc(ws.createDoc({ id: 'page0' })); + } + ), + }); - await workspace.engine.waitForSynced(); + await workspace.engine.waitForDocSynced(); - const { page } = workspace.services.get(PageManager).open('page0'); + const { doc } = workspace.scope.get(DocsService).open('page0'); - rootServices.get(CurrentWorkspaceService).openWorkspace(workspace); - - return { services: rootServices, workspace, page }; + return { framework: frameworkProvider, workspace, doc }; } diff --git a/packages/frontend/core/src/utils/cloud-utils.tsx b/packages/frontend/core/src/utils/cloud-utils.tsx deleted file mode 100644 index 2efae58470..0000000000 --- a/packages/frontend/core/src/utils/cloud-utils.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { apis } from '@affine/electron-api'; -import { - generateRandUTF16Chars, - getBaseUrl, - OAuthProviderType, - SPAN_ID_BYTES, - TRACE_ID_BYTES, - traceReporter, -} from '@affine/graphql'; -import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl'; - -type TraceParams = { - startTime: string; - spanId: string; - traceId: string; - event: string; -}; - -function genTraceParams(): TraceParams { - const startTime = new Date().toISOString(); - const spanId = generateRandUTF16Chars(SPAN_ID_BYTES); - const traceId = generateRandUTF16Chars(TRACE_ID_BYTES); - const event = 'signInCloud'; - return { startTime, spanId, traceId, event }; -} - -function onResolveHandleTrace( - res: Promise | T, - params: TraceParams -): Promise | T { - const { startTime, spanId, traceId, event } = params; - traceReporter && - traceReporter.cacheTrace(traceId, spanId, startTime, { event }); - return res; -} - -function onRejectHandleTrace( - res: Promise | T, - params: TraceParams -): Promise { - const { startTime, spanId, traceId, event } = params; - traceReporter && - traceReporter.uploadTrace(traceId, spanId, startTime, { event }); - return Promise.reject(res); -} - -type Providers = 'credentials' | 'email' | OAuthProviderType; - -export const signInCloud = async ( - provider: Providers, - credentials?: { email: string; password?: string }, - searchParams: Record = {} -): Promise => { - const traceParams = genTraceParams(); - - if (provider === 'credentials' || provider === 'email') { - if (!credentials) { - throw new Error('Invalid Credentials'); - } - - return signIn(credentials, searchParams) - .then(res => onResolveHandleTrace(res, traceParams)) - .catch(err => onRejectHandleTrace(err, traceParams)); - } else if (OAuthProviderType[provider]) { - if (environment.isDesktop) { - await apis?.ui.openExternal( - `${ - runtimeConfig.serverUrlPrefix - }/desktop-signin?provider=${provider}&redirect_uri=${buildRedirectUri( - '/open-app/signin-redirect' - )}` - ); - } else { - location.href = `${ - runtimeConfig.serverUrlPrefix - }/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent( - searchParams.redirectUri ?? location.pathname - )}`; - } - - return; - } else { - throw new Error('Invalid Provider'); - } -}; - -async function signIn( - credential: { email: string; password?: string }, - searchParams: Record = {} -) { - const url = new URL(getBaseUrl() + '/api/auth/sign-in'); - - for (const key in searchParams) { - url.searchParams.set(key, searchParams[key]); - } - - const redirectUri = new URL(location.href); - - if (environment.isDesktop) { - redirectUri.pathname = buildRedirectUri('/open-app/signin-redirect'); - } - - url.searchParams.set('redirect_uri', redirectUri.toString()); - - return fetch(url.toString(), { - method: 'POST', - body: JSON.stringify(credential), - headers: { - 'content-type': 'application/json', - }, - }); -} - -export const signOutCloud = async (redirectUri?: string) => { - const traceParams = genTraceParams(); - return fetch(getBaseUrl() + '/api/auth/sign-out') - .then(result => { - if (result.ok) { - new BroadcastChannel( - CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY - ).postMessage(1); - - if (redirectUri && location.href !== redirectUri) { - setTimeout(() => { - location.href = redirectUri; - }, 0); - } - } - return onResolveHandleTrace(result, traceParams); - }) - .catch(err => onRejectHandleTrace(err, traceParams)); -}; - -export function buildRedirectUri(callbackUrl: string) { - const params: string[][] = []; - if (environment.isDesktop && window.appInfo.schema) { - params.push(['schema', window.appInfo.schema]); - } - const query = - params.length > 0 - ? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') - : ''; - return callbackUrl + query; -} diff --git a/packages/frontend/core/src/utils/popup.ts b/packages/frontend/core/src/utils/popup.ts index 396dc95838..9b1675bb55 100644 --- a/packages/frontend/core/src/utils/popup.ts +++ b/packages/frontend/core/src/utils/popup.ts @@ -5,5 +5,6 @@ export function popupWindow(target: string) { : runtimeConfig.serverUrlPrefix + target; url.searchParams.set('redirect_uri', target); + console.log(url.href); return window.open(url, '_blank', `noreferrer noopener`); } diff --git a/packages/frontend/core/src/web.ts b/packages/frontend/core/src/web.ts deleted file mode 100644 index b1b41ac412..0000000000 --- a/packages/frontend/core/src/web.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { configureWorkspaceImplServices } from '@affine/workspace-impl'; -import type { ServiceCollection } from '@toeverything/infra'; -import { configureInfraServices } from '@toeverything/infra'; - -import { - configureBusinessServices, - configureWebInfraServices, -} from './modules/services'; - -export function configureWebServices(services: ServiceCollection) { - configureInfraServices(services); - configureWebInfraServices(services); - configureBusinessServices(services); - configureWorkspaceImplServices(services); -} diff --git a/packages/frontend/core/tsconfig.json b/packages/frontend/core/tsconfig.json index 70be8ec541..ed201eac2e 100644 --- a/packages/frontend/core/tsconfig.json +++ b/packages/frontend/core/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../frontend/i18n" }, - { - "path": "../../frontend/workspace-impl" - }, { "path": "../../frontend/electron-api" }, diff --git a/packages/frontend/electron/renderer/app.tsx b/packages/frontend/electron/renderer/app.tsx index dcbff671e5..552d943739 100644 --- a/packages/frontend/electron/renderer/app.tsx +++ b/packages/frontend/electron/renderer/app.tsx @@ -5,8 +5,11 @@ import { NotificationCenter } from '@affine/component'; import { AffineContext } from '@affine/component/context'; import { GlobalLoading } from '@affine/component/global-loading'; import { WorkspaceFallback } from '@affine/core/components/workspace'; -import { GlobalScopeProvider } from '@affine/core/modules/infra-web/global-scope'; -import { CloudSessionProvider } from '@affine/core/providers/session-provider'; +import { configureCommonModules, configureImpls } from '@affine/core/modules'; +import { + configureBrowserWorkspaceFlavours, + configureSqliteWorkspaceEngineStorageProvider, +} from '@affine/core/modules/workspace-engine'; import { router } from '@affine/core/router'; import { performanceLogger, @@ -14,14 +17,28 @@ import { } from '@affine/core/shared'; import { Telemetry } from '@affine/core/telemetry'; import createEmotionCache from '@affine/core/utils/create-emotion-cache'; -import { configureWebServices } from '@affine/core/web'; import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; -import { getCurrentStore, ServiceCollection } from '@toeverything/infra'; +import { + Framework, + FrameworkRoot, + getCurrentStore, + LifecycleService, +} from '@toeverything/infra'; import type { PropsWithChildren, ReactElement } from 'react'; import { lazy, Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; +if ( + !environment.isDesktop && + environment.isDebug && + !location.pathname.includes('/desktop-signin') && + !location.pathname.includes('/open-app/signin-redirect') +) { + document.body.innerHTML = `

Don't run electron entry in browser.

`; + throw new Error('Wrong distribution'); +} + const performanceI18nLogger = performanceLogger.namespace('i18n'); const cache = createEmotionCache(); @@ -55,9 +72,18 @@ async function loadLanguage() { let languageLoadingPromise: Promise | null = null; -const services = new ServiceCollection(); -configureWebServices(services); -const serviceProvider = services.provider(); +const framework = new Framework(); +configureCommonModules(framework); +configureImpls(framework); +configureBrowserWorkspaceFlavours(framework); +configureSqliteWorkspaceEngineStorageProvider(framework); +const frameworkProvider = framework.provider(); + +// setup application lifecycle events, and emit application start event +window.addEventListener('focus', () => { + frameworkProvider.get(LifecycleService).applicationFocus(); +}); +frameworkProvider.get(LifecycleService).applicationStart(); export function App() { performanceRenderLogger.info('App'); @@ -68,24 +94,22 @@ export function App() { return ( - + - - - - - - } - router={router} - future={future} - /> - - + + + + + } + router={router} + future={future} + /> + - + ); } diff --git a/packages/frontend/electron/src/main/security-restrictions.ts b/packages/frontend/electron/src/main/security-restrictions.ts index ec59be1ec3..c851c7e5b4 100644 --- a/packages/frontend/electron/src/main/security-restrictions.ts +++ b/packages/frontend/electron/src/main/security-restrictions.ts @@ -37,7 +37,7 @@ app.on('web-contents-created', (_, contents) => { * @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content */ contents.setWindowOpenHandler(({ url }) => { - if (!isInternalUrl(url)) { + if (!isInternalUrl(url) || url.includes('/redirect-proxy')) { // Open default browser shell.openExternal(url).catch(console.error); } diff --git a/packages/frontend/graphql/src/__tests__/fetcher.spec.ts b/packages/frontend/graphql/src/__tests__/fetcher.spec.ts index 586293ae0a..722e0f5eb8 100644 --- a/packages/frontend/graphql/src/__tests__/fetcher.spec.ts +++ b/packages/frontend/graphql/src/__tests__/fetcher.spec.ts @@ -1,15 +1,8 @@ -import { nanoid } from 'nanoid'; import type { Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { gqlFetcherFactory } from '../fetcher'; import type { GraphQLQuery } from '../graphql'; -import { - generateRandUTF16Chars, - SPAN_ID_BYTES, - TRACE_ID_BYTES, - TraceReporter, -} from '../utils'; const query: GraphQLQuery = { id: 'query', @@ -19,6 +12,7 @@ const query: GraphQLQuery = { }; let fetch: Mock; +let gql: ReturnType; describe('GraphQL fetcher', () => { beforeEach(() => { fetch = vi.fn(() => @@ -30,15 +24,13 @@ describe('GraphQL fetcher', () => { }) ) ); - vi.stubGlobal('fetch', fetch); + gql = gqlFetcherFactory('https://example.com/graphql', fetch); }); afterEach(() => { fetch.mockReset(); }); - const gql = gqlFetcherFactory('https://example.com/graphql'); - it('should send POST request to given endpoint', async () => { await gql( // @ts-expect-error variables is actually optional @@ -65,7 +57,6 @@ describe('GraphQL fetcher', () => { 'content-type': 'application/json', 'x-definition-name': 'query', 'x-operation-name': 'query', - 'x-request-id': expect.any(String), }), method: 'POST', }) @@ -119,41 +110,3 @@ describe('GraphQL fetcher', () => { `); }); }); - -describe('Trace Reporter', () => { - const startTime = new Date().toISOString(); - const traceId = generateRandUTF16Chars(TRACE_ID_BYTES); - const spanId = generateRandUTF16Chars(SPAN_ID_BYTES); - const requestId = nanoid(); - - it('spanId, traceId should be right format', () => { - expect( - new RegExp(`^[0-9a-f]{${SPAN_ID_BYTES * 2}}$`).test( - generateRandUTF16Chars(SPAN_ID_BYTES) - ) - ).toBe(true); - expect( - new RegExp(`^[0-9a-f]{${TRACE_ID_BYTES * 2}}$`).test( - generateRandUTF16Chars(TRACE_ID_BYTES) - ) - ).toBe(true); - }); - - it('test createTraceSpan', () => { - const traceSpan = TraceReporter.createTraceSpan( - traceId, - spanId, - startTime, - { requestId } - ); - expect(traceSpan.startTime).toBe(startTime); - expect( - traceSpan.name === - `projects/{GCP_PROJECT_ID}/traces/${traceId}/spans/${spanId}` - ).toBe(true); - expect(traceSpan.spanId).toBe(spanId); - expect(traceSpan.attributes.attributeMap.requestId?.stringValue.value).toBe( - requestId - ); - }); -}); diff --git a/packages/frontend/graphql/src/fetcher.ts b/packages/frontend/graphql/src/fetcher.ts index bb9e89f195..83f469c91e 100644 --- a/packages/frontend/graphql/src/fetcher.ts +++ b/packages/frontend/graphql/src/fetcher.ts @@ -1,18 +1,9 @@ import type { ExecutionResult } from 'graphql'; import { GraphQLError } from 'graphql'; import { isNil, isObject, merge } from 'lodash-es'; -import { nanoid } from 'nanoid'; import type { GraphQLQuery } from './graphql'; import type { Mutations, Queries } from './schema'; -import { - generateRandUTF16Chars, - SPAN_ID_BYTES, - TRACE_FLAG, - TRACE_ID_BYTES, - TRACE_VERSION, - traceReporter, -} from './utils'; export type NotArray = T extends Array ? never : T; @@ -166,7 +157,10 @@ function formatRequestBody({ return body; } -export const gqlFetcherFactory = (endpoint: string) => { +export const gqlFetcherFactory = ( + endpoint: string, + fetcher: (input: string, init?: RequestInit) => Promise = fetch +) => { const gqlFetch = async ( options: QueryOptions ): Promise> => { @@ -180,14 +174,13 @@ export const gqlFetcherFactory = (endpoint: string) => { if (!isFormData) { headers['content-type'] = 'application/json'; } - const ret = fetchWithTraceReport( + const ret = fetcher( endpoint, merge(options.context, { method: 'POST', headers, body: isFormData ? body : JSON.stringify(body), - }), - { event: 'GraphQLRequest' } + }) ).then(async res => { if (res.headers.get('content-type')?.startsWith('application/json')) { const result = (await res.json()) as ExecutionResult; @@ -205,7 +198,10 @@ export const gqlFetcherFactory = (endpoint: string) => { } } - throw new GraphQLError('GraphQL query responds unexpected result'); + throw new GraphQLError( + 'GraphQL query responds unexpected result, query ' + + options.query.operationName + ); }); return ret; @@ -213,47 +209,3 @@ export const gqlFetcherFactory = (endpoint: string) => { return gqlFetch; }; - -export const fetchWithTraceReport = async ( - input: RequestInfo | URL, - init?: RequestInit & { priority?: 'auto' | 'low' | 'high' }, // https://github.com/microsoft/TypeScript/issues/54472 - traceOptions?: { event: string } -): Promise => { - const startTime = new Date().toISOString(); - const spanId = generateRandUTF16Chars(SPAN_ID_BYTES); - const traceId = generateRandUTF16Chars(TRACE_ID_BYTES); - const traceparent = `${TRACE_VERSION}-${traceId}-${spanId}-${TRACE_FLAG}`; - init = init || {}; - init.headers = init.headers || new Headers(); - const requestId = nanoid(); - const event = traceOptions?.event; - if (init.headers instanceof Headers) { - init.headers.append('x-request-id', requestId); - init.headers.append('traceparent', traceparent); - } else { - const headers = init.headers as Record; - headers['x-request-id'] = requestId; - headers['traceparent'] = traceparent; - } - - if (!traceReporter) { - return fetch(input, init); - } - - try { - const response = await fetch(input, init); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - traceReporter!.cacheTrace(traceId, spanId, startTime, { - requestId, - ...(event ? { event } : {}), - }); - return response; - } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - traceReporter!.uploadTrace(traceId, spanId, startTime, { - requestId, - ...(event ? { event } : {}), - }); - throw err; - } -}; diff --git a/packages/frontend/graphql/src/graphql/blob-check-size.gql b/packages/frontend/graphql/src/graphql/blob-check-size.gql deleted file mode 100644 index db122f8df6..0000000000 --- a/packages/frontend/graphql/src/graphql/blob-check-size.gql +++ /dev/null @@ -1,5 +0,0 @@ -query checkBlobSizes($workspaceId: String!, $size: SafeInt!) { - checkBlobSize(workspaceId: $workspaceId, size: $size) { - size - } -} diff --git a/packages/frontend/graphql/src/graphql/blob-size.gql b/packages/frontend/graphql/src/graphql/blob-size.gql deleted file mode 100644 index bc8256ee23..0000000000 --- a/packages/frontend/graphql/src/graphql/blob-size.gql +++ /dev/null @@ -1,5 +0,0 @@ -query blobSizes($workspaceId: String!) { - workspace(id: $workspaceId) { - blobsSize - } -} diff --git a/packages/frontend/graphql/src/graphql/blobs-size.gql b/packages/frontend/graphql/src/graphql/blobs-size.gql deleted file mode 100644 index 8d5e90b3eb..0000000000 --- a/packages/frontend/graphql/src/graphql/blobs-size.gql +++ /dev/null @@ -1,5 +0,0 @@ -query allBlobSizes { - collectAllBlobSizes { - size - } -} diff --git a/packages/frontend/graphql/src/graphql/early-access-add.gql b/packages/frontend/graphql/src/graphql/early-access-add.gql deleted file mode 100644 index eb28bfd1c7..0000000000 --- a/packages/frontend/graphql/src/graphql/early-access-add.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation addToEarlyAccess($email: String!) { - addToEarlyAccess(email: $email) -} diff --git a/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql b/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql index 4d416aa148..14bee6e4d2 100644 --- a/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql +++ b/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql @@ -1,5 +1,6 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { workspace(id: $workspaceId) { + memberCount members(skip: $skip, take: $take) { id name diff --git a/packages/frontend/graphql/src/graphql/get-user-features.gql b/packages/frontend/graphql/src/graphql/get-user-features.gql index 5c0cc29f78..6fe8304cd8 100644 --- a/packages/frontend/graphql/src/graphql/get-user-features.gql +++ b/packages/frontend/graphql/src/graphql/get-user-features.gql @@ -1,5 +1,6 @@ query getUserFeatures { currentUser { + id features } } diff --git a/packages/frontend/graphql/src/graphql/get-workspace-public-page-by-id.gql b/packages/frontend/graphql/src/graphql/get-workspace-public-page-by-id.gql new file mode 100644 index 0000000000..e5ccaa7605 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-workspace-public-page-by-id.gql @@ -0,0 +1,8 @@ +query getWorkspacePublicPageById($workspaceId: String!, $pageId: String!) { + workspace(id: $workspaceId) { + publicPage(pageId: $pageId) { + id + mode + } + } +} diff --git a/packages/frontend/graphql/src/graphql/get-workspaces.gql b/packages/frontend/graphql/src/graphql/get-workspaces.gql index af151ac6c2..946fe8ab6b 100644 --- a/packages/frontend/graphql/src/graphql/get-workspaces.gql +++ b/packages/frontend/graphql/src/graphql/get-workspaces.gql @@ -1,5 +1,8 @@ query getWorkspaces { workspaces { id + owner { + id + } } } diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index a307c0ed0d..67d5f64671 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -18,19 +18,6 @@ fragment CredentialsRequirement on CredentialsRequirementType { ...PasswordLimits } }` -export const checkBlobSizesQuery = { - id: 'checkBlobSizesQuery' as const, - operationName: 'checkBlobSizes', - definitionName: 'checkBlobSize', - containsFile: false, - query: ` -query checkBlobSizes($workspaceId: String!, $size: SafeInt!) { - checkBlobSize(workspaceId: $workspaceId, size: $size) { - size - } -}`, -}; - export const deleteBlobMutation = { id: 'deleteBlobMutation' as const, operationName: 'deleteBlob', @@ -64,32 +51,6 @@ mutation setBlob($workspaceId: String!, $blob: Upload!) { }`, }; -export const blobSizesQuery = { - id: 'blobSizesQuery' as const, - operationName: 'blobSizes', - definitionName: 'workspace', - containsFile: false, - query: ` -query blobSizes($workspaceId: String!) { - workspace(id: $workspaceId) { - blobsSize - } -}`, -}; - -export const allBlobSizesQuery = { - id: 'allBlobSizesQuery' as const, - operationName: 'allBlobSizes', - definitionName: 'collectAllBlobSizes', - containsFile: false, - query: ` -query allBlobSizes { - collectAllBlobSizes { - size - } -}`, -}; - export const cancelSubscriptionMutation = { id: 'cancelSubscriptionMutation' as const, operationName: 'cancelSubscription', @@ -216,17 +177,6 @@ mutation deleteWorkspace($id: String!) { }`, }; -export const addToEarlyAccessMutation = { - id: 'addToEarlyAccessMutation' as const, - operationName: 'addToEarlyAccess', - definitionName: 'addToEarlyAccess', - containsFile: false, - query: ` -mutation addToEarlyAccess($email: String!) { - addToEarlyAccess(email: $email) -}`, -}; - export const earlyAccessUsersQuery = { id: 'earlyAccessUsersQuery' as const, operationName: 'earlyAccessUsers', @@ -395,6 +345,7 @@ export const getMembersByWorkspaceIdQuery = { query: ` query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { workspace(id: $workspaceId) { + memberCount members(skip: $skip, take: $take) { id name @@ -443,6 +394,7 @@ export const getUserFeaturesQuery = { query: ` query getUserFeatures { currentUser { + id features } }`, @@ -498,6 +450,22 @@ query getWorkspacePublicById($id: String!) { }`, }; +export const getWorkspacePublicPageByIdQuery = { + id: 'getWorkspacePublicPageByIdQuery' as const, + operationName: 'getWorkspacePublicPageById', + definitionName: 'workspace', + containsFile: false, + query: ` +query getWorkspacePublicPageById($workspaceId: String!, $pageId: String!) { + workspace(id: $workspaceId) { + publicPage(pageId: $pageId) { + id + mode + } + } +}`, +}; + export const getWorkspacePublicPagesQuery = { id: 'getWorkspacePublicPagesQuery' as const, operationName: 'getWorkspacePublicPages', @@ -536,6 +504,9 @@ export const getWorkspacesQuery = { query getWorkspaces { workspaces { id + owner { + id + } } }`, }; @@ -642,11 +613,18 @@ mutation publishPage($workspaceId: String!, $pageId: String!, $mode: PublicPageM export const quotaQuery = { id: 'quotaQuery' as const, operationName: 'quota', - definitionName: 'currentUser', + definitionName: 'currentUser,collectAllBlobSizes', containsFile: false, query: ` query quota { currentUser { + id + copilot { + quota { + limit + used + } + } quota { name blobLimit @@ -662,6 +640,9 @@ query quota { } } } + collectAllBlobSizes { + size + } }`, }; @@ -829,6 +810,7 @@ export const subscriptionQuery = { query: ` query subscription { currentUser { + id subscriptions { id status diff --git a/packages/frontend/graphql/src/graphql/quota.gql b/packages/frontend/graphql/src/graphql/quota.gql index e02b1c2784..176828f002 100644 --- a/packages/frontend/graphql/src/graphql/quota.gql +++ b/packages/frontend/graphql/src/graphql/quota.gql @@ -1,5 +1,12 @@ query quota { currentUser { + id + copilot { + quota { + limit + used + } + } quota { name blobLimit @@ -15,4 +22,7 @@ query quota { } } } + collectAllBlobSizes { + size + } } diff --git a/packages/frontend/graphql/src/graphql/subscription.gql b/packages/frontend/graphql/src/graphql/subscription.gql index 48811aa690..61d90af087 100644 --- a/packages/frontend/graphql/src/graphql/subscription.gql +++ b/packages/frontend/graphql/src/graphql/subscription.gql @@ -1,5 +1,6 @@ query subscription { currentUser { + id subscriptions { id status diff --git a/packages/frontend/graphql/src/index.ts b/packages/frontend/graphql/src/index.ts index c0c162802d..5c92f0e2bf 100644 --- a/packages/frontend/graphql/src/index.ts +++ b/packages/frontend/graphql/src/index.ts @@ -2,7 +2,6 @@ export * from './error'; export * from './fetcher'; export * from './graphql'; export * from './schema'; -export * from './utils'; import { setupGlobal } from '@affine/env/global'; @@ -14,6 +13,10 @@ export function getBaseUrl(): string { if (environment.isDesktop) { return runtimeConfig.serverUrlPrefix; } + if (typeof window === 'undefined') { + // is nodejs + return ''; + } const { protocol, hostname, port } = window.location; return `${protocol}//${hostname}${port ? `:${port}` : ''}`; } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 990453cbfc..23e8f5f0a2 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -58,8 +58,14 @@ export interface CreateCheckoutSessionInput { successCallbackLink: InputMaybe; } +export enum EarlyAccessType { + AI = 'AI', + App = 'App', +} + /** The type of workspace feature */ export enum FeatureType { + AIEarlyAccess = 'AIEarlyAccess', Copilot = 'Copilot', EarlyAccess = 'EarlyAccess', UnlimitedCopilot = 'UnlimitedCopilot', @@ -147,16 +153,6 @@ export interface UpdateWorkspaceInput { public: InputMaybe; } -export type CheckBlobSizesQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; - size: Scalars['SafeInt']['input']; -}>; - -export type CheckBlobSizesQuery = { - __typename?: 'Query'; - checkBlobSize: { __typename?: 'WorkspaceBlobSizes'; size: number }; -}; - export type DeleteBlobMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; hash: Scalars['String']['input']; @@ -180,22 +176,6 @@ export type SetBlobMutationVariables = Exact<{ export type SetBlobMutation = { __typename?: 'Mutation'; setBlob: string }; -export type BlobSizesQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type BlobSizesQuery = { - __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; blobsSize: number }; -}; - -export type AllBlobSizesQueryVariables = Exact<{ [key: string]: never }>; - -export type AllBlobSizesQuery = { - __typename?: 'Query'; - collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number }; -}; - export type CancelSubscriptionMutationVariables = Exact<{ idempotencyKey: Scalars['String']['input']; plan?: InputMaybe; @@ -296,15 +276,6 @@ export type DeleteWorkspaceMutation = { deleteWorkspace: boolean; }; -export type AddToEarlyAccessMutationVariables = Exact<{ - email: Scalars['String']['input']; -}>; - -export type AddToEarlyAccessMutation = { - __typename?: 'Mutation'; - addToEarlyAccess: number; -}; - export type EarlyAccessUsersQueryVariables = Exact<{ [key: string]: never }>; export type EarlyAccessUsersQuery = { @@ -476,6 +447,7 @@ export type GetMembersByWorkspaceIdQuery = { __typename?: 'Query'; workspace: { __typename?: 'WorkspaceType'; + memberCount: number; members: Array<{ __typename?: 'InviteUserType'; id: string; @@ -513,7 +485,11 @@ export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>; export type GetUserFeaturesQuery = { __typename?: 'Query'; - currentUser: { __typename?: 'UserType'; features: Array } | null; + currentUser: { + __typename?: 'UserType'; + id: string; + features: Array; + } | null; }; export type GetUserQueryVariables = Exact<{ @@ -557,6 +533,23 @@ export type GetWorkspacePublicByIdQuery = { workspace: { __typename?: 'WorkspaceType'; public: boolean }; }; +export type GetWorkspacePublicPageByIdQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + pageId: Scalars['String']['input']; +}>; + +export type GetWorkspacePublicPageByIdQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + publicPage: { + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + } | null; + }; +}; + export type GetWorkspacePublicPagesQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; @@ -586,7 +579,11 @@ export type GetWorkspacesQueryVariables = Exact<{ [key: string]: never }>; export type GetWorkspacesQuery = { __typename?: 'Query'; - workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>; + workspaces: Array<{ + __typename?: 'WorkspaceType'; + id: string; + owner: { __typename?: 'UserType'; id: string }; + }>; }; export type ListHistoryQueryVariables = Exact<{ @@ -686,6 +683,15 @@ export type QuotaQuery = { __typename?: 'Query'; currentUser: { __typename?: 'UserType'; + id: string; + copilot: { + __typename?: 'Copilot'; + quota: { + __typename?: 'CopilotQuota'; + limit: number | null; + used: number; + }; + }; quota: { __typename?: 'UserQuota'; name: string; @@ -703,6 +709,7 @@ export type QuotaQuery = { }; } | null; } | null; + collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number }; }; export type RecoverDocMutationVariables = Exact<{ @@ -850,6 +857,7 @@ export type SubscriptionQuery = { __typename?: 'Query'; currentUser: { __typename?: 'UserType'; + id: string; subscriptions: Array<{ __typename?: 'UserSubscription'; id: string; @@ -1032,26 +1040,11 @@ export type WorkspaceQuotaQuery = { }; export type Queries = - | { - name: 'checkBlobSizesQuery'; - variables: CheckBlobSizesQueryVariables; - response: CheckBlobSizesQuery; - } | { name: 'listBlobsQuery'; variables: ListBlobsQueryVariables; response: ListBlobsQuery; } - | { - name: 'blobSizesQuery'; - variables: BlobSizesQueryVariables; - response: BlobSizesQuery; - } - | { - name: 'allBlobSizesQuery'; - variables: AllBlobSizesQueryVariables; - response: AllBlobSizesQuery; - } | { name: 'earlyAccessUsersQuery'; variables: EarlyAccessUsersQueryVariables; @@ -1127,6 +1120,11 @@ export type Queries = variables: GetWorkspacePublicByIdQueryVariables; response: GetWorkspacePublicByIdQuery; } + | { + name: 'getWorkspacePublicPageByIdQuery'; + variables: GetWorkspacePublicPageByIdQueryVariables; + response: GetWorkspacePublicPageByIdQuery; + } | { name: 'getWorkspacePublicPagesQuery'; variables: GetWorkspacePublicPagesQueryVariables; @@ -1259,11 +1257,6 @@ export type Mutations = variables: DeleteWorkspaceMutationVariables; response: DeleteWorkspaceMutation; } - | { - name: 'addToEarlyAccessMutation'; - variables: AddToEarlyAccessMutationVariables; - response: AddToEarlyAccessMutation; - } | { name: 'removeEarlyAccessMutation'; variables: RemoveEarlyAccessMutationVariables; diff --git a/packages/frontend/graphql/src/utils.ts b/packages/frontend/graphql/src/utils.ts deleted file mode 100644 index 9c537d7521..0000000000 --- a/packages/frontend/graphql/src/utils.ts +++ /dev/null @@ -1,209 +0,0 @@ -export const SPAN_ID_BYTES = 8; -export const TRACE_ID_BYTES = 16; -export const TRACE_VERSION = '00'; -export const TRACE_FLAG = '01'; - -const BytesBuffer = Array.from({ length: 32 }); - -type TraceSpan = { - name: string; - spanId: string; - displayName: { - value: string; - truncatedByteCount: number; - }; - startTime: string; - endTime: string; - attributes: { - attributeMap: { - requestId?: { - stringValue: { - value: string; - truncatedByteCount: number; - }; - }; - event?: { - stringValue: { - value: string; - truncatedByteCount: 0; - }; - }; - }; - droppedAttributesCount: number; - }; -}; - -/** - * inspired by open-telemetry/opentelemetry-js - */ -export function generateRandUTF16Chars(bytes: number) { - for (let i = 0; i < bytes * 2; i++) { - BytesBuffer[i] = Math.floor(Math.random() * 16) + 48; - // valid hex characters in the range 48-57 and 97-102 - if (BytesBuffer[i] >= 58) { - BytesBuffer[i] += 39; - } - } - - return String.fromCharCode(...BytesBuffer.slice(0, bytes * 2)); -} - -export class TraceReporter { - static traceReportEndpoint = process.env.TRACE_REPORT_ENDPOINT; - static shouldReportTrace = process.env.SHOULD_REPORT_TRACE; - - private spansCache = new Array(); - private reportIntervalId: number | undefined | NodeJS.Timeout; - private readonly reportInterval = 60_000; - - private static instance: TraceReporter; - - public static getInstance(): TraceReporter { - if (!TraceReporter.instance) { - const instance = (TraceReporter.instance = new TraceReporter()); - instance.initTraceReport(); - } - - return TraceReporter.instance; - } - - public cacheTrace( - traceId: string, - spanId: string, - startTime: string, - attributes: { - requestId?: string; - event?: string; - } - ) { - const span = TraceReporter.createTraceSpan( - traceId, - spanId, - startTime, - attributes - ); - this.spansCache.push(span); - if (this.spansCache.length <= 1) { - this.initTraceReport(); - } - } - - public uploadTrace( - traceId: string, - spanId: string, - startTime: string, - attributes: { - requestId?: string; - event?: string; - } - ) { - const span = TraceReporter.createTraceSpan( - traceId, - spanId, - startTime, - attributes - ); - TraceReporter.reportToTraceEndpoint(JSON.stringify({ spans: [span] })); - } - - public static reportToTraceEndpoint(payload: string): void { - if (!TraceReporter.traceReportEndpoint) { - console.warn('No trace report endpoint found!'); - return; - } - if (typeof navigator !== 'undefined') { - navigator.sendBeacon(TraceReporter.traceReportEndpoint, payload); - } else { - fetch(TraceReporter.traceReportEndpoint, { - method: 'POST', - mode: 'cors', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - body: payload, - }).catch(console.warn); - } - } - - public static createTraceSpan( - traceId: string, - spanId: string, - startTime: string, - attributes: { - requestId?: string; - event?: string; - } - ): TraceSpan { - const requestId = attributes.requestId; - const event = attributes.event; - - return { - name: `projects/{GCP_PROJECT_ID}/traces/${traceId}/spans/${spanId}`, - spanId, - displayName: { - value: 'AFFiNE_REQUEST', - truncatedByteCount: 0, - }, - startTime, - endTime: new Date().toISOString(), - attributes: { - attributeMap: { - ...(!requestId - ? {} - : { - requestId: { - stringValue: { - value: requestId, - truncatedByteCount: 0, - }, - }, - }), - ...(!event - ? {} - : { - event: { - stringValue: { - value: event, - truncatedByteCount: 0, - }, - }, - }), - }, - droppedAttributesCount: 0, - }, - }; - } - - private readonly initTraceReport = () => { - if (!this.reportIntervalId && TraceReporter.shouldReportTrace) { - if (typeof window !== 'undefined') { - this.reportIntervalId = window.setInterval( - this.reportHandler, - this.reportInterval - ); - } else { - this.reportIntervalId = setInterval( - this.reportHandler, - this.reportInterval - ); - } - } - }; - - private readonly reportHandler = () => { - if (this.spansCache.length <= 0) { - clearInterval(this.reportIntervalId); - this.reportIntervalId = undefined; - return; - } - TraceReporter.reportToTraceEndpoint( - JSON.stringify({ spans: [...this.spansCache] }) - ); - this.spansCache = []; - }; -} - -export const traceReporter = process.env.SHOULD_REPORT_TRACE - ? TraceReporter.getInstance() - : null; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 66658075ff..153747e2b0 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -872,7 +872,7 @@ "com.affine.payment.ai.pricing-plan.title-caption-2": "A true multimodal AI copilot.", "com.affine.payment.ai.usage-description-purchased": "You have purchased AFFiNE AI.", "com.affine.payment.ai.usage-title": "AFFiNE AI Usage", - "com.affine.payment.ai.usage.change-button-label": "Upgraded", + "com.affine.payment.ai.usage.change-button-label": "Change", "com.affine.payment.ai.usage.purchase-button-label": "Upgrade", "com.affine.payment.ai.usage.used-caption": "Times used", "com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} Times", diff --git a/packages/frontend/web/src/app.tsx b/packages/frontend/web/src/app.tsx index dcbff671e5..02a04fcd2f 100644 --- a/packages/frontend/web/src/app.tsx +++ b/packages/frontend/web/src/app.tsx @@ -5,8 +5,11 @@ import { NotificationCenter } from '@affine/component'; import { AffineContext } from '@affine/component/context'; import { GlobalLoading } from '@affine/component/global-loading'; import { WorkspaceFallback } from '@affine/core/components/workspace'; -import { GlobalScopeProvider } from '@affine/core/modules/infra-web/global-scope'; -import { CloudSessionProvider } from '@affine/core/providers/session-provider'; +import { configureCommonModules, configureImpls } from '@affine/core/modules'; +import { + configureBrowserWorkspaceFlavours, + configureIndexedDBWorkspaceEngineStorageProvider, +} from '@affine/core/modules/workspace-engine'; import { router } from '@affine/core/router'; import { performanceLogger, @@ -14,14 +17,23 @@ import { } from '@affine/core/shared'; import { Telemetry } from '@affine/core/telemetry'; import createEmotionCache from '@affine/core/utils/create-emotion-cache'; -import { configureWebServices } from '@affine/core/web'; import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; -import { getCurrentStore, ServiceCollection } from '@toeverything/infra'; +import { + Framework, + FrameworkRoot, + getCurrentStore, + LifecycleService, +} from '@toeverything/infra'; import type { PropsWithChildren, ReactElement } from 'react'; import { lazy, Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; +if (!environment.isBrowser && environment.isDebug) { + document.body.innerHTML = `

Don't run web entry in electron.

`; + throw new Error('Wrong distribution'); +} + const performanceI18nLogger = performanceLogger.namespace('i18n'); const cache = createEmotionCache(); @@ -55,9 +67,18 @@ async function loadLanguage() { let languageLoadingPromise: Promise | null = null; -const services = new ServiceCollection(); -configureWebServices(services); -const serviceProvider = services.provider(); +const framework = new Framework(); +configureCommonModules(framework); +configureImpls(framework); +configureBrowserWorkspaceFlavours(framework); +configureIndexedDBWorkspaceEngineStorageProvider(framework); +const frameworkProvider = framework.provider(); + +// setup application lifecycle events, and emit application start event +window.addEventListener('focus', () => { + frameworkProvider.get(LifecycleService).applicationFocus(); +}); +frameworkProvider.get(LifecycleService).applicationStart(); export function App() { performanceRenderLogger.info('App'); @@ -68,24 +89,22 @@ export function App() { return ( - + - - - - - - } - router={router} - future={future} - /> - - + + + + + } + router={router} + future={future} + /> + - + ); } diff --git a/packages/frontend/workspace-impl/.gitignore b/packages/frontend/workspace-impl/.gitignore deleted file mode 100644 index a65b41774a..0000000000 --- a/packages/frontend/workspace-impl/.gitignore +++ /dev/null @@ -1 +0,0 @@ -lib diff --git a/packages/frontend/workspace-impl/package.json b/packages/frontend/workspace-impl/package.json deleted file mode 100644 index 95ba55c4d9..0000000000 --- a/packages/frontend/workspace-impl/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@affine/workspace-impl", - "private": true, - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "peerDependencies": { - "@blocksuite/blocks": "*", - "@blocksuite/global": "*", - "@blocksuite/store": "*" - }, - "dependencies": { - "@affine/debug": "workspace:*", - "@affine/electron-api": "workspace:*", - "@affine/env": "workspace:*", - "@affine/graphql": "workspace:*", - "@toeverything/infra": "workspace:*", - "idb": "^8.0.0", - "idb-keyval": "^6.2.1", - "is-svg": "^5.0.0", - "lodash-es": "^4.17.21", - "nanoid": "^5.0.7", - "socket.io-client": "^4.7.5", - "y-protocols": "^1.0.6", - "yjs": "^13.6.14" - }, - "devDependencies": { - "fake-indexeddb": "^5.0.2", - "vitest": "1.4.0", - "ws": "^8.16.0" - }, - "version": "0.14.0" -} diff --git a/packages/frontend/workspace-impl/src/cloud/consts.ts b/packages/frontend/workspace-impl/src/cloud/consts.ts deleted file mode 100644 index 47e9b8a7a6..0000000000 --- a/packages/frontend/workspace-impl/src/cloud/consts.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY = - 'affine-cloud-workspace-changed'; diff --git a/packages/frontend/workspace-impl/src/cloud/index.ts b/packages/frontend/workspace-impl/src/cloud/index.ts deleted file mode 100644 index 8d5e71d4ef..0000000000 --- a/packages/frontend/workspace-impl/src/cloud/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AffineCloudBlobStorage } from './blob'; -export { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts'; -export * from './list'; -export * from './workspace-factory'; diff --git a/packages/frontend/workspace-impl/src/cloud/list.ts b/packages/frontend/workspace-impl/src/cloud/list.ts deleted file mode 100644 index 929d71f1d9..0000000000 --- a/packages/frontend/workspace-impl/src/cloud/list.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { - createWorkspaceMutation, - deleteWorkspaceMutation, - fetcher, - findGraphQLError, - getWorkspacesQuery, -} from '@affine/graphql'; -import { DocCollection } from '@blocksuite/store'; -import type { - BlobStorage, - WorkspaceInfo, - WorkspaceListProvider, - WorkspaceMetadata, -} from '@toeverything/infra'; -import { globalBlockSuiteSchema } from '@toeverything/infra'; -import { difference } from 'lodash-es'; -import { nanoid } from 'nanoid'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -import { IndexedDBBlobStorage } from '../local/blob-indexeddb'; -import { SQLiteBlobStorage } from '../local/blob-sqlite'; -import { IndexedDBDocStorage } from '../local/doc-indexeddb'; -import { SqliteDocStorage } from '../local/doc-sqlite'; -import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts'; -import { AffineStaticDocStorage } from './doc-static'; - -async function getCloudWorkspaceList() { - try { - const { workspaces } = await fetcher({ - query: getWorkspacesQuery, - }).catch(() => { - return { workspaces: [] }; - }); - const ids = workspaces.map(({ id }) => id); - return ids.map(id => ({ - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - })); - } catch (err) { - console.log(err); - const e = findGraphQLError(err, e => e.extensions.code === 401); - if (e) { - // user not logged in - return []; - } - - throw err; - } -} - -export class CloudWorkspaceListProvider implements WorkspaceListProvider { - name = WorkspaceFlavour.AFFINE_CLOUD; - notifyChannel = new BroadcastChannel( - CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY - ); - - getList(): Promise { - return getCloudWorkspaceList(); - } - async delete(workspaceId: string): Promise { - await fetcher({ - query: deleteWorkspaceMutation, - variables: { - id: workspaceId, - }, - }); - // notify all browser tabs, so they can update their workspace list - this.notifyChannel.postMessage(null); - } - async create( - initial: ( - docCollection: DocCollection, - blobStorage: BlobStorage - ) => Promise - ): Promise { - const tempId = nanoid(); - - // create workspace on cloud, get workspace id - const { - createWorkspace: { id: workspaceId }, - } = await fetcher({ - query: createWorkspaceMutation, - }); - - // save the initial state to local storage, then sync to cloud - const blobStorage = environment.isDesktop - ? new SQLiteBlobStorage(workspaceId) - : new IndexedDBBlobStorage(workspaceId); - const docStorage = environment.isDesktop - ? new SqliteDocStorage(workspaceId) - : new IndexedDBDocStorage(workspaceId); - - const docCollection = new DocCollection({ - id: tempId, - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - blobStorages: [ - () => { - return { - crud: blobStorage, - }; - }, - ], - }); - - // apply initial state - await initial(docCollection, blobStorage); - - // save workspace to local storage, should be vary fast - await docStorage.doc.set( - workspaceId, - encodeStateAsUpdate(docCollection.doc) - ); - for (const subdocs of docCollection.doc.getSubdocs()) { - await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); - } - - // notify all browser tabs, so they can update their workspace list - this.notifyChannel.postMessage(null); - - return { id: workspaceId, flavour: WorkspaceFlavour.AFFINE_CLOUD }; - } - subscribe( - callback: (changed: { - added?: WorkspaceMetadata[] | undefined; - deleted?: WorkspaceMetadata[] | undefined; - }) => void - ): () => void { - let lastWorkspaceIDs: string[] = []; - - function scan() { - (async () => { - const allWorkspaceIDs = (await getCloudWorkspaceList()).map( - workspace => workspace.id - ); - const added = difference(allWorkspaceIDs, lastWorkspaceIDs); - const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs); - lastWorkspaceIDs = allWorkspaceIDs; - callback({ - added: added.map(id => ({ - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - })), - deleted: deleted.map(id => ({ - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - })), - }); - })().catch(err => { - console.error(err); - }); - } - - scan(); - - // rescan if other tabs notify us - this.notifyChannel.addEventListener('message', scan); - return () => { - this.notifyChannel.removeEventListener('message', scan); - }; - } - async getInformation(id: string): Promise { - // get information from both cloud and local storage - - // we use affine 'static' storage here, which use http protocol, no need to websocket. - const cloudStorage = new AffineStaticDocStorage(id); - const docStorage = environment.isDesktop - ? new SqliteDocStorage(id) - : new IndexedDBDocStorage(id); - // download root doc - const localData = await docStorage.doc.get(id); - const cloudData = await cloudStorage.pull(id); - - if (!cloudData && !localData) { - return; - } - - const bs = new DocCollection({ - id, - schema: globalBlockSuiteSchema, - }); - - if (localData) applyUpdate(bs.doc, localData); - if (cloudData) applyUpdate(bs.doc, cloudData.data); - - return { - name: bs.meta.name, - avatar: bs.meta.avatar, - }; - } -} diff --git a/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts b/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts deleted file mode 100644 index aa2c053022..0000000000 --- a/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { ServiceCollection, WorkspaceFactory } from '@toeverything/infra'; -import { - AwarenessContext, - AwarenessProvider, - DocServerImpl, - RemoteBlobStorage, - WorkspaceIdContext, - WorkspaceScope, -} from '@toeverything/infra'; - -import { LocalWorkspaceFactory } from '../local'; -import { IndexedDBBlobStorage } from '../local/blob-indexeddb'; -import { SQLiteBlobStorage } from '../local/blob-sqlite'; -import { AffineCloudAwarenessProvider } from './awareness'; -import { AffineCloudBlobStorage } from './blob'; -import { AffineCloudDocEngineServer } from './doc'; - -export class CloudWorkspaceFactory implements WorkspaceFactory { - name = WorkspaceFlavour.AFFINE_CLOUD; - configureWorkspace(services: ServiceCollection): void { - // configure local-first providers - new LocalWorkspaceFactory().configureWorkspace(services); - - services - .scope(WorkspaceScope) - .addImpl(RemoteBlobStorage('affine-cloud'), AffineCloudBlobStorage, [ - WorkspaceIdContext, - ]) - .addImpl(DocServerImpl, AffineCloudDocEngineServer, [WorkspaceIdContext]) - .addImpl( - AwarenessProvider('affine-cloud'), - AffineCloudAwarenessProvider, - [WorkspaceIdContext, AwarenessContext] - ); - } - async getWorkspaceBlob(id: string, blobKey: string): Promise { - // try to get blob from local storage first - const localBlobStorage = environment.isDesktop - ? new SQLiteBlobStorage(id) - : new IndexedDBBlobStorage(id); - - const localBlob = await localBlobStorage.get(blobKey); - if (localBlob) { - return localBlob; - } - - const blobStorage = new AffineCloudBlobStorage(id); - return await blobStorage.get(blobKey); - } -} diff --git a/packages/frontend/workspace-impl/src/index.ts b/packages/frontend/workspace-impl/src/index.ts deleted file mode 100644 index d53c7f76e4..0000000000 --- a/packages/frontend/workspace-impl/src/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ServiceCollection } from '@toeverything/infra'; -import { - GlobalState, - Workspace, - WorkspaceFactory, - WorkspaceListProvider, - WorkspaceLocalState, - WorkspaceScope, -} from '@toeverything/infra'; - -import { CloudWorkspaceFactory, CloudWorkspaceListProvider } from './cloud'; -import { LocalWorkspaceFactory, LocalWorkspaceListProvider } from './local'; -import { LOCAL_WORKSPACE_LOCAL_STORAGE_KEY } from './local/consts'; -import { WorkspaceLocalStateImpl } from './local-state'; - -export * from './cloud'; -export * from './local'; - -export function configureWorkspaceImplServices(services: ServiceCollection) { - services - .addImpl(WorkspaceListProvider('affine-cloud'), CloudWorkspaceListProvider) - .addImpl(WorkspaceFactory('affine-cloud'), CloudWorkspaceFactory) - .addImpl(WorkspaceListProvider('local'), LocalWorkspaceListProvider) - .addImpl(WorkspaceFactory('local'), LocalWorkspaceFactory) - .scope(WorkspaceScope) - .addImpl(WorkspaceLocalState, WorkspaceLocalStateImpl, [ - Workspace, - GlobalState, - ]); -} - -/** - * a hack for directly add local workspace to workspace list - * Used after copying sqlite database file to appdata folder - */ -export function _addLocalWorkspace(id: string) { - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - allWorkspaceIDs.push(id); - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify(allWorkspaceIDs) - ); -} diff --git a/packages/frontend/workspace-impl/src/local-state.ts b/packages/frontend/workspace-impl/src/local-state.ts deleted file mode 100644 index fdd22547b0..0000000000 --- a/packages/frontend/workspace-impl/src/local-state.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { - GlobalState, - Memento, - Workspace, - WorkspaceLocalState, -} from '@toeverything/infra'; -import { wrapMemento } from '@toeverything/infra'; - -export class WorkspaceLocalStateImpl implements WorkspaceLocalState { - wrapped: Memento; - constructor(workspace: Workspace, globalState: GlobalState) { - this.wrapped = wrapMemento(globalState, `workspace-state:${workspace.id}:`); - } - - keys(): string[] { - return this.wrapped.keys(); - } - - get(key: string): T | null { - return this.wrapped.get(key); - } - - watch(key: string) { - return this.wrapped.watch(key); - } - - set(key: string, value: T | null): void { - return this.wrapped.set(key, value); - } - - del(key: string): void { - return this.wrapped.del(key); - } - - clear(): void { - return this.wrapped.clear(); - } -} diff --git a/packages/frontend/workspace-impl/src/local/consts.ts b/packages/frontend/workspace-impl/src/local/consts.ts deleted file mode 100644 index 2855fcf80b..0000000000 --- a/packages/frontend/workspace-impl/src/local/consts.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace'; -export const LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY = - 'affine-local-workspace-created'; diff --git a/packages/frontend/workspace-impl/src/local/index.ts b/packages/frontend/workspace-impl/src/local/index.ts deleted file mode 100644 index 920c40e16b..0000000000 --- a/packages/frontend/workspace-impl/src/local/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { StaticBlobStorage } from './blob-static'; -export * from './list'; -export * from './workspace-factory'; diff --git a/packages/frontend/workspace-impl/src/local/list.ts b/packages/frontend/workspace-impl/src/local/list.ts deleted file mode 100644 index 3b2d904f35..0000000000 --- a/packages/frontend/workspace-impl/src/local/list.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { apis } from '@affine/electron-api'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { DocCollection } from '@blocksuite/store'; -import type { - BlobStorage, - WorkspaceInfo, - WorkspaceListProvider, - WorkspaceMetadata, -} from '@toeverything/infra'; -import { globalBlockSuiteSchema } from '@toeverything/infra'; -import { difference } from 'lodash-es'; -import { nanoid } from 'nanoid'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -import { IndexedDBBlobStorage } from './blob-indexeddb'; -import { SQLiteBlobStorage } from './blob-sqlite'; -import { - LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY, - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, -} from './consts'; -import { IndexedDBDocStorage } from './doc-indexeddb'; -import { SqliteDocStorage } from './doc-sqlite'; - -export class LocalWorkspaceListProvider implements WorkspaceListProvider { - name = WorkspaceFlavour.LOCAL; - - notifyChannel = new BroadcastChannel( - LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY - ); - - async getList() { - return JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL })); - } - - async delete(workspaceId: string) { - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId)) - ); - - if (apis && environment.isDesktop) { - await apis.workspace.delete(workspaceId); - } - - // notify all browser tabs, so they can update their workspace list - this.notifyChannel.postMessage(workspaceId); - } - - async create( - initial: ( - docCollection: DocCollection, - blobStorage: BlobStorage - ) => Promise - ): Promise { - const id = nanoid(); - - const blobStorage = environment.isDesktop - ? new SQLiteBlobStorage(id) - : new IndexedDBBlobStorage(id); - const docStorage = environment.isDesktop - ? new SqliteDocStorage(id) - : new IndexedDBDocStorage(id); - - const workspace = new DocCollection({ - id: id, - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - blobStorages: [ - () => { - return { - crud: blobStorage, - }; - }, - ], - }); - - // apply initial state - await initial(workspace, blobStorage); - - // save workspace to local storage - await docStorage.doc.set(id, encodeStateAsUpdate(workspace.doc)); - for (const subdocs of workspace.doc.getSubdocs()) { - await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); - } - - // save workspace id to local storage - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - allWorkspaceIDs.push(id); - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify(allWorkspaceIDs) - ); - - // notify all browser tabs, so they can update their workspace list - this.notifyChannel.postMessage(id); - - return { id, flavour: WorkspaceFlavour.LOCAL }; - } - subscribe( - callback: (changed: { - added?: WorkspaceMetadata[] | undefined; - deleted?: WorkspaceMetadata[] | undefined; - }) => void - ): () => void { - let lastWorkspaceIDs: string[] = []; - - function scan() { - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - const added = difference(allWorkspaceIDs, lastWorkspaceIDs); - const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs); - lastWorkspaceIDs = allWorkspaceIDs; - callback({ - added: added.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })), - deleted: deleted.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })), - }); - } - - scan(); - - // rescan if other tabs notify us - this.notifyChannel.addEventListener('message', scan); - return () => { - this.notifyChannel.removeEventListener('message', scan); - }; - } - async getInformation(id: string): Promise { - // get information from root doc - const storage = environment.isDesktop - ? new SqliteDocStorage(id) - : new IndexedDBDocStorage(id); - const data = await storage.doc.get(id); - - if (!data) { - return; - } - - const bs = new DocCollection({ - id, - schema: globalBlockSuiteSchema, - }); - - applyUpdate(bs.doc, data); - - return { - name: bs.meta.name, - avatar: bs.meta.avatar, - }; - } -} diff --git a/packages/frontend/workspace-impl/src/local/workspace-factory.ts b/packages/frontend/workspace-impl/src/local/workspace-factory.ts deleted file mode 100644 index f755eb9ae2..0000000000 --- a/packages/frontend/workspace-impl/src/local/workspace-factory.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ServiceCollection, WorkspaceFactory } from '@toeverything/infra'; -import { - AwarenessContext, - AwarenessProvider, - DocStorageImpl, - LocalBlobStorage, - RemoteBlobStorage, - WorkspaceIdContext, - WorkspaceScope, -} from '@toeverything/infra'; - -import { BroadcastChannelAwarenessProvider } from './awareness'; -import { IndexedDBBlobStorage } from './blob-indexeddb'; -import { SQLiteBlobStorage } from './blob-sqlite'; -import { StaticBlobStorage } from './blob-static'; -import { IndexedDBDocStorage } from './doc-indexeddb'; -import { SqliteDocStorage } from './doc-sqlite'; - -export class LocalWorkspaceFactory implements WorkspaceFactory { - name = 'local'; - configureWorkspace(services: ServiceCollection): void { - if (environment.isDesktop) { - services - .scope(WorkspaceScope) - .addImpl(LocalBlobStorage, SQLiteBlobStorage, [WorkspaceIdContext]) - .addImpl(DocStorageImpl, SqliteDocStorage, [WorkspaceIdContext]); - } else { - services - .scope(WorkspaceScope) - .addImpl(LocalBlobStorage, IndexedDBBlobStorage, [WorkspaceIdContext]) - .addImpl(DocStorageImpl, IndexedDBDocStorage, [WorkspaceIdContext]); - } - - services - .scope(WorkspaceScope) - .addImpl(RemoteBlobStorage('static'), StaticBlobStorage) - .addImpl( - AwarenessProvider('broadcast-channel'), - BroadcastChannelAwarenessProvider, - [WorkspaceIdContext, AwarenessContext] - ); - } - async getWorkspaceBlob(id: string, blobKey: string): Promise { - const blobStorage = environment.isDesktop - ? new SQLiteBlobStorage(id) - : new IndexedDBBlobStorage(id); - - return await blobStorage.get(blobKey); - } -} diff --git a/packages/frontend/workspace-impl/src/utils/affine-io.ts b/packages/frontend/workspace-impl/src/utils/affine-io.ts deleted file mode 100644 index 71055dd5a4..0000000000 --- a/packages/frontend/workspace-impl/src/utils/affine-io.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Manager } from 'socket.io-client'; - -let ioManager: Manager | null = null; - -function getBaseUrl(): string { - if (environment.isDesktop) { - return runtimeConfig.serverUrlPrefix; - } - const { protocol, hostname, port } = window.location; - return `${protocol === 'https:' ? 'wss' : 'ws'}://${hostname}${ - port ? `:${port}` : '' - }`; -} - -// use lazy initialization socket.io io manager -export function getIoManager(): Manager { - if (ioManager) { - return ioManager; - } - ioManager = new Manager(`${getBaseUrl()}/`, { - autoConnect: false, - transports: ['websocket'], - secure: location.protocol === 'https:', - }); - return ioManager; -} diff --git a/packages/frontend/workspace-impl/tsconfig.json b/packages/frontend/workspace-impl/tsconfig.json deleted file mode 100644 index 15af0a0980..0000000000 --- a/packages/frontend/workspace-impl/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "include": ["./src"], - "compilerOptions": { - "noEmit": false, - "outDir": "lib" - }, - "references": [ - { "path": "../../../tests/fixtures" }, - { "path": "../../common/env" }, - { "path": "../../common/debug" }, - { "path": "../../common/infra" }, - { "path": "../../frontend/graphql" }, - { "path": "../../frontend/electron-api" } - ] -} diff --git a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts index 7e585c7123..31862dfaee 100644 --- a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts @@ -77,7 +77,5 @@ test('Delete last workspace', async ({ page }) => { await page.getByTestId('create-workspace-create-button').click(); await page.waitForTimeout(1000); await page.waitForSelector('[data-testid="workspace-name"]'); - expect(await page.getByTestId('workspace-name').textContent()).toBe( - 'Test Workspace' - ); + await expect(page.getByTestId('workspace-name')).toHaveText('Test Workspace'); }); diff --git a/tests/affine-local/e2e/local-first-workspace-list.spec.ts b/tests/affine-local/e2e/local-first-workspace-list.spec.ts index 8052cb5134..57653d9af0 100644 --- a/tests/affine-local/e2e/local-first-workspace-list.spec.ts +++ b/tests/affine-local/e2e/local-first-workspace-list.spec.ts @@ -138,7 +138,6 @@ test('create multi workspace in the workspace list', async ({ await page.waitForTimeout(1000); // check workspace list length { - await page.waitForTimeout(1000); const workspaceCards = page.getByTestId('workspace-card'); await expect(workspaceCards).toHaveCount(3); } diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx index 1e18eb1f4b..217c215784 100644 --- a/tests/storybook/.storybook/preview.tsx +++ b/tests/storybook/.storybook/preview.tsx @@ -8,22 +8,24 @@ import { useDarkMode } from 'storybook-dark-mode'; import { AffineContext } from '@affine/component/context'; import useSWR from 'swr'; import type { Decorator } from '@storybook/react'; -import { _setCurrentStore } from '@toeverything/infra'; +import { + FrameworkRoot, + FrameworkScope, + GlobalContextService, + LifecycleService, + WorkspacesService, + _setCurrentStore, + configureTestingInfraModules, + useLiveData, +} from '@toeverything/infra'; import { setupGlobal, type Environment } from '@affine/env/global'; import type { Preview } from '@storybook/react'; -import { useLayoutEffect, useRef } from 'react'; +import { useLayoutEffect, useMemo, useRef } from 'react'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { ServiceCollection } from '@toeverything/infra'; -import { - WorkspaceManager, - configureInfraServices, - configureTestingInfraServices, -} from '@toeverything/infra'; -import { CurrentWorkspaceService } from '@affine/core/modules/workspace'; -import { configureBusinessServices } from '@affine/core/modules/services'; +import { Framework } from '@toeverything/infra'; +import { configureCommonModules } from '@affine/core/modules'; import { createStore } from 'jotai'; -import { GlobalScopeProvider } from '@affine/core/modules/infra-web/global-scope'; setupGlobal(); export const parameters = { @@ -71,41 +73,59 @@ window.localStorage.setItem('dismissAiOnboarding', 'true'); window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true'); window.localStorage.setItem('dismissAiOnboardingLocal', 'true'); -const services = new ServiceCollection(); +const framework = new Framework(); -configureInfraServices(services); -configureTestingInfraServices(services); -configureBusinessServices(services); +configureCommonModules(framework); +configureTestingInfraModules(framework); -const provider = services.provider(); +const frameworkProvider = framework.provider(); + +frameworkProvider.get(LifecycleService).applicationStart(); +const globalContextService = frameworkProvider.get(GlobalContextService); const store = createStore(); _setCurrentStore(store); -provider - .get(WorkspaceManager) - .createWorkspace(WorkspaceFlavour.LOCAL, async w => { +frameworkProvider + .get(WorkspacesService) + .create(WorkspaceFlavour.LOCAL, async w => { w.meta.setName('test-workspace'); w.meta.writeVersion(w); }) - .then(workspaceMetadata => { - const currentWorkspace = provider.get(CurrentWorkspaceService); - const workspaceManager = provider.get(WorkspaceManager); - currentWorkspace.openWorkspace( - workspaceManager.open(workspaceMetadata).workspace - ); + .then(meta => { + globalContextService.globalContext.workspaceId.set(meta.id); }); const withContextDecorator: Decorator = (Story, context) => { + const workspaceId = useLiveData( + globalContextService.globalContext.workspaceId.$ + ); + + const { workspace } = + useMemo(() => { + if (!workspaceId) { + return null; + } + return frameworkProvider.get(WorkspacesService).open({ + metadata: { flavour: WorkspaceFlavour.LOCAL, id: workspaceId }, + }); + }, []) ?? {}; + + if (!workspace) { + return <>loading..; + } + return ( - - - - - - - - + + + + + + + + + + ); }; diff --git a/tests/storybook/package.json b/tests/storybook/package.json index f1590820b3..122267ec08 100644 --- a/tests/storybook/package.json +++ b/tests/storybook/package.json @@ -10,7 +10,6 @@ "@affine/cli": "workspace:*", "@affine/component": "workspace:*", "@affine/i18n": "workspace:*", - "@affine/workspace-impl": "workspace:*", "@dnd-kit/sortable": "^8.0.0", "@storybook/jest": "^0.2.3", "@storybook/testing-library": "^0.2.2", diff --git a/tests/storybook/src/stories/core.stories.tsx b/tests/storybook/src/stories/core.stories.tsx index 727a572324..784a475890 100644 --- a/tests/storybook/src/stories/core.stories.tsx +++ b/tests/storybook/src/stories/core.stories.tsx @@ -1,5 +1,4 @@ -import { NavigateContext } from '@affine/core/hooks/use-navigate-helper'; -import { topLevelRoutes } from '@affine/core/router'; +import { NavigateContext, topLevelRoutes } from '@affine/core/router'; import { assertExists } from '@blocksuite/global/utils'; import type { StoryFn } from '@storybook/react'; import { screen, userEvent, waitFor, within } from '@storybook/testing-library'; @@ -35,7 +34,9 @@ export const Index: StoryFn = () => { Index.decorators = [withRouter]; Index.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(topLevelRoutes), + routing: reactRouterOutlets( + topLevelRoutes[0].children /* skip root wrapper */ + ), }), }; diff --git a/tests/storybook/src/stories/image-preview-modal.stories.tsx b/tests/storybook/src/stories/image-preview-modal.stories.tsx index a2326a58cd..059ac12702 100644 --- a/tests/storybook/src/stories/image-preview-modal.stories.tsx +++ b/tests/storybook/src/stories/image-preview-modal.stories.tsx @@ -3,11 +3,11 @@ import { ImagePreviewModal } from '@affine/core/components/image-preview'; import type { Meta, StoryFn } from '@storybook/react'; import type { Doc } from '@toeverything/infra'; import { + DocsService, + FrameworkScope, initEmptyPage, - PageManager, - ServiceProviderContext, useService, - Workspace, + WorkspaceService, } from '@toeverything/infra'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -19,16 +19,16 @@ export default { } satisfies Meta; export const Default: StoryFn = () => { - const workspace = useService(Workspace); - const pageManager = useService(PageManager); + const workspace = useService(WorkspaceService).workspace; + const docsService = useService(DocsService); - const [page, setPage] = useState(null); + const [doc, setDoc] = useState(null); useEffect(() => { - const bsPage = workspace.docCollection.createDoc({ id: 'page0' }); - initEmptyPage(bsPage); + const bsDoc = workspace.docCollection.createDoc({ id: 'page0' }); + initEmptyPage(bsDoc); - const { page, release } = pageManager.open(bsPage.meta!.id); + const { doc, release } = docsService.open(bsDoc.meta!.id); fetch(new URL('@affine-test/fixtures/large-image.png', import.meta.url)) .then(res => res.arrayBuffer()) @@ -36,17 +36,17 @@ export const Default: StoryFn = () => { const id = await workspace.docCollection.blob.set( new Blob([buffer], { type: 'image/png' }) ); - const frameId = bsPage.getBlockByFlavour('affine:note')[0].id; - bsPage.addBlock( + const frameId = bsDoc.getBlockByFlavour('affine:note')[0].id; + bsDoc.addBlock( 'affine:paragraph', { - text: new bsPage.Text( + text: new bsDoc.Text( 'Please double click the image to preview it.' ), }, frameId ); - bsPage.addBlock( + bsDoc.addBlock( 'affine:image', { sourceId: id, @@ -57,19 +57,19 @@ export const Default: StoryFn = () => { .catch(err => { console.error('Failed to load large-image.png', err); }); - setPage(page); + setDoc(doc); return () => { release(); }; - }, [pageManager, workspace]); + }, [docsService, workspace]); - if (!page) { + if (!doc) { return
; } return ( - +
{ overflow: 'auto', }} > - + {createPortal( , document.body )}
-
+ ); }; diff --git a/tests/storybook/src/stories/share-menu.stories.tsx b/tests/storybook/src/stories/share-menu.stories.tsx index c58429f17f..0f1d52dcad 100644 --- a/tests/storybook/src/stories/share-menu.stories.tsx +++ b/tests/storybook/src/stories/share-menu.stories.tsx @@ -5,7 +5,11 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; import type { Doc } from '@blocksuite/store'; import { expect } from '@storybook/jest'; import type { Meta, StoryFn } from '@storybook/react'; -import { initEmptyPage, useService, Workspace } from '@toeverything/infra'; +import { + initEmptyPage, + useService, + WorkspaceService, +} from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { useEffect, useState } from 'react'; @@ -22,7 +26,7 @@ async function unimplemented() { } export const Basic: StoryFn = () => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const [page, setPage] = useState(null); @@ -64,7 +68,7 @@ Basic.play = async ({ canvasElement }) => { }; export const AffineBasic: StoryFn = () => { - const workspace = useService(Workspace); + const workspace = useService(WorkspaceService).workspace; const [page, setPage] = useState(null); diff --git a/tests/storybook/src/stories/workspace-list.stories.tsx b/tests/storybook/src/stories/workspace-list.stories.tsx index 9515ed73d5..b234e0a298 100644 --- a/tests/storybook/src/stories/workspace-list.stories.tsx +++ b/tests/storybook/src/stories/workspace-list.stories.tsx @@ -1,7 +1,11 @@ import type { WorkspaceListProps } from '@affine/component/workspace-list'; import { WorkspaceList } from '@affine/component/workspace-list'; import type { Meta } from '@storybook/react'; -import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra'; +import { + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; export default { title: 'AFFiNE/WorkspaceList', @@ -12,7 +16,7 @@ export default { } satisfies Meta; export const Default = () => { - const list = useLiveData(useService(WorkspaceManager).list.workspaceList$); + const list = useLiveData(useService(WorkspacesService).list.workspaces$); return (