Merge branch 'canary' into stable

This commit is contained in:
李华桥
2023-12-01 16:12:17 +08:00
147 changed files with 1775 additions and 1993 deletions
+17
View File
@@ -126,6 +126,8 @@ const config = {
'no-cond-assign': 'off',
'no-constant-binary-expression': 'error',
'no-constructor-return': 'error',
'no-self-compare': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'react/prop-types': 'off',
'react/jsx-no-useless-fragment': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
@@ -133,6 +135,9 @@ const config = {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/unified-signatures': 'error',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
@@ -204,6 +209,17 @@ const config = {
},
],
'unicorn/no-unnecessary-await': 'error',
'unicorn/no-useless-fallback-in-spread': 'error',
'unicorn/prefer-dom-node-dataset': 'error',
'unicorn/prefer-dom-node-append': 'error',
'unicorn/prefer-dom-node-remove': 'error',
'unicorn/prefer-array-some': 'error',
'unicorn/prefer-date-now': 'error',
'unicorn/prefer-blob-reading-methods': 'error',
'unicorn/no-typeof-undefined': 'error',
'unicorn/no-useless-promise-resolve-reject': 'error',
'unicorn/no-new-array': 'error',
'unicorn/new-for-builtins': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-empty-collection': 'error',
@@ -254,6 +270,7 @@ const config = {
},
],
'@typescript-eslint/no-misused-promises': ['error'],
'@typescript-eslint/prefer-readonly': 'error',
'i/no-extraneous-dependencies': ['error'],
'react-hooks/exhaustive-deps': [
'warn',
+12 -1
View File
@@ -14,10 +14,17 @@ inputs:
runs:
using: 'composite'
steps:
- name: Print rustup toolchain version
shell: bash
id: rustup-version
run: |
export RUST_TOOLCHAIN_VERSION="$(grep 'channel' rust-toolchain.toml | head -1 | awk -F '"' '{print $2}')"
echo "Rust toolchain version: $RUST_TOOLCHAIN_VERSION"
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
toolchain: '${{ steps.rustup-version.outputs.RUST_TOOLCHAIN_VERSION }}'
targets: ${{ inputs.target }}
env:
CARGO_INCREMENTAL: '1'
@@ -49,6 +56,8 @@ runs:
run: |
export CC=x86_64-unknown-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-gcc
rm -rf /usr/local/rustup/downloads/*
rustup target add x86_64-unknown-linux-gnu
export RUSTFLAGS="-C debuginfo=1"
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} --target ${{ inputs.target }}
if [ -d "node_modules/.cache" ]; then
@@ -66,6 +75,8 @@ runs:
options: --user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build -e NX_CLOUD_ACCESS_TOKEN=${{ inputs.nx_token }}
run: |
export RUSTFLAGS="-C debuginfo=1"
rm -rf /usr/local/rustup/downloads/*
rustup target add aarch64-unknown-linux-gnu
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} --target ${{ inputs.target }}
if [ -d "node_modules/.cache" ]; then
chmod -R 777 node_modules/.cache
+1 -1
View File
@@ -41,7 +41,7 @@ runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: https://npm.pkg.github.com
+1 -3
View File
@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v4
- name: Run oxlint
# oxlint is fast, so wrong code will fail quickly
run: yarn dlx oxlint@latest .
run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'])")
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -58,8 +58,6 @@ jobs:
run: |
git checkout .yarnrc.yml
yarn lint:prettier
- name: Run circular
run: yarn circular
- name: Run Type Check
run: yarn typecheck
+3 -5
View File
@@ -9,7 +9,6 @@ on:
default: canary
env:
BUILD_TYPE: canary
APP_NAME: affine
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
@@ -17,7 +16,6 @@ jobs:
build-server:
name: Build Server
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -43,12 +41,12 @@ jobs:
- name: Build Plugins
run: yarn run build:plugins
- name: Build Core
run: yarn nx build @affine/core
run: yarn nx build @affine/core --skip-nx-cache
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
BUILD_TYPE_OVERRIDE: ${{ github.event.inputs.flavor }}
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: true
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
@@ -209,7 +207,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to dev
- name: Deploy to ${{ github.event.inputs.flavor }}
uses: ./.github/actions/deploy
with:
build-type: ${{ github.event.inputs.flavor }}
+3 -2
View File
@@ -65,14 +65,15 @@ jobs:
- name: Replace Version
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}
- name: generate-assets
working-directory: packages/frontend/electron
run: yarn generate-assets
run: yarn workspace @affine/electron generate-assets
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }}
SKIP_PLUGIN_BUILD: 'true'
SKIP_NX_CACHE: 'true'
- name: Upload core artifact
uses: actions/upload-artifact@v3
+6 -3
View File
@@ -19,7 +19,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
uses: actions/setup-node@v4
with:
electron-install: false
- run: echo "${{ github.event.pull_request.title }}" | yarn dlx commitlint -g ./.commitlintrc.json
cache: 'yarn'
node-version-file: '.nvmrc'
- name: Install dependencies
run: yarn workspaces focus @affine/commitlint-config
- run: echo "${{ github.event.pull_request.title }}" | yarn workspace @affine/commitlint-config commitlint -g ./.commitlintrc.json
@@ -69,6 +69,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
RELEASE_VERSION: ${{ github.event.inputs.version || steps.get-canary-version.outputs.RELEASE_VERSION }}
SKIP_PLUGIN_BUILD: 'true'
SKIP_NX_CACHE: 'true'
- name: Upload core artifact
uses: actions/upload-artifact@v3
-165
View File
@@ -1,165 +0,0 @@
name: Release
on:
push:
branches:
- canary
env:
BUILD_TYPE: stable
APP_NAME: affine
COVERAGE: false
DISTRIBUTION: browser
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
jobs:
release:
name: Try publishing npm@latest release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Try publishing to NPM
run: ./scripts/publish.sh
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
build-core:
name: Build @affine/core
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Plugins
run: yarn run build:plugins
- name: Build Core
run: yarn nx build @affine/core
- name: Upload core artifact
uses: actions/upload-artifact@v3
with:
name: core
path: ./packages/frontend/core/dist
if-no-files-found: error
build-server:
name: Build Server
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
- name: Build Server
run: yarn nx build @affine/server
- name: Upload server dist
uses: actions/upload-artifact@v3
with:
name: server-dist
path: ./packages/backend/server/dist
if-no-files-found: error
build-storage:
name: Build Storage
runs-on: ubuntu-latest
env:
RUSTFLAGS: '-C debuginfo=1'
environment: development
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: 'x86_64-unknown-linux-gnu'
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storage.node
uses: actions/upload-artifact@v3
with:
name: storage.node
path: ./packages/backend/storage/storage.node
if-no-files-found: error
build-docker:
if: github.ref == 'refs/heads/canary'
name: Build Docker
runs-on: ubuntu-latest
needs:
- build-server
- build-core
- build-storage
steps:
- uses: actions/checkout@v4
- name: Download core artifact
uses: actions/download-artifact@v3
with:
name: core
path: ./packages/frontend/core/dist
- name: Download server dist
uses: actions/download-artifact@v3
with:
name: server-dist
path: ./packages/backend/server/dist
- name: Download storage.node
uses: actions/download-artifact@v3
with:
name: storage.node
path: ./packages/backend/server
- name: Setup Git short hash
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
logout: false
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build front Dockerfile
uses: docker/build-push-action@v5
with:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64
provenance: true
file: .github/deployment/front/Dockerfile
tags: ghcr.io/toeverything/affine-front:${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-front:latest
# setup node without cache configuration
# Prisma cache is not compatible with docker build cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
- name: Install Node.js dependencies
run: yarn workspaces focus @affine/server --production
- name: Generate Prisma client
run: yarn workspace @affine/server prisma generate
- name: Build graphql Dockerfile
uses: docker/build-push-action@v5
with:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64
provenance: true
file: .github/deployment/node/Dockerfile
tags: ghcr.io/toeverything/affine-graphql:${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:latest
+1 -20
View File
@@ -1,23 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# check lockfile is up to date
yarn install --mode=skip-build --inline-builds --immutable
# build infra code
yarn -T run build:infra
# generate prisma client type
yarn workspace @affine/server prisma generate
# generate i18n
yarn i18n-codegen gen
# lint staged files
yarn exec lint-staged
# type check
yarn typecheck
# circular dependency check
yarn circular
yarn lint-staged && yarn lint:ox && cargo fmt --all && git add .
+1 -1
View File
@@ -228,7 +228,7 @@ See [LICENSE] for details.
[jobs available]: ./docs/jobs.md
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
[rust-version-icon]: https://img.shields.io/badge/Rust-1.71.0-dea584
[rust-version-icon]: https://img.shields.io/badge/Rust-1.74.0-dea584
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success
+4 -6
View File
@@ -33,12 +33,12 @@
"lint:eslint:fix": "yarn lint:eslint --fix",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint --deny-warnings --import-plugin -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"circular": "madge --circular --ts-config ./tsconfig.json ./packages/frontend/core/src/pages/**/*.tsx ./packages/frontend/core/src/index.tsx ./packages/frontend/electron/src/*/index.ts",
"typecheck": "tsc -b tsconfig.json --diagnostics",
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install"
},
@@ -49,7 +49,6 @@
"eslint --cache --fix"
],
"*.toml": [
"prettier --ignore-unknown --write",
"taplo format"
]
},
@@ -71,8 +70,8 @@
"@types/affine__env": "workspace:*",
"@types/eslint": "^8.44.7",
"@types/node": "^20.9.3",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"@vanilla-extract/vite-plugin": "^3.9.2",
"@vanilla-extract/webpack-plugin": "^2.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
@@ -82,7 +81,6 @@
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-i": "^2.29.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
@@ -94,12 +92,12 @@
"happy-dom": "^12.10.3",
"husky": "^8.0.3",
"lint-staged": "^15.1.0",
"madge": "^6.1.0",
"msw": "^2.0.8",
"nanoid": "^5.0.3",
"nx": "^17.1.3",
"nx-cloud": "^16.5.2",
"nyc": "^15.1.0",
"oxlint": "^0.0.18",
"prettier": "^3.1.0",
"semver": "^7.5.4",
"serve": "^14.2.1",
+1
View File
@@ -48,6 +48,7 @@
"@opentelemetry/instrumentation-ioredis": "^0.35.3",
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
"@opentelemetry/instrumentation-socket.io": "^0.34.3",
"@opentelemetry/resources": "^1.18.1",
"@opentelemetry/sdk-metrics": "^1.18.1",
"@opentelemetry/sdk-node": "^0.45.1",
"@opentelemetry/sdk-trace-node": "^1.18.1",
+1 -1
View File
@@ -50,7 +50,7 @@ function boolean(value: string) {
}
export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
if (typeof value === 'undefined') {
if (value === undefined) {
return;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
? [config, process.env[env]]
: [config[0], parseEnvValue(process.env[env], config[1])];
if (typeof value !== 'undefined') {
if (value !== undefined) {
set(rawConfig, path, value);
}
}
@@ -20,7 +20,7 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
const res = reqContext.contextValue.req.res as Response;
const operation = reqContext.request.operationName;
metrics().gqlRequest.add(1, { operation });
metrics.gql.counter('query_counter').add(1, { operation });
const start = Date.now();
return Promise.resolve({
@@ -30,7 +30,9 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL"`
);
metrics().gqlTimer.record(costInMilliseconds, { operation });
metrics.gql
.histogram('query_duration')
.record(costInMilliseconds, { operation });
return Promise.resolve();
},
didEncounterErrors: () => {
@@ -39,7 +41,9 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
);
metrics().gqlTimer.record(costInMilliseconds, { operation });
metrics.gql
.histogram('query_duration')
.record(costInMilliseconds, { operation });
return Promise.resolve();
},
});
+114 -61
View File
@@ -1,76 +1,129 @@
import opentelemetry, { Attributes, Observable } from '@opentelemetry/api';
import {
Attributes,
Counter,
Histogram,
Meter,
MetricOptions,
} from '@opentelemetry/api';
interface AsyncMetric {
ob: Observable;
get value(): any;
get attrs(): Attributes | undefined;
}
import { getMeter } from './opentelemetry';
let _metrics: ReturnType<typeof createBusinessMetrics> | undefined = undefined;
type MetricType = 'counter' | 'gauge' | 'histogram';
type Metric<T extends MetricType> = T extends 'counter'
? Counter
: T extends 'gauge'
? Histogram
: T extends 'histogram'
? Histogram
: never;
export function getMeter(name = 'business') {
return opentelemetry.metrics.getMeter(name);
}
export type ScopedMetrics = {
[T in MetricType]: (name: string, opts?: MetricOptions) => Metric<T>;
};
type MetricCreators = {
[T in MetricType]: (
meter: Meter,
name: string,
opts?: MetricOptions
) => Metric<T>;
};
function createBusinessMetrics() {
const meter = getMeter();
const asyncMetrics: AsyncMetric[] = [];
export type KnownMetricScopes =
| 'socketio'
| 'gql'
| 'jwst'
| 'auth'
| 'controllers'
| 'doc';
function createGauge(name: string) {
const metricCreators: MetricCreators = {
counter(meter: Meter, name: string, opts?: MetricOptions) {
return meter.createCounter(name, opts);
},
gauge(meter: Meter, name: string, opts?: MetricOptions) {
let value: any;
let attrs: Attributes | undefined;
const ob = meter.createObservableGauge(name);
asyncMetrics.push({
ob,
get value() {
return value;
},
get attrs() {
return attrs;
},
const ob = meter.createObservableGauge(name, opts);
ob.addCallback(result => {
result.observe(value, attrs);
});
return (newValue: any, newAttrs?: Attributes) => {
value = newValue;
attrs = newAttrs;
};
return {
record: (newValue, newAttrs) => {
value = newValue;
attrs = newAttrs;
},
} satisfies Histogram;
},
histogram(meter: Meter, name: string, opts?: MetricOptions) {
return meter.createHistogram(name, opts);
},
};
const scopes = new Map<string, ScopedMetrics>();
function make(scope: string) {
const meter = getMeter();
const metrics = new Map<string, { type: MetricType; metric: any }>();
const prefix = scope + '/';
function getOrCreate<T extends MetricType>(
type: T,
name: string,
opts?: MetricOptions
): Metric<T> {
name = prefix + name;
const metric = metrics.get(name);
if (metric) {
if (type !== metric.type) {
throw new Error(
`Metric ${name} has already been registered as ${metric.type} mode, but get as ${type} again.`
);
}
return metric.metric;
} else {
const metric = metricCreators[type](meter, name, opts);
metrics.set(name, { type, metric });
return metric;
}
}
const metrics = {
socketIOConnectionGauge: createGauge('socket_io_connection'),
gqlRequest: meter.createCounter('gql_request'),
gqlError: meter.createCounter('gql_error'),
gqlTimer: meter.createHistogram('gql_timer'),
jwstCodecMerge: meter.createCounter('jwst_codec_merge'),
jwstCodecDidnotMatch: meter.createCounter('jwst_codec_didnot_match'),
jwstCodecFail: meter.createCounter('jwst_codec_fail'),
authCounter: meter.createCounter('auth'),
authFailCounter: meter.createCounter('auth_fail'),
docHistoryCounter: meter.createCounter('doc_history_created'),
docRecoverCounter: meter.createCounter('doc_history_recovered'),
};
meter.addBatchObservableCallback(
result => {
asyncMetrics.forEach(metric => {
result.observe(metric.ob, metric.value, metric.attrs);
});
return {
counter(name, opts) {
return getOrCreate('counter', name, opts);
},
asyncMetrics.map(({ ob }) => ob)
);
return metrics;
gauge(name, opts) {
return getOrCreate('gauge', name, opts);
},
histogram(name, opts) {
return getOrCreate('histogram', name, opts);
},
} satisfies ScopedMetrics;
}
export function registerBusinessMetrics() {
if (!_metrics) {
_metrics = createBusinessMetrics();
/**
* @example
*
* ```
* metrics.scope.counter('example_count').add(1, {
* attr1: 'example-event'
* })
* ```
*/
export const metrics = new Proxy<Record<KnownMetricScopes, ScopedMetrics>>(
// @ts-expect-error proxied
{},
{
get(_, scopeName: string) {
let scope = scopes.get(scopeName);
if (!scope) {
scope = make(scopeName);
scopes.set(scopeName, scope);
}
return scope;
},
}
return _metrics;
}
export const metrics = registerBusinessMetrics;
);
@@ -1,5 +1,6 @@
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
import { metrics } from '@opentelemetry/api';
import {
CompositePropagator,
W3CBaggagePropagator,
@@ -16,6 +17,8 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
import {
ConsoleMetricExporter,
type MeterProvider,
MetricProducer,
MetricReader,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
@@ -24,10 +27,11 @@ import {
BatchSpanProcessor,
ConsoleSpanExporter,
SpanExporter,
TraceIdRatioBasedSampler,
} from '@opentelemetry/sdk-trace-node';
import { PrismaInstrumentation } from '@prisma/instrumentation';
import { registerBusinessMetrics } from './metrics';
import { PrismaMetricProducer } from './prisma';
abstract class OpentelemetryFactor {
abstract getMetricReader(): MetricReader;
@@ -44,9 +48,14 @@ abstract class OpentelemetryFactor {
];
}
getMetricsProducers(): MetricProducer[] {
return [new PrismaMetricProducer()];
}
create() {
const traceExporter = this.getSpanExporter();
return new NodeSDK({
sampler: new TraceIdRatioBasedSampler(0.1),
traceExporter,
metricReader: this.getMetricReader(),
spanProcessor: new BatchSpanProcessor(traceExporter),
@@ -67,7 +76,10 @@ class GCloudOpentelemetryFactor extends OpentelemetryFactor {
return new PeriodicExportingMetricReader({
exportIntervalMillis: 30000,
exportTimeoutMillis: 10000,
exporter: new MetricExporter(),
exporter: new MetricExporter({
prefix: 'custom.googleapis.com',
}),
metricProducers: this.getMetricsProducers(),
});
}
@@ -78,7 +90,9 @@ class GCloudOpentelemetryFactor extends OpentelemetryFactor {
class LocalOpentelemetryFactor extends OpentelemetryFactor {
override getMetricReader(): MetricReader {
return new PrometheusExporter();
return new PrometheusExporter({
metricProducers: this.getMetricsProducers(),
});
}
override getSpanExporter(): SpanExporter {
@@ -90,6 +104,7 @@ class DebugOpentelemetryFactor extends OpentelemetryFactor {
override getMetricReader(): MetricReader {
return new PeriodicExportingMetricReader({
exporter: new ConsoleMetricExporter(),
metricProducers: this.getMetricsProducers(),
});
}
@@ -111,9 +126,30 @@ function createSDK() {
return factor?.create();
}
let OPENTELEMETRY_STARTED = false;
function ensureStarted() {
if (!OPENTELEMETRY_STARTED) {
OPENTELEMETRY_STARTED = true;
start();
}
}
function getMeterProvider() {
ensureStarted();
return metrics.getMeterProvider();
}
function registerCustomMetrics() {
const host = new HostMetrics({ name: 'instance-host-metrics' });
host.start();
const hostMetricsMonitoring = new HostMetrics({
name: 'instance-host-metrics',
meterProvider: getMeterProvider() as MeterProvider,
});
hostMetricsMonitoring.start();
}
export function getMeter(name = 'business') {
return getMeterProvider().getMeter(name);
}
export function start() {
@@ -122,6 +158,5 @@ export function start() {
if (sdk) {
sdk.start();
registerCustomMetrics();
registerBusinessMetrics();
}
}
@@ -0,0 +1,132 @@
import { HrTime, ValueType } from '@opentelemetry/api';
import { hrTime } from '@opentelemetry/core';
import { Resource } from '@opentelemetry/resources';
import {
AggregationTemporality,
CollectionResult,
DataPointType,
InstrumentType,
MetricProducer,
ScopeMetrics,
} from '@opentelemetry/sdk-metrics';
import { PrismaService } from '../prisma';
function transformPrismaKey(key: string) {
// replace first '_' to '/' as a scope prefix
// example: prisma_client_query_duration_seconds_sum -> prisma/client_query_duration_seconds_sum
return key.replace(/_/, '/');
}
export class PrismaMetricProducer implements MetricProducer {
private readonly startTime: HrTime = hrTime();
async collect(): Promise<CollectionResult> {
const result: CollectionResult = {
resourceMetrics: {
resource: Resource.EMPTY,
scopeMetrics: [],
},
errors: [],
};
if (!PrismaService.INSTANCE) {
return result;
}
const prisma = PrismaService.INSTANCE;
const endTime = hrTime();
const metrics = await prisma.$metrics.json();
const scopeMetrics: ScopeMetrics = {
scope: {
name: '',
},
metrics: [],
};
for (const counter of metrics.counters) {
scopeMetrics.metrics.push({
descriptor: {
name: transformPrismaKey(counter.key),
description: counter.description,
unit: '1',
type: InstrumentType.COUNTER,
valueType: ValueType.INT,
},
dataPointType: DataPointType.SUM,
aggregationTemporality: AggregationTemporality.CUMULATIVE,
dataPoints: [
{
startTime: this.startTime,
endTime: endTime,
value: counter.value,
attributes: counter.labels,
},
],
isMonotonic: true,
});
}
for (const gauge of metrics.gauges) {
scopeMetrics.metrics.push({
descriptor: {
name: transformPrismaKey(gauge.key),
description: gauge.description,
unit: '1',
type: InstrumentType.UP_DOWN_COUNTER,
valueType: ValueType.INT,
},
dataPointType: DataPointType.GAUGE,
aggregationTemporality: AggregationTemporality.CUMULATIVE,
dataPoints: [
{
startTime: this.startTime,
endTime: endTime,
value: gauge.value,
attributes: gauge.labels,
},
],
});
}
for (const histogram of metrics.histograms) {
const boundaries = [];
const counts = [];
for (const [boundary, count] of histogram.value.buckets) {
boundaries.push(boundary);
counts.push(count);
}
scopeMetrics.metrics.push({
descriptor: {
name: transformPrismaKey(histogram.key),
description: histogram.description,
unit: 'ms',
type: InstrumentType.HISTOGRAM,
valueType: ValueType.DOUBLE,
},
dataPointType: DataPointType.HISTOGRAM,
aggregationTemporality: AggregationTemporality.CUMULATIVE,
dataPoints: [
{
startTime: this.startTime,
endTime: endTime,
value: {
buckets: {
boundaries,
counts,
},
count: histogram.value.count,
sum: histogram.value.sum,
},
attributes: histogram.labels,
},
],
});
}
result.resourceMetrics.scopeMetrics.push(scopeMetrics);
return result;
}
}
+7 -3
View File
@@ -1,8 +1,9 @@
import { Attributes } from '@opentelemetry/api';
import { getMeter } from './metrics';
import { KnownMetricScopes, metrics } from './metrics';
export const CallTimer = (
scope: KnownMetricScopes,
name: string,
attrs?: Attributes
): MethodDecorator => {
@@ -18,9 +19,11 @@ export const CallTimer = (
}
desc.value = function (...args: any[]) {
const timer = getMeter().createHistogram(name, {
const timer = metrics[scope].histogram(name, {
description: `function call time costs of ${name}`,
unit: 'ms',
});
const start = Date.now();
const end = () => {
@@ -48,6 +51,7 @@ export const CallTimer = (
};
export const CallCounter = (
scope: KnownMetricScopes,
name: string,
attrs?: Attributes
): MethodDecorator => {
@@ -63,7 +67,7 @@ export const CallCounter = (
}
desc.value = function (...args: any[]) {
const count = getMeter().createCounter(name, {
const count = metrics[scope].counter(name, {
description: `function call counter of ${name}`,
});
@@ -14,7 +14,7 @@ const TrivialExceptions = [NotFoundException];
@Catch()
export class ExceptionLogger implements ExceptionFilter {
private logger = new Logger('ExceptionLogger');
private readonly logger = new Logger('ExceptionLogger');
catch(exception: Error, host: ArgumentsHost) {
// with useGlobalFilters, the context is always HTTP
@@ -53,8 +53,8 @@ class AuthGuard implements CanActivate {
constructor(
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private auth: AuthService,
private prisma: PrismaService,
private readonly auth: AuthService,
private readonly prisma: PrismaService,
private readonly reflector: Reflector
) {}
@@ -89,12 +89,13 @@ export class NextAuthController {
res.redirect(`/signin${query}`);
return;
}
metrics().authCounter.add(1);
const [action, providerId] = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part
.split('/') as [AuthAction, string]; // as array of strings;
metrics.auth.counter('call_counter').add(1, { action, providerId });
const credentialsSignIn =
req.method === 'POST' && providerId === 'credentials';
let userId: string | undefined;
@@ -126,7 +127,9 @@ export class NextAuthController {
const options = this.nextAuthOptions;
if (req.method === 'POST' && action === 'session') {
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
metrics().authFailCounter.add(1, { reason: 'invalid_session_data' });
metrics.auth
.counter('call_fails_counter')
.add(1, { reason: 'invalid_session_data' });
throw new BadRequestException(`Invalid new session data`);
}
const user = await this.updateSession(req, req.body.data);
@@ -209,9 +212,10 @@ export class NextAuthController {
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
this.logger.log(`Early access redirect headers: ${req.headers}`);
metrics().authFailCounter.add(1, {
reason: 'no_early_access_permission',
});
metrics.auth
.counter('call_fails_counter')
.add(1, { reason: 'no_early_access_permission' });
if (
!req.headers?.referer ||
checkUrlOrigin(req.headers.referer, 'https://accounts.google.com')
@@ -23,14 +23,14 @@ export type UserClaim = Pick<
hasPassword?: boolean;
};
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
@Injectable()
export class AuthService {
constructor(
private config: Config,
private prisma: PrismaService,
private mailer: MailService
private readonly config: Config,
private readonly prisma: PrismaService,
private readonly mailer: MailService
) {}
sign(user: UserClaim) {
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { Field, ObjectType, Query } from '@nestjs/graphql';
import { SERVER_FLAVOR } from '../modules';
export const { SERVER_FLAVOR } = process.env;
@ObjectType()
export class ServerConfigType {
@@ -68,7 +68,11 @@ export class DocHistoryManager {
// safe to ignore
// only happens when duplicated history record created in multi processes
});
metrics().docHistoryCounter.add(1, {});
metrics.doc
.counter('history_created_counter', {
description: 'How many times the snapshot history created',
})
.add(1);
this.logger.log(
`History created for ${snapshot.id} in workspace ${snapshot.workspaceId}.`
);
@@ -182,7 +186,11 @@ export class DocHistoryManager {
// which is not the solution in CRDT.
// let user revert in client and update the data in sync system
// `await this.db.snapshot.update();`
metrics().docRecoverCounter.add(1, {});
metrics.doc
.counter('history_recovered_counter', {
description: 'How many times history recovered request happened',
})
.add(1);
return history.timestamp;
}
@@ -42,7 +42,7 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
function isEmptyBuffer(buf: Buffer): boolean {
return (
buf.length == 0 ||
buf.length === 0 ||
// 0x0000
(buf.length === 2 && buf[0] === 0 && buf[1] === 0)
);
@@ -60,9 +60,9 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
*/
@Injectable()
export class DocManager implements OnModuleInit, OnModuleDestroy {
private logger = new Logger(DocManager.name);
private readonly logger = new Logger(DocManager.name);
private job: NodeJS.Timeout | null = null;
private seqMap = new Map<string, number>();
private readonly seqMap = new Map<string, number>();
private busy = false;
constructor(
@@ -125,13 +125,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.config.doc.manager.experimentalMergeWithJwstCodec &&
updates.length < 100 /* avoid overloading */
) {
metrics().jwstCodecMerge.add(1);
metrics.jwst.counter('codec_merge_counter').add(1);
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
let log = false;
try {
const jwstResult = jwstMergeUpdates(updates);
if (!compare(yjsResult, jwstResult)) {
metrics().jwstCodecDidnotMatch.add(1);
metrics.jwst.counter('codec_not_match').add(1);
this.logger.warn(
`jwst codec result doesn't match yjs codec result for: ${guid}`
);
@@ -142,7 +142,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}
}
} catch (e) {
metrics().jwstCodecFail.add(1);
metrics.jwst.counter('codec_fails_counter').add(1);
this.logger.warn(`jwst apply update failed for ${guid}: ${e}`);
log = true;
} finally {
+1 -3
View File
@@ -3,7 +3,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { GqlModule } from '../graphql.module';
import { ServerConfigModule } from './config';
import { SERVER_FLAVOR, ServerConfigModule } from './config';
import { DocModule } from './doc';
import { PaymentModule } from './payment';
import { SelfHostedModule } from './self-hosted';
@@ -11,8 +11,6 @@ import { SyncModule } from './sync';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
const { SERVER_FLAVOR } = process.env;
const BusinessModules: (Type | DynamicModule)[] = [
EventEmitterModule.forRoot({
global: true,
@@ -45,6 +45,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
try {
result = originalMethod.apply(this, args);
} catch (e) {
metrics.socketio.counter('unhandled_errors').add(1);
return {
error: new InternalError(e as Error),
};
@@ -52,6 +53,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
if (result instanceof Promise) {
return result.catch(e => {
metrics.socketio.counter('unhandled_errors').add(1);
return {
error: new InternalError(e),
};
@@ -68,7 +70,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
const SubscribeMessage = (event: string) =>
applyDecorators(
GatewayErrorWrapper(),
CallTimer('socket_io_event_duration', { event }),
CallTimer('socketio', 'event_duration', { event }),
RawSubscribeMessage(event)
);
@@ -104,12 +106,12 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
handleConnection() {
this.connectionCount++;
metrics().socketIOConnectionGauge(this.connectionCount);
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
}
handleDisconnect() {
this.connectionCount--;
metrics().socketIOConnectionGauge(this.connectionCount);
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
}
@Auth()
@@ -34,7 +34,7 @@ export class WorkspacesController {
//
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
@Get('/:id/blobs/:name')
@CallTimer('doc_controller', { method: 'get_blob' })
@CallTimer('controllers', 'workspace_get_blob')
async blob(
@Param('id') workspaceId: string,
@Param('name') name: string,
@@ -59,7 +59,7 @@ export class WorkspacesController {
@Get('/:id/docs/:guid')
@Auth()
@Publicable()
@CallTimer('doc_controller', { method: 'get_doc' })
@CallTimer('controllers', 'workspace_get_doc')
async doc(
@CurrentUser() user: UserType | undefined,
@Param('id') ws: string,
@@ -106,7 +106,7 @@ export class WorkspacesController {
@Get('/:id/docs/:guid/histories/:timestamp')
@Auth()
@CallTimer('doc_controller', { method: 'get_history' })
@CallTimer('controllers', 'workspace_get_history')
async history(
@CurrentUser() user: UserType,
@Param('id') ws: string,
@@ -7,6 +7,13 @@ export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
static INSTANCE: PrismaService | null = null;
constructor() {
super();
PrismaService.INSTANCE = this;
}
async onModuleInit() {
await this.$connect();
}
+1 -1
View File
@@ -16,7 +16,7 @@ export class DocID {
raw: string;
workspace: string;
variant: DocVariant;
private sub: string | null;
private readonly sub: string | null;
static parse(raw: string): DocID | null {
try {
@@ -17,7 +17,7 @@ class FakePrisma {
get workspace() {
return {
async findUnique() {
throw Error('exception from graphql');
throw new Error('exception from graphql');
},
};
}
+50 -40
View File
@@ -183,26 +183,30 @@ test('should correctly list all history records', async t => {
// insert expired data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
});
// insert available data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
const list = await manager.list(
@@ -243,14 +247,16 @@ test('should be able to get last history record', async t => {
// insert available data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
const history = await manager.last(snapshot.workspaceId, snapshot.id);
@@ -299,26 +305,30 @@ test('should be able to cleanup expired history', async t => {
// insert expired data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
});
// insert available data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
let count = await db.snapshotHistory.count();
@@ -65,9 +65,7 @@ test.afterEach.always(async t => {
test('should get blob size limit', async t => {
const { resolver } = t.context;
fakeUserService.getStorageQuotaById.returns(
Promise.resolve(100 * 1024 * 1024 * 1024)
);
fakeUserService.getStorageQuotaById.resolves(100 * 1024 * 1024 * 1024);
const res = await resolver.checkBlobSize(new FakePrisma().fakeUser, '', 100);
t.not(res, false);
// @ts-expect-error
+1 -1
View File
@@ -24,7 +24,7 @@ if (typeof window !== 'undefined') {
}
export class DebugLogger {
private _debug: debug.Debugger;
private readonly _debug: debug.Debugger;
constructor(namespace: string) {
this._debug = debug(namespace);
+6
View File
@@ -39,6 +39,12 @@ export const runtimeFlagsSchema = z.object({
editorFlags: blockSuiteFeatureFlags,
appVersion: z.string(),
editorVersion: z.string(),
appBuildType: z.union([
z.literal('stable'),
z.literal('beta'),
z.literal('internal'),
z.literal('canary'),
]),
});
export type BlockSuiteFeatureFlags = z.infer<typeof blockSuiteFeatureFlags>;
+1 -1
View File
@@ -1,7 +1,7 @@
import { assertExists } from '@blocksuite/global/utils';
export class UaHelper {
private uaMap;
private readonly uaMap;
public isLinux = false;
public isMacOs = false;
public isSafari = false;
@@ -3,8 +3,11 @@ import type { Page, PageMeta, Workspace } from '@blocksuite/store';
import type { createStore, WritableAtom } from 'jotai/vanilla';
import { nanoid } from 'nanoid';
import { checkWorkspaceCompatibility, MigrationPoint } from '..';
import { migratePages } from '../migration/blocksuite';
import {
checkWorkspaceCompatibility,
MigrationPoint,
} from '../migration/workspace';
export async function initEmptyPage(page: Page, title?: string) {
await page.load(() => {
@@ -50,7 +50,7 @@ function migrateDatabase(data: YMap<unknown>) {
update: (cell: { id: string; value: unknown }) => void
) => {
Object.values(cells).forEach(row => {
if (row[id] != null) {
if (row[id] !== null && row[id] !== undefined) {
update(row[id]);
}
});
+10 -2
View File
@@ -45,8 +45,8 @@ export abstract class HandlerManager<
Handlers extends Record<string, PrimitiveHandlers>,
> {
static instance: HandlerManager<string, Record<string, PrimitiveHandlers>>;
private _app: App<Namespace, Handlers>;
private _namespace: Namespace;
private readonly _app: App<Namespace, Handlers>;
private readonly _namespace: Namespace;
private _handlers: Handlers;
constructor() {
@@ -190,9 +190,17 @@ export interface UpdateMeta {
allowAutoUpdate: boolean;
}
export type UpdaterConfig = {
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
};
export type UpdaterHandlers = {
currentVersion: () => Promise<string>;
quitAndInstall: () => Promise<void>;
downloadUpdate: () => Promise<void>;
getConfig: () => Promise<UpdaterConfig>;
setConfig: (newConfig: Partial<UpdaterConfig>) => Promise<void>;
checkForUpdatesAndNotify: () => Promise<{ version: string } | null>;
};
@@ -1,69 +0,0 @@
import { isBrowser } from '@affine/env/constant';
import type { UpdateMeta } from '@toeverything/infra/type';
import { atomWithObservable, atomWithStorage } from 'jotai/utils';
import { Observable } from 'rxjs';
// todo: move to utils?
function rpcToObservable<
T,
H extends () => Promise<T>,
E extends (callback: (t: T) => void) => () => void,
>(
initialValue: T | null,
{
event,
handler,
onSubscribe,
}: {
event?: E;
handler?: H;
onSubscribe?: () => void;
}
): Observable<T | null> {
return new Observable<T | null>(subscriber => {
subscriber.next(initialValue);
onSubscribe?.();
if (!isBrowser || !environment.isDesktop || !event) {
subscriber.complete();
return;
}
handler?.()
.then(t => {
subscriber.next(t);
})
.catch(err => {
subscriber.error(err);
});
return event(t => {
subscriber.next(t);
});
});
}
export const updateReadyAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateReady,
});
});
export const updateAvailableAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateAvailable,
onSubscribe: () => {
window.apis?.updater.checkForUpdatesAndNotify().catch(err => {
console.error(err);
});
},
});
});
export const downloadProgressAtom = atomWithObservable(() => {
return rpcToObservable(null as number | null, {
event: window.events?.updater.onDownloadProgress,
});
});
export const changelogCheckedAtom = atomWithStorage<Record<string, boolean>>(
'affine:client-changelog-checked',
{}
);
@@ -1,18 +1,21 @@
import { isBrowser, Unreachable } from '@affine/env/constant';
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { startTransition, useCallback, useState } from 'react';
import * as styles from './index.css';
import {
changelogCheckedAtom,
currentChangelogUnreadAtom,
currentVersionAtom,
downloadProgressAtom,
updateAvailableAtom,
updateReadyAtom,
} from './index.jotai';
useAppUpdater,
} from '@toeverything/hooks/use-app-updater';
import clsx from 'clsx';
import { useAtomValue, useSetAtom } from 'jotai';
import { startTransition, useCallback } from 'react';
import * as styles from './index.css';
export interface AddPageButtonPureProps {
onClickUpdate: () => void;
@@ -29,26 +32,6 @@ export interface AddPageButtonPureProps {
style?: React.CSSProperties;
}
const currentVersionAtom = atom(async () => {
if (!isBrowser) {
return null;
}
const currentVersion = await window.apis?.updater.currentVersion();
return currentVersion;
});
const currentChangelogUnreadAtom = atom(async get => {
if (!isBrowser) {
return false;
}
const mapping = get(changelogCheckedAtom);
const currentVersion = await get(currentVersionAtom);
if (currentVersion) {
return !mapping[currentVersion];
}
return false;
});
export function AppUpdaterButtonPure({
updateReady,
onClickUpdate,
@@ -198,12 +181,12 @@ export function AppUpdaterButton({
const currentChangelogUnread = useAtomValue(currentChangelogUnreadAtom);
const updateReady = useAtomValue(updateReadyAtom);
const updateAvailable = useAtomValue(updateAvailableAtom);
const currentVersion = useAtomValue(currentVersionAtom);
const downloadProgress = useAtomValue(downloadProgressAtom);
const currentVersion = useAtomValue(currentVersionAtom);
const { quitAndInstall, appQuitting } = useAppUpdater();
const setChangelogCheckAtom = useSetAtom(changelogCheckedAtom);
const [appQuitting, setAppQuitting] = useState(false);
const onDismissCurrentChangelog = useCallback(() => {
const dismissCurrentChangelog = useCallback(() => {
if (!currentVersion) {
return;
}
@@ -216,13 +199,10 @@ export function AppUpdaterButton({
})
);
}, [currentVersion, setChangelogCheckAtom]);
const onClickUpdate = useCallback(() => {
const handleClickUpdate = useCallback(() => {
if (updateReady) {
setAppQuitting(true);
window.apis?.updater.quitAndInstall().catch(err => {
// TODO: add error toast here
console.error(err);
});
quitAndInstall();
} else if (updateAvailable) {
if (updateAvailable.allowAutoUpdate) {
// wait for download to finish
@@ -234,23 +214,25 @@ export function AppUpdaterButton({
}
} else if (currentChangelogUnread) {
window.open(runtimeConfig.changelogUrl, '_blank');
onDismissCurrentChangelog();
dismissCurrentChangelog();
} else {
throw new Unreachable();
}
}, [
currentChangelogUnread,
currentVersion,
onDismissCurrentChangelog,
updateAvailable,
updateReady,
quitAndInstall,
updateAvailable,
currentChangelogUnread,
dismissCurrentChangelog,
currentVersion,
]);
return (
<AppUpdaterButtonPure
appQuitting={appQuitting}
updateReady={!!updateReady}
onClickUpdate={onClickUpdate}
onDismissCurrentChangelog={onDismissCurrentChangelog}
onClickUpdate={handleClickUpdate}
onDismissCurrentChangelog={dismissCurrentChangelog}
currentChangelogUnread={currentChangelogUnread}
updateAvailable={updateAvailable}
downloadProgress={downloadProgress}
@@ -259,5 +241,3 @@ export function AppUpdaterButton({
/>
);
}
export * from './index.jotai';
@@ -2,8 +2,10 @@ import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, true);
export const appSidebarFloatingAtom = atom(false);
export const isMobile = window.innerWidth < 768;
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, !isMobile);
export const appSidebarFloatingAtom = atom(isMobile);
export const appSidebarResizingAtom = atom(false);
export const appSidebarWidthAtom = atomWithStorage(
@@ -10,9 +10,9 @@ export const RootBlockHub = () => {
const div = ref.current;
if (blockHub) {
if (div.hasChildNodes()) {
div.removeChild(div.firstChild as ChildNode);
(div.firstChild as ChildNode).remove();
}
div.appendChild(blockHub);
div.append(blockHub);
}
}
}, [blockHub]);
@@ -192,9 +192,9 @@ const BlockSuiteEditorImpl = ({
if (!container) {
return;
}
container.appendChild(editor);
container.append(editor);
return () => {
container.removeChild(editor);
editor.remove();
};
}, [editor]);
@@ -4,9 +4,11 @@ import { typesystem } from './typesystem';
type MatcherData<Data, Type extends TType = TType> = { type: Type; data: Data };
export class Matcher<Data, Type extends TType = TType> {
private list: MatcherData<Data, Type>[] = [];
private readonly list: MatcherData<Data, Type>[] = [];
constructor(private _match?: (type: Type, target: TType) => boolean) {}
constructor(
private readonly _match?: (type: Type, target: TType) => boolean
) {}
register(type: Type, data: Data) {
this.list.push({ type, data });
@@ -92,8 +92,8 @@ export type ValueOfData<T extends DataDefine> = T extends DataDefine<infer R>
export class DataDefine<Data extends DataTypeShape = Record<string, unknown>> {
constructor(
private config: DataDefineConfig<Data>,
private dataMap: Map<string, DataDefine>
private readonly config: DataDefineConfig<Data>,
private readonly dataMap: Map<string, DataDefine>
) {}
create(data?: Data): TDataType<Data> {
@@ -195,7 +195,7 @@ export class Typesystem {
): boolean {
if (superType.type === 'typeRef') {
// TODO both are ref
if (context && sub.type != 'typeRef') {
if (context && sub.type !== 'typeRef') {
context[superType.name] = sub;
}
// TODO bound
@@ -141,7 +141,7 @@ filterMatcher.register(
name: 'is',
defaultArgs: () => [true],
impl: (value, target) => {
return value == target;
return value === target;
},
}
);
@@ -209,7 +209,7 @@ filterMatcher.register(
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length == 0;
return safeTags.length === 0;
},
}
);
@@ -1,5 +1,4 @@
import type { Tag } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
@@ -19,93 +18,8 @@ import {
useAtom,
useAtomValue,
} from './scoped-atoms';
import type {
PageGroupDefinition,
PageGroupProps,
PageListItemProps,
PageListProps,
} from './types';
import { type DateKey } from './types';
import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils';
// todo: optimize date matchers
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
{
id: 'today',
label: <Trans i18nKey="com.affine.today" />,
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
},
{
id: 'yesterday',
label: <Trans i18nKey="com.affine.yesterday" />,
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
},
{
id: 'last7Days',
label: <Trans i18nKey="com.affine.last7Days" />,
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
},
{
id: 'last30Days',
label: <Trans i18nKey="com.affine.last30Days" />,
match: item =>
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
},
{
id: 'moreThan30Days',
label: <Trans i18nKey="com.affine.moreThan30Days" />,
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
},
];
const pageGroupDefinitions = {
createDate: getDateGroupDefinitions('createDate'),
updatedDate: getDateGroupDefinitions('updatedDate'),
// add more here later
// todo: some page group definitions maybe dynamic
};
export function pagesToPageGroups(
pages: PageMeta[],
key?: DateKey
): PageGroupProps[] {
if (!key) {
return [
{
id: 'all',
items: pages,
allItems: pages,
},
];
}
// assume pages are already sorted, we will use the page order to determine the group order
const groupDefs = pageGroupDefinitions[key];
const groups: PageGroupProps[] = [];
for (const page of pages) {
// for a single page, there could be multiple groups that it belongs to
const matchedGroups = groupDefs.filter(def => def.match(page));
for (const groupDef of matchedGroups) {
const group = groups.find(g => g.id === groupDef.id);
if (group) {
group.items.push(page);
} else {
const label =
typeof groupDef.label === 'function'
? groupDef.label()
: groupDef.label;
groups.push({
id: groupDef.id,
label: label,
items: [page],
allItems: pages,
});
}
}
}
return groups;
}
import type { PageGroupProps, PageListItemProps, PageListProps } from './types';
import { shallowEqual } from './utils';
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
@@ -0,0 +1,85 @@
import { Trans } from '@affine/i18n';
import type { PageMeta } from '@blocksuite/store';
import type { PageGroupDefinition, PageGroupProps } from './types';
import { type DateKey } from './types';
import { betweenDaysAgo, withinDaysAgo } from './utils';
// todo: optimize date matchers
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
{
id: 'today',
label: <Trans i18nKey="com.affine.today" />,
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
},
{
id: 'yesterday',
label: <Trans i18nKey="com.affine.yesterday" />,
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
},
{
id: 'last7Days',
label: <Trans i18nKey="com.affine.last7Days" />,
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
},
{
id: 'last30Days',
label: <Trans i18nKey="com.affine.last30Days" />,
match: item =>
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
},
{
id: 'moreThan30Days',
label: <Trans i18nKey="com.affine.moreThan30Days" />,
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
},
];
const pageGroupDefinitions = {
createDate: getDateGroupDefinitions('createDate'),
updatedDate: getDateGroupDefinitions('updatedDate'),
// add more here later
// todo: some page group definitions maybe dynamic
};
export function pagesToPageGroups(
pages: PageMeta[],
key?: DateKey
): PageGroupProps[] {
if (!key) {
return [
{
id: 'all',
items: pages,
allItems: pages,
},
];
}
// assume pages are already sorted, we will use the page order to determine the group order
const groupDefs = pageGroupDefinitions[key];
const groups: PageGroupProps[] = [];
for (const page of pages) {
// for a single page, there could be multiple groups that it belongs to
const matchedGroups = groupDefs.filter(def => def.match(page));
for (const groupDef of matchedGroups) {
const group = groups.find(g => g.id === groupDef.id);
if (group) {
group.items.push(page);
} else {
const label =
typeof groupDef.label === 'function'
? groupDef.label()
: groupDef.label;
groups.push({
id: groupDef.id,
label: label,
items: [page],
allItems: pages,
});
}
}
}
return groups;
}
@@ -4,7 +4,7 @@ import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { createIsolation } from 'jotai-scope';
import { pagesToPageGroups } from './page-group';
import { pagesToPageGroups } from './pages-to-page-group';
import type {
PageListProps,
PageMetaRecord,
@@ -10,9 +10,9 @@ import * as styles from './page-list.css';
export function isToday(date: Date): boolean {
const today = new Date();
return (
date.getDate() == today.getDate() &&
date.getMonth() == today.getMonth() &&
date.getFullYear() == today.getFullYear()
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
}
@@ -162,10 +162,10 @@ export function shallowEqual(objA: any, objB: any) {
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
for (const key of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!Object.is(objA[keysA[i]], objB[keysA[i]])
!Object.prototype.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])
) {
return false;
}
@@ -5,7 +5,7 @@ import type {
} from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilteredIcon } from '@blocksuite/icons';
import { FilterIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Menu } from '@toeverything/components/menu';
import { useCallback, useState } from 'react';
@@ -75,7 +75,7 @@ export const CollectionList = ({
<Button
className={styles.filterMenuTrigger}
type="default"
icon={<FilteredIcon />}
icon={<FilterIcon />}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
@@ -99,7 +99,7 @@ export const CollectionList = ({
<Button
className={styles.filterMenuTrigger}
type="default"
icon={<FilteredIcon />}
icon={<FilterIcon />}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
@@ -1,13 +1,6 @@
import {
createEmptyCollection,
useEditCollectionName,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SaveIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Modal } from '@toeverything/components/modal';
import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react';
import Input from '../../../ui/input';
@@ -127,40 +120,3 @@ export const CreateCollection = ({
</div>
);
};
interface SaveAsCollectionButtonProps {
onConfirm: (collection: Collection) => Promise<void>;
}
export const SaveAsCollectionButton = ({
onConfirm,
}: SaveAsCollectionButtonProps) => {
const t = useAFFiNEI18N();
const { open, node } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
const handleClick = useCallback(() => {
open('')
.then(name => {
return onConfirm(createEmptyCollection(nanoid(), { name }));
})
.catch(err => {
console.error(err);
});
}, [open, onConfirm]);
return (
<>
<Button
onClick={handleClick}
data-testid="save-as-collection"
icon={<SaveIcon />}
size="large"
style={{ padding: '7px 8px' }}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
{node}
</>
);
};
@@ -6,7 +6,7 @@ import { Button } from '@toeverything/components/button';
import { Modal } from '@toeverything/components/modal';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { RadioButton, RadioButtonGroup } from '../../../../index';
import { RadioButton, RadioButtonGroup } from '../../../../ui/button';
import * as styles from './edit-collection.css';
import { PagesMode } from './pages-mode';
import { RulesMode } from './rules-mode';
@@ -1,12 +1,7 @@
import {
type AllPageListConfig,
filterPageByRules,
} from '@affine/component/page-list';
import type { Filter } from '@affine/env/filter';
import type { PageMeta } from '@blocksuite/store';
import { Modal } from '@toeverything/components/modal';
import { type MouseEvent, useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import type { AllPageListConfig } from './edit-collection';
import { SelectPage } from './select-page';
export const useSelectPage = ({
allPageListConfig,
@@ -60,47 +55,3 @@ export const useSelectPage = ({
}),
};
};
export const useFilter = (list: PageMeta[]) => {
const [filters, changeFilters] = useState<Filter[]>([]);
const [showFilter, setShowFilter] = useState(false);
const clickFilter = useCallback(
(e: MouseEvent) => {
if (showFilter || filters.length !== 0) {
e.stopPropagation();
e.preventDefault();
setShowFilter(!showFilter);
}
},
[filters.length, showFilter]
);
const onCreateFilter = useCallback(
(filter: Filter) => {
changeFilters([...filters, filter]);
setShowFilter(true);
},
[filters]
);
return {
showFilter,
filters,
updateFilters: changeFilters,
clickFilter,
createFilter: onCreateFilter,
filteredList: list.filter(v => {
if (v.trash) {
return false;
}
return filterPageByRules(filters, [], v);
}),
};
};
export const useSearch = (list: PageMeta[]) => {
const [value, onChange] = useState('');
return {
searchText: value,
updateSearchText: onChange,
searchedList: value
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
: list,
};
};
@@ -1,8 +1,3 @@
import {
type AllPageListConfig,
FilterList,
VirtualizedPageList,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
@@ -11,10 +6,14 @@ import { Menu } from '@toeverything/components/menu';
import clsx from 'clsx';
import { type ReactNode, useCallback } from 'react';
import { FilterList } from '../../filter/filter-list';
import { VariableSelect } from '../../filter/vars';
import { VirtualizedPageList } from '../../virtualized-page-list';
import type { AllPageListConfig } from './edit-collection';
import * as styles from './edit-collection.css';
import { useFilter, useSearch } from './hooks';
import { EmptyList } from './select-page';
import { useFilter } from './use-filter';
import { useSearch } from './use-search';
export const PagesMode = ({
switchMode,
@@ -6,13 +6,15 @@ import { Menu } from '@toeverything/components/menu';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { VirtualizedPageList } from '../..';
import { FilterList } from '../../filter';
import { VariableSelect } from '../../filter/vars';
import { VirtualizedPageList } from '../../virtualized-page-list';
import { AffineShapeIcon } from '../affine-shape';
import type { AllPageListConfig } from './edit-collection';
import * as styles from './edit-collection.css';
import { useFilter, useSearch } from './hooks';
import { useFilter } from './use-filter';
import { useSearch } from './use-search';
export const SelectPage = ({
allPageListConfig,
init,
@@ -0,0 +1,40 @@
import type { Filter } from '@affine/env/filter';
import type { PageMeta } from '@blocksuite/store';
import { type MouseEvent, useCallback, useState } from 'react';
import { filterPageByRules } from '../../use-collection-manager';
export const useFilter = (list: PageMeta[]) => {
const [filters, changeFilters] = useState<Filter[]>([]);
const [showFilter, setShowFilter] = useState(false);
const clickFilter = useCallback(
(e: MouseEvent) => {
if (showFilter || filters.length !== 0) {
e.stopPropagation();
e.preventDefault();
setShowFilter(!showFilter);
}
},
[filters.length, showFilter]
);
const onCreateFilter = useCallback(
(filter: Filter) => {
changeFilters([...filters, filter]);
setShowFilter(true);
},
[filters]
);
return {
showFilter,
filters,
updateFilters: changeFilters,
clickFilter,
createFilter: onCreateFilter,
filteredList: list.filter(v => {
if (v.trash) {
return false;
}
return filterPageByRules(filters, [], v);
}),
};
};
@@ -0,0 +1,13 @@
import type { PageMeta } from '@blocksuite/store';
import { useState } from 'react';
export const useSearch = (list: PageMeta[]) => {
const [value, onChange] = useState('');
return {
searchText: value,
updateSearchText: onChange,
searchedList: value
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
: list,
};
};
@@ -4,4 +4,5 @@ export * from './collection-list';
export * from './collection-operations';
export * from './create-collection';
export * from './edit-collection/edit-collection';
export * from './save-as-collection-button';
export * from './use-edit-collection';
@@ -0,0 +1,46 @@
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SaveIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection } from '../use-collection-manager';
import { useEditCollectionName } from './use-edit-collection';
interface SaveAsCollectionButtonProps {
onConfirm: (collection: Collection) => Promise<void>;
}
export const SaveAsCollectionButton = ({
onConfirm,
}: SaveAsCollectionButtonProps) => {
const t = useAFFiNEI18N();
const { open, node } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
const handleClick = useCallback(() => {
open('')
.then(name => {
return onConfirm(createEmptyCollection(nanoid(), { name }));
})
.catch(err => {
console.error(err);
});
}, [open, onConfirm]);
return (
<>
<Button
onClick={handleClick}
data-testid="save-as-collection"
icon={<SaveIcon />}
size="large"
style={{ padding: '7px 8px' }}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
{node}
</>
);
};
@@ -1,12 +1,13 @@
import {
type AllPageListConfig,
CreateCollectionModal,
EditCollectionModal,
type EditCollectionMode,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { useCallback, useState } from 'react';
import { CreateCollectionModal } from './create-collection';
import {
type AllPageListConfig,
EditCollectionModal,
type EditCollectionMode,
} from './edit-collection/edit-collection';
export const useEditCollection = (config: AllPageListConfig) => {
const [data, setData] = useState<{
collection: Collection;
@@ -8,17 +8,19 @@ export const WorkspaceDetailSkeleton = () => {
<>
<SettingHeader title={<Skeleton />} subtitle={<Skeleton />} />
{new Array(3).fill(0).map((_, index) => {
return (
<SettingWrapper title={<Skeleton />} key={index}>
<SettingRow
name={<Skeleton />}
desc={<Skeleton />}
spreadCol={false}
></SettingRow>
</SettingWrapper>
);
})}
{Array.from({ length: 3 })
.fill(0)
.map((_, index) => {
return (
<SettingWrapper title={<Skeleton />} key={index}>
<SettingRow
name={<Skeleton />}
desc={<Skeleton />}
spreadCol={false}
></SettingRow>
</SettingWrapper>
);
})}
</>
);
};
@@ -26,9 +26,11 @@ export const WorkspaceListItemSkeleton = () => {
export const WorkspaceListSkeleton = () => {
return (
<>
{new Array(5).fill(0).map((_, index) => {
return <WorkspaceListItemSkeleton key={index} />;
})}
{Array.from({ length: 5 })
.fill(0)
.map((_, index) => {
return <WorkspaceListItemSkeleton key={index} />;
})}
</>
);
};
@@ -61,6 +61,7 @@ export const dropdownIcon = style({
export const radioButton = style({
flexGrow: 1,
flex: 1,
selectors: {
'&:not(:last-of-type)': {
marginRight: '4px',
@@ -72,7 +73,8 @@ export const radioButtonContent = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '24px',
height: '28px',
padding: '4px 8px',
borderRadius: '8px',
filter: 'drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.1))',
whiteSpace: 'nowrap',
@@ -84,7 +84,7 @@ export const playCheckAnimation = async (refElement: Element) => {
border-radius: 50%;
font-size: inherit;
`;
refElement.appendChild(sparkingEl);
refElement.append(sparkingEl);
await sparkingEl.animate(
[
@@ -29,7 +29,7 @@ export const Skeleton = ({
const style = {
width,
height,
...(_style || {}),
..._style,
};
return (
@@ -46,7 +46,7 @@ const createToastContainer = (portal?: HTMLElement) => {
data-testid="affine-toast-container"
></div>`;
const element = htmlToElement<HTMLDivElement>(template);
portal.appendChild(element);
portal.append(element);
return element;
};
@@ -98,7 +98,7 @@ const createAndShowNewToast = (
const toastElement = htmlToElement<HTMLDivElement>(toastTemplate);
// message is not trusted
toastElement.textContent = message;
ToastContainer.appendChild(toastElement);
ToastContainer.append(toastElement);
logger.debug(`toast with message: "${message}"`);
window.dispatchEvent(
new CustomEvent('affine-toast:emit', { detail: message })
@@ -38,18 +38,21 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
editorFlags,
appVersion: packageJson.version,
editorVersion: packageJson.dependencies['@blocksuite/editor'],
appBuildType: 'stable',
},
get beta() {
return {
...this.stable,
enablePageHistory: false,
serverUrlPrefix: 'https://insider.affine.pro',
appBuildType: 'beta' as const,
};
},
get internal() {
return {
...this.stable,
serverUrlPrefix: 'https://insider.affine.pro',
appBuildType: 'internal' as const,
};
},
// canary will be aggressive and enable all features
@@ -82,6 +85,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
editorFlags,
appVersion: packageJson.version,
editorVersion: packageJson.dependencies['@blocksuite/editor'],
appBuildType: 'canary',
},
};
+21
View File
@@ -25,6 +25,9 @@
{
"env": "BUILD_TYPE"
},
{
"env": "BUILD_TYPE_OVERRIDE"
},
{
"env": "PERFSEE_TOKEN"
},
@@ -48,6 +51,24 @@
},
{
"env": "DISABLE_DEV_OVERLAY"
},
{
"env": "CAPTCHA_SITE_KEY"
},
{
"env": "SHOULD_REPORT_TRACE"
},
{
"env": "TRACE_REPORT_ENDPOINT"
},
{
"env": "R2_ACCOUNT_ID"
},
{
"env": "R2_ACCESS_KEY_ID"
},
{
"env": "R2_SECRET_ACCESS_KEY"
}
],
"options": {
@@ -81,7 +81,7 @@ export async function bootstrapPluginSystem(
logger.debug(`registering plugin ${pluginName}`);
logger.debug(`package.json: ${packageJson}`);
if (!release && !runtimeConfig.enablePlugin) {
return Promise.resolve();
return;
}
const baseURL = url;
const entryURL = `${baseURL}/${core}`;
@@ -95,7 +95,7 @@ export async function bootstrapPluginSystem(
const loadedAssetName = `${pluginName}_${asset}`;
// todo(himself65): add assets into shadow dom
if (loadedAssets.has(loadedAssetName)) {
return Promise.resolve();
return;
}
if (asset.endsWith('.css')) {
loadedAssets.add(loadedAssetName);
@@ -106,12 +106,12 @@ export async function bootstrapPluginSystem(
const style = document.createElement('style');
style.setAttribute('plugin-id', pluginName);
style.textContent = text;
document.head.appendChild(style);
document.head.append(style);
});
}
return null;
} else {
return Promise.resolve();
return;
}
})
);
@@ -1,6 +1,6 @@
import { updateReadyAtom } from '@affine/component/app-sidebar/app-updater-button';
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ResetIcon } from '@blocksuite/icons';
import { updateReadyAtom } from '@toeverything/hooks/use-app-updater';
import { registerAffineCommand } from '@toeverything/infra/command';
import type { createStore } from 'jotai';
@@ -33,13 +33,14 @@ export const LanguageMenu = () => {
contentOptions={{
style: {
background: 'var(--affine-white)',
width: '250px',
},
align: 'end',
}}
>
<MenuTrigger
data-testid="language-menu-button"
style={{ textTransform: 'capitalize', fontWeight: 600 }}
style={{ textTransform: 'capitalize', fontWeight: 600, width: '250px' }}
block={true}
>
{currentLanguage?.originalName || ''}
@@ -96,6 +96,8 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
[pushNotification, update]
);
const canAdjustAvatar = workspaceAvatar && isOwner;
return (
<div className={style.profileWrapper}>
<Upload
@@ -110,11 +112,15 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
name={name}
colorfulFallback
hoverIcon={isOwner ? <CameraIcon /> : undefined}
onRemove={
workspaceAvatar && isOwner ? handleRemoveUserAvatar : undefined
onRemove={canAdjustAvatar ? handleRemoveUserAvatar : undefined}
avatarTooltipOptions={
canAdjustAvatar
? { content: t['Click to replace photo']() }
: undefined
}
removeTooltipOptions={
canAdjustAvatar ? { content: t['Remove photo']() } : undefined
}
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
removeTooltipOptions={{ content: t['Remove photo']() }}
data-testid="workspace-setting-avatar"
removeButtonProps={{
['data-testid' as string]: 'workspace-setting-remove-avatar-button',
@@ -1,7 +1,7 @@
import { Scrollable } from '@affine/component';
import {
BlockSuiteEditor,
BlockSuiteFallback,
EditorLoading,
} from '@affine/component/block-suite-editor';
import type { PageMode } from '@affine/core/atoms';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -147,7 +147,7 @@ const HistoryEditorPreview = ({
onModeChange={onModeChange}
/>
) : (
<BlockSuiteFallback />
<EditorLoading />
)}
</div>
);
@@ -410,7 +410,7 @@ export const PageHistoryModal = ({
return (
<ModalContainer onOpenChange={onOpenChange} open={open}>
<Suspense fallback={<BlockSuiteFallback />}>
<Suspense fallback={<EditorLoading />}>
<PageHistoryManager
onClose={onClose}
pageId={pageId}
@@ -4,14 +4,39 @@ import { SettingRow } from '@affine/component/setting-components';
import { SettingWrapper } from '@affine/component/setting-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons';
import { useAppUpdater } from '@toeverything/hooks/use-app-updater';
import { useCallback } from 'react';
import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting-helper';
import { appIconMap, appNames } from '../../../../../pages/open-app';
import { relatedLinks } from './config';
import { communityItem, communityWrapper, link } from './style.css';
import * as styles from './style.css';
import { UpdateCheckSection } from './update-check-section';
export const AboutAffine = () => {
const t = useAFFiNEI18N();
const { appSettings, updateSettings } = useAppSettingHelper();
const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater();
const channel = runtimeConfig.appBuildType;
const appIcon = appIconMap[channel];
const appName = appNames[channel];
const onSwitchAutoCheck = useCallback(
(checked: boolean) => {
toggleAutoCheck(checked);
updateSettings('autoCheckUpdate', checked);
},
[toggleAutoCheck, updateSettings]
);
const onSwitchAutoDownload = useCallback(
(checked: boolean) => {
toggleAutoDownload(checked);
updateSettings('autoDownloadUpdate', checked);
},
[toggleAutoDownload, updateSettings]
);
return (
<>
<SettingHeader
@@ -21,26 +46,26 @@ export const AboutAffine = () => {
/>
<SettingWrapper title={t['com.affine.aboutAFFiNE.version.title']()}>
<SettingRow
name={t['com.affine.aboutAFFiNE.version.app']()}
name={appName}
desc={runtimeConfig.appVersion}
/>
className={styles.appImageRow}
>
<img src={appIcon} alt={appName} width={56} height={56} />
</SettingRow>
<SettingRow
name={t['com.affine.aboutAFFiNE.version.editor.title']()}
desc={runtimeConfig.editorVersion}
/>
{runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? (
{environment.isDesktop ? (
<>
<SettingRow
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
desc={t['com.affine.aboutAFFiNE.checkUpdate.description']()}
/>
<UpdateCheckSection />
<SettingRow
name={t['com.affine.aboutAFFiNE.autoCheckUpdate.title']()}
desc={t['com.affine.aboutAFFiNE.autoCheckUpdate.description']()}
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={checked => updateSettings('autoCheckUpdate', checked)}
onChange={onSwitchAutoCheck}
/>
</SettingRow>
<SettingRow
@@ -50,8 +75,8 @@ export const AboutAffine = () => {
]()}
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={checked => updateSettings('autoCheckUpdate', checked)}
checked={appSettings.autoDownloadUpdate}
onChange={onSwitchAutoDownload}
/>
</SettingRow>
<SettingRow
@@ -69,7 +94,7 @@ export const AboutAffine = () => {
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.contact.title']()}>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://affine.pro"
target="_blank"
@@ -78,7 +103,7 @@ export const AboutAffine = () => {
<OpenInNewIcon className="icon" />
</a>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://community.affine.pro"
target="_blank"
@@ -88,11 +113,11 @@ export const AboutAffine = () => {
</a>
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.community.title']()}>
<div className={communityWrapper}>
<div className={styles.communityWrapper}>
{relatedLinks.map(({ icon, title, link }) => {
return (
<div
className={communityItem}
className={styles.communityItem}
onClick={() => {
window.open(link, '_blank');
}}
@@ -107,7 +132,7 @@ export const AboutAffine = () => {
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.legal.title']()}>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://affine.pro/privacy"
target="_blank"
@@ -116,7 +141,7 @@ export const AboutAffine = () => {
<OpenInNewIcon className="icon" />
</a>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://affine.pro/terms"
target="_blank"
@@ -43,3 +43,37 @@ globalStyle(`${communityItem} p`, {
fontSize: 'var(--affine-font-xs)',
textAlign: 'center',
});
export const checkUpdateDesc = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'flex-start',
selectors: {
'&.active': {
color: 'var(--affine-text-emphasis-color)',
},
'&.error': {
color: 'var(--affine-error-color)',
},
},
});
globalStyle(`${checkUpdateDesc} svg`, {
marginRight: '4px',
});
export const appImageRow = style({
flexDirection: 'row-reverse',
selectors: {
'&.two-col': {
justifyContent: 'flex-end',
},
},
});
globalStyle(`${appImageRow} .right-col`, {
paddingLeft: '0',
paddingRight: '20px',
});
@@ -0,0 +1,165 @@
import { SettingRow } from '@affine/component/setting-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import {
downloadProgressAtom,
isCheckingForUpdatesAtom,
updateAvailableAtom,
updateReadyAtom,
useAppUpdater,
} from '@toeverything/hooks/use-app-updater';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo, useState } from 'react';
import { Loading } from '../../../../pure/workspace-slider-bar/workspace-card/loading-icon';
import * as styles from './style.css';
enum CheckUpdateStatus {
UNCHECK = 'uncheck',
LATEST = 'latest',
UPDATE_AVAILABLE = 'update-available',
ERROR = 'error',
}
const useUpdateStatusLabels = (checkUpdateStatus: CheckUpdateStatus) => {
const t = useAFFiNEI18N();
const isCheckingForUpdates = useAtomValue(isCheckingForUpdatesAtom);
const updateAvailable = useAtomValue(updateAvailableAtom);
const updateReady = useAtomValue(updateReadyAtom);
const downloadProgress = useAtomValue(downloadProgressAtom);
const buttonLabel = useMemo(() => {
if (updateAvailable && downloadProgress === null) {
return t['com.affine.aboutAFFiNE.checkUpdate.button.download']();
}
if (updateReady) {
return t['com.affine.aboutAFFiNE.checkUpdate.button.restart']();
}
if (
checkUpdateStatus === CheckUpdateStatus.LATEST ||
checkUpdateStatus === CheckUpdateStatus.ERROR
) {
return t['com.affine.aboutAFFiNE.checkUpdate.button.retry']();
}
return t['com.affine.aboutAFFiNE.checkUpdate.button.check']();
}, [checkUpdateStatus, downloadProgress, t, updateAvailable, updateReady]);
const subtitleLabel = useMemo(() => {
if (updateAvailable && downloadProgress === null) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available']({
version: updateAvailable.version,
});
} else if (isCheckingForUpdates) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.checking']();
} else if (updateAvailable && downloadProgress !== null) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading']();
} else if (updateReady) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.restart']();
} else if (checkUpdateStatus === CheckUpdateStatus.ERROR) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.error']();
} else if (checkUpdateStatus === CheckUpdateStatus.LATEST) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.latest']();
}
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.check']();
}, [
checkUpdateStatus,
downloadProgress,
isCheckingForUpdates,
t,
updateAvailable,
updateReady,
]);
const subtitle = useMemo(() => {
return (
<span
className={clsx(styles.checkUpdateDesc, {
active:
updateReady ||
(updateAvailable && downloadProgress === null) ||
checkUpdateStatus === CheckUpdateStatus.LATEST,
error: checkUpdateStatus === CheckUpdateStatus.ERROR,
})}
>
{isCheckingForUpdates ? <Loading size={14} /> : null}
{subtitleLabel}
</span>
);
}, [
checkUpdateStatus,
downloadProgress,
isCheckingForUpdates,
subtitleLabel,
updateAvailable,
updateReady,
]);
return { subtitle, buttonLabel };
};
export const UpdateCheckSection = () => {
const t = useAFFiNEI18N();
const { checkForUpdates, downloadUpdate, quitAndInstall } = useAppUpdater();
const updateAvailable = useAtomValue(updateAvailableAtom);
const updateReady = useAtomValue(updateReadyAtom);
const downloadProgress = useAtomValue(downloadProgressAtom);
const [checkUpdateStatus, setCheckUpdateStatus] = useState<CheckUpdateStatus>(
CheckUpdateStatus.UNCHECK
);
const { buttonLabel, subtitle } = useUpdateStatusLabels(checkUpdateStatus);
const asyncCheckForUpdates = useAsyncCallback(async () => {
let statusCheck = CheckUpdateStatus.UNCHECK;
try {
const status = await checkForUpdates();
if (status === null) {
statusCheck = CheckUpdateStatus.ERROR;
} else if (status === false) {
statusCheck = CheckUpdateStatus.LATEST;
} else if (typeof status === 'string') {
statusCheck = CheckUpdateStatus.UPDATE_AVAILABLE;
}
} catch (e) {
console.error(e);
statusCheck = CheckUpdateStatus.ERROR;
} finally {
setCheckUpdateStatus(statusCheck);
}
}, [checkForUpdates]);
const handleClick = useCallback(() => {
if (updateAvailable && downloadProgress === null) {
return downloadUpdate();
}
if (updateReady) {
return quitAndInstall();
}
asyncCheckForUpdates();
}, [
asyncCheckForUpdates,
downloadProgress,
downloadUpdate,
quitAndInstall,
updateAvailable,
updateReady,
]);
return (
<SettingRow
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
desc={subtitle}
>
<Button
data-testid="check-update-button"
onClick={handleClick}
disabled={downloadProgress !== null && !updateReady}
>
{buttonLabel}
</Button>
</SettingRow>
);
};
@@ -84,8 +84,7 @@ const Settings = () => {
scrollWrapper.current.getBoundingClientRect().left -
parseInt(wrapperComputedStyle.paddingLeft)
: 0;
const appeared =
scrollWrapper.current.getAttribute('data-appeared') === 'true';
const appeared = scrollWrapper.current.dataset.appeared === 'true';
const animationFrameId = requestAnimationFrame(() => {
scrollWrapper.current?.scrollTo({
behavior: appeared ? 'smooth' : 'instant',
@@ -179,7 +179,7 @@ export const PlanCard = (props: PlanCardProps) => {
{detail.benefits.map((content, i) => (
<div key={i} className={styles.planBenefit}>
<div className={styles.planBenefitIcon}>
{detail.type == 'dynamic' ? (
{detail.type === 'dynamic' ? (
<BulledListIcon color="var(--affine-processing-color)" />
) : (
<DoneIcon
@@ -130,7 +130,7 @@ export const SettingModal = ({
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
</Suspense>
) : null}
{generalSettingList.find(v => v.key === activeTab) ? (
{generalSettingList.some(v => v.key === activeTab) ? (
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
) : null}
{activeTab === 'account' && loginStatus === 'authenticated' ? (
@@ -38,16 +38,20 @@ import {
export type UserInfoProps = {
onAccountSettingClick: () => void;
active?: boolean;
};
export const UserInfo = ({
onAccountSettingClick,
active,
}: UserInfoProps): ReactElement => {
const user = useCurrentUser();
return (
<div
data-testid="user-info-card"
className={accountButton}
className={clsx(accountButton, {
active: active,
})}
onClick={onAccountSettingClick}
>
<Avatar size={28} name={user.name} url={user.image} className="avatar" />
@@ -163,7 +167,10 @@ export const SettingSidebar = ({
{runtimeConfig.enableCloud && loginStatus === 'authenticated' ? (
<Suspense>
<UserInfo onAccountSettingClick={onAccountSettingClick} />
<UserInfo
onAccountSettingClick={onAccountSettingClick}
active={selectedGeneralKey === 'account'}
/>
</Suspense>
) : null}
</div>
@@ -105,6 +105,11 @@ export const accountButton = style({
':hover': {
background: 'var(--affine-hover-color)',
},
selectors: {
'&.active': {
background: 'var(--affine-hover-color)',
},
},
});
globalStyle(`${accountButton} .avatar`, {
@@ -112,9 +112,6 @@ export const radioButton = style({
},
},
});
export const spanStyle = style({
padding: '4px 20px',
});
export const disableSharePage = style({
color: 'var(--affine-error-color)',
@@ -174,18 +174,10 @@ export const AffineSharePage = (props: ShareMenuProps) => {
value={mode}
onValueChange={onShareModeChange}
>
<RadioButton
className={styles.radioButton}
value={'page'}
spanStyle={styles.spanStyle}
>
<RadioButton className={styles.radioButton} value={'page'}>
{t['com.affine.pageMode.page']()}
</RadioButton>
<RadioButton
className={styles.radioButton}
value={'edgeless'}
spanStyle={styles.spanStyle}
>
<RadioButton className={styles.radioButton} value={'edgeless'}>
{t['com.affine.pageMode.edgeless']()}
</RadioButton>
</RadioButtonGroup>
@@ -20,23 +20,15 @@ import {
MenuItem,
MenuSeparator,
} from '@toeverything/components/menu';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import {
useBlockSuitePageMeta,
usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { useAtomValue, useSetAtom } from 'jotai';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue } from 'jotai';
import { useCallback, useRef, useState } from 'react';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { setPageModeAtom } from '../../../atoms';
import { currentModeAtom } from '../../../atoms/mode';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useExportPage } from '../../../hooks/affine/use-export-page';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { toast } from '../../../utils';
import { PageHistoryModal } from '../../affine/page-history-modal/history-modal';
import { HeaderDropDownButton } from '../../pure/header-drop-down-button';
@@ -50,7 +42,6 @@ type PageMenuProps = {
export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
const t = useAFFiNEI18N();
const ref = useRef(null);
const { openPage } = useNavigateHelper();
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
@@ -64,11 +55,9 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
const currentMode = useAtomValue(currentModeAtom);
const favorite = pageMeta.favorite ?? false;
const { setPageMeta, setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
const { togglePageMode, toggleFavorite } =
const { togglePageMode, toggleFavorite, duplicate } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const { importFile } = usePageHelper(blockSuiteWorkspace);
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
@@ -107,34 +96,11 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
};
const exportHandler = useExportPage(currentPage);
const setPageMode = useSetAtom(setPageModeAtom);
const duplicate = useAsyncCallback(async () => {
const currentPageMeta = currentPage.meta;
const newPage = createPage();
await newPage.waitForLoaded();
const handleDuplicate = useCallback(() => {
duplicate(pageId);
}, [duplicate, pageId]);
const update = encodeStateAsUpdate(currentPage.spaceDoc);
applyUpdate(newPage.spaceDoc, update);
setPageMeta(newPage.id, {
tags: currentPageMeta.tags,
favorite: currentPageMeta.favorite,
});
setPageMode(newPage.id, currentMode);
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
openPage(blockSuiteWorkspace.id, newPage.id);
}, [
blockSuiteWorkspace.id,
createPage,
currentMode,
currentPage.meta,
currentPage.spaceDoc,
openPage,
setPageMeta,
setPageMode,
setPageTitle,
]);
const EditMenu = (
<>
<MenuItem
@@ -199,7 +165,7 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
</MenuIcon>
}
data-testid="editor-option-menu-duplicate"
onSelect={duplicate}
onSelect={handleDuplicate}
style={menuItemStyle}
>
{t['com.affine.header.option.duplicate']()}
@@ -32,6 +32,8 @@ export const titleInput = style({
borderRadius: '8px',
height: '32px',
padding: '6px 8px',
borderColor: 'var(--affine-primary-color)',
boxShadow: 'var(--affine-active-shadow)',
},
},
});
@@ -14,11 +14,14 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
const { openPage, jumpToSubPath } = useNavigateHelper();
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const pageSettings = useAtomValue(pageSettingsAtom);
const isPreferredEdgeless = useCallback(
(pageId: string) => pageSettings[pageId]?.mode === 'edgeless',
[pageSettings]
);
const setPageMode = useSetAtom(setPageModeAtom);
const createPageAndOpen = useCallback(
(mode?: 'page' | 'edgeless') => {
const page = createPage();
@@ -31,9 +34,11 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
},
[blockSuiteWorkspace.id, createPage, openPage, setPageMode]
);
const createEdgelessAndOpen = useCallback(() => {
return createPageAndOpen('edgeless');
}, [createPageAndOpen]);
const importFileAndOpen = useAsyncCallback(async () => {
const { showImportModal } = await import('@blocksuite/blocks');
const onSuccess = (pageIds: string[], isWorkspaceFile: boolean) => {
@@ -55,6 +60,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
};
showImportModal({ workspace: blockSuiteWorkspace, onSuccess });
}, [blockSuiteWorkspace, openPage, jumpToSubPath]);
return useMemo(() => {
return {
createPage: createPageAndOpen,
@@ -174,10 +174,10 @@ const EditorWrapper = memo(function EditorWrapper({
div.setAttribute('plugin-id', id);
const cleanup = editorItem(div, editor);
assertExists(parent);
document.body.appendChild(div);
document.body.append(div);
return () => {
cleanup();
document.body.removeChild(div);
div.remove();
};
});
});
@@ -235,14 +235,14 @@ const PluginContentAdapter = memo<PluginContentAdapterProps>(
}
const div = document.createElement('div');
const cleanup = windowItem(div);
root.appendChild(div);
root.append(div);
if (abortController.signal.aborted) {
cleanup();
root.removeChild(div);
div.remove();
} else {
const cl = () => {
cleanup();
root.removeChild(div);
div.remove();
};
const dispose = addCleanup(pluginName, cl);
abortController.signal.addEventListener('abort', () => {
@@ -12,6 +12,7 @@ export const searchInput = style({
color: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-h-5)',
padding: '21px 24px',
marginBottom: '8px',
width: '100%',
borderBottom: '1px solid var(--affine-border-color)',
flexShrink: 0,
@@ -114,13 +115,6 @@ globalStyle(`${root} [cmdk-group][hidden]`, {
display: 'none',
});
globalStyle(
`${root} [cmdk-group]:not([hidden]):first-of-type [cmdk-group-heading]`,
{
paddingTop: 16,
}
);
globalStyle(`${root} [cmdk-list]`, {
maxHeight: 400,
minHeight: 120,
@@ -187,3 +181,11 @@ globalStyle(
color: 'var(--affine-error-color)',
}
);
export const resultGroupHeader = style({
padding: '8px',
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
fontWeight: 600,
lineHeight: '1.67',
});
@@ -1,11 +1,12 @@
import { Command } from '@affine/cmdk';
import { useCommandState } from '@affine/cmdk';
import { formatDate } from '@affine/component/page-list';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMeta } from '@blocksuite/store';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import type { CommandCategory } from '@toeverything/infra/command';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { Suspense, useLayoutEffect, useMemo, useState } from 'react';
import {
@@ -71,7 +72,7 @@ const QuickSearchGroup = ({
);
return (
<Command.Group key={category} heading={t[i18nkey]()}>
<Command.Group key={category} heading={query ? '' : t[i18nkey]()}>
{commands.map(command => {
const label =
typeof command.label === 'string'
@@ -129,18 +130,43 @@ const QuickSearchCommands = ({
}: {
onOpenChange?: (open: boolean) => void;
}) => {
const t = useAFFiNEI18N();
const groups = useCMDKCommandGroups();
return groups.map(([category, commands]) => {
return (
<QuickSearchGroup
key={category}
onOpenChange={onOpenChange}
category={category}
commands={commands}
/>
);
});
const query = useAtomValue(cmdkQueryAtom);
const resultCount = useCommandState(state => state.filtered.count);
const resultGroupHeader = useMemo(() => {
if (query) {
return (
<div className={styles.resultGroupHeader}>
{
// hack: use resultCount to determine if it is creation or results
// because the creation(as 2 results) is always shown at the top when there is no result
resultCount === 2
? t['com.affine.cmdk.affine.category.affine.creation']()
: t['com.affine.cmdk.affine.category.results']()
}
</div>
);
}
return null;
}, [query, resultCount, t]);
return (
<>
{resultGroupHeader}
{groups.map(([category, commands]) => {
return (
<QuickSearchGroup
key={category}
onOpenChange={onOpenChange}
category={category}
commands={commands}
/>
);
})}
</>
);
};
export const CMDKContainer = ({
@@ -25,12 +25,12 @@ export const PluginHeader = () => {
div.setAttribute('plugin-id', pluginName);
startTransition(() => {
const cleanup = create(div);
root.appendChild(div);
root.append(div);
addCleanup(pluginName, () => {
pluginsRef.current = pluginsRef.current.filter(
name => name !== pluginName
);
root.removeChild(div);
div.remove();
cleanup();
});
});
@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const filterTab = style({
fontSize: 'var(--affine-font-xs)',
fontWeight: 600,
textTransform: 'capitalize',
minWidth: '91px',
});
@@ -3,6 +3,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
import { allPageModeSelectAtom } from '../../../atoms';
import * as styles from './index.css';
export const WorkspaceModeFilterTab = () => {
const t = useAFFiNEI18N();
@@ -15,16 +16,14 @@ export const WorkspaceModeFilterTab = () => {
};
return (
<RadioButtonGroup
width={300}
value={value}
onValueChange={handleValueChange}
>
<RadioButton value="all" style={{ textTransform: 'capitalize' }}>
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
<RadioButton value="all" spanStyle={styles.filterTab}>
{t['com.affine.pageMode.all']()}
</RadioButton>
<RadioButton value="page">{t['com.affine.pageMode.page']()}</RadioButton>
<RadioButton value="edgeless">
<RadioButton spanStyle={styles.filterTab} value="page">
{t['com.affine.pageMode.page']()}
</RadioButton>
<RadioButton spanStyle={styles.filterTab} value="edgeless">
{t['com.affine.pageMode.edgeless']()}
</RadioButton>
</RadioButtonGroup>
@@ -85,7 +85,7 @@ const CollectionRenderer = ({
async (id: string) => {
await setting.updateCollection({
...collection,
allowList: collection.allowList?.filter(v => v != id),
allowList: collection.allowList?.filter(v => v !== id),
});
},
[collection, setting]
@@ -20,6 +20,11 @@ import type { Page } from '@blocksuite/store';
import { useDroppable } from '@dnd-kit/core';
import { Menu } from '@toeverything/components/menu';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import {
isAutoCheckUpdateAtom,
isAutoDownloadUpdateAtom,
useAppUpdater,
} from '@toeverything/hooks/use-app-updater';
import { useAtom, useAtomValue } from 'jotai';
import type { HTMLAttributes, ReactElement } from 'react';
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
@@ -102,6 +107,10 @@ export const RootAppSidebar = ({
}: RootAppSidebarProps): ReactElement => {
const currentWorkspaceId = currentWorkspace.id;
const { appSettings } = useAppSettingHelper();
const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater();
const { autoCheckUpdate, autoDownloadUpdate } = appSettings;
const isAutoDownload = useAtomValue(isAutoDownloadUpdateAtom);
const isAutoCheck = useAtomValue(isAutoCheckUpdateAtom);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const t = useAFFiNEI18N();
const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom(
@@ -150,6 +159,26 @@ export const RootAppSidebar = ({
}
}, [sidebarOpen]);
useEffect(() => {
if (!environment.isDesktop) {
return;
}
if (isAutoCheck !== autoCheckUpdate) {
toggleAutoCheck(autoCheckUpdate);
}
if (isAutoDownload !== autoDownloadUpdate) {
toggleAutoDownload(autoDownloadUpdate);
}
}, [
autoCheckUpdate,
autoDownloadUpdate,
isAutoCheck,
isAutoDownload,
toggleAutoCheck,
toggleAutoDownload,
]);
const [history, setHistory] = useHistoryAtom();
const router = useMemo(() => {
return {
@@ -1,25 +1,31 @@
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import {
useBlockSuitePageMeta,
usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode';
import type { BlockSuiteWorkspace } from '../../shared';
import { getWorkspaceSetting } from '../../utils/workspace-setting';
import { useNavigateHelper } from '../use-navigate-helper';
import { useReferenceLinkHelper } from './use-reference-link-helper';
export function useBlockSuiteMetaHelper(
blockSuiteWorkspace: BlockSuiteWorkspace
) {
const { setPageMeta, getPageMeta, setPageReadonly } =
const { setPageMeta, getPageMeta, setPageReadonly, setPageTitle } =
usePageMetaHelper(blockSuiteWorkspace);
const { addReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace);
const metas = useBlockSuitePageMeta(blockSuiteWorkspace);
const setPageMode = useSetAtom(setPageModeAtom);
const currentMode = useAtomValue(currentModeAtom);
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const { openPage } = useNavigateHelper();
const switchToPageMode = useCallback(
(pageId: string) => {
@@ -79,7 +85,7 @@ export function useBlockSuiteMetaHelper(
setPageMeta(pageId, {
trash: true,
trashDate: +new Date(),
trashDate: Date.now(),
trashRelate: isRoot ? parentMeta?.id : undefined,
});
setPageReadonly(pageId, true);
@@ -140,6 +146,40 @@ export function useBlockSuiteMetaHelper(
[setPageMeta]
);
const duplicate = useAsyncCallback(
async (pageId: string) => {
const currentPageMeta = getPageMeta(pageId);
const newPage = createPage();
const currentPage = blockSuiteWorkspace.getPage(pageId);
await newPage.waitForLoaded();
if (!currentPageMeta || !currentPage) {
return;
}
const update = encodeStateAsUpdate(currentPage.spaceDoc);
applyUpdate(newPage.spaceDoc, update);
setPageMeta(newPage.id, {
tags: currentPageMeta.tags,
favorite: currentPageMeta.favorite,
});
setPageMode(newPage.id, currentMode);
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
openPage(blockSuiteWorkspace.id, newPage.id);
},
[
blockSuiteWorkspace,
createPage,
currentMode,
getPageMeta,
openPage,
setPageMeta,
setPageMode,
setPageTitle,
]
);
return {
switchToPageMode,
switchToEdgelessMode,
@@ -155,5 +195,7 @@ export function useBlockSuiteMetaHelper(
removeToTrash,
restoreFromTrash,
permanentlyDeletePage,
duplicate,
};
}
@@ -41,7 +41,7 @@ export function useRegisterBlocksuiteEditorCommands(
}));
}, [pageId, setPageHistoryModalState]);
const { togglePageMode, toggleFavorite, restoreFromTrash } =
const { togglePageMode, toggleFavorite, restoreFromTrash, duplicate } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const exportHandler = useExportPage(currentPage);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
@@ -125,6 +125,19 @@ export function useRegisterBlocksuiteEditorCommands(
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-duplicate`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.header.option.duplicate'](),
run() {
duplicate(pageId);
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-pdf`,
@@ -234,5 +247,6 @@ export function useRegisterBlocksuiteEditorCommands(
trash,
isCloudWorkspace,
openHistoryModal,
duplicate,
]);
}

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