Compare commits

...

140 Commits

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

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

add `useSyncEngineStatus` hooks
add loading style

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

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

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

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

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

fix https://github.com/toeverything/AFFiNE/issues/4978
fix https://github.com/toeverything/AFFiNE/issues/4466
2024-02-21 14:19:23 +08:00
LongYinan
b3f272ba70 fix: selfhost build (#5833) 2024-02-21 14:18:05 +08:00
Whitewater
a5df5a7c8a chore: skip sync when offline (#5786) 2024-02-21 14:17:20 +08:00
DarkSky
90de90403a feat: refresh new workspace feature (#5834) 2024-02-20 16:12:35 +08:00
李华桥
4d4e4fc4e2 Merge branch 'stable' into beta 2024-02-20 15:52:22 +08:00
liuyi
aa73e532d3 fix(server): doc upsert without row lock (#5765) 2024-02-16 22:06:23 +08:00
liuyi
31faa93c71 chore(storage): bump y-octo (#5751) 2024-02-16 19:36:26 +08:00
liuyi
def60f4c61 fix(server): doc upsert without row lock (#5765) 2024-02-05 16:39:48 +08:00
EYHN
d15ec0ff77 fix(workspace): fix sync handshake (hot-fix) (#5797) 2024-02-05 10:56:46 +08:00
EYHN
d2acd0385a fix(core): prevent data loss (hot-fix) (#5798) 2024-02-05 10:54:51 +08:00
EYHN
1effb2f25f fix(workspace): fix sync stuck (#5762) (#5772) 2024-02-01 17:41:49 +08:00
Joooye_34
9189d26332 feat: support sign-in with subscription coupon (#5768) 2024-02-01 17:03:29 +08:00
liuyi
79a8be7799 feat(server): allow pass coupon to checkout session (#5749) 2024-02-01 17:03:16 +08:00
liuyi
1a643cc70c fix(server): doc upsert race condition (#5755) 2024-01-31 21:36:35 +08:00
liuyi
9321be3ff5 fix(server): doc upsert race condition (#5755) 2024-01-31 11:08:52 +00:00
李华桥
24dc3f95ff fix: consume blob stream correctly (#5706) 2024-01-31 11:38:40 +08:00
Cats Juice
4257b5f3a4 fix(core): set createDate to journal's date when journal created (#5701) 2024-01-30 23:13:02 +08:00
Cats Juice
ea17e86032 feat(core): ignore empty journals for page lists (#5744) 2024-01-30 17:21:20 +08:00
Joooye_34
48cd8999bd fix: static resource not found in web server (#5745) 2024-01-30 17:21:05 +08:00
李华桥
cdf1d9002e Merge branch 'canary' into stable 2024-01-29 17:53:10 +08:00
李华桥
79b39f14d2 Merge branch 'canary' into stable 2024-01-25 13:46:21 +08:00
李华桥
619420cfd1 chore: recover yarn.lock 2024-01-25 00:38:29 +08:00
李华桥
739e914b5f Merge branch 'canary' into stable 2024-01-25 00:33:28 +08:00
liuyi
5e9739eb3a fix(server): del staled update count cache if unmatch (#5674) 2024-01-23 16:55:49 +08:00
liuyi
0a89b7f528 fix(server): standalone early access users detection (#5601) 2024-01-16 11:39:36 +08:00
DarkSky
0a0ee37ac2 fix: return empty resp if user not exists in login preflight (#5588) 2024-01-13 23:30:01 +08:00
Peng Xiao
a143379161 fix(electron): remove cors headers hack (#5581) 2024-01-12 16:49:16 +08:00
regischen
8e7dedfe82 feat: bump blocksuite (#5575) 2024-01-12 12:43:56 +08:00
EYHN
d25a8547d0 refactor(core): move page list to core (#5556) 2024-01-12 12:43:45 +08:00
Peng Xiao
4d16229fea chore(core): remove affine/cmdk package (#5552)
patch cmdk based on https://github.com/pengx17/cmdk/tree/patch-1
fix https://github.com/toeverything/AFFiNE/issues/5548
2024-01-12 12:43:35 +08:00
EYHN
99371be7e8 fix(core): workspace not found after import (#5571)
close TOV-393
2024-01-12 11:05:59 +08:00
李华桥
34ed8dd7a5 Merge branch 'canary' into stable 2024-01-10 10:59:28 +08:00
李华桥
39b7b671b1 Merge branch 'canary' into stable 2024-01-09 19:44:52 +08:00
李华桥
207b56d5af Merge branch 'canary' into stable 2024-01-09 17:16:17 +08:00
DarkSky
9e94e7195b fix: use absolute path in gql client (#5454) (#5462) 2023-12-29 16:02:29 +08:00
Peng Xiao
de951c8779 fix(core): enable page history for beta/stable (#5415) 2023-12-27 14:39:59 +08:00
EYHN
fd37026ca5 fix(component): fix font display on safari (#5393)
before

![CleanShot 2023-12-25 at 13.09.26.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/4fe08951-67bb-4050-ba14-94391db1cac1.png)

after

![CleanShot 2023-12-25 at 13.09.13.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/fbfb17ec-b871-4746-9d3c-d24f850ecca1.png)
2023-12-27 14:39:50 +08:00
JimmFly
4fd5812a89 fix(core): avatars are not aligned (#5404) 2023-12-26 20:43:08 +08:00
Peng Xiao
d01e987ecc fix(core): trash page footer display issue (#5402)
Before

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/eb5e5b18-c4a2-469b-8763-be34c39ba736.png)

After

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7b3ef339-0cb5-44fe-9e75-cec0e97d28b7.png)
2023-12-26 20:42:54 +08:00
Joooye_34
d87c218c0b fix(electron): set stable base url to app.affine.pro (#5401)
close TOV-282
2023-12-26 20:42:41 +08:00
Peng Xiao
a5bf5cc244 fix(core): about setting blink issue (#5399) 2023-12-26 20:42:33 +08:00
Peng Xiao
16bcd6e76b fix(core): workpace list blink issue on open (#5400) 2023-12-26 20:42:19 +08:00
JimmFly
2e2ace8472 chore(core): add background color to questionnaire (#5396) 2023-12-26 20:42:06 +08:00
Cats Juice
37cff8fe8d fix(core): correct title of onboarding article-2 (#5387) 2023-12-26 20:41:58 +08:00
DarkSky
70ab3b4916 fix: use prefix in electron to prevent formdata bug (#5395) 2023-12-26 20:41:47 +08:00
EYHN
f42ba54578 fix(core): fix flickering workspace list (#5391) 2023-12-26 20:41:36 +08:00
EYHN
a67c8181fc fix(workspace): fix svg file with xml header (#5388) 2023-12-26 20:41:28 +08:00
regischen
613efbded9 feat: bump blocksuite (#5386) 2023-12-26 20:41:18 +08:00
李华桥
549419d102 Merge branch 'canary' into stable 2023-12-22 16:29:51 +08:00
李华桥
21c42f8771 Merge branch 'canary' into stable 2023-12-22 01:29:30 +08:00
李华桥
9012adda7a Merge branch 'canary' into stable 2023-12-21 18:42:56 +08:00
李华桥
fb442e9055 Merge branch 'canary' into stable 2023-12-21 16:22:57 +08:00
李华桥
a231474dd2 Merge branch 'canary' into stable 2023-12-21 14:26:01 +08:00
李华桥
833b42000b Merge branch 'canary' into stable 2023-12-20 16:36:44 +08:00
李华桥
7690c48710 Merge branch 'canary' into stable 2023-12-20 16:32:36 +08:00
DarkSky
579828a700 fix: use secure websocket (#5297) 2023-12-13 22:28:04 +08:00
DarkSky
746db2ccfc feat: only follow serverUrlPrefix at redirect to client (#5295) 2023-12-13 20:37:20 +08:00
李华桥
eff344a9c1 Merge branch 'canary' into stable 2023-12-12 16:45:47 +08:00
李华桥
c89ebab596 Merge branch 'canary' into stable 2023-12-12 11:04:33 +08:00
liuyi
62f4421b7c fix(server): avoid updates persist forever (#5258) 2023-12-11 17:42:25 +08:00
李华桥
42383dbd29 Merge branch 'canary' into stable 2023-12-10 21:04:15 +08:00
李华桥
120e7397ba Merge branch 'canary' into stable 2023-12-01 16:12:17 +08:00
李华桥
24123ad01c Revert "Revert "Merge remote-tracking branch 'origin/canary' into stable""
This reverts commit 89197bacef.
2023-12-01 13:29:43 +08:00
李华桥
ad50320391 v0.10.3 2023-12-01 12:52:15 +08:00
李华桥
eb21a60dda v0.10.3-beta.7 2023-12-01 12:12:20 +08:00
Joooye_34
c0e3be2d40 fix(core): rerender error boundary when route change and improve sentry report (#5147) 2023-12-01 04:04:44 +00:00
李华桥
09d3b72358 v0.10.3-beta.6 2023-11-30 23:02:26 +08:00
Joooye_34
246e16c6c0 fix(infra): compatibility logic follow blocksuite (#5143) 2023-11-30 23:01:38 +08:00
李华桥
dc279d062b v0.10.3-beta.5 2023-11-30 16:49:55 +08:00
Joooye_34
47d5f9e1c2 fix(infra): use blocksuite api to check compatibility (#5137) 2023-11-30 08:48:13 +00:00
Joooye_34
a226eb8d5f fix(core): expose catched editor load error (#5133) 2023-11-29 20:31:35 +08:00
Joooye_34
908c4e1a6f ci: add sentry env when frontend assets build (#5131) 2023-11-29 10:03:49 +00:00
李华桥
1d0bcc80a0 v0.10.3-beta.4 2023-11-29 16:14:06 +08:00
Joooye_34
50010bd824 fix(core): implement editor timeout and report error from boundary (#5105) 2023-11-29 08:10:38 +00:00
liuyi
c0ede1326d fix(server): wrong OTEL config (#5084) 2023-11-29 11:19:13 +08:00
李华桥
89197bacef Revert "Merge remote-tracking branch 'origin/canary' into stable"
This reverts commit 992ed89a89, reversing
changes made to d272d7922d.
2023-11-29 11:18:45 +08:00
李华桥
f97d323ab5 Revert "Revert "refactor(server): standarderlize metrics and trace with OTEL (#5054)""
This reverts commit c1cd1713b9.
2023-11-29 11:07:28 +08:00
EYHN
2acb219dcc fix(workspace): filter awareness from other workspace (#5093) 2023-11-28 16:47:45 +08:00
LongYinan
992ed89a89 Merge remote-tracking branch 'origin/canary' into stable 2023-11-28 15:12:52 +08:00
李华桥
d272d7922d v0.10.3-beta.2 2023-11-25 23:50:40 +08:00
李华桥
c1cd1713b9 Revert "refactor(server): standarderlize metrics and trace with OTEL (#5054)"
This reverts commit 91efca107a.
2023-11-25 23:50:39 +08:00
李华桥
b20e91bee0 v0.10.3-beta.1 2023-11-25 14:14:40 +08:00
李华桥
9a4e5ec8c3 Merge branch 'canary' into stable 2023-11-25 14:14:14 +08:00
李华桥
2019838ae7 v0.10.3-beta.0 2023-11-24 11:39:23 +08:00
李华桥
30ff25f400 Merge branch 'canary' into stable 2023-11-23 23:40:32 +08:00
李华桥
e766208c18 chore: reset merge wrong codes 2023-11-23 22:53:06 +08:00
李华桥
8742f28148 Merge branch 'canary' into stable 2023-11-23 21:31:42 +08:00
LongYinan
cd291bb60e build: remove useless source-map-loader to speedup webpack (#4910) 2023-11-20 10:52:28 +08:00
LongYinan
62c0efcfd1 fix(core): handle the getSession network error properly (#4909)
If network offline or API error happens, the `session` returned by the `useSession` hook will be null, so we can't assume it is not null.

There should be following changes:
1. create a page in ErrorBoundary to let the user refetch the session.
2. The `SessionProvider` stop to pull the new session once the session is null, we need to figure out a way to pull the new session when the network is back or the user click the refetch button.
2023-11-17 16:50:48 +08:00
liuyi
87248b3337 fix(server): all viewers can share public link (#4968) 2023-11-17 12:34:15 +08:00
Joooye_34
00c940f7df chore: bump affine version to 0.10.2 (#4959) 2023-11-16 15:48:37 +08:00
Flrande
931b459fbd chore: bump blocksuite (#4958) 2023-11-16 14:27:39 +08:00
LongYinan
51e71f4a0a ci: prevent error if rust build is cached by nx (#4951)
If Rust build was cached by nx, only the output file will be presented. The chmod command will be failed in this case like: https://github.com/toeverything/AFFiNE/actions/runs/6874496337/job/18697360212
2023-11-16 10:31:51 +08:00
Peng Xiao
9b631f2328 fix(infra): page id compat fix for page ids in workspace.meta (#4950)
since we strip `page:` in keys of workspacedoc.spaces, we should also strip the prefix in meta.pages as well.
2023-11-15 17:36:08 +08:00
LongYinan
01f481a9b6 ci: only disable postinstall on macOS in nightly desktop build (#4938) 2023-11-14 23:00:30 +08:00
Joooye_34
0177ab5c87 fix(infra): workspace migration without blockVersions (#4936) 2023-11-14 14:38:11 +01:00
Peng Xiao
4db35d341c perf(component): use png instead of svg for rendering noise svg (#4935) 2023-11-14 11:52:51 +00:00
DarkSky
3c4a803c97 fix: change password token check (#4934) (#4932) 2023-11-14 11:15:54 +00:00
LongYinan
05154dc7ca ci: disable postinstall in nightly desktop build (#4930)
Should be part of https://github.com/toeverything/AFFiNE/pull/4885
2023-11-14 14:13:55 +08:00
Peng Xiao
c90b477f60 fix(core): change server url of stable to insider (#4902) (#4926) 2023-11-14 12:05:52 +08:00
李华桥
6f18ddbe85 v0.10.1 2023-11-13 19:49:26 +08:00
LongYinan
dde779a71d test(e2e): add subdoc migration test (#4921)
test(e2e): add subdoc migration test

fix: remove .only
2023-11-13 18:00:40 +08:00
Peng Xiao
bd9f66fbc7 fix(infra): compatibility fix for space prefix (#4912)
It seems there are some cases that [this upstream PR](https://github.com/toeverything/blocksuite/pull/4747) will cause data loss.

Because of some historical reasons, the page id could be different with its doc id.
It might be caused by subdoc migration in the following (not 100% sure if all white screen issue is caused by it) 0714c12703/packages/common/infra/src/blocksuite/index.ts (L538-L540)

In version 0.10, page id in spaces no longer has prefix "space:"
The data flow for fetching a doc's updates is:
- page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
- because of guid logic change, the doc that previously prefixed with `space:` will not be found in `doc.spaces`
- when fetching the rows of this doc using the doc id === page id,
  it will return EMPTY since there is no updates associated with the page id

The provided fix in the PR will patch the `spaces` field of the root doc so that after 0.10 the page doc can still be found in the `spaces` map. It shall apply to both of the idb & sqlite datasources.

Special thanks to @lawvs 's db file for investigation!
2023-11-13 17:57:56 +08:00
liuyi
92f1f40bfa fix(server): wrap updates applying in a transaction (#4922) 2023-11-13 08:49:30 +00:00
LongYinan
48dc1049b3 Merge pull request #4913 from toeverything/darksky/cleanup-depolyment
chore: cleanup deployment
2023-11-12 11:20:02 +08:00
DarkSky
9add530370 chore: cleanup deployment 2023-11-12 11:03:25 +08:00
LongYinan
b77460d871 Merge pull request #4908 from toeverything/61/hotfix-websocket-payload
fix(server): increase server acceptable websocket payload size
2023-11-10 22:01:48 +08:00
forehalo
42db41776b fix(server): increase server acceptable websocket payload size 2023-11-10 21:31:45 +08:00
李华桥
075439c74f fix(core): change server url of stable to insider 2023-11-10 18:32:53 +08:00
Yifeng Wang
fc6c553ece chore: bump theme (#4904)
Co-authored-by: 李华桥 <joooye1991@gmail.com>
2023-11-10 15:40:38 +08:00
Joooye_34
59cb3d5df1 fix(core): change server url of stable to insider (#4902) 2023-11-10 14:50:57 +08:00
194 changed files with 4573 additions and 3550 deletions

View File

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

View File

@@ -1,6 +1,6 @@
FROM openresty/openresty:1.21.4.3-0-buster
WORKDIR /app
COPY ./packages/frontend/core/dist/index.html ./dist/index.html
COPY ./packages/frontend/core/dist ./dist
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

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

1000
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
import { useSubscriptionSearch } from './use-subscription';
function validateEmail(email: string) {
return emailRegex.test(email);
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const subscriptionData = useSubscriptionSearch();
const {
isMutating: isSigningIn,
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
if (verifyToken) {
if (user) {
// provider password sign-in if user has by default
if (user.hasPassword) {
// If with payment, onl support email sign in to avoid redirect to affine app
if (user.hasPassword && !subscriptionData) {
setAuthState('signInWithPassword');
} else {
const res = await signIn(email, verifyToken, challenge);
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}
}
}, [
subscriptionData,
challenge,
email,
setAuthEmail,

View File

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

View File

@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
enum SubscriptionKey {
Recurring = 'subscription_recurring',
Plan = 'subscription_plan',
Coupon = 'coupon',
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
Token = 'token', // When signup, there should have a token to set password
}
@@ -22,11 +23,13 @@ export function useSubscriptionSearch() {
const recurring = searchParams.get(SubscriptionKey.Recurring);
const plan = searchParams.get(SubscriptionKey.Plan);
const coupon = searchParams.get(SubscriptionKey.Coupon);
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
const passwordToken = searchParams.get(SubscriptionKey.Token);
return {
recurring,
plan,
coupon,
withSignUp,
passwordToken,
getRedirectUrl(signUp?: boolean) {
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
[SubscriptionKey.Plan, plan ?? ''],
]);
if (coupon) {
paymentParams.set(SubscriptionKey.Coupon, coupon);
}
if (signUp) {
paymentParams.set(SubscriptionKey.SignUp, '1');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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