Compare commits

...

46 Commits

Author SHA1 Message Date
LongYinan
9203980a8c build: fix selfhost config 2024-02-27 23:49:25 +08:00
liuyi
d34eb2cbe5 fix(server): apply env overrides after all config merged (#5795) 2024-02-27 21:46:08 +08:00
liuyi
7f3f993ce4 refactor(server): reorganize server configs (#5753) 2024-02-27 21:45:26 +08:00
Peng Xiao
df17001284 feat(core): add shortcut for openning settings (#5883)
fix https://github.com/toeverything/AFFiNE/issues/5881
2024-02-23 15:14:39 +08:00
Peng Xiao
e400abf1f4 fix: keyboard shortcut style in cmdk (#5882)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7eb2fa93-675a-43a6-8db4-9681fbbd1406.png)
2024-02-23 15:14:39 +08:00
EYHN
640aa00148 fix(core): fix app boot speed (#5884) 2024-02-23 06:54:42 +00:00
Ayush Agrawal
5ae8f029f7 chore: bump blocksuite (#5868)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-22 17:15:27 +08:00
EYHN
a26e0b3ec9 fix(core): disable sidebar user select (#5862)
close #5846
2024-02-22 17:15:26 +08:00
Umar Faiz
f492b6711b fix(core): the pitch zooming function incorrectly zooms the toolbar (#5456)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-22 17:15:26 +08:00
EYHN
81aae61394 fix(core): fix desktop e2e (#5867) 2024-02-22 16:12:23 +08:00
Peng Xiao
e08f58beea chore: bump electron dependencies (#5770)
to include this fix https://github.com/electron/electron/pull/40994
2024-02-22 15:26:09 +08:00
EYHN
4560819f76 fix(core): fix 404 after signout (hotfix) (#5865) 2024-02-22 15:11:11 +08:00
liuyi
193c197a54 fix(core): window.open to a new origin will be blocked by browser (#5856) 2024-02-21 20:51:25 +08:00
LongYinan
449c0a38a7 fix(electron): linux AppImage output path 2024-02-21 15:48:38 +08:00
Ayush Agrawal
8d141e5a81 feat: blocksuite integration for pageMode & pageUpdatedAt (#5849)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-21 15:16:49 +08:00
Lye Hongtao
e04911315f feat: move templates into AFFiNE (#5750)
Related to https://github.com/toeverything/blocksuite/pull/6156

### Change
Move the edgeless templates to AFFiNE. All templates are stored as zip files. Run `node build-edgeless.mjs` in `@affine/templates` to generate JSON-format templates and importing script. The template will be generated automatically during building and dev (`yarn dev`).
2024-02-21 15:10:47 +08:00
Ayush Agrawal
75d58679b6 chore: bump blocksuite (#5852) 2024-02-21 15:10:35 +08:00
Ayush Agrawal
2e6386e4cf feat: bump blocksuite (#5845) 2024-02-21 15:09:33 +08:00
Ayush Agrawal
f345a61df0 feat: bump blocksuite (#5817) 2024-02-21 15:01:45 +08:00
Flrande
a6420fcd76 feat: bump blocksuite (#5812) 2024-02-21 15:00:43 +08:00
Yifeng Wang
fec406f7e8 feat: bump blocksuite (#5767)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-21 14:57:05 +08:00
LongYinan
769398591b fix: resolve deps and types issues after cherry-pick 2024-02-21 14:51:29 +08:00
JimmFly
e01569fff7 feat(core): add loading to quick search modal (#5785)
close AFF-285

add `useSyncEngineStatus` hooks
add loading style

<img width="977" alt="test1" src="https://github.com/toeverything/AFFiNE/assets/102217452/e8bf6714-e42b-4adf-a279-341ef5f5cfc0">
2024-02-21 14:40:30 +08:00
JimmFly
6bde2de783 feat(core): add starAFFiNE and issueFeedback modal (#5718)
close TOV-482

https://github.com/toeverything/AFFiNE/assets/102217452/da1f74bc-4b8d-4d7f-987d-f53da98d92fe
2024-02-21 14:23:09 +08:00
JimmFly
3513ced6cb fix(core): match page preview and page title in page list (#5840)
close TOV-578
2024-02-21 14:21:05 +08:00
Adithyan
8dc9addc40 feat: Duplicate page in page list and clone naming improvements (#5818) 2024-02-21 14:20:38 +08:00
Muhammad Arsil
9d9f89ef2e fix: cards overlapping issue (#5727)
Co-authored-by: EYHN <cneyhn@gmail.com>
2024-02-21 14:20:31 +08:00
DarkSky
6cfe5d4566 feat: use custom verify token policy (#5836) 2024-02-21 14:20:08 +08:00
Peng Xiao
6032b432f8 build(electron): generate latest-linux.yml (#5822) 2024-02-21 14:19:37 +08:00
Peng Xiao
5823787ded fix(electron): linux login issues (#5821)
Looks like there are some issues using `@reforged/maker-appimage`:
- deep link not working properly (cannot login)
- cannot be installed via AppImageLauncher, which is required to enable deep link on linux

I forked saleae/electron-forge-maker-appimage into https://github.com/toeverything/electron-forge-maker-appimage to fix these issues
See changes: https://github.com/saleae/electron-forge-maker-appimage/compare/master...toeverything:electron-forge-maker-appimage:master?w=1

To enable login on Linux, the app must be installed via AppImageLauncher.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/28dcaadb-49d1-4c95-8a4f-ef41bef604be.png)

fix https://github.com/toeverything/AFFiNE/issues/4978
fix https://github.com/toeverything/AFFiNE/issues/4466
2024-02-21 14:19:23 +08:00
LongYinan
b3f272ba70 fix: selfhost build (#5833) 2024-02-21 14:18:05 +08:00
Whitewater
a5df5a7c8a chore: skip sync when offline (#5786) 2024-02-21 14:17:20 +08:00
DarkSky
90de90403a feat: refresh new workspace feature (#5834) 2024-02-20 16:12:35 +08:00
李华桥
4d4e4fc4e2 Merge branch 'stable' into beta 2024-02-20 15:52:22 +08:00
liuyi
aa73e532d3 fix(server): doc upsert without row lock (#5765) 2024-02-16 22:06:23 +08:00
liuyi
31faa93c71 chore(storage): bump y-octo (#5751) 2024-02-16 19:36:26 +08:00
liuyi
def60f4c61 fix(server): doc upsert without row lock (#5765) 2024-02-05 16:39:48 +08:00
EYHN
d15ec0ff77 fix(workspace): fix sync handshake (hot-fix) (#5797) 2024-02-05 10:56:46 +08:00
EYHN
d2acd0385a fix(core): prevent data loss (hot-fix) (#5798) 2024-02-05 10:54:51 +08:00
EYHN
1effb2f25f fix(workspace): fix sync stuck (#5762) (#5772) 2024-02-01 17:41:49 +08:00
Joooye_34
9189d26332 feat: support sign-in with subscription coupon (#5768) 2024-02-01 17:03:29 +08:00
liuyi
79a8be7799 feat(server): allow pass coupon to checkout session (#5749) 2024-02-01 17:03:16 +08:00
liuyi
1a643cc70c fix(server): doc upsert race condition (#5755) 2024-01-31 21:36:35 +08:00
liuyi
9321be3ff5 fix(server): doc upsert race condition (#5755) 2024-01-31 11:08:52 +00:00
李华桥
24dc3f95ff fix: consume blob stream correctly (#5706) 2024-01-31 11:38:40 +08:00
Cats Juice
4257b5f3a4 fix(core): set createDate to journal's date when journal created (#5701) 2024-01-30 23:13:02 +08:00
191 changed files with 4513 additions and 3521 deletions

View File

@@ -12,3 +12,4 @@ static
web-static
public
packages/frontend/i18n/src/i18n-generated.ts
packages/frontend/templates/edgeless-templates.gen.ts

View File

@@ -1,6 +1,6 @@
services:
affine:
image: ghcr.io/toeverything/affine-graphql:beta
image: ghcr.io/toeverything/affine-graphql:stable
container_name: affine_selfhosted
command:
['sh', '-c', 'node ./scripts/self-host-predeploy && node ./dist/index.js']
@@ -23,13 +23,11 @@ services:
max-size: '1000m'
restart: unless-stopped
environment:
- NODE_OPTIONS=--es-module-specifier-resolution node
- NODE_OPTIONS=--es-module-specifier-resolution=node
- AFFINE_CONFIG_PATH=/root/.affine/config
- REDIS_SERVER_HOST=redis
- DATABASE_URL=postgres://affine:affine@postgres:5432/affine
- DISABLE_TELEMETRY=true
- NODE_ENV=production
- SERVER_FLAVOR=selfhosted
- AFFINE_ADMIN_EMAIL=${AFFINE_ADMIN_EMAIL}
- AFFINE_ADMIN_PASSWORD=${AFFINE_ADMIN_PASSWORD}
redis:

View File

@@ -39,6 +39,8 @@ spec:
value: "--max-old-space-size=4096"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "affine"
- name: SERVER_FLAVOR
value: "graphql"
- name: AFFINE_ENV

View File

@@ -36,6 +36,8 @@ spec:
value: "{{ .Values.env }}"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "affine"
- name: SERVER_FLAVOR
value: "sync"
- name: NEXTAUTH_URL

View File

@@ -47,11 +47,11 @@
"groupName": "electron-forge"
},
{
"groupName": "blocksuite-nightly",
"groupName": "blocksuite-canary",
"matchPackagePatterns": ["^@blocksuite"],
"excludePackageNames": ["@blocksuite/icons"],
"rangeStrategy": "replace",
"followTag": "nightly"
"followTag": "canary"
},
{
"groupName": "all non-major dependencies",

View File

@@ -19,7 +19,7 @@ env:
MACOSX_DEPLOYMENT_TARGET: '10.13'
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright
DISABLE_TELEMETRY: true
DEPLOYMENT_TYPE: affine
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -291,6 +291,7 @@ jobs:
runs-on: ubuntu-latest
needs: build-storage
env:
NODE_ENV: test
DISTRIBUTION: browser
services:
postgres:

View File

@@ -143,7 +143,7 @@ jobs:
run: |
mkdir -p builds
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
mv packages/frontend/electron/out/*/make/AppImage/x64/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
- name: Upload Artifact
uses: actions/upload-artifact@v4

3
.gitignore vendored
View File

@@ -79,3 +79,6 @@ lib
affine.db
apps/web/next-routes.conf
.nx
packages/frontend/templates/edgeless
packages/frontend/core/public/static/templates

View File

@@ -16,6 +16,7 @@ packages/frontend/i18n/src/i18n-generated.ts
packages/frontend/graphql/src/graphql/index.ts
tests/affine-legacy/**/static
.yarnrc.yml
packages/frontend/templates/edgeless-templates.gen.ts
packages/frontend/templates/templates.gen.ts
packages/frontend/templates/onboarding

1000
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -76,7 +76,7 @@
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/coverage-istanbul": "1.1.3",
"@vitest/ui": "1.1.3",
"electron": "^28.1.4",
"electron": "^28.2.1",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-i": "^2.29.0",

View File

@@ -40,21 +40,21 @@
"@node-rs/crc32": "^1.7.2",
"@node-rs/jsonwebtoken": "^0.3.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/core": "^1.20.0",
"@opentelemetry/exporter-prometheus": "^0.47.0",
"@opentelemetry/exporter-zipkin": "^1.20.0",
"@opentelemetry/host-metrics": "^0.34.0",
"@opentelemetry/instrumentation": "^0.47.0",
"@opentelemetry/instrumentation-graphql": "^0.36.0",
"@opentelemetry/instrumentation-http": "^0.47.0",
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
"@opentelemetry/instrumentation-socket.io": "^0.35.0",
"@opentelemetry/resources": "^1.20.0",
"@opentelemetry/sdk-metrics": "^1.20.0",
"@opentelemetry/sdk-node": "^0.47.0",
"@opentelemetry/sdk-trace-node": "^1.20.0",
"@opentelemetry/semantic-conventions": "^1.20.0",
"@opentelemetry/core": "^1.21.0",
"@opentelemetry/exporter-prometheus": "^0.48.0",
"@opentelemetry/exporter-zipkin": "^1.21.0",
"@opentelemetry/host-metrics": "^0.35.0",
"@opentelemetry/instrumentation": "^0.48.0",
"@opentelemetry/instrumentation-graphql": "^0.37.0",
"@opentelemetry/instrumentation-http": "^0.48.0",
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-metrics": "^1.21.0",
"@opentelemetry/sdk-node": "^0.48.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"@prisma/client": "^5.7.1",
"@prisma/instrumentation": "^5.7.1",
"@socket.io/redis-adapter": "^8.2.1",
@@ -162,7 +162,6 @@
"env": {
"TS_NODE_TRANSPILE_ONLY": true,
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "development",
"DEBUG": "affine:*",
"FORCE_COLOR": true,
"DEBUG_COLORS": true

View File

@@ -265,7 +265,9 @@ model Snapshot {
seq Int @default(0) @db.Integer
state Bytes? @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// the `updated_at` field will not record the time of record changed,
// but the created time of last seen update that has been merged into snapshot.
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
@@id([id, workspaceId])
@@map("snapshots")

View File

@@ -13,7 +13,10 @@ const configFiles = [
];
function configCleaner(content) {
return content.replace(/(\/\/#.*$)|(\/\/\s+TODO.*$)/gm, '');
return content.replace(
/(^\/\/#.*$)|(^\/\/\s+TODO.*$)|("use\sstrict";?)|(^.*eslint-disable.*$)/gm,
''
);
}
function prepare() {

View File

@@ -11,6 +11,7 @@ export class AppController {
return {
compatibility: this.config.version,
message: `AFFiNE ${this.config.version} Server`,
type: this.config.type,
flavor: this.config.flavor,
};
}

View File

@@ -109,7 +109,7 @@ export class AppModuleBuilder {
},
],
imports: this.modules,
controllers: this.config.flavor.selfhosted ? [] : [AppController],
controllers: this.config.isSelfhosted ? [] : [AppController],
})
class AppModule {}
@@ -132,9 +132,9 @@ function buildAppModule() {
// sync server only
.useIf(config => config.flavor.sync, SyncModule)
// main server only
// graphql server only
.useIf(
config => config.flavor.main,
config => config.flavor.graphql,
ServerConfigModule,
WebSocketModule,
GqlModule,
@@ -147,7 +147,7 @@ function buildAppModule() {
// self hosted server only
.useIf(
config => config.flavor.selfhosted,
config => config.isSelfhosted,
ServeStaticModule.forRoot({
rootPath: join('/app', 'static'),
})

View File

@@ -3,8 +3,7 @@ AFFiNE.ENV_MAP = {
AFFINE_SERVER_PORT: ['port', 'int'],
AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path',
AFFIHE_SERVER_HTTPS: ['https', 'boolean'],
AFFINE_ENV: 'affineEnv',
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
DATABASE_URL: 'db.url',
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
@@ -28,7 +27,7 @@ AFFiNE.ENV_MAP = {
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
DOC_MERGE_USE_JWST_CODEC: [
'doc.manager.experimentalMergeWithJwstCodec',
'doc.manager.experimentalMergeWithYOcto',
'boolean',
],
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
@@ -36,5 +35,3 @@ AFFiNE.ENV_MAP = {
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
};
export default AFFiNE;

View File

@@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
// Custom configurations for AFFiNE Cloud
// ====================================================================================
// Q: WHY THIS FILE EXISTS?
// A: AFFiNE deployment environment may have a lot of custom environment variables,
// which are not suitable to be put in the `affine.ts` file.
// For example, AFFiNE Cloud Clusters are deployed on Google Cloud Platform.
// We need to enable the `gcloud` plugin to make sure the nodes working well,
// but the default selfhost version may not require it.
// So it's not a good idea to put such logic in the common `affine.ts` file.
//
// ```
// if (AFFiNE.deploy) {
// AFFiNE.plugins.use('gcloud');
// }
// ```
// ====================================================================================
const env = process.env;
AFFiNE.metrics.enabled = !AFFiNE.node.test;
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.storage.providers.r2 = {
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
credentials: {
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
},
};
AFFiNE.storage.storages.avatar.provider = 'r2';
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
`https://avatar.affineassets.com/${key}`;
AFFiNE.storage.storages.blob.provider = 'r2';
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
}
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment');
if (AFFiNE.deploy) {
AFFiNE.plugins.use('gcloud');
}

View File

@@ -1,39 +1,94 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
// Custom configurations
const env = process.env;
// TODO(@forehalo): detail explained
// Storage
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.storage.providers.r2 = {
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
credentials: {
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
},
};
AFFiNE.storage.storages.avatar.provider = 'r2';
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
`https://avatar.affineassets.com/${key}`;
AFFiNE.storage.storages.blob.provider = 'r2';
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
}
// Metrics
AFFiNE.metrics.enabled = true;
// Plugins Section Start
AFFiNE.plugins.use('payment', {
stripe: {
keys: {},
apiVersion: '2023-10-16',
},
//
// ###############################################################
// ## AFFiNE Configuration System ##
// ###############################################################
// Here is the file of all AFFiNE configurations that will affect runtime behavior.
// Override any configuration here and it will be merged when starting the server.
// Any changes in this file won't take effect before server restarted.
//
//
// > Configurations merge order
// 1. load environment variables (`.env` if provided, and from system)
// 2. load `src/fundamentals/config/default.ts` for all default settings
// 3. apply `./affine.ts` patches (this file)
// 4. apply `./affine.env.ts` patches
//
//
// ###############################################################
// ## General settings ##
// ###############################################################
//
// /* The unique identity of the server */
// AFFiNE.serverId = 'some-randome-uuid';
//
// /* The name of AFFiNE Server, may show on the UI */
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
//
// /* Whether the server is deployed behind a HTTPS proxied environment */
AFFiNE.https = false;
// /* Domain of your server that your server will be available at */
AFFiNE.host = 'localhost';
// /* The local port of your server that will listen on */
AFFiNE.port = 3010;
// /* The sub path of your server */
// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */
// AFFiNE.path = '/affine';
//
//
// ###############################################################
// ## Database settings ##
// ###############################################################
//
// /* The URL of the database where most of AFFiNE server data will be stored in */
// AFFiNE.db.url = 'postgres://user:passsword@localhost:5432/affine';
//
//
// ###############################################################
// ## Server Function settings ##
// ###############################################################
//
// /* Whether enable metrics and tracing while running the server */
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
// AFFiNE.metrics.enabled = true;
//
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
// /* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server */
// AFFiNE.graphql = {
// /* Path to mount GraphQL API */
// path: '/graphql',
// buildSchemaOptions: {
// numberScalarMode: 'integer',
// },
// /* Whether allow client to query the schema introspection */
// introspection: true,
// /* Whether enable GraphQL Playground UI */
// playground: true,
// }
//
// /* Doc Store & Collaberation */
// /* How long the buffer time of creating a new history snapshot when doc get updated */
// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes
//
// /* Use `y-octo` to merge updates at the same time when merging using Yjs */
// AFFiNE.doc.manager.experimentalMergeWithYOcto = true;
//
// /* How often the manager will start a new turn of merging pending updates into doc snapshot */
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
//
//
// ###############################################################
// ## Plugins settings ##
// ###############################################################
//
// /* Redis Plugin */
// /* Provide caching and session storing backed by Redis. */
// /* Useful when you deploy AFFiNE server in a cluster. */
AFFiNE.plugins.use('redis', {
/* override options */
});
AFFiNE.plugins.use('redis');
// Plugins Section end
export default AFFiNE;
// /* Payment Plugin */
AFFiNE.plugins.use('payment', {
stripe: { keys: {}, apiVersion: '2023-10-16' },
});
//

View File

@@ -96,6 +96,24 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
}
return result;
};
prismaAdapter.createVerificationToken = async data => {
await session.set(
`${data.identifier}:${data.token}`,
Date.now() + session.sessionTtl
);
return data;
};
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
const expires = await session.get(`${identifier}:${token}`);
if (expires) {
return { identifier, token, expires: new Date(expires) };
} else {
return null;
}
};
const nextAuthOptions: NextAuthOptions = {
providers: [
// @ts-expect-error esm interop issue

View File

@@ -136,7 +136,7 @@ export class AuthService {
return (
!!outcome.success &&
// skip hostname check in dev mode
(this.config.affineEnv === 'dev' || outcome.hostname === this.config.host)
(this.config.node.dev || outcome.hostname === this.config.host)
);
}

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../fundamentals';
export enum ServerFeature {
Payment = 'payment',
}
@@ -9,6 +11,10 @@ registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
const ENABLED_FEATURES: ServerFeature[] = [];
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.push(feature);
@@ -28,6 +34,9 @@ export class ServerConfigType {
@Field({ description: 'server base url' })
baseUrl!: string;
@Field(() => DeploymentType, { description: 'server type' })
type!: DeploymentType;
/**
* @deprecated
*/
@@ -46,7 +55,11 @@ export class ServerConfigResolver {
name: AFFiNE.serverName,
version: AFFiNE.version,
baseUrl: AFFiNE.baseUrl,
flavor: AFFiNE.flavor.type,
type: AFFiNE.type,
// BACKWARD COMPATIBILITY
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
// this field should be removed after frontend feature flags implemented
flavor: AFFiNE.type,
features: ENABLED_FEATURES,
};
}

View File

@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs';
import {
applyUpdate,
decodeStateVector,
Doc,
encodeStateAsUpdate,
encodeStateVector,
@@ -19,6 +18,7 @@ import {
import {
Cache,
CallTimer,
Config,
EventEmitter,
type EventPayload,
@@ -45,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
return compare(yBinary, yBinary2, true);
}
/**
* Detect whether rhs state is newer than lhs state.
*
* How could we tell a state is newer:
*
* i. if the state vector size is larger, it's newer
* ii. if the state vector size is same, compare each client's state
*/
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
const lhsVector = decodeStateVector(lhs);
const rhsVector = decodeStateVector(rhs);
if (lhsVector.size < rhsVector.size) {
return true;
}
for (const [client, state] of lhsVector) {
const rstate = rhsVector.get(client);
if (!rstate) {
return false;
}
if (state < rstate) {
return true;
}
}
return false;
}
export function isEmptyBuffer(buf: Buffer): boolean {
return (
buf.length === 0 ||
@@ -119,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.destroy();
}
@CallTimer('doc', 'yjs_recover_updates_to_doc')
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
const doc = new Doc();
const chunks = chunk(updates, 10);
@@ -154,11 +125,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
const doc = await this.recoverDoc(...updates);
// test jwst codec
if (
this.config.affine.canary &&
this.config.doc.manager.experimentalMergeWithJwstCodec &&
updates.length < 100 /* avoid overloading */
) {
if (this.config.doc.manager.experimentalMergeWithYOcto) {
metrics.jwst.counter('codec_merge_counter').add(1);
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
let log = false;
@@ -209,7 +176,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}, this.config.doc.manager.updatePollInterval);
this.logger.log('Automation started');
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
if (this.config.doc.manager.experimentalMergeWithYOcto) {
this.logger.warn(
'Experimental feature enabled: merge updates with jwst codec is enabled'
);
@@ -382,7 +349,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
const doc = await this.squash(updates, snapshot);
const doc = await this.squash(snapshot, updates);
return Buffer.from(encodeStateVector(doc));
}
@@ -415,7 +382,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
// take it ease, we don't want to overload db and or cpu
// if we limit the taken number here,
// user will never see the latest doc if there are too many updates pending to be merged.
take: 100,
take: this.config.doc.manager.maxUpdatesPullCount,
});
// perf(memory): avoid sorting in db
@@ -463,80 +430,92 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
});
}
/**
* @returns whether the snapshot is updated to the latest, `undefined` means the doc to be upserted is outdated.
*/
@CallTimer('doc', 'upsert')
private async upsert(
workspaceId: string,
guid: string,
doc: Doc,
// we always delay the snapshot update to avoid db overload,
// so the value of `updatedAt` will not be accurate to user's real action time
// so the value of auto updated `updatedAt` by db will never be accurate to user's real action time
updatedAt: Date,
initialSeq?: number
seq: number
) {
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
const blob = Buffer.from(encodeStateAsUpdate(doc));
const blob = Buffer.from(encodeStateAsUpdate(doc));
if (isEmptyBuffer(blob)) {
return false;
if (isEmptyBuffer(blob)) {
return undefined;
}
const state = Buffer.from(encodeStateVector(doc));
// CONCERNS:
// i. Because we save the real user's last seen action time as `updatedAt`,
// it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
//
// ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
// In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
// where: { id_workspaceId: {}, updatedAt: { lt: updatedAt } }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// iii. Only set the seq number when creating the snapshot.
// For updating scenario, the seq number will be updated when updates pushed to db.
try {
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "state", "seq", "created_at", "updated_at")
VALUES (${workspaceId}, ${guid}, ${blob}, ${state}, ${seq}, DEFAULT, ${updatedAt})
ON CONFLICT ("workspace_id", "guid")
DO UPDATE SET "blob" = ${blob}, "state" = ${state}, "updated_at" = ${updatedAt}, "seq" = ${seq}
WHERE "snapshots"."workspace_id" = ${workspaceId} AND "snapshots"."guid" = ${guid} AND "snapshots"."updated_at" <= ${updatedAt}
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
`;
// const result = await this.db.snapshot.upsert({
// select: {
// updatedAt: true,
// seq: true,
// },
// where: {
// id_workspaceId: {
// workspaceId,
// id: guid,
// },
// ⬇️ NOT SUPPORTED BY PRISMA YET
// updatedAt: {
// lt: updatedAt,
// },
// },
// update: {
// blob,
// state,
// updatedAt,
// },
// create: {
// workspaceId,
// id: guid,
// blob,
// state,
// updatedAt,
// seq,
// },
// });
// if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
// the updates has been applied to current `doc` must have been seen by the other process as well.
// The `updatedSnapshot` will be `undefined` in this case.
const updatedSnapshot = result.at(0);
if (!updatedSnapshot) {
return undefined;
}
const state = Buffer.from(encodeStateVector(doc));
return await this.db.$transaction(async db => {
const snapshot = await db.snapshot.findUnique({
where: {
id_workspaceId: {
id: guid,
workspaceId,
},
},
});
// update
if (snapshot) {
// only update if state is newer
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
await db.snapshot.update({
select: {
seq: true,
},
where: {
id_workspaceId: {
workspaceId,
id: guid,
},
},
data: {
blob,
state,
updatedAt,
},
});
return true;
} else {
return false;
}
} else {
// create
await db.snapshot.create({
select: {
seq: true,
},
data: {
id: guid,
workspaceId,
blob,
state,
seq: initialSeq,
createdAt: updatedAt,
updatedAt,
},
});
return true;
}
});
});
return true;
} catch (e) {
this.logger.error('Failed to upsert snapshot', e);
return false;
}
}
private async _get(
@@ -548,7 +527,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
if (updates.length) {
return {
doc: await this.squash(updates, snapshot),
doc: await this.squash(snapshot, updates),
};
}
@@ -559,17 +538,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* Squash updates into a single update and save it as snapshot,
* and delete the updates records at the same time.
*/
private async squash(updates: Update[], snapshot: Snapshot | null) {
@CallTimer('doc', 'squash')
private async squash(snapshot: Snapshot | null, updates: Update[]) {
if (!updates.length) {
throw new Error('No updates to squash');
}
const first = updates[0];
const last = updates[updates.length - 1];
const { id, workspaceId } = first;
const last = updates[updates.length - 1];
const { id, workspaceId } = last;
const doc = await this.applyUpdates(
first.id,
id,
snapshot ? snapshot.blob : Buffer.from([0, 0]),
...updates.map(u => u.blob)
);
@@ -600,19 +579,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
);
}
// always delete updates
// the upsert will return false if the state is not newer, so we don't need to worry about it
const { count } = await this.db.update.deleteMany({
where: {
id,
workspaceId,
seq: {
in: updates.map(u => u.seq),
// we will keep the updates only if the upsert failed on unknown reason
// `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
// `done === true` means the upsert is successful, safe to be deleted
if (done !== false) {
// always delete updates
// the upsert will return false if the state is not newer, so we don't need to worry about it
const { count } = await this.db.update.deleteMany({
where: {
id,
workspaceId,
seq: {
in: updates.map(u => u.seq),
},
},
},
});
});
await this.updateCachedUpdatesCount(workspaceId, id, -count);
await this.updateCachedUpdatesCount(workspaceId, id, -count);
}
return doc;
}
@@ -761,18 +745,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
);
}
async lockSnapshotForUpsert<T>(
workspaceId: string,
guid: string,
job: () => Promise<T>
) {
return this.doWithLock(
'doc:manager:snapshot',
`${workspaceId}::${guid}`,
job
);
}
@Cron(CronExpression.EVERY_MINUTE)
async reportUpdatesQueueCount() {
metrics.doc

View File

@@ -56,7 +56,7 @@ export class WorkspacesController {
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
}
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);
}
@@ -106,6 +106,7 @@ export class WorkspacesController {
}
res.setHeader('content-type', 'application/octet-stream');
res.setHeader('cache-control', 'no-cache');
res.send(update);
}
@@ -142,6 +143,7 @@ export class WorkspacesController {
if (history) {
res.setHeader('content-type', 'application/octet-stream');
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
res.send(history.blob);
} else {
throw new NotFoundException('Doc history not found');

View File

@@ -277,6 +277,7 @@ export class WorkspaceResolver {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
updatedAt: new Date(),
},
});
}

View File

@@ -8,7 +8,7 @@ export class SelfHostAdmin1605053000403 {
// do the migration
static async up(db: PrismaClient, ref: ModuleRef) {
const config = ref.get(Config, { strict: false });
if (config.flavor.selfhosted) {
if (config.isSelfhosted) {
if (
!process.env.AFFINE_ADMIN_EMAIL ||
!process.env.AFFINE_ADMIN_PASSWORD

View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class RefreshUnlimitedWorkspaceFeature1708321519830 {
// do the migration
static async up(db: PrismaClient) {
// add unlimited workspace feature
await upsertLatestFeatureVersion(db, FeatureType.UnlimitedWorkspace);
}
// revert the migration
static async down(_db: PrismaClient) {}
}

View File

@@ -3,6 +3,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
import {
CommonFeature,
FeatureKind,
Features,
FeatureType,
} from '../../../core/features';
@@ -33,6 +34,16 @@ export async function upsertFeature(
}
}
export async function upsertLatestFeatureVersion(
db: PrismaClient,
type: FeatureType
) {
const feature = Features.filter(f => f.feature === type);
feature.sort((a, b) => b.version - a.version);
const latestFeature = feature[0];
await upsertFeature(db, latestFeature);
}
export async function migrateNewFeatureTable(prisma: PrismaClient) {
const waitingList = await prisma.newFeaturesWaitingList.findMany();
for (const oldUser of waitingList) {

View File

@@ -18,18 +18,22 @@ export enum ExternalAccount {
firebase = 'firebase',
}
export type ServerFlavor =
| 'allinone'
| 'main'
// @deprecated
| 'graphql'
| 'sync'
| 'selfhosted';
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
export type NODE_ENV = 'development' | 'test' | 'production';
export enum DeploymentType {
Affine = 'affine',
Selfhosted = 'selfhosted',
}
export type ConfigPaths = LeafPaths<
Omit<
AFFiNEConfig,
| 'ENV_MAP'
| 'version'
| 'type'
| 'isSelfhosted'
| 'flavor'
| 'env'
| 'affine'
@@ -63,27 +67,36 @@ export interface AFFiNEConfig {
*/
readonly version: string;
/**
* Deployment type, AFFiNE Cloud, or Selfhosted
*/
get type(): DeploymentType;
/**
* Fast detect whether currently deployed in a selfhosted environment
*/
get isSelfhosted(): boolean;
/**
* Server flavor
*/
get flavor(): {
type: string;
main: boolean;
graphql: boolean;
sync: boolean;
selfhosted: boolean;
};
/**
* Deployment environment
*/
readonly affineEnv: 'dev' | 'beta' | 'production';
readonly AFFINE_ENV: AFFINE_ENV;
/**
* alias to `process.env.NODE_ENV`
*
* @default 'production'
* @default 'development'
* @env NODE_ENV
*/
readonly env: string;
readonly NODE_ENV: NODE_ENV;
/**
* fast AFFiNE environment judge
@@ -101,6 +114,7 @@ export interface AFFiNEConfig {
dev: boolean;
test: boolean;
};
get deploy(): boolean;
/**
@@ -302,11 +316,17 @@ export interface AFFiNEConfig {
updatePollInterval: number;
/**
* Use JwstCodec to merge updates at the same time when merging using Yjs.
* The maximum number of updates that will be pulled from the server at once.
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
*/
maxUpdatesPullCount: number;
/**
* Use `y-octo` to merge updates at the same time when merging using Yjs.
*
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
*/
experimentalMergeWithJwstCodec: boolean;
experimentalMergeWithYOcto: boolean;
};
history: {
/**

View File

@@ -6,7 +6,14 @@ import { merge } from 'lodash-es';
import parse from 'parse-duration';
import pkg from '../../../package.json' assert { type: 'json' };
import type { AFFiNEConfig, ServerFlavor } from './def';
import {
type AFFINE_ENV,
AFFiNEConfig,
DeploymentType,
type NODE_ENV,
type ServerFlavor,
} from './def';
import { readEnv } from './env';
import { getDefaultAFFiNEStorageConfig } from './storage';
// Don't use this in production
@@ -46,40 +53,62 @@ const jwtKeyPair = (function () {
})();
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
let isHttps: boolean | null = null;
let flavor = (process.env.SERVER_FLAVOR ?? 'allinone') as ServerFlavor;
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'development', [
'development',
'test',
'production',
]);
const AFFINE_ENV = readEnv<AFFINE_ENV>('AFFINE_ENV', 'dev', [
'dev',
'beta',
'production',
]);
const flavor = readEnv<ServerFlavor>('SERVER_FLAVOR', 'allinone', [
'allinone',
'graphql',
'sync',
]);
const deploymentType = readEnv<DeploymentType>(
'DEPLOYMENT_TYPE',
NODE_ENV === 'development'
? DeploymentType.Affine
: DeploymentType.Selfhosted,
Object.values(DeploymentType)
);
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
const defaultConfig = {
serverId: 'affine-nestjs-server',
serverName: flavor === 'selfhosted' ? 'Self-Host Cloud' : 'AFFiNE Cloud',
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
version: pkg.version,
get type() {
return deploymentType;
},
get isSelfhosted() {
return isSelfhosted;
},
get flavor() {
if (flavor === 'graphql') {
flavor = 'main';
}
return {
type: flavor,
main: flavor === 'main' || flavor === 'allinone',
graphql: flavor === 'graphql' || flavor === 'allinone',
sync: flavor === 'sync' || flavor === 'allinone',
selfhosted: flavor === 'selfhosted',
};
},
ENV_MAP: {},
affineEnv: 'dev',
AFFINE_ENV,
get affine() {
const env = this.affineEnv;
return {
canary: env === 'dev',
beta: env === 'beta',
stable: env === 'production',
canary: AFFINE_ENV === 'dev',
beta: AFFINE_ENV === 'beta',
stable: AFFINE_ENV === 'production',
};
},
env: process.env.NODE_ENV ?? 'development',
NODE_ENV,
get node() {
const env = this.env;
return {
prod: env === 'production',
dev: env === 'development',
test: env === 'test',
prod: NODE_ENV === 'production',
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
},
get deploy() {
@@ -88,12 +117,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
featureFlags: {
earlyAccessPreview: false,
},
get https() {
return isHttps ?? !this.node.dev;
},
set https(value: boolean) {
isHttps = value;
},
https: false,
host: 'localhost',
port: 3010,
path: '',
@@ -160,7 +184,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
manager: {
enableUpdateAutoMerging: flavor !== 'sync',
updatePollInterval: 3000,
experimentalMergeWithJwstCodec: false,
maxUpdatesPullCount: 500,
experimentalMergeWithYOcto: false,
},
history: {
interval: 1000 * 60 * 10 /* 10 mins */,

View File

@@ -48,3 +48,24 @@ export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
}
}
}
export function readEnv<T>(
env: string,
defaultValue: T,
availableValues?: T[]
) {
const value = process.env[env];
if (value === undefined) {
return defaultValue;
}
if (availableValues && !availableValues.includes(value as any)) {
throw new Error(
`Invalid value '${value}' for environment variable ${env}, expected one of [${availableValues.join(
', '
)}]`
);
}
return value as T;
}

View File

@@ -9,6 +9,7 @@ export {
applyEnvToConfig,
Config,
type ConfigPaths,
DeploymentType,
getDefaultAFFiNEStorageConfig,
} from './config';
export { EventEmitter, type EventPayload, OnEvent } from './event';

View File

@@ -1,28 +1,48 @@
import { Global, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
Global,
Module,
OnModuleDestroy,
OnModuleInit,
Provider,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { Config, parseEnvValue } from '../config';
import { createSDK, registerCustomMetrics } from './opentelemetry';
import { Config } from '../config';
import {
LocalOpentelemetryFactory,
OpentelemetryFactory,
registerCustomMetrics,
} from './opentelemetry';
const factorProvider: Provider = {
provide: OpentelemetryFactory,
useFactory: (config: Config) => {
return config.metrics.enabled ? new LocalOpentelemetryFactory() : null;
},
inject: [Config],
};
@Global()
@Module({})
@Module({
providers: [factorProvider],
exports: [factorProvider],
})
export class MetricsModule implements OnModuleInit, OnModuleDestroy {
private sdk: NodeSDK | null = null;
constructor(private readonly config: Config) {}
constructor(private readonly ref: ModuleRef) {}
onModuleInit() {
if (
this.config.metrics.enabled &&
!parseEnvValue(process.env.DISABLE_TELEMETRY, 'boolean')
) {
this.sdk = createSDK();
const factor = this.ref.get(OpentelemetryFactory, { strict: false });
if (factor) {
this.sdk = factor.create();
this.sdk.start();
registerCustomMetrics();
}
}
async onModuleDestroy() {
if (this.config.metrics.enabled && this.sdk) {
if (this.sdk) {
await this.sdk.shutdown();
}
}
@@ -30,3 +50,4 @@ export class MetricsModule implements OnModuleInit, OnModuleDestroy {
export * from './metrics';
export * from './utils';
export { OpentelemetryFactory };

View File

@@ -1,6 +1,4 @@
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util';
import { OnModuleDestroy } from '@nestjs/common';
import { metrics } from '@opentelemetry/api';
import {
CompositePropagator,
@@ -18,16 +16,13 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
import { Resource } from '@opentelemetry/resources';
import {
ConsoleMetricExporter,
type MeterProvider,
MetricProducer,
MetricReader,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import {
BatchSpanProcessor,
ConsoleSpanExporter,
SpanExporter,
TraceIdRatioBasedSampler,
} from '@opentelemetry/sdk-trace-node';
@@ -38,7 +33,7 @@ import { PrismaMetricProducer } from './prisma';
const { PrismaInstrumentation } = prismaInstrument;
abstract class OpentelemetryFactor {
export abstract class OpentelemetryFactory {
abstract getMetricReader(): MetricReader;
abstract getSpanExporter(): SpanExporter;
@@ -59,7 +54,7 @@ abstract class OpentelemetryFactor {
getResource() {
return new Resource({
[SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.affineEnv,
[SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.AFFINE_ENV,
[SemanticResourceAttributes.SERVICE_NAME]: AFFiNE.flavor.type,
[SemanticResourceAttributes.SERVICE_VERSION]: AFFiNE.version,
});
@@ -85,32 +80,20 @@ abstract class OpentelemetryFactor {
}
}
class GCloudOpentelemetryFactor extends OpentelemetryFactor {
override getResource(): Resource {
return super.getResource().merge(new GcpDetectorSync().detect());
export class LocalOpentelemetryFactory
extends OpentelemetryFactory
implements OnModuleDestroy
{
private readonly metricsExporter = new PrometheusExporter({
metricProducers: this.getMetricsProducers(),
});
async onModuleDestroy() {
await this.metricsExporter.shutdown();
}
override getMetricReader(): MetricReader {
return new PeriodicExportingMetricReader({
exportIntervalMillis: 30000,
exportTimeoutMillis: 10000,
exporter: new MetricExporter({
prefix: 'custom.googleapis.com',
}),
metricProducers: this.getMetricsProducers(),
});
}
override getSpanExporter(): SpanExporter {
return new TraceExporter();
}
}
class LocalOpentelemetryFactor extends OpentelemetryFactor {
override getMetricReader(): MetricReader {
return new PrometheusExporter({
metricProducers: this.getMetricsProducers(),
});
return this.metricsExporter;
}
override getSpanExporter(): SpanExporter {
@@ -118,33 +101,6 @@ class LocalOpentelemetryFactor extends OpentelemetryFactor {
}
}
class DebugOpentelemetryFactor extends OpentelemetryFactor {
override getMetricReader(): MetricReader {
return new PeriodicExportingMetricReader({
exporter: new ConsoleMetricExporter(),
metricProducers: this.getMetricsProducers(),
});
}
override getSpanExporter(): SpanExporter {
return new ConsoleSpanExporter();
}
}
// TODO(@forehalo): make it configurable
export function createSDK() {
let factor: OpentelemetryFactor | null = null;
if (process.env.NODE_ENV === 'production') {
factor = new GCloudOpentelemetryFactor();
} else if (process.env.DEBUG_METRICS) {
factor = new DebugOpentelemetryFactor();
} else {
factor = new LocalOpentelemetryFactor();
}
return factor?.create();
}
function getMeterProvider() {
return metrics.getMeterProvider();
}

View File

@@ -5,7 +5,7 @@ import { SessionCache } from '../cache';
@Injectable()
export class SessionService {
private readonly prefix = 'session:';
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
public readonly sessionTtl = 30 * 60 * 1000; // 30 min
constructor(private readonly cache: SessionCache) {}

View File

@@ -15,8 +15,6 @@ try {
: require('../../../storage.node');
}
export { storageModule as OctoBaseStorageModule };
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
export const verifyChallengeResponse = async (

View File

@@ -1,14 +1,16 @@
/// <reference types="./global.d.ts" />
// keep the config import at the top
// eslint-disable-next-line simple-import-sort/imports
import './prelude';
import { Logger } from '@nestjs/common';
import { createApp } from './app';
const app = await createApp();
const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost';
await app.listen(AFFiNE.port, listeningHost);
console.log(
`AFFiNE Server has been started on http://${listeningHost}:${AFFiNE.port}.`
);
console.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
const logger = new Logger('App');
logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`);
logger.log(`Listening on http://${listeningHost}:${AFFiNE.port}`);
logger.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);

View File

@@ -1,3 +1,4 @@
import { GCloudConfig } from './gcloud/config';
import { PaymentConfig } from './payment';
import { RedisOptions } from './redis';
@@ -5,6 +6,7 @@ declare module '../fundamentals/config' {
interface PluginsConfig {
readonly payment: PaymentConfig;
readonly redis: RedisOptions;
readonly gcloud: GCloudConfig;
}
export type AvailablePlugins = keyof PluginsConfig;

View File

@@ -0,0 +1 @@
export interface GCloudConfig {}

View File

@@ -0,0 +1,10 @@
import { Global } from '@nestjs/common';
import { OptionalModule } from '../../fundamentals';
import { GCloudMetrics } from './metrics';
@Global()
@OptionalModule({
imports: [GCloudMetrics],
})
export class GCloudModule {}

View File

@@ -0,0 +1,46 @@
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util';
import { Global, Provider } from '@nestjs/common';
import { Resource } from '@opentelemetry/resources';
import {
MetricReader,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { SpanExporter } from '@opentelemetry/sdk-trace-node';
import { OptionalModule } from '../../fundamentals';
import { OpentelemetryFactory } from '../../fundamentals/metrics';
export class GCloudOpentelemetryFactory extends OpentelemetryFactory {
override getResource(): Resource {
return super.getResource().merge(new GcpDetectorSync().detect());
}
override getMetricReader(): MetricReader {
return new PeriodicExportingMetricReader({
exportIntervalMillis: 30000,
exportTimeoutMillis: 10000,
exporter: new MetricExporter({
prefix: 'custom.googleapis.com',
}),
metricProducers: this.getMetricsProducers(),
});
}
override getSpanExporter(): SpanExporter {
return new TraceExporter();
}
}
const factorProvider: Provider = {
provide: OpentelemetryFactory,
useFactory: () => new GCloudOpentelemetryFactory(),
};
@Global()
@OptionalModule({
if: config => config.metrics.enabled,
overrides: [factorProvider],
})
export class GCloudMetrics {}

View File

@@ -1,8 +1,10 @@
import type { AvailablePlugins } from '../fundamentals/config';
import { GCloudModule } from './gcloud';
import { PaymentModule } from './payment';
import { RedisModule } from './redis';
export const pluginsMap = new Map<AvailablePlugins, AFFiNEModule>([
['payment', PaymentModule],
['redis', RedisModule],
['gcloud', GCloudModule],
]);

View File

@@ -23,6 +23,7 @@ import { StripeWebhook } from './webhook';
// 'plugins.payment.stripe.keys.webhookKey',
// ],
contributesTo: ServerFeature.Payment,
if: config => config.flavor.graphql,
})
export class PaymentModule {}

View File

@@ -3,6 +3,7 @@ import {
Args,
Context,
Field,
InputType,
Int,
Mutation,
ObjectType,
@@ -125,6 +126,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
updatedAt!: Date;
}
@InputType()
class CreateCheckoutSessionInput {
@Field(() => SubscriptionRecurring, {
nullable: true,
defaultValue: SubscriptionRecurring.Yearly,
})
recurring!: SubscriptionRecurring;
@Field(() => SubscriptionPlan, {
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan!: SubscriptionPlan;
@Field(() => String, { nullable: true })
coupon!: string | null;
@Field(() => String, { nullable: true })
successCallbackLink!: string | null;
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
@Field(() => String)
idempotencyKey!: string;
}
@Auth()
@Resolver(() => UserSubscriptionType)
export class SubscriptionResolver {
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
});
}
/**
* @deprecated
*/
@Mutation(() => String, {
deprecationReason: 'use `createCheckoutSession` instead',
description: 'Create a subscription checkout link of stripe',
})
async checkout(
@@ -193,6 +223,7 @@ export class SubscriptionResolver {
) {
const session = await this.service.createCheckoutSession({
user,
plan: SubscriptionPlan.Pro,
recurring,
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
idempotencyKey,
@@ -210,6 +241,36 @@ export class SubscriptionResolver {
return session.url;
}
@Mutation(() => String, {
description: 'Create a subscription checkout link of stripe',
})
async createCheckoutSession(
@CurrentUser() user: User,
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
input: CreateCheckoutSessionInput
) {
const session = await this.service.createCheckoutSession({
user,
plan: input.plan,
recurring: input.recurring,
promotionCode: input.coupon,
redirectUrl:
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
idempotencyKey: input.idempotencyKey,
});
if (!session.url) {
throw new GraphQLError('Failed to create checkout session', {
extensions: {
status: HttpStatus[HttpStatus.BAD_GATEWAY],
code: HttpStatus.BAD_GATEWAY,
},
});
}
return session.url;
}
@Mutation(() => String, {
description: 'Create a stripe customer portal to manage payment methods',
})
@@ -276,7 +337,7 @@ export class UserSubscriptionResolver {
// @FIXME(@forehalo): should not mock any api for selfhosted server
// the frontend should avoid calling such api if feature is not enabled
if (this.config.flavor.selfhosted) {
if (this.config.isSelfhosted) {
const start = new Date();
const end = new Date();
end.setFullYear(start.getFullYear() + 1);

View File

@@ -69,13 +69,15 @@ export class SubscriptionService {
async createCheckoutSession({
user,
recurring,
plan,
promotionCode,
redirectUrl,
idempotencyKey,
plan = SubscriptionPlan.Pro,
}: {
user: User;
plan?: SubscriptionPlan;
recurring: SubscriptionRecurring;
plan: SubscriptionPlan;
promotionCode?: string | null;
redirectUrl: string;
idempotencyKey: string;
}) {
@@ -95,7 +97,28 @@ export class SubscriptionService {
`${idempotencyKey}-getOrCreateCustomer`,
user
);
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
let discount: { coupon?: string; promotion_code?: string } | undefined;
if (promotionCode) {
const code = await this.getAvailablePromotionCode(
promotionCode,
customer.stripeCustomerId
);
if (code) {
discount ??= {};
discount.promotion_code = code;
}
} else {
const coupon = await this.getAvailableCoupon(
user,
CouponType.EarlyAccess
);
if (coupon) {
discount ??= {};
discount.coupon = coupon;
}
}
return await this.stripe.checkout.sessions.create(
{
@@ -108,13 +131,11 @@ export class SubscriptionService {
tax_id_collection: {
enabled: true,
},
...(coupon
...(discount
? {
discounts: [{ coupon }],
discounts: [discount],
}
: {
allow_promotion_codes: true,
}),
: { allow_promotion_codes: true }),
mode: 'subscription',
success_url: redirectUrl,
customer: customer.stripeCustomerId,
@@ -643,4 +664,33 @@ export class SubscriptionService {
return null;
}
private async getAvailablePromotionCode(
userFacingPromotionCode: string,
customer?: string
) {
const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode,
active: true,
limit: 1,
});
const code = list.data[0];
if (!code) {
return null;
}
let available = false;
if (code.customer) {
available =
typeof code.customer === 'string'
? code.customer === customer
: code.customer.id === customer;
} else {
available = true;
}
return available ? code.id : null;
}
}

View File

@@ -5,6 +5,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import { omit } from 'lodash-es';
import {
applyEnvToConfig,
@@ -43,14 +44,23 @@ async function load() {
// 3. load env => config map to `globalThis.AFFiNE.ENV_MAP
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.env.js');
// 4. apply `process.env` map overriding to `globalThis.AFFiNE`
applyEnvToConfig(globalThis.AFFiNE);
// 5. load `config/affine` to patch custom configs
// 4. load `config/affine` to patch custom configs
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.js');
if (process.env.NODE_ENV === 'development') {
console.log('AFFiNE Config:', JSON.stringify(globalThis.AFFiNE, null, 2));
// 5. load `config/affine.self` to patch custom configs
// This is the file only take effect in [AFFiNE Cloud]
if (!AFFiNE.isSelfhosted) {
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.self.js');
}
// 6. apply `process.env` map overriding to `globalThis.AFFiNE`
applyEnvToConfig(globalThis.AFFiNE);
if (AFFiNE.node.dev) {
console.log(
'AFFiNE Config:',
JSON.stringify(omit(globalThis.AFFiNE, 'ENV_MAP'), null, 2)
);
}
}

View File

@@ -2,6 +2,14 @@
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
input CreateCheckoutSessionInput {
coupon: String
idempotencyKey: String!
plan: SubscriptionPlan = Pro
recurring: SubscriptionRecurring = Yearly
successCallbackLink: String
}
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
@@ -107,7 +115,10 @@ type Mutation {
changePassword(newPassword: String!, token: String!): UserType!
"""Create a subscription checkout link of stripe"""
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String!
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
"""Create a subscription checkout link of stripe"""
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
"""Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String!
@@ -229,10 +240,18 @@ type ServerConfigType {
"""server identical name could be shown as badge on user interface"""
name: String!
"""server type"""
type: ServerDeploymentType!
"""server version"""
version: String!
}
enum ServerDeploymentType {
Affine
Selfhosted
}
enum ServerFeature {
Payment
}

View File

@@ -18,7 +18,7 @@ test.afterEach.always(async () => {
test('should be able to get config', t => {
t.true(typeof config.host === 'string');
t.is(config.env, 'test');
t.is(config.NODE_ENV, 'test');
});
test('should be able to override config', async t => {

View File

@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import * as Sinon from 'sinon';
import {
applyUpdate,
decodeStateVector,
Doc as YDoc,
encodeStateAsUpdate,
} from 'yjs';
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { DocManager, DocModule } from '../src/core/doc';
import { QuotaModule } from '../src/core/quota';
@@ -277,72 +272,120 @@ test('should throw if meet max retry times', async t => {
t.is(stub.callCount, 5);
});
test('should not update snapshot if state is outdated', async t => {
const db = m.get(PrismaClient);
test('should be able to insert the snapshot if it is new created', async t => {
const manager = m.get(DocManager);
await db.snapshot.create({
data: {
id: '2',
workspaceId: '2',
blob: Buffer.from([0, 0]),
seq: 1,
},
});
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
const update = encodeStateAsUpdate(doc);
await Promise.all(updates.map(update => manager.push('2', '2', update)));
await manager.push('1', '1', Buffer.from(update));
const updateWith3Records = await manager.getUpdates('2', '2');
text.insert(11, '!');
await manager.push('2', '2', updates[3]);
const updateWith4Records = await manager.getUpdates('2', '2');
// Simulation:
// Node A get 3 updates and squash them at time 1, will finish at time 10
// Node B get 4 updates and squash them at time 3, will finish at time 8
// Node B finish the squash first, and update the snapshot
// Node A finish the squash later, and update the snapshot to an outdated state
// Time: ---------------------->
// A: ^get ^upsert
// B: ^get ^upsert
//
// We should avoid such situation
const updates = await manager.getUpdates('1', '1');
t.is(updates.length, 1);
// @ts-expect-error private
await manager.squash(updateWith4Records, null);
// @ts-expect-error private
await manager.squash(updateWith3Records, null);
const snapshot = await manager.squash(null, updates);
const result = await db.snapshot.findUnique({
t.truthy(snapshot);
t.is(snapshot.getText('content').toString(), 'hello');
const restUpdates = await manager.getUpdates('1', '1');
t.is(restUpdates.length, 0);
});
test('should be able to merge updates into snapshot', async t => {
const manager = m.get(DocManager);
const updates: Buffer[] = [];
{
const doc = new YDoc();
doc.on('update', data => {
updates.push(Buffer.from(data));
});
const text = doc.getText('content');
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
text.insert(11, '!');
}
{
await manager.batchPush('1', '1', updates.slice(0, 2));
// do the merge
const doc = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'helloworld');
}
{
await manager.batchPush('1', '1', updates.slice(2));
const doc = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'hello world!');
}
const restUpdates = await manager.getUpdates('1', '1');
t.is(restUpdates.length, 0);
});
test('should not update snapshot if doc is outdated', async t => {
const manager = m.get(DocManager);
const db = m.get(PrismaClient);
const updates: Buffer[] = [];
{
const doc = new YDoc();
doc.on('update', data => {
updates.push(Buffer.from(data));
});
const text = doc.getText('content');
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
text.insert(11, '!');
}
await manager.batchPush('2', '1', updates.slice(0, 2)); // 'helloworld'
// merge updates into snapshot
await manager.get('2', '1');
// fake the snapshot is a lot newer
await db.snapshot.update({
where: {
id_workspaceId: {
id: '2',
workspaceId: '2',
id: '1',
},
},
data: {
updatedAt: new Date(Date.now() + 10000),
},
});
if (!result) {
t.fail('snapshot not found');
return;
{
const snapshot = await manager.getSnapshot('2', '1');
await manager.batchPush('2', '1', updates.slice(2)); // 'hello world!'
const updateRecords = await manager.getUpdates('2', '1');
// @ts-expect-error private
const doc = await manager.squash(snapshot, updateRecords);
// all updated will merged into doc not matter it's timestamp is outdated or not,
// but the snapshot record will not be updated
t.is(doc.getText('content').toString(), 'hello world!');
}
const state = decodeStateVector(result.state!);
t.is(state.get(doc.clientID), 12);
{
const doc = new YDoc();
applyUpdate(doc, (await manager.getSnapshot('2', '1'))!.blob);
// the snapshot will not get touched if the new doc's timestamp is outdated
t.is(doc.getText('content').toString(), 'helloworld');
const d = new YDoc();
applyUpdate(d, result.blob!);
const dtext = d.getText('content');
t.is(dtext.toString(), 'hello world!');
// the updates are known as outdated, so they will be deleted
t.is((await manager.getUpdates('2', '1')).length, 0);
}
});

View File

@@ -8,9 +8,6 @@ crate-type = ["cdylib"]
[dependencies]
chrono = "0.4"
jwst-codec = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
jwst-core = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
jwst-storage = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
napi = { version = "2", default-features = false, features = [
"napi5",
"async",
@@ -18,6 +15,7 @@ napi = { version = "2", default-features = false, features = [
napi-derive = { version = "2", features = ["type-def"] }
rand = "0.8"
sha3 = "0.10"
y-octo = { git = "https://github.com/y-crdt/y-octo.git", branch = "main" }
[dev-dependencies]
tokio = "1"

View File

@@ -1,28 +1,6 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
export class Storage {
/** Create a storage instance and establish connection to persist store. */
static connect(database: string, debugOnlyAutoMigrate?: boolean | undefined | null): Promise<Storage>
/** List all blobs in a workspace. */
listBlobs(workspaceId?: string | undefined | null): Promise<Array<string>>
/** Fetch a workspace blob. */
getBlob(workspaceId: string, name: string): Promise<Blob | null>
/** Upload a blob into workspace storage. */
uploadBlob(workspaceId: string, blob: Buffer): Promise<string>
/** Delete a blob from workspace storage. */
deleteBlob(workspaceId: string, hash: string): Promise<boolean>
/** Workspace size taken by blobs. */
blobsSize(workspaces: Array<string>): Promise<number>
}
export interface Blob {
contentType: string
lastModified: string
size: number
data: Buffer
}
/**
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
* result binary.

View File

@@ -2,16 +2,10 @@
pub mod hashcash;
use std::{
collections::HashMap,
fmt::{Debug, Display},
path::PathBuf,
};
use std::fmt::{Debug, Display};
use jwst_codec::Doc;
use jwst_core::BlobStorage;
use jwst_storage::{BlobStorageType, JwstStorage, JwstStorageError};
use napi::{bindgen_prelude::*, Error, Result, Status};
use y_octo::Doc;
#[macro_use]
extern crate napi_derive;
@@ -35,132 +29,13 @@ macro_rules! map_err {
};
}
macro_rules! napi_wrap {
($( ($name: ident, $target: ident) ),*) => {
$(
#[napi]
pub struct $name($target);
impl std::ops::Deref for $name {
type Target = $target;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<$target> for $name {
fn from(t: $target) -> Self {
Self(t)
}
}
)*
};
}
napi_wrap!((Storage, JwstStorage));
#[napi(object)]
pub struct Blob {
pub content_type: String,
pub last_modified: String,
pub size: i64,
pub data: Buffer,
}
#[napi]
impl Storage {
/// Create a storage instance and establish connection to persist store.
#[napi]
pub async fn connect(database: String, debug_only_auto_migrate: Option<bool>) -> Result<Storage> {
let inner = match if cfg!(debug_assertions) && debug_only_auto_migrate.unwrap_or(false) {
JwstStorage::new_with_migration(&database, BlobStorageType::DB).await
} else {
JwstStorage::new(&database, BlobStorageType::DB).await
} {
Ok(storage) => storage,
Err(JwstStorageError::Db(e)) => {
return Err(Error::new(
Status::GenericFailure,
format!("failed to connect to database: {}", e),
));
}
Err(e) => return Err(Error::new(Status::GenericFailure, e.to_string())),
};
Ok(inner.into())
}
/// List all blobs in a workspace.
#[napi]
pub async fn list_blobs(&self, workspace_id: Option<String>) -> Result<Vec<String>> {
map_err!(self.blobs().list_blobs(workspace_id).await)
}
/// Fetch a workspace blob.
#[napi]
pub async fn get_blob(&self, workspace_id: String, name: String) -> Result<Option<Blob>> {
let (id, params) = {
let path = PathBuf::from(name.clone());
let ext = path
.extension()
.and_then(|s| s.to_str().map(|s| s.to_string()));
let id = path
.file_stem()
.and_then(|s| s.to_str().map(|s| s.to_string()))
.unwrap_or(name);
(id, ext.map(|ext| HashMap::from([("format".into(), ext)])))
};
let Ok(meta) = self
.blobs()
.get_metadata(Some(workspace_id.clone()), id.clone(), params.clone())
.await
else {
return Ok(None);
};
let Ok(file) = self.blobs().get_blob(Some(workspace_id), id, params).await else {
return Ok(None);
};
Ok(Some(Blob {
content_type: meta.content_type,
last_modified: format!("{:?}", meta.last_modified),
size: meta.size,
data: file.into(),
}))
}
/// Upload a blob into workspace storage.
#[napi]
pub async fn upload_blob(&self, workspace_id: String, blob: Buffer) -> Result<String> {
// TODO: can optimize, avoid copy
let blob = blob.as_ref().to_vec();
map_err!(self.blobs().put_blob(Some(workspace_id), blob).await)
}
/// Delete a blob from workspace storage.
#[napi]
pub async fn delete_blob(&self, workspace_id: String, hash: String) -> Result<bool> {
map_err!(self.blobs().delete_blob(Some(workspace_id), hash).await)
}
/// Workspace size taken by blobs.
#[napi]
pub async fn blobs_size(&self, workspaces: Vec<String>) -> Result<i64> {
map_err!(self.blobs().get_blobs_size(workspaces).await)
}
}
/// Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
/// result binary.
#[napi(catch_unwind)]
pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
let mut doc = Doc::default();
for update in updates {
map_err!(doc.apply_update_from_binary(update.as_ref().to_vec()))?;
map_err!(doc.apply_update_from_binary_v1(update.as_ref()))?;
}
let buf = map_err!(doc.encode_update_v1())?;

View File

@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
"react": "18.2.0",
"react-dom": "18.2.0",
"vitest": "1.1.3"

View File

@@ -7,6 +7,7 @@ import { isDesktop, isServer } from './constant.js';
import { UaHelper } from './ua-helper.js';
export const blockSuiteFeatureFlags = z.object({
enable_synced_doc_block: z.boolean(),
enable_expand_database_block: z.boolean(),
enable_bultin_ledits: z.boolean(),
});
@@ -15,6 +16,7 @@ export const runtimeFlagsSchema = z.object({
enableTestProperties: z.boolean(),
enableBroadcastChannelProvider: z.boolean(),
enableDebugPage: z.boolean(),
githubUrl: z.string(),
changelogUrl: z.string(),
downloadUrl: z.string(),
// see: tools/workers

View File

@@ -13,9 +13,9 @@
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
"jotai": "^2.5.1",
"jotai-effect": "^0.2.3",
"nanoid": "^5.0.3",
@@ -26,8 +26,8 @@
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
"async-call-rpc": "^6.3.1",
"react": "^18.2.0",
"rxjs": "^7.8.1",

View File

@@ -13,8 +13,8 @@ import { Map as YMap } from 'yjs';
import { getLatestVersions } from '../migration/blocksuite';
import { replaceIdMiddleware } from './middleware';
export async function initEmptyPage(page: Page, title?: string) {
await page.load(() => {
export function initEmptyPage(page: Page, title?: string) {
page.load(() => {
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(title ?? ''),
});

View File

@@ -1,4 +1,5 @@
import { DebugLogger } from '@affine/debug';
import { setupEditorFlags } from '@affine/env/global';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { assertEquals } from '@blocksuite/global/utils';
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
@@ -164,6 +165,8 @@ export class WorkspaceManager {
// apply compatibility fix
fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc);
setupEditorFlags(workspace.blockSuiteWorkspace);
return workspace;
}
}

View File

@@ -32,14 +32,14 @@
}
},
"dependencies": {
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
"idb": "^8.0.0",
"nanoid": "^5.0.3",
"y-provider": "workspace:*"
},
"devDependencies": {
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
"fake-indexeddb": "^5.0.0",
"vite": "^5.0.6",
"vite-plugin-dts": "3.7.0",

View File

@@ -100,7 +100,7 @@ describe('indexeddb provider', () => {
],
});
const page = workspace.createPage({ id: 'page0' });
await page.waitForLoaded();
page.waitForLoaded();
const pageBlockId = page.addBlock('affine:page', { title: '' });
const frameId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, frameId);
@@ -129,7 +129,7 @@ describe('indexeddb provider', () => {
| WorkspacePersist
| undefined;
assertExists(data);
await testWorkspace.getPage('page0')?.waitForLoaded();
testWorkspace.getPage('page0')?.waitForLoaded();
data.updates.forEach(({ update }) => {
Workspace.Y.applyUpdate(subPage, update);
});
@@ -148,7 +148,7 @@ describe('indexeddb provider', () => {
expect(provider.connected).toBe(false);
{
const page = workspace.createPage({ id: 'page0' });
await page.waitForLoaded();
page.waitForLoaded();
const pageBlockId = page.addBlock('affine:page', { title: '' });
const frameId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, frameId);
@@ -203,7 +203,7 @@ describe('indexeddb provider', () => {
provider.connect();
{
const page = workspace.createPage({ id: 'page0' });
await page.waitForLoaded();
page.waitForLoaded();
const pageBlockId = page.addBlock('affine:page', { title: '' });
const frameId = page.addBlock('affine:note', {}, pageBlockId);
for (let i = 0; i < 99; i++) {
@@ -369,14 +369,14 @@ describe('subDoc', () => {
const page0 = workspace.createPage({
id: 'page0',
});
await page0.waitForLoaded();
page0.waitForLoaded();
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
provider.connect();
const page1 = workspace.createPage({
id: 'page1',
});
await page1.waitForLoaded();
page1.waitForLoaded();
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
await setTimeout(200);
provider.disconnect();
@@ -390,14 +390,14 @@ describe('subDoc', () => {
provider.connect();
await setTimeout(200);
const page0 = newWorkspace.getPage('page0') as Page;
await page0.waitForLoaded();
page0.waitForLoaded();
await setTimeout(200);
{
const block = page0.getBlockById(paragraphBlockIdPage1);
assertExists(block);
}
const page1 = newWorkspace.getPage('page1') as Page;
await page1.waitForLoaded();
page1.waitForLoaded();
await setTimeout(200);
{
const block = page1.getBlockById(paragraphBlockIdPage2);
@@ -410,7 +410,7 @@ describe('subDoc', () => {
describe('utils', () => {
test('download binary', async () => {
const page = workspace.createPage({ id: 'page0' });
await page.waitForLoaded();
page.waitForLoaded();
initEmptyPage(page);
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
provider.connect();

View File

@@ -24,7 +24,7 @@
"build": "vite build"
},
"devDependencies": {
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
"vite": "^5.0.6",
"vite-plugin-dts": "3.7.0",
"vitest": "1.1.3",

View File

@@ -73,12 +73,12 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/icons": "2.1.44",
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
"@storybook/addon-actions": "^7.5.3",
"@storybook/addon-essentials": "^7.5.3",
"@storybook/addon-interactions": "^7.5.3",

View File

@@ -1,195 +0,0 @@
import { keyframes, style } from '@vanilla-extract/css';
export const modalStyle = style({
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
backgroundColor: 'var(--affine-background-secondary-color)',
borderRadius: '16px',
overflow: 'hidden',
});
export const titleContainerStyle = style({
width: 'calc(100% - 72px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
height: '60px',
overflow: 'hidden',
});
export const titleStyle = style({
fontSize: 'var(--affine-font-h6)',
fontWeight: '600',
marginTop: '12px',
position: 'absolute',
marginBottom: '12px',
});
const slideToLeft = keyframes({
'0%': {
transform: 'translateX(0)',
opacity: 1,
},
'100%': {
transform: 'translateX(-300px)',
opacity: 0,
},
});
const slideToRight = keyframes({
'0%': {
transform: 'translateX(0)',
opacity: 1,
},
'100%': {
transform: 'translateX(300px)',
opacity: 0,
},
});
const slideFormLeft = keyframes({
'0%': {
transform: 'translateX(300px)',
opacity: 0,
},
'100%': {
transform: 'translateX(0)',
opacity: 1,
},
});
const slideFormRight = keyframes({
'0%': {
transform: 'translateX(-300px)',
opacity: 0,
},
'100%': {
transform: 'translateX(0)',
opacity: 1,
},
});
export const formSlideToLeftStyle = style({
animation: `${slideFormLeft} 0.3s ease-in-out forwards`,
});
export const formSlideToRightStyle = style({
animation: `${slideFormRight} 0.3s ease-in-out forwards`,
});
export const slideToLeftStyle = style({
animation: `${slideToLeft} 0.3s ease-in-out forwards`,
});
export const slideToRightStyle = style({
animation: `${slideToRight} 0.3s ease-in-out forwards`,
});
export const containerStyle = style({
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const videoContainerStyle = style({
height: '300px',
width: 'calc(100% - 72px)',
display: 'flex',
alignItems: 'center',
flexGrow: 1,
justifyContent: 'space-between',
position: 'relative',
overflow: 'hidden',
});
export const videoSlideStyle = style({
width: '100%',
position: 'absolute',
top: 0,
display: 'flex',
justifyContent: 'center',
});
export const videoStyle = style({
position: 'absolute',
objectFit: 'fill',
height: '300px',
border: '1px solid var(--affine-border-color)',
transition: 'opacity 0.5s ease-in-out',
});
const fadeIn = keyframes({
'0%': {
transform: 'translateX(300px)',
},
'100%': {
transform: 'translateX(0)',
},
});
export const videoActiveStyle = style({
animation: `${fadeIn} 0.5s ease-in-out forwards`,
opacity: 0,
});
export const arrowStyle = style({
wordBreak: 'break-all',
wordWrap: 'break-word',
width: '36px',
fontSize: '32px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '240px',
flexGrow: 0.2,
cursor: 'pointer',
});
export const descriptionContainerStyle = style({
width: 'calc(100% - 112px)',
height: '100px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'hidden',
});
export const descriptionStyle = style({
marginTop: '15px',
width: '100%',
display: 'flex',
fontSize: 'var(--affine-font-sm)',
lineHeight: '18px',
position: 'absolute',
});
export const tabStyle = style({
width: '40px',
height: '40px',
content: '""',
margin: '40px 10px 40px 0',
transition: 'all 0.15s ease-in-out',
position: 'relative',
cursor: 'pointer',
':hover': {
opacity: 1,
},
'::after': {
content: '""',
position: 'absolute',
bottom: '20px',
left: '0',
width: '100%',
height: '2px',
background: 'var(--affine-text-primary-color)',
transition: 'all 0.15s ease-in-out',
opacity: 0.2,
cursor: 'pointer',
},
});
export const tabActiveStyle = style({
'::after': {
opacity: 1,
},
});
export const tabContainerStyle = style({
width: '100%',
marginTop: '20px',
position: 'relative',
height: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const buttonDisableStyle = style({
cursor: 'not-allowed',
color: 'var(--affine-text-disable-color)',
});

View File

@@ -1 +0,0 @@
export * from './tour-modal';

View File

@@ -1,160 +0,0 @@
/// <reference types="../../type.d.ts" />
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useState } from 'react';
import { Modal, type ModalProps } from '../../ui/modal';
import editingVideo from './editingVideo.mp4';
import {
arrowStyle,
buttonDisableStyle,
containerStyle,
descriptionContainerStyle,
descriptionStyle,
formSlideToLeftStyle,
formSlideToRightStyle,
modalStyle,
slideToLeftStyle,
slideToRightStyle,
tabActiveStyle,
tabContainerStyle,
tabStyle,
titleContainerStyle,
titleStyle,
videoContainerStyle,
videoSlideStyle,
videoStyle,
} from './index.css';
import switchVideo from './switchVideo.mp4';
export const TourModal = (props: ModalProps) => {
const t = useAFFiNEI18N();
const [step, setStep] = useState(-1);
return (
<Modal
width={545}
contentOptions={{
['data-testid' as string]: 'onboarding-modal',
style: {
minHeight: '480px',
padding: 0,
},
}}
overlayOptions={{
style: {
background: 'transparent',
},
}}
closeButtonOptions={{
// @ts-expect-error - fix upstream type
'data-testid': 'onboarding-modal-close-button',
}}
{...props}
>
<div className={modalStyle}>
<div className={titleContainerStyle}>
{step !== -1 && (
<div
className={clsx(titleStyle, {
[slideToRightStyle]: step === 0,
[formSlideToLeftStyle]: step === 1,
})}
>
{t['com.affine.onboarding.title2']()}
</div>
)}
<div
className={clsx(titleStyle, {
[slideToLeftStyle]: step === 1,
[formSlideToRightStyle]: step === 0,
})}
>
{t['com.affine.onboarding.title1']()}
</div>
</div>
<div className={containerStyle}>
<div
className={clsx(arrowStyle, { [buttonDisableStyle]: step !== 1 })}
onClick={() => step === 1 && setStep(0)}
data-testid="onboarding-modal-pre-button"
>
<ArrowLeftSmallIcon />
</div>
<div className={videoContainerStyle}>
<div className={videoSlideStyle}>
{step !== -1 && (
<video
autoPlay
muted
loop
className={clsx(videoStyle, {
[slideToRightStyle]: step === 0,
[formSlideToLeftStyle]: step === 1,
})}
data-testid="onboarding-modal-editing-video"
>
<source src={editingVideo} type="video/mp4" />
</video>
)}
<video
autoPlay
muted
loop
className={clsx(videoStyle, {
[slideToLeftStyle]: step === 1,
[formSlideToRightStyle]: step === 0,
})}
data-testid="onboarding-modal-switch-video"
>
<source src={switchVideo} type="video/mp4" />
</video>
</div>
</div>
<div
className={clsx(arrowStyle, { [buttonDisableStyle]: step === 1 })}
onClick={() => setStep(1)}
data-testid="onboarding-modal-next-button"
>
<ArrowRightSmallIcon />
</div>
</div>
<ul className={tabContainerStyle}>
<li
className={clsx(tabStyle, {
[tabActiveStyle]: step !== 1,
})}
onClick={() => setStep(0)}
></li>
<li
className={clsx(tabStyle, { [tabActiveStyle]: step === 1 })}
onClick={() => setStep(1)}
></li>
</ul>
<div className={descriptionContainerStyle}>
{step !== -1 && (
<div
className={clsx(descriptionStyle, {
[slideToRightStyle]: step === 0,
[formSlideToLeftStyle]: step === 1,
})}
>
{t['com.affine.onboarding.videoDescription2']()}
</div>
)}
<div
className={clsx(descriptionStyle, {
[slideToLeftStyle]: step === 1,
[formSlideToRightStyle]: step === 0,
})}
>
{t['com.affine.onboarding.videoDescription1']()}
</div>
</div>
</div>
</Modal>
);
};
export default TourModal;

View File

@@ -1,2 +1,3 @@
export * from './confirm-modal';
export * from './modal';
export * from './overlay-modal';

View File

@@ -5,6 +5,7 @@ import { Button } from '../button';
import { Input, type InputProps } from '../input';
import { ConfirmModal, type ConfirmModalProps } from './confirm-modal';
import { Modal, type ModalProps } from './modal';
import { OverlayModal, type OverlayModalProps } from './overlay-modal';
export default {
title: 'UI/Modal',
@@ -65,5 +66,38 @@ const ConfirmModalTemplate: StoryFn<ConfirmModalProps> = () => {
);
};
const OverlayModalTemplate: StoryFn<OverlayModalProps> = () => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open Overlay Modal</Button>
<OverlayModal
open={open}
onOpenChange={setOpen}
title="Modal Title"
description="Modal description"
confirmButtonOptions={{
type: 'primary',
}}
topImage={
<div
style={{
width: '400px',
height: '300px',
background: '#66ccff',
opacity: 0.1,
color: '#fff',
}}
></div>
}
/>
</>
);
};
export const Confirm: StoryFn<ModalProps> =
ConfirmModalTemplate.bind(undefined);
export const Overlay: StoryFn<ModalProps> =
OverlayModalTemplate.bind(undefined);

View File

@@ -0,0 +1,37 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const title = style({
padding: '20px 24px 8px 24px',
fontSize: cssVar('fontH6'),
fontFamily: cssVar('fontFamily'),
fontWeight: '600',
lineHeight: '26px',
});
export const content = style({
padding: '0px 24px 8px',
fontSize: cssVar('fontBase'),
lineHeight: '24px',
fontWeight: 400,
});
export const footer = style({
padding: '20px 24px',
display: 'flex',
justifyContent: 'flex-end',
gap: '20px',
});
export const gotItBtn = style({
fontWeight: 500,
});
export const buttonText = style({
color: cssVar('pureWhite'),
textDecoration: 'none',
cursor: 'pointer',
':visited': {
color: cssVar('pureWhite'),
},
});

View File

@@ -0,0 +1,102 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import { cssVar } from '@toeverything/theme';
import { memo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Button, type ButtonProps } from '../button';
import { Modal, type ModalProps } from './modal';
import * as styles from './overlay-modal.css';
const defaultContentOptions: ModalProps['contentOptions'] = {
style: {
padding: 0,
overflow: 'hidden',
boxShadow: cssVar('menuShadow'),
},
};
const defaultOverlayOptions: ModalProps['overlayOptions'] = {
style: {
background: cssVar('white80'),
backdropFilter: 'blur(2px)',
},
};
export interface OverlayModalProps extends ModalProps {
to?: string;
external?: boolean;
topImage?: React.ReactNode;
confirmText?: string;
confirmButtonOptions?: ButtonProps;
onConfirm?: () => void;
cancelText?: string;
cancelButtonOptions?: ButtonProps;
withoutCancelButton?: boolean;
}
export const OverlayModal = memo(function OverlayModal({
open,
topImage,
onOpenChange,
title,
description,
onConfirm,
to,
external,
confirmButtonOptions,
cancelButtonOptions,
withoutCancelButton,
contentOptions = defaultContentOptions,
overlayOptions = defaultOverlayOptions,
// FIXME: we need i18n
cancelText = 'Cancel',
confirmText = 'Confirm',
width = 400,
}: OverlayModalProps) {
const handleConfirm = useCallback(() => {
onOpenChange?.(false);
onConfirm?.();
}, [onOpenChange, onConfirm]);
return (
<Modal
contentOptions={contentOptions}
overlayOptions={overlayOptions}
open={open}
width={width}
onOpenChange={onOpenChange}
withoutCloseButton
>
{topImage}
<div className={styles.title}>{title}</div>
<div className={styles.content}>{description}</div>
<div className={styles.footer}>
{!withoutCancelButton ? (
<DialogTrigger asChild>
<Button {...cancelButtonOptions}>{cancelText}</Button>
</DialogTrigger>
) : null}
{to ? (
external ? (
//FIXME: we need a more standardized way to implement this link with other click events
<a href={to} target="_blank" rel="noreferrer">
<Button onClick={handleConfirm} {...confirmButtonOptions}>
{confirmText}
</Button>
</a>
) : (
<Link to={to}>
<Button onClick={handleConfirm} {...confirmButtonOptions}>
{confirmText}
</Button>
</Link>
)
) : (
<Button onClick={handleConfirm} {...confirmButtonOptions}>
{confirmText}
</Button>
)}
</div>
</Modal>
);
});

View File

@@ -6,6 +6,7 @@ const require = createRequire(import.meta.url);
const packageJson = require('../package.json');
const editorFlags: BlockSuiteFeatureFlags = {
enable_synced_doc_block: false,
enable_expand_database_block: false,
enable_bultin_ledits: false,
};
@@ -16,6 +17,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableTestProperties: false,
enableBroadcastChannelProvider: true,
enableDebugPage: true,
githubUrl: 'https://github.com/toeverything/AFFiNE',
changelogUrl: 'https://affine.pro/what-is-new',
downloadUrl: 'https://affine.pro/download',
imageProxyUrl: '/api/worker/image-proxy',
@@ -57,6 +59,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableTestProperties: true,
enableBroadcastChannelProvider: true,
enableDebugPage: true,
githubUrl: 'https://github.com/toeverything/AFFiNE',
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
downloadUrl: 'https://affine.pro/download',
imageProxyUrl: '/api/worker/image-proxy',

View File

@@ -26,14 +26,14 @@
"@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*",
"@affine/workspace-impl": "workspace:*",
"@blocksuite/block-std": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/block-std": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/icons": "2.1.44",
"@blocksuite/inline": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
"@blocksuite/inline": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^8.0.0",
"@emotion/cache": "^11.11.0",

Binary file not shown.

Binary file not shown.

View File

@@ -55,21 +55,6 @@ export const guideChangeLogAtom = atom<
}));
}
);
export const guideOnboardingAtom = atom<
Guide['onBoarding'],
[open: boolean],
void
>(
get => {
return get(guidePrimitiveAtom).onBoarding;
},
(_, set, open) => {
set(guidePrimitiveAtom, tips => ({
...tips,
onBoarding: open,
}));
}
);
export const guideDownloadClientTipAtom = atom<
Guide['downloadClientTip'],

View File

@@ -10,10 +10,11 @@ import type { SettingProps } from '../components/affine/setting-modal';
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
export const openQuickSearchModalAtom = atom(false);
export const openOnboardingModalAtom = atom(false);
export const openSignOutModalAtom = atom(false);
export const openPaymentDisableAtom = atom(false);
export const openQuotaModalAtom = atom(false);
export const openStarAFFiNEModalAtom = atom(false);
export const openIssueFeedbackModalAtom = atom(false);
export type SettingAtom = Pick<
SettingProps,

View File

@@ -0,0 +1,4 @@
import type { SyncEngineStatus } from '@affine/workspace';
import { atom } from 'jotai';
export const syncEngineStatusAtom = atom<SyncEngineStatus | null>(null);

View File

@@ -0,0 +1,7 @@
import { builtInTemplates } from '@affine/templates/edgeless';
import {
EdgelessTemplatePanel,
type TemplateManager,
} from '@blocksuite/blocks';
EdgelessTemplatePanel.templates.extend(builtInTemplates as TemplateManager);

View File

@@ -33,7 +33,7 @@ export async function createFirstAppData() {
workspace.setPageMeta(page.id, {
jumpOnce: true,
});
await initEmptyPage(page);
initEmptyPage(page);
}
logger.debug('create first workspace');
}

View File

@@ -1,4 +1,5 @@
import './register-blocksuite-components';
import './edgeless-template';
import { setupGlobal } from '@affine/env/global';
import * as Sentry from '@sentry/react';

View File

@@ -1,9 +1,9 @@
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ContactWithUsIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
import { ContactWithUsIcon, NewIcon } from '@blocksuite/icons';
import { registerAffineCommand } from '@toeverything/infra/command';
import type { createStore } from 'jotai';
import { openOnboardingModalAtom, openSettingModalAtom } from '../atoms';
import { openSettingModalAtom } from '../atoms';
export function registerAffineHelpCommands({
t,
@@ -39,18 +39,6 @@ export function registerAffineHelpCommands({
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:help-getting-started',
category: 'affine:help',
icon: <UserGuideIcon />,
label: t['com.affine.cmdk.affine.getting-started'](),
preconditionStrategy: () => environment.isDesktop,
run() {
store.set(openOnboardingModalAtom, true);
},
})
);
return () => {
unsubs.forEach(unsub => unsub());

View File

@@ -83,11 +83,12 @@ export function registerAffineNavigationCommands({
category: 'affine:navigation',
icon: <ArrowRightBigIcon />,
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
keyBinding: '$mod+,',
run() {
store.set(openSettingModalAtom, {
store.set(openSettingModalAtom, s => ({
activeTab: 'appearance',
open: true,
});
open: !s.open,
}));
},
})
);

View File

@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
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);
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const subscriptionData = useSubscriptionSearch();
const {
isMutating: isSigningIn,
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
if (verifyToken) {
if (user) {
// provider password sign-in if user has by default
if (user.hasPassword) {
// If with payment, onl support email sign in to avoid redirect to affine app
if (user.hasPassword && !subscriptionData) {
setAuthState('signInWithPassword');
} else {
const res = await signIn(email, verifyToken, challenge);
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}
}
}, [
subscriptionData,
challenge,
email,
setAuthEmail,

View File

@@ -3,10 +3,10 @@ import { Button } from '@affine/component/ui/button';
import { Loading } from '@affine/component/ui/loading';
import { AffineShapeIcon } from '@affine/core/components/page-list';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { SubscriptionRecurring } from '@affine/graphql';
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import {
changePasswordMutation,
checkoutMutation,
createCheckoutSessionMutation,
subscriptionQuery,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -30,18 +30,25 @@ const usePaymentRedirect = () => {
}
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: checkoutMutation,
mutation: createCheckoutSessionMutation,
});
return useAsyncCallback(async () => {
const { checkout } = await checkoutSubscription({
recurring,
idempotencyKey,
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
input: {
recurring,
plan,
coupon,
idempotencyKey,
successCallbackLink: null,
},
});
window.open(checkout, '_self', 'norefferer');
}, [recurring, idempotencyKey, checkoutSubscription]);
window.open(checkoutUrl, '_self', 'norefferer');
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
};
const CenterLoading = () => {

View File

@@ -4,6 +4,7 @@ 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
}
@@ -22,11 +23,13 @@ export function useSubscriptionSearch() {
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) {
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
[SubscriptionKey.Plan, plan ?? ''],
]);
if (coupon) {
paymentParams.set(SubscriptionKey.Coupon, coupon);
}
if (signUp) {
paymentParams.set(SubscriptionKey.SignUp, '1');
}

View File

@@ -164,7 +164,7 @@ export const CreateWorkspaceModal = ({
workspace.setPageMeta(page.id, {
jumpOnce: true,
});
await initEmptyPage(page);
initEmptyPage(page);
}
logger.debug('create first workspace');
}

View File

@@ -0,0 +1,35 @@
import { OverlayModal } from '@affine/component';
import { openIssueFeedbackModalAtom } from '@affine/core/atoms';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
export const IssueFeedbackModal = () => {
const t = useAFFiNEI18N();
const [open, setOpen] = useAtom(openIssueFeedbackModalAtom);
return (
<OverlayModal
open={open}
topImage={
<video
width={400}
height={300}
style={{ objectFit: 'cover' }}
src={'/static/newIssue.mp4'}
autoPlay
loop
/>
}
title={t['com.affine.issue-feedback.title']()}
onOpenChange={setOpen}
description={t['com.affine.issue-feedback.description']()}
cancelText={t['com.affine.issue-feedback.cancel']()}
to={`${runtimeConfig.githubUrl}/issues/new/choose`}
confirmText={t['com.affine.issue-feedback.confirm']()}
confirmButtonOptions={{
type: 'primary',
}}
external
/>
);
};

View File

@@ -1,23 +0,0 @@
import { TourModal } from '@affine/component/tour-modal';
import { useAtom } from 'jotai';
import { memo, useCallback } from 'react';
import { openOnboardingModalAtom } from '../../atoms';
import { guideOnboardingAtom } from '../../atoms/guide';
export const OnboardingModal = memo(function OnboardingModal() {
const [open, setOpen] = useAtom(openOnboardingModalAtom);
const [guideOpen, setShowOnboarding] = useAtom(guideOnboardingAtom);
const onOpenChange = useCallback(
(open: boolean) => {
if (open) return;
setShowOnboarding(false);
setOpen(false);
},
[setOpen, setShowOnboarding]
);
return (
<TourModal open={!open ? guideOpen : open} onOpenChange={onOpenChange} />
);
});

View File

@@ -17,11 +17,11 @@ const paperLocations = {
},
'1': {
x: -240,
y: -100,
y: -30,
},
'2': {
x: 240,
y: -100,
y: -35,
},
'3': {
x: -480,

View File

@@ -1,14 +1,11 @@
import { Button, Modal, type ModalProps } from '@affine/component';
import { OverlayModal } from '@affine/component';
import type { ModalProps } from '@affine/component/ui/modal';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { memo, useCallback, useEffect, useState } from 'react';
import { useAppConfigStorage } from '../../../hooks/use-app-config-storage';
import Thumb from './assets/thumb';
import * as styles from './workspace-guide-modal.css';
const contentOptions: ModalProps['contentOptions'] = {
style: { padding: 0, overflow: 'hidden' },
};
const overlayOptions: ModalProps['overlayOptions'] = {
style: {
background:
@@ -36,7 +33,6 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
}, [open]);
const gotIt = useCallback(() => {
setOpen(false);
setDismiss(true);
}, [setDismiss]);
@@ -47,28 +43,23 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
}, []);
return (
<Modal
withoutCloseButton
contentOptions={contentOptions}
overlayOptions={overlayOptions}
<OverlayModal
open={open}
width={400}
onOpenChange={onOpenChange}
>
<Thumb />
<div className={styles.title}>
{t['com.affine.onboarding.workspace-guide.title']()}
</div>
<div className={styles.content}>
{t['com.affine.onboarding.workspace-guide.content']()}
</div>
<div className={styles.footer}>
<Button type="primary" size="large" onClick={gotIt}>
<span className={styles.gotItBtn}>
{t['com.affine.onboarding.workspace-guide.got-it']()}
</span>
</Button>
</div>
</Modal>
topImage={<Thumb />}
title={t['com.affine.onboarding.workspace-guide.title']()}
description={t['com.affine.onboarding.workspace-guide.content']()}
onConfirm={gotIt}
overlayOptions={overlayOptions}
withoutCancelButton
confirmButtonOptions={{
style: {
fontWeight: 500,
},
type: 'primary',
size: 'large',
}}
confirmText={t['com.affine.onboarding.workspace-guide.got-it']()}
/>
);
});

View File

@@ -111,7 +111,6 @@ const getOrCreateShellWorkspace = (workspaceId: string) => {
const blobStorage = createAffineCloudBlobStorage(workspaceId);
workspace = new Workspace({
id: workspaceId,
providerCreators: [],
blobStorages: [
() => ({
crud: blobStorage,
@@ -162,12 +161,10 @@ export const useSnapshotPage = (
});
page.awarenessStore.setReadonly(page, true);
const spaceDoc = page.spaceDoc;
page
.load(() => {
applyUpdate(spaceDoc, new Uint8Array(snapshot));
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
})
.catch(console.error); // must load before applyUpdate
page.load(() => {
applyUpdate(spaceDoc, new Uint8Array(snapshot));
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
}); // must load before applyUpdate
}
return page ?? undefined;
}, [pageDocId, snapshot, ts, workspace]);

View File

@@ -6,7 +6,7 @@ import type {
SubscriptionMutator,
} from '@affine/core/hooks/use-subscription';
import {
checkoutMutation,
createCheckoutSessionMutation,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
@@ -359,7 +359,7 @@ const Upgrade = ({
}) => {
const t = useAFFiNEI18N();
const { isMutating, trigger } = useMutation({
mutation: checkoutMutation,
mutation: createCheckoutSessionMutation,
});
const newTabRef = useRef<Window | null>(null);
@@ -383,13 +383,21 @@ const Upgrade = ({
newTabRef.current.focus();
} else {
await trigger(
{ recurring, idempotencyKey },
{
input: {
recurring,
idempotencyKey,
plan: SubscriptionPlan.Pro, // Only support prod plan now.
coupon: null,
successCallbackLink: null,
},
},
{
onSuccess: data => {
// FIXME: safari prevents from opening new tab by window api
// TODO(@xp): what if electron?
const newTab = window.open(
data.checkout,
data.createCheckoutSession,
'_blank',
'noopener noreferrer'
);

View File

@@ -1,8 +1,13 @@
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
import { Modal, type ModalProps } from '@affine/component/ui/modal';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
openIssueFeedbackModalAtom,
openStarAFFiNEModalAtom,
} from '@affine/core/atoms';
import { Trans } from '@affine/i18n';
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
import { ContactWithUsIcon } from '@blocksuite/icons';
import { useSetAtom } from 'jotai';
import { debounce } from 'lodash-es';
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
@@ -37,7 +42,6 @@ export const SettingModal = ({
onSettingClick,
...modalProps
}: SettingProps) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const modalContentRef = useRef<HTMLDivElement>(null);
@@ -79,6 +83,16 @@ export const SettingModal = ({
},
[onSettingClick]
);
const setOpenIssueFeedbackModal = useSetAtom(openIssueFeedbackModalAtom);
const setOpenStarAFFiNEModal = useSetAtom(openStarAFFiNEModalAtom);
const handleOpenIssueFeedbackModal = useCallback(() => {
setOpenIssueFeedbackModal(true);
}, [setOpenIssueFeedbackModal]);
const handleOpenStarAFFiNEModal = useCallback(() => {
setOpenStarAFFiNEModal(true);
}, [setOpenStarAFFiNEModal]);
return (
<Modal
@@ -126,17 +140,24 @@ export const SettingModal = ({
</Suspense>
</div>
<div className={style.footer}>
<a
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={style.suggestionLink}
>
<span className={style.suggestionLinkIcon}>
<ContactWithUsIcon width="16" height="16" />
</span>
{t['com.affine.settings.suggestion']()}
</a>
<ContactWithUsIcon fontSize={16} />
<Trans
i18nKey={'com.affine.settings.suggestion-2'}
components={{
1: (
<span
className={style.link}
onClick={handleOpenStarAFFiNEModal}
/>
),
2: (
<span
className={style.link}
onClick={handleOpenIssueFeedbackModal}
/>
),
}}
/>
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const wrapper = style({
@@ -50,4 +51,12 @@ export const footer = style({
justifyContent: 'center',
alignItems: 'center',
paddingBottom: '20px',
gap: '4px',
fontSize: cssVar('fontXs'),
flexWrap: 'wrap',
});
export const link = style({
color: cssVar('linkColor'),
cursor: 'pointer',
});

View File

@@ -2,6 +2,7 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useSystemOnline } from '@affine/core/hooks/use-system-online';
import { apis } from '@affine/electron-api';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
@@ -20,6 +21,7 @@ export const ExportPanel = ({
const workspaceId = workspaceMetadata.id;
const t = useAFFiNEI18N();
const [saving, setSaving] = useState(false);
const isOnline = useSystemOnline();
const pushNotification = useSetAtom(pushNotificationAtom);
const onExport = useAsyncCallback(async () => {
@@ -28,8 +30,11 @@ export const ExportPanel = ({
}
setSaving(true);
try {
await workspace.engine.sync.waitForSynced();
await workspace.engine.blob.sync();
if (isOnline) {
await workspace.engine.sync.waitForSynced();
await workspace.engine.blob.sync();
}
const result = await apis?.dialog.saveDBFileAs(workspaceId);
if (result?.error) {
throw new Error(result.error);
@@ -48,7 +53,7 @@ export const ExportPanel = ({
} finally {
setSaving(false);
}
}, [pushNotification, saving, t, workspace, workspaceId]);
}, [isOnline, pushNotification, saving, t, workspace, workspaceId]);
return (
<SettingRow name={t['Export']()} desc={t['Export Description']()}>

View File

@@ -0,0 +1,35 @@
import { OverlayModal } from '@affine/component';
import { openStarAFFiNEModalAtom } from '@affine/core/atoms';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
export const StarAFFiNEModal = () => {
const t = useAFFiNEI18N();
const [open, setOpen] = useAtom(openStarAFFiNEModalAtom);
return (
<OverlayModal
open={open}
topImage={
<video
width={400}
height={300}
style={{ objectFit: 'cover' }}
src={'/static/gitHubStar.mp4'}
autoPlay
loop
/>
}
title={t['com.affine.star-affine.title']()}
onOpenChange={setOpen}
description={t['com.affine.star-affine.description']()}
cancelText={t['com.affine.star-affine.cancel']()}
to={runtimeConfig.githubUrl}
confirmButtonOptions={{
type: 'primary',
}}
confirmText={t['com.affine.star-affine.confirm']()}
external
/>
);
};

View File

@@ -48,7 +48,7 @@ interface BlocksuiteEditorContainerProps {
// mimic the interface of the webcomponent and expose slots & host
type BlocksuiteEditorContainerRef = Pick<
(typeof AffineEditorContainer)['prototype'],
'mode' | 'page' | 'model' | 'slots' | 'host'
'mode' | 'page' | 'slots' | 'host'
> &
HTMLDivElement;

View File

@@ -37,24 +37,14 @@ export type EditorProps = {
className?: string;
};
/**
* TODO: Defined async cache to support suspense, instead of reflect symbol to provider persistent error cache.
*/
const PAGE_LOAD_KEY = Symbol('PAGE_LOAD');
const PAGE_ROOT_KEY = Symbol('PAGE_ROOT');
function usePageRoot(page: Page) {
let load$ = Reflect.get(page, PAGE_LOAD_KEY);
if (!load$) {
load$ = page.load();
Reflect.set(page, PAGE_LOAD_KEY, load$);
if (!page.ready) {
page.load();
}
use(load$);
if (!page.root) {
let root$: Promise<void> | undefined = Reflect.get(page, PAGE_ROOT_KEY);
if (!root$) {
root$ = new Promise((resolve, reject) => {
use(
new Promise<void>((resolve, reject) => {
const disposable = page.slots.rootAdded.once(() => {
resolve();
});
@@ -62,10 +52,8 @@ function usePageRoot(page: Page) {
disposable.dispose();
reject(new NoPageRootError(page));
}, 20 * 1000);
});
Reflect.set(page, PAGE_ROOT_KEY, root$);
}
use(root$);
})
);
}
return page.root;

View File

@@ -1,9 +1,12 @@
import type { BlockSpec } from '@blocksuite/block-std';
import type { ParagraphService } from '@blocksuite/blocks';
import type { PageService, ParagraphService } from '@blocksuite/blocks';
import {
AttachmentService,
CanvasTextFonts,
DocEditorBlockSpecs,
DocPageService,
EdgelessEditorBlockSpecs,
EdgelessPageService,
} from '@blocksuite/blocks';
import bytes from 'bytes';
import { html, unsafeStatic } from 'lit/static-html.js';
@@ -17,6 +20,31 @@ class CustomAttachmentService extends AttachmentService {
}
}
function customLoadFonts(service: PageService): void {
const officialDomains = new Set(['app.affine.pro', 'affine.fail']);
if (!officialDomains.has(window.location.host)) {
const fonts = CanvasTextFonts.map(font => ({
...font,
// self-hosted fonts are served from /assets
url: '/assets/' + new URL(font.url).pathname.split('/').pop(),
}));
service.fontLoader.load(fonts);
} else {
service.fontLoader.load(CanvasTextFonts);
}
}
class CustomDocPageService extends DocPageService {
override loadFonts(): void {
customLoadFonts(this);
}
}
class CustomEdgelessPageService extends EdgelessPageService {
override loadFonts(): void {
customLoadFonts(this);
}
}
type AffineReference = HTMLElementTagNameMap['affine-reference'];
type PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
@@ -76,6 +104,12 @@ export const docModeSpecs = DocEditorBlockSpecs.map(spec => {
service: CustomAttachmentService,
};
}
if (spec.schema.model.flavour === 'affine:page') {
return {
...spec,
service: CustomDocPageService,
};
}
return spec;
});
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
@@ -85,5 +119,11 @@ export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
service: CustomAttachmentService,
};
}
if (spec.schema.model.flavour === 'affine:page') {
return {
...spec,
service: CustomEdgelessPageService,
};
}
return spec;
});

View File

@@ -27,9 +27,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
const createPageAndOpen = useCallback(
(mode?: 'page' | 'edgeless') => {
const page = createPage();
initEmptyPage(page).catch(error => {
toast(`Failed to initialize Page: ${error.message}`);
});
initEmptyPage(page);
setPageMode(page.id, mode || 'page');
openPage(blockSuiteWorkspace.id, page.id);
return page;
@@ -66,10 +64,10 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
const createLinkedPageAndOpen = useAsyncCallback(
async (pageId: string) => {
const page = createPageAndOpen();
await page.load();
page.load();
const parentPage = blockSuiteWorkspace.getPage(pageId);
if (parentPage) {
await parentPage.load();
parentPage.load();
const text = parentPage.Text.fromDelta([
{
insert: ' ',

Some files were not shown because too many files have changed in this diff Show More